mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-03-17 03:00:27 -04:00
Compare commits
9 Commits
feat/githu
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b071cd42f5 | ||
|
|
210a69d33e | ||
|
|
2ddddc0257 | ||
|
|
59f05ed23c | ||
|
|
09fd30e14f | ||
|
|
ef6118c640 | ||
|
|
5d527eab85 | ||
|
|
f0af149f16 | ||
|
|
5ad71099ac |
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user