Skip to content

Commit 9b962d1

Browse files
akaclaude
andcommitted
fix(orchestrator): auto-reply for terminal agent nodes without reply_chat
When an agent node (agent/deep_agent) is terminal (no downstream executable nodes) and the workflow has no explicit reply_chat node, promote its output to state["output"] so _extract_output() → deliver() sends the response back via the gateway. This fixes the E2E chat round-trip for the default-agent workflow, which has no reply_chat node but should still reply to the user. Constraint: Only agent-type nodes trigger auto-reply — other terminal nodes (switch, code, etc.) do not produce user-facing responses Rejected: Add reply_chat to every workflow template | couples template design to delivery mechanism Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 657ee9b commit 9b962d1

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

platform/services/orchestrator.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,46 @@ def _resume_from_child(
10241024

10251025
# ── Advance / finalize ─────────────────────────────────────────────────────────
10261026

1027+
_AGENT_COMPONENT_TYPES = {"agent", "deep_agent"}
1028+
1029+
1030+
def _auto_promote_agent_output(
1031+
completed_node_id: str,
1032+
state: dict,
1033+
topo_data: dict,
1034+
execution_id: str,
1035+
) -> None:
1036+
"""Promote a terminal agent node's output to state["output"].
1037+
1038+
When an agent node is terminal (no downstream executable nodes) and the
1039+
workflow has no explicit ``reply_chat`` node, the agent's output is the
1040+
intended chat reply. Setting ``state["output"]`` ensures
1041+
``_extract_output()`` finds it and ``deliver()`` sends it back.
1042+
"""
1043+
if state.get("output") is not None:
1044+
return # already set by another node
1045+
1046+
node_info = topo_data["nodes"].get(completed_node_id)
1047+
if not node_info or node_info.get("component_type") not in _AGENT_COMPONENT_TYPES:
1048+
return
1049+
1050+
has_reply_chat = any(
1051+
n.get("component_type") == "reply_chat"
1052+
for n in topo_data["nodes"].values()
1053+
)
1054+
if has_reply_chat:
1055+
return
1056+
1057+
node_output = state.get("node_outputs", {}).get(completed_node_id, {})
1058+
output_text = node_output.get("output")
1059+
if output_text:
1060+
state["output"] = output_text
1061+
save_state(execution_id, state)
1062+
logger.info(
1063+
"Auto-promoted terminal agent %s output for execution %s",
1064+
completed_node_id, execution_id,
1065+
)
1066+
10271067

10281068
def _advance(
10291069
execution_id: str,
@@ -1043,6 +1083,12 @@ def _advance(
10431083
if _check_loop_body_done(execution_id, completed_node_id, topo_data, db, delay_seconds=delay_seconds):
10441084
r.decr(_inflight_key(execution_id))
10451085
return
1086+
1087+
# Auto-reply: if a terminal agent node completes and the workflow
1088+
# has no explicit reply_chat node, promote its output to
1089+
# state["output"] so _finalize → deliver() sends the reply.
1090+
_auto_promote_agent_output(completed_node_id, state, topo_data, execution_id)
1091+
10461092
remaining = r.decr(_inflight_key(execution_id))
10471093
if remaining <= 0:
10481094
_finalize(execution_id, db)

platform/tests/test_orchestrator.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,123 @@ def test_advance_no_successors_tries_finalize(self):
373373
_advance("exec-1", "a", state, topo_data, MagicMock())
374374
mock_fin.assert_called_once()
375375

376+
def test_advance_terminal_agent_auto_promotes_output(self):
377+
"""Terminal agent node with no reply_chat promotes output to state."""
378+
from services.orchestrator import _advance
379+
380+
topo_data = {
381+
"nodes": {"deep_agent_1": {"component_type": "deep_agent"}},
382+
"edges_by_source": {},
383+
"incoming_count": {"deep_agent_1": 0},
384+
}
385+
state = {
386+
"node_outputs": {"deep_agent_1": {"output": "Hello from agent"}},
387+
}
388+
389+
with patch("services.orchestrator._finalize") as mock_fin, \
390+
patch("services.orchestrator._redis") as mock_r, \
391+
patch("services.orchestrator.save_state") as mock_save, \
392+
patch("services.orchestrator._publish_event"):
393+
mock_redis = MagicMock()
394+
mock_r.return_value = mock_redis
395+
mock_redis.decr.return_value = 0
396+
397+
_advance("exec-1", "deep_agent_1", state, topo_data, MagicMock())
398+
399+
assert state["output"] == "Hello from agent"
400+
mock_save.assert_called_once_with("exec-1", state)
401+
mock_fin.assert_called_once()
402+
403+
def test_advance_terminal_agent_skips_when_reply_chat_exists(self):
404+
"""Terminal agent does NOT auto-promote if reply_chat node exists."""
405+
from services.orchestrator import _advance
406+
407+
topo_data = {
408+
"nodes": {
409+
"agent_1": {"component_type": "agent"},
410+
"reply_chat_1": {"component_type": "reply_chat"},
411+
},
412+
"edges_by_source": {},
413+
"incoming_count": {"agent_1": 0, "reply_chat_1": 1},
414+
}
415+
state = {
416+
"node_outputs": {"agent_1": {"output": "Hello"}},
417+
}
418+
419+
with patch("services.orchestrator._finalize") as mock_fin, \
420+
patch("services.orchestrator._redis") as mock_r, \
421+
patch("services.orchestrator.save_state") as mock_save, \
422+
patch("services.orchestrator._publish_event"):
423+
mock_redis = MagicMock()
424+
mock_r.return_value = mock_redis
425+
mock_redis.decr.return_value = 0
426+
427+
_advance("exec-1", "agent_1", state, topo_data, MagicMock())
428+
429+
assert "output" not in state
430+
mock_save.assert_not_called()
431+
432+
def test_advance_terminal_non_agent_does_not_promote(self):
433+
"""Terminal non-agent node (e.g. switch) does NOT auto-promote."""
434+
from services.orchestrator import _advance
435+
436+
topo_data = {
437+
"nodes": {"switch_1": {"component_type": "switch"}},
438+
"edges_by_source": {},
439+
"incoming_count": {"switch_1": 0},
440+
}
441+
state = {
442+
"node_outputs": {"switch_1": {"route": "category_a"}},
443+
}
444+
445+
with patch("services.orchestrator._finalize") as mock_fin, \
446+
patch("services.orchestrator._redis") as mock_r, \
447+
patch("services.orchestrator.save_state") as mock_save, \
448+
patch("services.orchestrator._publish_event"):
449+
mock_redis = MagicMock()
450+
mock_r.return_value = mock_redis
451+
mock_redis.decr.return_value = 0
452+
453+
_advance("exec-1", "switch_1", state, topo_data, MagicMock())
454+
455+
assert "output" not in state
456+
mock_save.assert_not_called()
457+
458+
def test_advance_non_terminal_agent_does_not_promote(self):
459+
"""Agent with downstream edges does NOT auto-promote."""
460+
from services.orchestrator import _advance
461+
462+
topo_data = {
463+
"nodes": {
464+
"agent_1": {"component_type": "agent"},
465+
"switch_1": {"component_type": "switch"},
466+
},
467+
"edges_by_source": {
468+
"agent_1": [{"source_node_id": "agent_1", "target_node_id": "switch_1", "edge_type": "direct"}],
469+
},
470+
"incoming_count": {"agent_1": 0, "switch_1": 1},
471+
}
472+
state = {
473+
"node_outputs": {"agent_1": {"output": "Hello"}},
474+
}
475+
476+
with patch("services.orchestrator._queue") as mock_q, \
477+
patch("services.orchestrator._redis") as mock_r, \
478+
patch("services.orchestrator.save_state") as mock_save, \
479+
patch("services.orchestrator._publish_event"):
480+
mock_queue = MagicMock()
481+
mock_q.return_value = mock_queue
482+
mock_redis = MagicMock()
483+
mock_r.return_value = mock_redis
484+
mock_redis.decr.return_value = 0
485+
486+
_advance("exec-1", "agent_1", state, topo_data, MagicMock())
487+
488+
assert "output" not in state
489+
mock_save.assert_not_called()
490+
# Should enqueue the switch instead
491+
mock_queue.enqueue.assert_called_once()
492+
376493

377494
# ── Switch component tests ────────────────────────────────────────────────────
378495

0 commit comments

Comments
 (0)