Compare commits

...

9 Commits

Author SHA1 Message Date
Zamil Majdy
b071cd42f5 Merge remote-tracking branch 'origin/dev' into feat/agent-gen-smart-decision 2026-03-17 06:17:15 +07:00
Zamil Majdy
210a69d33e fix(copilot): update stale docstring for SDM fixer default
The docstring still referenced -1 (infinite) after changing the default
to 10 (bounded).
2026-03-17 00:19:39 +07:00
Zamil Majdy
2ddddc0257 fix(copilot): use bounded default for agent_mode_max_iterations
Change fixer default from -1 (infinite) to 10 (bounded) for safety.
Update guide to let LLM choose iteration count based on task complexity:
1 for single-step, 3-10 for multi-step, -1 for open-ended orchestration.
2026-03-17 00:17:56 +07:00
Zamil Majdy
59f05ed23c fix(copilot): guard SDM input_default type and reject invalid iterations
- Handle input_default=None by replacing with empty dict in fixer
- Reject agent_mode_max_iterations < -1 in validator (only -1 or
  positive values are valid)
- Add tests for both edge cases
2026-03-16 22:41:40 +07:00
Zamil Majdy
09fd30e14f fix(copilot): reject agent_mode_max_iterations=0 in SDM validator
Traditional mode (0) requires complex external conversation-history
loop wiring that the agent generator does not produce. Validate that
generated SDM nodes use agent mode (-1 or positive) only.
2026-03-16 22:25:43 +07:00
Zamil Majdy
ef6118c640 fix(copilot): treat explicit null SDM fields as missing defaults
The fixer now treats `None` values in SDM input_default as missing,
overwriting them with the correct defaults. This handles cases where
the LLM generates `null` for fields it doesn't know the value of.
2026-03-16 22:21:39 +07:00
Zamil Majdy
5d527eab85 fix(copilot): address review comments on SDM validator and tests
- Filter out AgentInput/OutputBlocks from SDM tool link check (interface
  blocks aren't real tools)
- Add test for interface-block-only links failing validation
- Add test for explicit None values being treated as missing in fixer
- Assert full graph validity in e2e pipeline test
2026-03-16 22:19:45 +07:00
Zamil Majdy
f0af149f16 fix(copilot): keep SmartDecisionMakerBlock excluded from CoPilot standalone
Address review feedback:
- Revert find_block.py exclusion removal — SDM requires graph context
  and would crash if run via run_block (missing execution_processor)
- The guide hardcodes the block ID so agent generation still works
- Add warning against agent_mode_max_iterations=0 in guide
- Hoist _DEFAULTS to module-level _SDM_DEFAULTS constant in fixer.py
2026-03-16 21:58:57 +07:00
Zamil Majdy
5ad71099ac feat(copilot): enable SmartDecisionMakerBlock in agent generator
Allow the agent generator to create orchestrator agents that use
SmartDecisionMakerBlock with agent mode to autonomously decide which
tools or sub-agents to call in a loop until the task is complete.

Changes:
- Remove SmartDecisionMakerBlock from COPILOT_EXCLUDED_BLOCK_IDS
- Add SMART_DECISION_MAKER_BLOCK_ID constant to helpers
- Add fixer to populate agent-mode defaults (max_iterations=-1, etc.)
- Add validator to ensure downstream tool blocks are connected
- Document SmartDecisionMakerBlock usage in agent_generation_guide.md
- Add 18 tests covering fixer, validator, and e2e pipeline
2026-03-16 21:51:24 +07:00
6 changed files with 835 additions and 1 deletions

View File

@@ -143,6 +143,48 @@ To use an MCP (Model Context Protocol) tool as a node in the agent:
tool_arguments.
6. Output: `result` (the tool's return value) and `error` (error message)
### Using SmartDecisionMakerBlock (AI Orchestrator with Agent Mode)
To create an agent where AI autonomously decides which tools or sub-agents to
call in a loop until the task is complete:
1. Create a `SmartDecisionMakerBlock` node
(ID: `3b191d9f-356f-482d-8238-ba04b6d18381`)
2. Set `input_default`:
- `agent_mode_max_iterations`: Choose based on task complexity:
- `1` for single-step tool calls (AI picks one tool, calls it, done)
- `3``10` for multi-step tasks (AI calls tools iteratively)
- `-1` for open-ended orchestration (AI loops until it decides it's done)
Do NOT use `0` (traditional mode) — it requires complex external
conversation-history loop wiring that the agent generator does not
produce.
- `conversation_compaction`: `true` (recommended to avoid context overflow)
- Optional: `sys_prompt` for extra LLM context about how to orchestrate
3. Wire the `prompt` input from an `AgentInputBlock` (the user's task)
4. Create downstream tool blocks — regular blocks **or** `AgentExecutorBlock`
nodes that call sub-agents
5. Link each tool to the SmartDecisionMaker: set `source_name: "tools"` on
the SmartDecisionMaker side and `sink_name: <input_field>` on each tool
block's input. Create one link per input field the tool needs.
6. Wire the `finished` output to an `AgentOutputBlock` for the final result
7. Credentials (LLM API key) are configured by the user in the platform UI
after saving — do NOT require them upfront
**Example — Orchestrator calling two sub-agents:**
- Node 1: `AgentInputBlock` (input_default: `{"name": "task"}`)
- Node 2: `SmartDecisionMakerBlock` (input_default:
`{"agent_mode_max_iterations": 10, "conversation_compaction": true}`)
- Node 3: `AgentExecutorBlock` (sub-agent A — set `graph_id`, `graph_version`,
`input_schema`, `output_schema` from library agent)
- Node 4: `AgentExecutorBlock` (sub-agent B — same pattern)
- Node 5: `AgentOutputBlock` (input_default: `{"name": "result"}`)
- Links:
- Input→SDM: `source_name: "result"`, `sink_name: "prompt"`
- SDM→Agent A (per input field): `source_name: "tools"`,
`sink_name: "<agent_a_input_field>"`
- SDM→Agent B (per input field): `source_name: "tools"`,
`sink_name: "<agent_b_input_field>"`
- SDM→Output: `source_name: "finished"`, `sink_name: "value"`
### Example: Simple AI Text Processor
A minimal agent with input, processing, and output:

View File

@@ -7,6 +7,7 @@ from typing import Any
from .helpers import (
AGENT_EXECUTOR_BLOCK_ID,
MCP_TOOL_BLOCK_ID,
SMART_DECISION_MAKER_BLOCK_ID,
AgentDict,
are_types_compatible,
generate_uuid,
@@ -30,6 +31,14 @@ _GET_CURRENT_DATE_BLOCK_ID = "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0b1"
_GMAIL_SEND_BLOCK_ID = "6c27abc2-e51d-499e-a85f-5a0041ba94f0"
_TEXT_REPLACE_BLOCK_ID = "7e7c87ab-3469-4bcc-9abe-67705091b713"
# Defaults applied to SmartDecisionMakerBlock nodes by the fixer.
_SDM_DEFAULTS: dict[str, object] = {
"agent_mode_max_iterations": 10,
"conversation_compaction": True,
"retry": 3,
"multiple_tool_calls": False,
}
class AgentFixer:
"""
@@ -1630,6 +1639,43 @@ class AgentFixer:
return agent
def fix_smart_decision_maker_blocks(self, agent: AgentDict) -> AgentDict:
"""Fix SmartDecisionMakerBlock nodes to ensure agent-mode defaults.
Ensures:
1. ``agent_mode_max_iterations`` defaults to ``10`` (bounded agent mode)
2. ``conversation_compaction`` defaults to ``True``
3. ``retry`` defaults to ``3``
4. ``multiple_tool_calls`` defaults to ``False``
Args:
agent: The agent dictionary to fix
Returns:
The fixed agent dictionary
"""
nodes = agent.get("nodes", [])
for node in nodes:
if node.get("block_id") != SMART_DECISION_MAKER_BLOCK_ID:
continue
node_id = node.get("id", "unknown")
input_default = node.get("input_default")
if not isinstance(input_default, dict):
input_default = {}
node["input_default"] = input_default
for field, default_value in _SDM_DEFAULTS.items():
if field not in input_default or input_default[field] is None:
input_default[field] = default_value
self.add_fix_log(
f"SmartDecisionMakerBlock {node_id}: "
f"Set {field}={default_value!r}"
)
return agent
def fix_dynamic_block_sink_names(self, agent: AgentDict) -> AgentDict:
"""Fix links that use _#_ notation for dynamic block sink names.
@@ -1717,6 +1763,9 @@ class AgentFixer:
# Apply fixes for MCPToolBlock nodes
agent = self.fix_mcp_tool_blocks(agent)
# Apply fixes for SmartDecisionMakerBlock nodes (agent-mode defaults)
agent = self.fix_smart_decision_maker_blocks(agent)
# Apply fixes for AgentExecutorBlock nodes (sub-agents)
if library_agents:
agent = self.fix_agent_executor_blocks(agent, library_agents)

View File

@@ -12,6 +12,7 @@ __all__ = [
"AGENT_OUTPUT_BLOCK_ID",
"AgentDict",
"MCP_TOOL_BLOCK_ID",
"SMART_DECISION_MAKER_BLOCK_ID",
"UUID_REGEX",
"are_types_compatible",
"generate_uuid",
@@ -33,6 +34,7 @@ UUID_REGEX = re.compile(r"^" + UUID_RE_STR + r"$")
AGENT_EXECUTOR_BLOCK_ID = "e189baac-8c20-45a1-94a7-55177ea42565"
MCP_TOOL_BLOCK_ID = "a0a4b1c2-d3e4-4f56-a7b8-c9d0e1f2a3b4"
SMART_DECISION_MAKER_BLOCK_ID = "3b191d9f-356f-482d-8238-ba04b6d18381"
AGENT_INPUT_BLOCK_ID = "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b"
AGENT_OUTPUT_BLOCK_ID = "363ae599-353e-4804-937e-b2ee3cef3da4"

View File

@@ -10,6 +10,7 @@ from .helpers import (
AGENT_INPUT_BLOCK_ID,
AGENT_OUTPUT_BLOCK_ID,
MCP_TOOL_BLOCK_ID,
SMART_DECISION_MAKER_BLOCK_ID,
AgentDict,
are_types_compatible,
get_defined_property_type,
@@ -809,6 +810,73 @@ class AgentValidator:
return valid
def validate_smart_decision_maker_blocks(self, agent: AgentDict) -> bool:
"""Validate that SmartDecisionMakerBlock nodes have downstream tools.
Checks that each SmartDecisionMakerBlock node has at least one link
with ``source_name == "tools"`` connecting to a downstream block.
Without tools, the block has nothing to call and will error at runtime.
Returns True if all SmartDecisionMakerBlock nodes are valid.
"""
valid = True
nodes = agent.get("nodes", [])
links = agent.get("links", [])
node_lookup = {node.get("id", ""): node for node in nodes}
non_tool_block_ids = {AGENT_INPUT_BLOCK_ID, AGENT_OUTPUT_BLOCK_ID}
for node in nodes:
if node.get("block_id") != SMART_DECISION_MAKER_BLOCK_ID:
continue
node_id = node.get("id", "unknown")
customized_name = (node.get("metadata") or {}).get(
"customized_name", node_id
)
# Warn if agent_mode_max_iterations is 0 (traditional mode) —
# requires complex external conversation-history loop wiring
# that the agent generator does not produce.
input_default = node.get("input_default", {})
max_iter = input_default.get("agent_mode_max_iterations")
if isinstance(max_iter, int) and max_iter < -1:
self.add_error(
f"SmartDecisionMakerBlock node '{customized_name}' "
f"({node_id}) has invalid "
f"agent_mode_max_iterations={max_iter}. "
f"Use -1 for infinite or a positive number for "
f"bounded iterations."
)
valid = False
elif max_iter == 0:
self.add_error(
f"SmartDecisionMakerBlock node '{customized_name}' "
f"({node_id}) has agent_mode_max_iterations=0 "
f"(traditional mode). The agent generator only supports "
f"agent mode (set to -1 for infinite or a positive "
f"number for bounded iterations)."
)
valid = False
has_tools = any(
link.get("source_id") == node_id
and link.get("source_name") == "tools"
and node_lookup.get(link.get("sink_id", ""), {}).get("block_id")
not in non_tool_block_ids
for link in links
)
if not has_tools:
self.add_error(
f"SmartDecisionMakerBlock node '{customized_name}' "
f"({node_id}) has no downstream tool blocks connected. "
f"Connect at least one block to its 'tools' output so "
f"the AI has tools to call."
)
valid = False
return valid
def validate_mcp_tool_blocks(self, agent: AgentDict) -> bool:
"""Validate that MCPToolBlock nodes have required fields.
@@ -913,6 +981,10 @@ class AgentValidator:
"MCP tool blocks",
self.validate_mcp_tool_blocks(agent),
),
(
"SmartDecisionMaker blocks",
self.validate_smart_decision_maker_blocks(agent),
),
]
# Add AgentExecutorBlock detailed validation if library_agents

View File

@@ -37,7 +37,8 @@ COPILOT_EXCLUDED_BLOCK_TYPES = {
# Specific block IDs excluded from CoPilot (STANDARD type but still require graph context)
COPILOT_EXCLUDED_BLOCK_IDS = {
# SmartDecisionMakerBlock - dynamically discovers downstream blocks via graph topology
# SmartDecisionMakerBlock - dynamically discovers downstream blocks via graph topology;
# usable in agent graphs (guide hardcodes its ID) but cannot run standalone.
"3b191d9f-356f-482d-8238-ba04b6d18381",
}

View File

@@ -0,0 +1,668 @@
"""
Tests for SmartDecisionMakerBlock support in agent generator.
Covers:
- AgentFixer.fix_smart_decision_maker_blocks()
- AgentValidator.validate_smart_decision_maker_blocks()
- End-to-end fix → validate → pipeline for SmartDecisionMaker agents
"""
import uuid
from backend.copilot.tools.agent_generator.fixer import AgentFixer
from backend.copilot.tools.agent_generator.helpers import (
AGENT_EXECUTOR_BLOCK_ID,
AGENT_INPUT_BLOCK_ID,
AGENT_OUTPUT_BLOCK_ID,
SMART_DECISION_MAKER_BLOCK_ID,
)
from backend.copilot.tools.agent_generator.validator import AgentValidator
def _uid() -> str:
return str(uuid.uuid4())
def _make_sdm_node(
node_id: str | None = None,
input_default: dict | None = None,
metadata: dict | None = None,
) -> dict:
"""Create a SmartDecisionMakerBlock node dict."""
return {
"id": node_id or _uid(),
"block_id": SMART_DECISION_MAKER_BLOCK_ID,
"input_default": input_default or {},
"metadata": metadata or {"position": {"x": 0, "y": 0}},
}
def _make_agent_executor_node(
node_id: str | None = None,
graph_id: str | None = None,
) -> dict:
"""Create an AgentExecutorBlock node dict."""
return {
"id": node_id or _uid(),
"block_id": AGENT_EXECUTOR_BLOCK_ID,
"input_default": {
"graph_id": graph_id or _uid(),
"graph_version": 1,
"input_schema": {"properties": {"query": {"type": "string"}}},
"output_schema": {"properties": {"result": {"type": "string"}}},
"user_id": "",
"inputs": {},
},
"metadata": {"position": {"x": 800, "y": 0}},
}
def _make_input_node(node_id: str | None = None, name: str = "task") -> dict:
return {
"id": node_id or _uid(),
"block_id": AGENT_INPUT_BLOCK_ID,
"input_default": {"name": name, "title": name.title()},
"metadata": {"position": {"x": -800, "y": 0}},
}
def _make_output_node(node_id: str | None = None, name: str = "result") -> dict:
return {
"id": node_id or _uid(),
"block_id": AGENT_OUTPUT_BLOCK_ID,
"input_default": {"name": name, "title": name.title()},
"metadata": {"position": {"x": 1600, "y": 0}},
}
def _link(
source_id: str,
source_name: str,
sink_id: str,
sink_name: str,
is_static: bool = False,
) -> dict:
return {
"id": _uid(),
"source_id": source_id,
"source_name": source_name,
"sink_id": sink_id,
"sink_name": sink_name,
"is_static": is_static,
}
def _make_orchestrator_agent() -> dict:
"""Build a complete orchestrator agent with SDM + 2 sub-agent tools."""
input_node = _make_input_node()
sdm_node = _make_sdm_node()
agent_a = _make_agent_executor_node()
agent_b = _make_agent_executor_node()
output_node = _make_output_node()
return {
"id": _uid(),
"version": 1,
"is_active": True,
"name": "Orchestrator Agent",
"description": "Uses AI to orchestrate sub-agents",
"nodes": [input_node, sdm_node, agent_a, agent_b, output_node],
"links": [
# Input → SDM prompt
_link(input_node["id"], "result", sdm_node["id"], "prompt"),
# SDM tools → Agent A
_link(sdm_node["id"], "tools", agent_a["id"], "query"),
# SDM tools → Agent B
_link(sdm_node["id"], "tools", agent_b["id"], "query"),
# SDM finished → Output
_link(sdm_node["id"], "finished", output_node["id"], "value"),
],
}
# ---------------------------------------------------------------------------
# Fixer tests
# ---------------------------------------------------------------------------
class TestFixSmartDecisionMakerBlocks:
"""Tests for AgentFixer.fix_smart_decision_maker_blocks()."""
def test_fills_defaults_when_missing(self):
"""All agent-mode defaults are populated for a bare SDM node."""
fixer = AgentFixer()
agent = {"nodes": [_make_sdm_node()], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10
assert defaults["conversation_compaction"] is True
assert defaults["retry"] == 3
assert defaults["multiple_tool_calls"] is False
assert len(fixer.fixes_applied) == 4
def test_preserves_existing_values(self):
"""Existing user-set values are never overwritten."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(
input_default={
"agent_mode_max_iterations": 5,
"conversation_compaction": False,
"retry": 1,
"multiple_tool_calls": True,
}
)
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 5
assert defaults["conversation_compaction"] is False
assert defaults["retry"] == 1
assert defaults["multiple_tool_calls"] is True
assert len(fixer.fixes_applied) == 0
def test_partial_defaults(self):
"""Only missing fields are filled; existing ones are kept."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(
input_default={
"agent_mode_max_iterations": 10,
}
)
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10 # kept
assert defaults["conversation_compaction"] is True # filled
assert defaults["retry"] == 3 # filled
assert defaults["multiple_tool_calls"] is False # filled
assert len(fixer.fixes_applied) == 3
def test_skips_non_sdm_nodes(self):
"""Non-SmartDecisionMaker nodes are untouched."""
fixer = AgentFixer()
other_node = {
"id": _uid(),
"block_id": AGENT_INPUT_BLOCK_ID,
"input_default": {"name": "test"},
"metadata": {},
}
agent = {"nodes": [other_node], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
assert "agent_mode_max_iterations" not in result["nodes"][0]["input_default"]
assert len(fixer.fixes_applied) == 0
def test_handles_missing_input_default(self):
"""Node with no input_default key gets one created."""
fixer = AgentFixer()
node = {
"id": _uid(),
"block_id": SMART_DECISION_MAKER_BLOCK_ID,
"metadata": {},
}
agent = {"nodes": [node], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
assert "input_default" in result["nodes"][0]
assert result["nodes"][0]["input_default"]["agent_mode_max_iterations"] == 10
def test_handles_none_input_default(self):
"""Node with input_default set to None gets a dict created."""
fixer = AgentFixer()
node = {
"id": _uid(),
"block_id": SMART_DECISION_MAKER_BLOCK_ID,
"input_default": None,
"metadata": {},
}
agent = {"nodes": [node], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
assert isinstance(result["nodes"][0]["input_default"], dict)
assert result["nodes"][0]["input_default"]["agent_mode_max_iterations"] == 10
def test_treats_none_values_as_missing(self):
"""Explicit None values are overwritten with defaults."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(
input_default={
"agent_mode_max_iterations": None,
"conversation_compaction": None,
"retry": 3,
"multiple_tool_calls": False,
}
)
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10 # None → default
assert defaults["conversation_compaction"] is True # None → default
assert defaults["retry"] == 3 # kept
assert defaults["multiple_tool_calls"] is False # kept
assert len(fixer.fixes_applied) == 2
def test_multiple_sdm_nodes(self):
"""Multiple SDM nodes are all fixed independently."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(input_default={"agent_mode_max_iterations": 3}),
_make_sdm_node(input_default={}),
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
# First node: 3 defaults filled (agent_mode was already set)
assert result["nodes"][0]["input_default"]["agent_mode_max_iterations"] == 3
# Second node: all 4 defaults filled
assert result["nodes"][1]["input_default"]["agent_mode_max_iterations"] == 10
assert len(fixer.fixes_applied) == 7 # 3 + 4
def test_registered_in_apply_all_fixes(self):
"""fix_smart_decision_maker_blocks runs as part of apply_all_fixes."""
fixer = AgentFixer()
agent = {
"nodes": [_make_sdm_node()],
"links": [],
}
result = fixer.apply_all_fixes(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10
assert any("SmartDecisionMakerBlock" in fix for fix in fixer.fixes_applied)
# ---------------------------------------------------------------------------
# Validator tests
# ---------------------------------------------------------------------------
class TestValidateSmartDecisionMakerBlocks:
"""Tests for AgentValidator.validate_smart_decision_maker_blocks()."""
def test_valid_sdm_with_tools(self):
"""SDM with downstream tool links passes validation."""
validator = AgentValidator()
agent = _make_orchestrator_agent()
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is True
assert len(validator.errors) == 0
def test_sdm_without_tools_fails(self):
"""SDM with no 'tools' links fails validation."""
validator = AgentValidator()
sdm = _make_sdm_node()
agent = {
"nodes": [sdm],
"links": [], # no tool links
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
assert "no downstream tool blocks" in validator.errors[0]
def test_sdm_with_non_tools_links_fails(self):
"""Links that don't use source_name='tools' don't count."""
validator = AgentValidator()
sdm = _make_sdm_node()
other = _make_agent_executor_node()
agent = {
"nodes": [sdm, other],
"links": [
# Link from 'finished' output, not 'tools'
_link(sdm["id"], "finished", other["id"], "query"),
],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
def test_no_sdm_nodes_passes(self):
"""Agent without SmartDecisionMaker nodes passes trivially."""
validator = AgentValidator()
agent = {
"nodes": [_make_input_node(), _make_output_node()],
"links": [],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is True
assert len(validator.errors) == 0
def test_error_includes_customized_name(self):
"""Error message includes the node's customized_name if set."""
validator = AgentValidator()
sdm = _make_sdm_node(
metadata={
"position": {"x": 0, "y": 0},
"customized_name": "My Orchestrator",
}
)
agent = {"nodes": [sdm], "links": []}
validator.validate_smart_decision_maker_blocks(agent)
assert "My Orchestrator" in validator.errors[0]
def test_multiple_sdm_nodes_mixed(self):
"""One valid and one invalid SDM node: only the invalid one errors."""
validator = AgentValidator()
sdm_valid = _make_sdm_node()
sdm_invalid = _make_sdm_node()
tool = _make_agent_executor_node()
agent = {
"nodes": [sdm_valid, sdm_invalid, tool],
"links": [
_link(sdm_valid["id"], "tools", tool["id"], "query"),
# sdm_invalid has no tool links
],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
assert sdm_invalid["id"] in validator.errors[0]
def test_sdm_with_traditional_mode_fails(self):
"""agent_mode_max_iterations=0 (traditional mode) is rejected."""
validator = AgentValidator()
sdm = _make_sdm_node(input_default={"agent_mode_max_iterations": 0})
tool = _make_agent_executor_node()
agent = {
"nodes": [sdm, tool],
"links": [_link(sdm["id"], "tools", tool["id"], "query")],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert any("agent_mode_max_iterations=0" in e for e in validator.errors)
def test_sdm_with_negative_iterations_below_minus_one_fails(self):
"""agent_mode_max_iterations < -1 is rejected."""
validator = AgentValidator()
sdm = _make_sdm_node(input_default={"agent_mode_max_iterations": -5})
tool = _make_agent_executor_node()
agent = {
"nodes": [sdm, tool],
"links": [_link(sdm["id"], "tools", tool["id"], "query")],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert any("invalid" in e and "-5" in e for e in validator.errors)
def test_sdm_with_only_interface_block_links_fails(self):
"""Links to AgentInput/OutputBlocks don't count as tool connections."""
validator = AgentValidator()
sdm = _make_sdm_node()
input_node = _make_input_node()
output_node = _make_output_node()
agent = {
"nodes": [sdm, input_node, output_node],
"links": [
# These link to interface blocks, not real tools
_link(sdm["id"], "tools", input_node["id"], "name"),
_link(sdm["id"], "tools", output_node["id"], "value"),
],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
assert "no downstream tool blocks" in validator.errors[0]
def test_registered_in_validate(self):
"""validate_smart_decision_maker_blocks runs as part of validate()."""
validator = AgentValidator()
sdm = _make_sdm_node()
agent = {
"id": _uid(),
"version": 1,
"is_active": True,
"name": "Test",
"description": "test",
"nodes": [sdm, _make_input_node(), _make_output_node()],
"links": [],
}
# Build a minimal blocks list with the SDM block info
blocks = [
{
"id": SMART_DECISION_MAKER_BLOCK_ID,
"name": "SmartDecisionMakerBlock",
"inputSchema": {"properties": {"prompt": {"type": "string"}}},
"outputSchema": {
"properties": {
"tools": {},
"finished": {"type": "string"},
"conversations": {"type": "array"},
}
},
},
{
"id": AGENT_INPUT_BLOCK_ID,
"name": "AgentInputBlock",
"inputSchema": {
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
"outputSchema": {"properties": {"result": {}}},
},
{
"id": AGENT_OUTPUT_BLOCK_ID,
"name": "AgentOutputBlock",
"inputSchema": {
"properties": {
"name": {"type": "string"},
"value": {},
},
"required": ["name"],
},
"outputSchema": {"properties": {"output": {}}},
},
]
is_valid, error_msg = validator.validate(agent, blocks)
assert is_valid is False
assert error_msg is not None
assert "no downstream tool blocks" in error_msg
# ---------------------------------------------------------------------------
# E2E pipeline test: fix → validate for a complete orchestrator agent
# ---------------------------------------------------------------------------
class TestSmartDecisionMakerE2EPipeline:
"""End-to-end tests: build agent JSON → fix → validate."""
def test_orchestrator_agent_fix_then_validate(self):
"""A well-formed orchestrator agent passes fix + validate."""
agent = _make_orchestrator_agent()
# Fix
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
# Verify defaults were applied
sdm_nodes = [
n for n in fixed["nodes"] if n["block_id"] == SMART_DECISION_MAKER_BLOCK_ID
]
assert len(sdm_nodes) == 1
assert sdm_nodes[0]["input_default"]["agent_mode_max_iterations"] == 10
assert sdm_nodes[0]["input_default"]["conversation_compaction"] is True
# Validate (standalone SDM check)
validator = AgentValidator()
assert validator.validate_smart_decision_maker_blocks(fixed) is True
def test_bare_sdm_no_tools_fix_then_validate(self):
"""SDM without tools: fixer fills defaults, validator catches error."""
input_node = _make_input_node()
sdm_node = _make_sdm_node()
output_node = _make_output_node()
agent = {
"id": _uid(),
"version": 1,
"is_active": True,
"name": "Bare SDM Agent",
"description": "SDM with no tools",
"nodes": [input_node, sdm_node, output_node],
"links": [
_link(input_node["id"], "result", sdm_node["id"], "prompt"),
_link(sdm_node["id"], "finished", output_node["id"], "value"),
],
}
# Fix fills defaults fine
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
assert fixed["nodes"][1]["input_default"]["agent_mode_max_iterations"] == 10
# Validate catches missing tools
validator = AgentValidator()
assert validator.validate_smart_decision_maker_blocks(fixed) is False
assert any("no downstream tool blocks" in e for e in validator.errors)
def test_sdm_with_user_set_bounded_iterations(self):
"""User-set bounded iterations are preserved through fix pipeline."""
agent = _make_orchestrator_agent()
# Simulate user setting bounded iterations
for node in agent["nodes"]:
if node["block_id"] == SMART_DECISION_MAKER_BLOCK_ID:
node["input_default"]["agent_mode_max_iterations"] = 5
node["input_default"]["sys_prompt"] = "You are a helpful orchestrator"
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
sdm = next(
n for n in fixed["nodes"] if n["block_id"] == SMART_DECISION_MAKER_BLOCK_ID
)
assert sdm["input_default"]["agent_mode_max_iterations"] == 5
assert sdm["input_default"]["sys_prompt"] == "You are a helpful orchestrator"
# Other defaults still filled
assert sdm["input_default"]["conversation_compaction"] is True
assert sdm["input_default"]["retry"] == 3
def test_full_pipeline_with_blocks_list(self):
"""Full validate() with blocks list for a valid orchestrator agent."""
agent = _make_orchestrator_agent()
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
blocks = [
{
"id": SMART_DECISION_MAKER_BLOCK_ID,
"name": "SmartDecisionMakerBlock",
"inputSchema": {
"properties": {
"prompt": {"type": "string"},
"model": {"type": "object"},
"sys_prompt": {"type": "string"},
"agent_mode_max_iterations": {"type": "integer"},
"conversation_compaction": {"type": "boolean"},
"retry": {"type": "integer"},
"multiple_tool_calls": {"type": "boolean"},
},
"required": ["prompt"],
},
"outputSchema": {
"properties": {
"tools": {},
"finished": {"type": "string"},
"conversations": {"type": "array"},
}
},
},
{
"id": AGENT_EXECUTOR_BLOCK_ID,
"name": "AgentExecutorBlock",
"inputSchema": {
"properties": {
"graph_id": {"type": "string"},
"graph_version": {"type": "integer"},
"input_schema": {"type": "object"},
"output_schema": {"type": "object"},
"user_id": {"type": "string"},
"inputs": {"type": "object"},
"query": {"type": "string"},
},
"required": ["graph_id"],
},
"outputSchema": {
"properties": {"result": {"type": "string"}},
},
},
{
"id": AGENT_INPUT_BLOCK_ID,
"name": "AgentInputBlock",
"inputSchema": {
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
"outputSchema": {"properties": {"result": {}}},
},
{
"id": AGENT_OUTPUT_BLOCK_ID,
"name": "AgentOutputBlock",
"inputSchema": {
"properties": {
"name": {"type": "string"},
"value": {},
},
"required": ["name"],
},
"outputSchema": {"properties": {"output": {}}},
},
]
validator = AgentValidator()
is_valid, error_msg = validator.validate(fixed, blocks)
# Full graph validation should pass
assert is_valid, f"Validation failed: {error_msg}"
# SDM-specific validation should pass (has tool links)
sdm_errors = [e for e in validator.errors if "SmartDecisionMakerBlock" in e]
assert len(sdm_errors) == 0, f"Unexpected SDM errors: {sdm_errors}"