diff --git a/sdk/python/e2e/test_suite14_stateful_domain.py b/sdk/python/e2e/test_suite14_stateful_domain.py index 85f84830d..e85aa8785 100644 --- a/sdk/python/e2e/test_suite14_stateful_domain.py +++ b/sdk/python/e2e/test_suite14_stateful_domain.py @@ -11,7 +11,7 @@ - Stateful swarm handoff + check_transfer execute in domain - Pipeline sub-agent tools inherit parent's domain - Concurrent stateful executions are isolated (different domains) - - Agent without stateful flag works without domain + - Non-stateful agents work without domain (regression guard) Validation: all assertions inspect the workflow execution via server API. No mocks, no LLM output parsing, fully deterministic. @@ -512,12 +512,13 @@ def _make_agent(suffix): f"{[(t['taskDefName'], t.get('pollCount')) for t in scheduled]}" ) - # ── Test 6: Agent without stateful flag has no domain ────────── + # ── Test 6: Non-stateful has no domain (regression) ──────────── def test_non_stateful_no_domain(self, fresh_runtime, model): - """Agent without stateful=True works without domain assignment. + """Non-stateful agent works without domain assignment. Validates: taskToDomain is empty, tasks have no domain, execution completes. + This is a regression guard — the domain fix must not break non-stateful agents. """ agent = Agent( name="e2e_s14_non_stateful", diff --git a/sdk/python/src/agentspan/agents/runtime/runtime.py b/sdk/python/src/agentspan/agents/runtime/runtime.py index f419359b9..778ab618a 100644 --- a/sdk/python/src/agentspan/agents/runtime/runtime.py +++ b/sdk/python/src/agentspan/agents/runtime/runtime.py @@ -417,6 +417,20 @@ def _clear_workflow_credentials( with _workflow_credentials_lock: _workflow_credentials.pop(execution_id, None) + def _resolve_worker_domain(self, execution_id: str, run_id: Optional[str]) -> Optional[str]: + """Return the domain workers should poll for this execution. + + A fresh stateful start uses ``run_id`` as the task domain. If the + server returns an existing execution for an idempotency key, that + execution already has its original ``taskToDomain`` mapping, so the + freshly generated ``run_id`` would be wrong. Prefer the server's + recorded domain and fall back to the generated one for brand-new runs + or older servers. + """ + if not run_id: + return None + return self._extract_domain(execution_id) or run_id + def _pre_deploy_nested_skills(self, agent: Agent) -> list: """Pre-deploy any skill agents nested inside agent_tool wrappers. @@ -716,10 +730,12 @@ def _prepare(self, agent: Agent) -> Any: logger.debug("Starting workers for agent '%s'", agent.name) self._worker_manager.start() self._workers_started = True - elif new_workers: - # Inject new workers into the running TaskHandler without - # stopping existing ones. This avoids the fork() deadlock - # window caused by a full stop/restart cycle. + else: + # New stateful runs can register the same task names under + # a different domain. WorkerManager is domain-aware and + # starts only missing (task_name, domain) pairs, so call it + # even when the task-name set has not changed — this avoids + # the fork() deadlock window of a full stop/restart cycle. self._worker_manager.start() return wf @@ -832,10 +848,12 @@ def _prepare_workers( logger.debug("Starting workers for agent '%s'", agent.name) self._worker_manager.start() self._workers_started = True - elif new_workers: - # Inject new workers into the running TaskHandler without - # stopping existing ones. This avoids the fork() deadlock - # window caused by a full stop/restart cycle. + else: + # New stateful runs can register the same task names under + # a different domain. WorkerManager is domain-aware and + # starts only missing (task_name, domain) pairs, so call it + # even when the task-name set has not changed — this avoids + # the fork() deadlock window of a full stop/restart cycle. self._worker_manager.start() def _collect_worker_names( @@ -942,8 +960,9 @@ def _collect_worker_names( ): names.add(f"{agent.name}_router_fn") - # Handoff check (swarm with handoff conditions) - if agent.handoffs: + # Handoff check — needed for any SWARM parent (server always generates + # the task) or any agent with explicit handoff conditions. + if agent.handoffs or (agent.strategy == "swarm" and agent.agents): names.add(f"{agent.name}_handoff_check") # Swarm transfer workers — prefixed with SOURCE agent name @@ -1127,8 +1146,9 @@ def _server_needs(task_name: str) -> bool: if _server_needs(task_name): self._register_router_worker(agent, domain=domain) - # 7. Handoff check (swarm with handoff conditions) - if agent.handoffs: + # 7. Handoff check — needed for any SWARM parent (server always + # generates the task) or any agent with explicit handoff conditions. + if agent.handoffs or (agent.strategy == "swarm" and agent.agents): task_name = f"{agent.name}_handoff_check" if _server_needs(task_name): self._register_handoff_worker(agent, domain=domain) @@ -2545,8 +2565,10 @@ def run( run_id=run_id, ) - self._prepare_workers(agent, required_workers=required_workers, domain=run_id) - self._register_and_start_skill_workers(pre_deployed_skills, domain=run_id) + worker_domain = self._resolve_worker_domain(execution_id, run_id) + + self._prepare_workers(agent, required_workers=required_workers, domain=worker_domain) + self._register_and_start_skill_workers(pre_deployed_skills, domain=worker_domain) self._register_workflow_credentials(execution_id, credentials) @@ -3675,8 +3697,10 @@ def start( run_id=run_id, ) - self._prepare_workers(agent, required_workers=required_workers, domain=run_id) - self._register_and_start_skill_workers(pre_deployed_skills, domain=run_id) + worker_domain = self._resolve_worker_domain(execution_id, run_id) + + self._prepare_workers(agent, required_workers=required_workers, domain=worker_domain) + self._register_and_start_skill_workers(pre_deployed_skills, domain=worker_domain) return AgentHandle( execution_id=execution_id, runtime=self, correlation_id=correlation_id, run_id=run_id @@ -4070,8 +4094,10 @@ async def run_async( run_id=run_id, ) - self._prepare_workers(agent, required_workers=required_workers, domain=run_id) - self._register_and_start_skill_workers(pre_deployed_skills, domain=run_id) + worker_domain = self._resolve_worker_domain(execution_id, run_id) + + self._prepare_workers(agent, required_workers=required_workers, domain=worker_domain) + self._register_and_start_skill_workers(pre_deployed_skills, domain=worker_domain) self._register_workflow_credentials(execution_id, credentials) effective_timeout = timeout or ( @@ -4206,8 +4232,10 @@ async def start_async( run_id=run_id, ) - self._prepare_workers(agent, required_workers=required_workers, domain=run_id) - self._register_and_start_skill_workers(pre_deployed_skills, domain=run_id) + worker_domain = self._resolve_worker_domain(execution_id, run_id) + + self._prepare_workers(agent, required_workers=required_workers, domain=worker_domain) + self._register_and_start_skill_workers(pre_deployed_skills, domain=worker_domain) return AgentHandle( execution_id=execution_id, runtime=self, correlation_id=correlation_id, run_id=run_id diff --git a/sdk/python/tests/unit/test_runtime.py b/sdk/python/tests/unit/test_runtime.py index fedbeaf3a..cb418b134 100644 --- a/sdk/python/tests/unit/test_runtime.py +++ b/sdk/python/tests/unit/test_runtime.py @@ -8,6 +8,7 @@ """ import logging +import threading import uuid from unittest.mock import AsyncMock, MagicMock, patch @@ -713,6 +714,74 @@ def test_sub_agent_with_string_tools_does_not_raise(self): assert _has_stateful_tools(parent) is False +class TestStatefulWorkerDomains: + """Stateful workers must use the execution's real task domain.""" + + def test_resolve_worker_domain_prefers_server_domain(self): + from agentspan.agents.runtime.runtime import AgentRuntime + + rt = AgentRuntime.__new__(AgentRuntime) + rt._extract_domain = lambda execution_id: "original-domain" + + assert rt._resolve_worker_domain("wf-1", "fresh-domain") == "original-domain" + + def test_resolve_worker_domain_falls_back_to_generated_run_id(self): + from agentspan.agents.runtime.runtime import AgentRuntime + + rt = AgentRuntime.__new__(AgentRuntime) + rt._extract_domain = lambda execution_id: None + + assert rt._resolve_worker_domain("wf-1", "fresh-domain") == "fresh-domain" + + def test_resolve_worker_domain_returns_none_for_stateless_execution(self): + from agentspan.agents.runtime.runtime import AgentRuntime + + rt = AgentRuntime.__new__(AgentRuntime) + rt._extract_domain = lambda execution_id: "should-not-be-used" + + assert rt._resolve_worker_domain("wf-1", None) is None + + def test_prepare_workers_starts_worker_manager_when_only_domain_changes(self): + """Same task name under a new domain still needs a new polling process.""" + from types import SimpleNamespace + + from agentspan.agents.runtime.runtime import AgentRuntime + + class FakeWorkerManager: + def __init__(self): + self.starts = 0 + + def start(self): + self.starts += 1 + + rt = AgentRuntime.__new__(AgentRuntime) + rt._config = SimpleNamespace( + auto_register_integrations=False, + auto_start_workers=True, + ) + rt._worker_start_lock = threading.Lock() + rt._registered_tool_names = {"write_architecture"} + rt._workers_started = True + rt._worker_manager = FakeWorkerManager() + rt._associate_templates_with_models = lambda agent: None + registered_domains = [] + rt._register_workers = lambda agent, required_workers=None, domain=None: ( + registered_domains.append(domain) + ) + rt._has_worker_tools = lambda agent: True + rt._collect_worker_names = lambda agent, required_workers=None: {"write_architecture"} + + agent = Agent(name="pipeline", model="openai/gpt-4o") + rt._prepare_workers( + agent, + required_workers={"write_architecture"}, + domain="original-domain", + ) + + assert registered_domains == ["original-domain"] + assert rt._worker_manager.starts == 1 + + # ── _extract_token_usage ──────────────────────────────────────────────── diff --git a/sdk/python/tests/unit/test_swarm_handoff_check.py b/sdk/python/tests/unit/test_swarm_handoff_check.py new file mode 100644 index 000000000..bf0780e34 --- /dev/null +++ b/sdk/python/tests/unit/test_swarm_handoff_check.py @@ -0,0 +1,688 @@ +"""Tests for SWARM handoff_check worker registration and routing. + +The server ALWAYS generates a {parent}_handoff_check task for SWARM workflows. +The SDK must register the corresponding worker regardless of whether the parent +agent has explicit handoff conditions. The worker handles two mechanisms: + + 1. Transfer tools (primary): LLM calls transfer_to_ → is_transfer=true + 2. Condition-based (fallback): OnTextMention / OnCondition on the parent + +Bug (pre-fix): SDK only registered handoff_check when agent.handoffs was +non-empty. A SWARM parent with handoffs on children only (e.g. coder_qa_loop +with OnTextMention on coder and qa_agent) never got the worker registered. +The task sat SCHEDULED with pollCount=0 forever. + +This affects BOTH stateful and non-stateful agents: + - Stateful: task routed to UUID domain, no worker in that domain + - Non-stateful: task in default domain, no worker in default domain + +These tests are fully deterministic — no LLM, no server, no mocks of +external services. They exercise the exact registration logic and worker +routing logic from runtime.py. +""" + +from unittest.mock import patch + +import pytest + +from agentspan.agents import Agent, Strategy +from agentspan.agents.handoff import OnTextMention +from agentspan.agents.runtime.runtime import AgentRuntime + + +def _collect_names(agent: Agent) -> set: + """Call _collect_worker_names without a real server connection.""" + rt = AgentRuntime.__new__(AgentRuntime) + return rt._collect_worker_names(agent) + + +# --------------------------------------------------------------------------- +# Fixtures: reusable agent topologies +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def child_a(): + return Agent(name="child_a", model="openai/gpt-4o") + + +@pytest.fixture() +def child_b(): + return Agent(name="child_b", model="openai/gpt-4o") + + +@pytest.fixture() +def coder(): + return Agent( + name="coder", + model="openai/gpt-4o", + handoffs=[OnTextMention(text="HANDOFF_TO_QA", target="qa_agent")], + ) + + +@pytest.fixture() +def qa_agent(): + return Agent( + name="qa_agent", + model="openai/gpt-4o", + handoffs=[OnTextMention(text="HANDOFF_TO_CODER", target="coder")], + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Worker name collection — does _collect_worker_names include +# handoff_check for all SWARM configurations? +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestSwarmHandoffCheckRegistration: + """Verify handoff_check is collected for every SWARM variant.""" + + def test_swarm_parent_no_handoffs_gets_handoff_check(self, child_a, child_b): + """THE BUG: SWARM parent with no handoffs must still get handoff_check. + + The server always generates the task. Transfer tools are the primary + mechanism — they don't require condition-based handoffs. + """ + swarm = Agent( + name="my_swarm", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=Strategy.SWARM, + # No handoffs on parent! + ) + names = _collect_names(swarm) + assert "my_swarm_handoff_check" in names + + def test_swarm_parent_with_handoffs_gets_handoff_check(self, child_a, child_b): + """Existing behavior: parent with explicit handoffs gets handoff_check.""" + swarm = Agent( + name="my_swarm", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=Strategy.SWARM, + handoffs=[OnTextMention(text="GO_TO_B", target="child_b")], + ) + names = _collect_names(swarm) + assert "my_swarm_handoff_check" in names + + def test_issue_fixer_pattern_handoffs_on_children_only(self, coder, qa_agent): + """Exact pattern from issue fixer: handoffs on children, not parent. + + coder has OnTextMention("HANDOFF_TO_QA" → qa_agent) + qa_agent has OnTextMention("HANDOFF_TO_CODER" → coder) + Parent (coder_qa_loop) has NO handoffs — just SWARM + stop_when. + """ + loop = Agent( + name="coder_qa_loop", + model="openai/gpt-4o", + agents=[coder, qa_agent], + strategy=Strategy.SWARM, + stop_when=lambda ctx, **kw: "QA_APPROVED" in ctx.get("result", ""), + max_turns=90, + ) + names = _collect_names(loop) + assert "coder_qa_loop_handoff_check" in names + # Also verify stop_when and transfer tools are collected + assert "coder_qa_loop_stop_when" in names + assert "coder_transfer_to_qa_agent" in names + assert "qa_agent_transfer_to_coder" in names + + def test_swarm_with_stop_when_and_no_handoffs(self, child_a, child_b): + """SWARM + stop_when but no handoffs — both workers must be collected.""" + swarm = Agent( + name="loop", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=Strategy.SWARM, + stop_when=lambda ctx, **kw: "DONE" in ctx.get("result", ""), + ) + names = _collect_names(swarm) + assert "loop_handoff_check" in names + assert "loop_stop_when" in names + + def test_swarm_three_agents_no_handoffs(self): + """SWARM with 3 children, no handoffs — all transfer tools + handoff_check.""" + a = Agent(name="agent_a", model="openai/gpt-4o") + b = Agent(name="agent_b", model="openai/gpt-4o") + c = Agent(name="agent_c", model="openai/gpt-4o") + swarm = Agent( + name="trio", + model="openai/gpt-4o", + agents=[a, b, c], + strategy=Strategy.SWARM, + ) + names = _collect_names(swarm) + assert "trio_handoff_check" in names + # Each agent gets transfer tools to every peer (including parent) + # 4 agents × 3 peers = 12 transfer tools + transfer_names = {n for n in names if "_transfer_to_" in n} + assert len(transfer_names) == 12 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Negative tests — handoff_check must NOT be collected for non-SWARM +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestNonSwarmNoHandoffCheck: + """Non-SWARM strategies must NOT get handoff_check (unless explicit handoffs).""" + + @pytest.mark.parametrize( + "strategy", + [ + Strategy.SEQUENTIAL, + Strategy.PARALLEL, + Strategy.ROUND_ROBIN, + Strategy.RANDOM, + Strategy.MANUAL, + ], + ) + def test_non_swarm_strategies_no_handoff_check(self, strategy, child_a, child_b): + """Only SWARM generates handoff_check tasks on the server.""" + extra = {} + if strategy == Strategy.MANUAL: + extra = {} # manual doesn't need special config for name collection + parent = Agent( + name="parent", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=strategy, + **extra, + ) + names = _collect_names(parent) + assert "parent_handoff_check" not in names + + def test_single_agent_no_handoff_check(self): + """A leaf agent (no sub-agents) never gets handoff_check.""" + agent = Agent(name="solo", model="openai/gpt-4o") + names = _collect_names(agent) + assert "solo_handoff_check" not in names + + def test_handoff_strategy_without_explicit_handoffs(self, child_a, child_b): + """HANDOFF strategy without handoffs list — no handoff_check.""" + parent = Agent( + name="parent", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=Strategy.HANDOFF, + ) + names = _collect_names(parent) + assert "parent_handoff_check" not in names + + def test_non_swarm_with_explicit_handoffs_gets_handoff_check(self, child_a, child_b): + """Any strategy with explicit handoffs DOES get handoff_check.""" + parent = Agent( + name="parent", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=Strategy.HANDOFF, + handoffs=[OnTextMention(text="GO_B", target="child_b")], + ) + names = _collect_names(parent) + assert "parent_handoff_check" in names + + +# ═══════════════════════════════════════════════════════════════════════════ +# 3. Handoff worker routing logic — verify the worker function handles +# all cases correctly (transfer-only, condition-only, mixed, empty) +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestHandoffWorkerRouting: + """Exercise the handoff_check_worker logic directly. + + Recreates the exact logic from _register_handoff_worker without + needing a server connection. This tests the algorithm, not the + registration plumbing. + """ + + @staticmethod + def _make_handoff_fn(parent_name, sub_names, handoff_conditions=None, allowed=None): + """Build the handoff check function identical to _register_handoff_worker.""" + conditions = handoff_conditions or [] + name_to_idx = {parent_name: "0"} + name_to_idx.update({name: str(i + 1) for i, name in enumerate(sub_names)}) + idx_to_name = {v: k for k, v in name_to_idx.items()} + + def _is_transfer_truthy(val): + if val is True: + return True + if isinstance(val, str): + return val.strip().lower() == "true" + return False + + def _is_allowed(source_idx, target_name): + if not allowed: + return True + source_name = idx_to_name.get(source_idx, "") + return target_name in allowed.get(source_name, []) + + def check(result="", active_agent="0", is_transfer=False, transfer_to=""): + if _is_transfer_truthy(is_transfer): + if _is_allowed(active_agent, transfer_to): + target_idx = name_to_idx.get(transfer_to, active_agent) + if target_idx != active_agent: + return {"active_agent": target_idx, "handoff": True} + + context = {"result": result, "messages": "", "tool_name": "", "tool_result": ""} + for cond in conditions: + if cond.should_handoff(context): + if _is_allowed(active_agent, cond.target): + target_idx = name_to_idx.get(cond.target, active_agent) + if target_idx != active_agent: + return {"active_agent": target_idx, "handoff": True} + + return {"active_agent": active_agent, "handoff": False} + + return check + + def test_transfer_only_no_conditions(self): + """SWARM with no handoff conditions — transfer tools are the only mechanism. + + This is the exact scenario from the issue fixer agent bug. + """ + check = self._make_handoff_fn("loop", ["coder", "qa_agent"]) + + # coder (1) transfers to qa_agent (2) via transfer tool + r = check(active_agent="1", is_transfer=True, transfer_to="qa_agent") + assert r == {"active_agent": "2", "handoff": True} + + # qa_agent (2) transfers back to coder (1) + r = check(active_agent="2", is_transfer=True, transfer_to="coder") + assert r == {"active_agent": "1", "handoff": True} + + # No transfer, no conditions → loop exits + r = check(active_agent="1", is_transfer=False) + assert r == {"active_agent": "1", "handoff": False} + + def test_condition_only_no_transfer(self): + """Handoff conditions fire when transfer tools aren't used.""" + conditions = [ + OnTextMention(text="GO_TO_B", target="agent_b"), + OnTextMention(text="GO_TO_A", target="agent_a"), + ] + check = self._make_handoff_fn("parent", ["agent_a", "agent_b"], conditions) + + # Text mention triggers handoff to agent_b + r = check(result="Please GO_TO_B now", active_agent="1") + assert r == {"active_agent": "2", "handoff": True} + + # Text mention triggers handoff to agent_a + r = check(result="GO_TO_A please", active_agent="2") + assert r == {"active_agent": "1", "handoff": True} + + # No matching text → loop exits + r = check(result="Nothing relevant here", active_agent="1") + assert r == {"active_agent": "1", "handoff": False} + + def test_transfer_takes_priority_over_conditions(self): + """Transfer tool fires even when condition text is also present.""" + conditions = [OnTextMention(text="HANDOFF", target="agent_b")] + check = self._make_handoff_fn("parent", ["agent_a", "agent_b"], conditions) + + # Transfer to agent_a even though text says HANDOFF (which targets agent_b) + r = check( + result="HANDOFF to someone", + active_agent="0", + is_transfer=True, + transfer_to="agent_a", + ) + assert r == {"active_agent": "1", "handoff": True} + + def test_transfer_to_unknown_agent_stays_put(self): + """Transfer to a non-existent agent keeps current agent active.""" + check = self._make_handoff_fn("parent", ["agent_a", "agent_b"]) + + r = check(active_agent="1", is_transfer=True, transfer_to="nonexistent") + assert r == {"active_agent": "1", "handoff": False} + + def test_transfer_to_self_no_handoff(self): + """Transfer to the same agent doesn't count as handoff.""" + check = self._make_handoff_fn("parent", ["agent_a", "agent_b"]) + + r = check(active_agent="1", is_transfer=True, transfer_to="agent_a") + assert r == {"active_agent": "1", "handoff": False} + + def test_is_transfer_string_truthy(self): + """is_transfer can be string 'true' (server serialization).""" + check = self._make_handoff_fn("parent", ["agent_a", "agent_b"]) + + r = check(active_agent="1", is_transfer="true", transfer_to="agent_b") + assert r == {"active_agent": "2", "handoff": True} + + r = check(active_agent="1", is_transfer="True", transfer_to="agent_b") + assert r == {"active_agent": "2", "handoff": True} + + r = check(active_agent="1", is_transfer="false", transfer_to="agent_b") + assert r == {"active_agent": "1", "handoff": False} + + def test_allowed_transitions_block_disallowed(self): + """allowed_transitions restricts which transfers are valid.""" + allowed = { + "parent": ["agent_a"], + "agent_a": ["agent_b"], + "agent_b": ["agent_a"], # agent_b cannot go to parent + } + check = self._make_handoff_fn("parent", ["agent_a", "agent_b"], allowed=allowed) + + # Allowed: agent_a → agent_b + r = check(active_agent="1", is_transfer=True, transfer_to="agent_b") + assert r == {"active_agent": "2", "handoff": True} + + # Blocked: agent_b → parent (not in allowed[agent_b]) + r = check(active_agent="2", is_transfer=True, transfer_to="parent") + assert r == {"active_agent": "2", "handoff": False} + + def test_condition_text_mention_case_insensitive(self): + """OnTextMention is case-insensitive (per handoff.py implementation).""" + conditions = [OnTextMention(text="HANDOFF_TO_QA", target="qa")] + check = self._make_handoff_fn("loop", ["coder", "qa"], conditions) + + r = check(result="handoff_to_qa", active_agent="1") + assert r == {"active_agent": "2", "handoff": True} + + r = check(result="Handoff_To_QA", active_agent="1") + assert r == {"active_agent": "2", "handoff": True} + + def test_full_coder_qa_loop_scenario(self): + """End-to-end simulation of the issue fixer coder↔qa loop. + + Parent: coder_qa_loop (SWARM, no handoffs, stop_when QA_APPROVED) + Children: coder, qa_agent (each with OnTextMention handoffs) + + The children's OnTextMention handoffs are NOT evaluated by the parent's + handoff_check. Only transfer tools (is_transfer=true) work. + """ + # Parent has NO conditions — children's OnTextMention don't propagate + check = self._make_handoff_fn("coder_qa_loop", ["coder", "qa_agent"]) + + # Turn 1: coder runs, calls transfer_to_qa_agent + r = check( + result="I've implemented the fix. HANDOFF_TO_QA", + active_agent="1", # coder + is_transfer=True, + transfer_to="qa_agent", + ) + assert r == {"active_agent": "2", "handoff": True} + + # Turn 2: qa_agent runs, finds issues, calls transfer_to_coder + r = check( + result="Found bugs. HANDOFF_TO_CODER", + active_agent="2", # qa_agent + is_transfer=True, + transfer_to="coder", + ) + assert r == {"active_agent": "1", "handoff": True} + + # Turn 3: coder fixes, transfers to qa again + r = check( + result="Fixed the bugs. HANDOFF_TO_QA", + active_agent="1", + is_transfer=True, + transfer_to="qa_agent", + ) + assert r == {"active_agent": "2", "handoff": True} + + # Turn 4: qa approves, does NOT transfer — loop should exit + r = check( + result="QA_APPROVED — all tests pass", + active_agent="2", + is_transfer=False, + ) + assert r == {"active_agent": "2", "handoff": False} + # stop_when would catch "QA_APPROVED" and terminate the DO_WHILE + + +# ═══════════════════════════════════════════════════════════════════════════ +# 4. Counterfactual: verify the test WOULD fail without the fix +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestCounterfactualWithoutFix: + """Prove the fix is necessary by showing the old logic would miss handoff_check.""" + + def test_old_logic_misses_swarm_without_handoffs(self, child_a, child_b): + """The OLD condition (agent.handoffs only) would NOT include handoff_check.""" + swarm = Agent( + name="my_swarm", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=Strategy.SWARM, + ) + # Simulate the OLD logic: only check agent.handoffs + old_would_register = bool(swarm.handoffs) + assert old_would_register is False, "Old logic would miss this — that's the bug" + + # NEW logic: also check strategy == SWARM with sub-agents + new_would_register = bool(swarm.handoffs) or ( + swarm.strategy == "swarm" and bool(swarm.agents) + ) + assert new_would_register is True, "New logic catches it" + + def test_old_logic_works_for_swarm_with_handoffs(self, child_a, child_b): + """The OLD logic was fine when parent had explicit handoffs.""" + swarm = Agent( + name="my_swarm", + model="openai/gpt-4o", + agents=[child_a, child_b], + strategy=Strategy.SWARM, + handoffs=[OnTextMention(text="GO", target="child_b")], + ) + old_would_register = bool(swarm.handoffs) + assert old_would_register is True + + def test_issue_fixer_exact_topology(self, coder, qa_agent): + """The exact issue fixer topology that triggered the production bug.""" + loop = Agent( + name="coder_qa_loop", + model="openai/gpt-4o", + agents=[coder, qa_agent], + strategy=Strategy.SWARM, + max_turns=90, + ) + + # Old logic: coder_qa_loop.handoffs is empty → would NOT register + assert loop.handoffs == [] + + # But children DO have handoffs (these are decorative for SWARM parent) + assert len(coder.handoffs) == 1 + assert len(qa_agent.handoffs) == 1 + + # New logic: strategy=SWARM + agents → register + names = _collect_names(loop) + assert "coder_qa_loop_handoff_check" in names + + +# ═══════════════════════════════════════════════════════════════════════════ +# 5. Exhaustive strategy coverage — handoff_check presence for every strategy +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestHandoffCheckAllStrategies: + """For every Strategy enum value, verify handoff_check presence/absence.""" + + @pytest.mark.parametrize( + "strategy,expect_handoff_check", + [ + (Strategy.SWARM, True), # Always — server generates it + (Strategy.HANDOFF, False), # No — server uses SUB_WORKFLOW + (Strategy.SEQUENTIAL, False), # No — server uses DO_WHILE + SUB_WORKFLOW + (Strategy.PARALLEL, False), # No — server uses FORK_JOIN + (Strategy.ROUND_ROBIN, False), # No — server uses DO_WHILE + SWITCH + (Strategy.RANDOM, False), # No — server uses DO_WHILE + SWITCH + (Strategy.MANUAL, False), # No — server uses WAIT + SUB_WORKFLOW + ], + ) + def test_strategy_handoff_check(self, strategy, expect_handoff_check): + """Only SWARM gets handoff_check when parent has no explicit handoffs.""" + a = Agent(name="a", model="openai/gpt-4o") + b = Agent(name="b", model="openai/gpt-4o") + parent = Agent( + name="parent", + model="openai/gpt-4o", + agents=[a, b], + strategy=strategy, + ) + names = _collect_names(parent) + if expect_handoff_check: + assert "parent_handoff_check" in names, ( + f"Strategy {strategy.value} should include handoff_check" + ) + else: + assert "parent_handoff_check" not in names, ( + f"Strategy {strategy.value} should NOT include handoff_check " + f"(without explicit handoffs)" + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 6. Registration path — verify _register_handoff_worker is actually called +# for both stateful (domain=UUID) and non-stateful (domain=None) +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestHandoffCheckRegistrationPath: + """Test that _register_workers actually calls _register_handoff_worker. + + _collect_worker_names decides WHAT to collect. + _register_workers decides WHAT to register. + They use the same condition — but we must test both paths. + + Patches _register_handoff_worker to capture calls without needing + a real Conductor client. + """ + + @staticmethod + def _make_runtime(): + """Create a minimal AgentRuntime without server connection.""" + rt = AgentRuntime.__new__(AgentRuntime) + # _register_workers calls ToolRegistry and other registration methods. + # We patch them all to no-op so only _register_handoff_worker matters. + return rt + + def test_non_stateful_swarm_registers_handoff_worker(self): + """Non-stateful SWARM (domain=None): _register_handoff_worker called.""" + a = Agent(name="agent_a", model="openai/gpt-4o") + b = Agent(name="agent_b", model="openai/gpt-4o") + swarm = Agent( + name="swarm_parent", + model="openai/gpt-4o", + agents=[a, b], + strategy=Strategy.SWARM, + ) + assert swarm.handoffs == [] # no explicit handoffs + + rt = self._make_runtime() + with patch.object(rt, "_register_handoff_worker") as mock_handoff, \ + patch.object(rt, "_register_swarm_transfer_workers"), \ + patch.object(rt, "_register_check_transfer_worker"): + # required_workers=None → fallback mode, register everything + rt._register_workers(swarm, required_workers=None, domain=None) + + mock_handoff.assert_called_once_with(swarm, domain=None) + + def test_stateful_swarm_registers_handoff_worker_with_domain(self): + """Stateful SWARM (domain=UUID): _register_handoff_worker called with domain.""" + a = Agent(name="agent_a", model="openai/gpt-4o") + b = Agent(name="agent_b", model="openai/gpt-4o") + swarm = Agent( + name="swarm_parent", + model="openai/gpt-4o", + agents=[a, b], + strategy=Strategy.SWARM, + stateful=True, + ) + assert swarm.handoffs == [] + + fake_domain = "abc123-uuid-domain" + rt = self._make_runtime() + with patch.object(rt, "_register_handoff_worker") as mock_handoff, \ + patch.object(rt, "_register_swarm_transfer_workers"), \ + patch.object(rt, "_register_check_transfer_worker"): + rt._register_workers(swarm, required_workers=None, domain=fake_domain) + + mock_handoff.assert_called_once_with(swarm, domain=fake_domain) + + def test_non_stateful_swarm_with_handoffs_on_children(self): + """Non-stateful, handoffs on children only — parent still gets registered.""" + coder = Agent( + name="coder", + model="openai/gpt-4o", + handoffs=[OnTextMention(text="HANDOFF_TO_QA", target="qa")], + ) + qa = Agent( + name="qa", + model="openai/gpt-4o", + handoffs=[OnTextMention(text="HANDOFF_TO_CODER", target="coder")], + ) + loop = Agent( + name="coder_qa_loop", + model="openai/gpt-4o", + agents=[coder, qa], + strategy=Strategy.SWARM, + ) + assert loop.handoffs == [] # parent has NO handoffs + + rt = self._make_runtime() + with patch.object(rt, "_register_handoff_worker") as mock_handoff, \ + patch.object(rt, "_register_swarm_transfer_workers"), \ + patch.object(rt, "_register_check_transfer_worker"): + rt._register_workers(loop, required_workers=None, domain=None) + + # Parent gets registered (SWARM + agents) + parent_calls = [c for c in mock_handoff.call_args_list if c[0][0].name == "coder_qa_loop"] + assert len(parent_calls) == 1 + assert parent_calls[0].kwargs["domain"] is None + + # Children also get registered (they have explicit handoffs) + child_calls = [c for c in mock_handoff.call_args_list if c[0][0].name != "coder_qa_loop"] + child_names = {c[0][0].name for c in child_calls} + assert "coder" in child_names + assert "qa" in child_names + + def test_non_swarm_without_handoffs_skips_registration(self): + """Sequential with no handoffs: _register_handoff_worker NOT called.""" + a = Agent(name="agent_a", model="openai/gpt-4o") + b = Agent(name="agent_b", model="openai/gpt-4o") + seq = Agent( + name="pipeline", + model="openai/gpt-4o", + agents=[a, b], + strategy=Strategy.SEQUENTIAL, + ) + + rt = self._make_runtime() + with patch.object(rt, "_register_handoff_worker") as mock_handoff: + rt._register_workers(seq, required_workers=None, domain=None) + + mock_handoff.assert_not_called() + + def test_server_required_workers_controls_registration(self): + """When server provides required_workers, only listed tasks are registered.""" + a = Agent(name="agent_a", model="openai/gpt-4o") + b = Agent(name="agent_b", model="openai/gpt-4o") + swarm = Agent( + name="swarm_parent", + model="openai/gpt-4o", + agents=[a, b], + strategy=Strategy.SWARM, + ) + + # Server says it needs handoff_check + required_with = {"swarm_parent_handoff_check", "swarm_parent_stop_when"} + rt = self._make_runtime() + with patch.object(rt, "_register_handoff_worker") as mock_handoff, \ + patch.object(rt, "_register_swarm_transfer_workers"), \ + patch.object(rt, "_register_check_transfer_worker"): + rt._register_workers(swarm, required_workers=required_with, domain=None) + mock_handoff.assert_called_once() + + # Server says it does NOT need handoff_check + required_without = {"swarm_parent_stop_when"} + rt2 = self._make_runtime() + with patch.object(rt2, "_register_handoff_worker") as mock_handoff2, \ + patch.object(rt2, "_register_swarm_transfer_workers"), \ + patch.object(rt2, "_register_check_transfer_worker"): + rt2._register_workers(swarm, required_workers=required_without, domain=None) + mock_handoff2.assert_not_called() diff --git a/sdk/typescript/tests/unit/swarm-workers.test.ts b/sdk/typescript/tests/unit/swarm-workers.test.ts index 3ab04c1aa..97c95be86 100644 --- a/sdk/typescript/tests/unit/swarm-workers.test.ts +++ b/sdk/typescript/tests/unit/swarm-workers.test.ts @@ -615,3 +615,223 @@ describe("_registerSystemWorkers integration", () => { expect(taskNames).not.toContain("coordinator_process_selection"); }); }); + +// ── SWARM handoff_check registration (no explicit handoffs) ── +// This is the exact pattern that caused a deadlock in Python: +// SWARM parent has NO handoffs, only children have OnTextMention. +// Server always generates {parent}_handoff_check for SWARM workflows. + +describe("SWARM handoff_check without explicit handoffs on parent", () => { + let runtime: AgentRuntime; + + beforeEach(() => { + runtime = createRuntime(); + }); + + it("registers handoff_check for SWARM parent with NO explicit handoffs", async () => { + const coder = new Agent({ + name: "coder", + model: "gpt-4o", + handoffs: [new OnTextMention({ target: "qa_agent", text: "HANDOFF_TO_QA" })], + }); + const qa = new Agent({ + name: "qa_agent", + model: "gpt-4o", + handoffs: [new OnTextMention({ target: "coder", text: "HANDOFF_TO_CODER" })], + }); + const swarmParent = new Agent({ + name: "coder_qa_loop", + model: "gpt-4o", + agents: [coder, qa], + strategy: "swarm", + // NO handoffs on parent — only children have them + }); + + await (runtime as any)._registerSystemWorkers(swarmParent, null); + + const workers = getRegisteredWorkers(runtime); + const taskNames = workers.map((w) => w.taskName); + + // The critical assertion: handoff_check must be registered + expect(taskNames).toContain("coder_qa_loop_handoff_check"); + }); + + it("registers handoff_check for bare SWARM parent (no handoffs anywhere)", async () => { + const a = new Agent({ name: "agent_a", model: "gpt-4o" }); + const b = new Agent({ name: "agent_b", model: "gpt-4o" }); + const swarm = new Agent({ + name: "my_swarm", + model: "gpt-4o", + agents: [a, b], + strategy: "swarm", + }); + + await (runtime as any)._registerSystemWorkers(swarm, null); + + const workers = getRegisteredWorkers(runtime); + const taskNames = workers.map((w) => w.taskName); + + expect(taskNames).toContain("my_swarm_handoff_check"); + }); + + it("registers handoff_check for 3-agent SWARM with no handoffs", async () => { + const a = new Agent({ name: "a", model: "gpt-4o" }); + const b = new Agent({ name: "b", model: "gpt-4o" }); + const c = new Agent({ name: "c", model: "gpt-4o" }); + const swarm = new Agent({ + name: "trio", + model: "gpt-4o", + agents: [a, b, c], + strategy: "swarm", + }); + + await (runtime as any)._registerSystemWorkers(swarm, null); + + const workers = getRegisteredWorkers(runtime); + const taskNames = workers.map((w) => w.taskName); + + expect(taskNames).toContain("trio_handoff_check"); + }); + + it("respects requiredWorkers filter for SWARM without handoffs", async () => { + const a = new Agent({ name: "a", model: "gpt-4o" }); + const b = new Agent({ name: "b", model: "gpt-4o" }); + const swarm = new Agent({ + name: "my_swarm", + model: "gpt-4o", + agents: [a, b], + strategy: "swarm", + }); + + // Server says only handoff_check is needed + const required = new Set(["my_swarm_handoff_check"]); + await (runtime as any)._registerSystemWorkers(swarm, required); + + const workers = getRegisteredWorkers(runtime); + const taskNames = workers.map((w) => w.taskName); + + expect(taskNames).toContain("my_swarm_handoff_check"); + }); + + it("does NOT register handoff_check for non-SWARM strategies without handoffs", async () => { + for (const strategy of ["sequential", "parallel", "round_robin", "random"] as const) { + const rt = createRuntime(); + const a = new Agent({ name: "a", model: "gpt-4o" }); + const b = new Agent({ name: "b", model: "gpt-4o" }); + const parent = new Agent({ + name: `parent_${strategy}`, + model: "gpt-4o", + agents: [a, b], + strategy, + }); + + await (rt as any)._registerSystemWorkers(parent, null); + + const workers = getRegisteredWorkers(rt); + const taskNames = workers.map((w) => w.taskName); + + expect(taskNames).not.toContain(`parent_${strategy}_handoff_check`); + } + }); + + it("single agent (no children) does NOT get handoff_check", async () => { + const single = new Agent({ name: "solo", model: "gpt-4o" }); + + await (runtime as any)._registerSystemWorkers(single, null); + + const workers = getRegisteredWorkers(runtime); + const taskNames = workers.map((w) => w.taskName); + + expect(taskNames).not.toContain("solo_handoff_check"); + }); +}); + +// ── Counterfactual: prove the condition matters ────────── + +describe("Counterfactual: handoff_check condition verification", () => { + it("condition `agent.handoffs.length > 0 || agent.strategy === 'swarm'` covers SWARM without handoffs", () => { + // This test verifies the LOGIC of the condition at runtime.ts:877 + // by checking both branches independently. + + // Branch 1: handoffs on parent → should register + const withHandoffs = new Agent({ + name: "p", + model: "gpt-4o", + agents: [new Agent({ name: "c", model: "gpt-4o" })], + strategy: "sequential", + handoffs: [new OnTextMention({ target: "c", text: "GO" })], + }); + expect(withHandoffs.handoffs.length > 0 || withHandoffs.strategy === "swarm").toBe(true); + + // Branch 2: SWARM strategy, no handoffs → should register + const swarmNoHandoffs = new Agent({ + name: "p", + model: "gpt-4o", + agents: [new Agent({ name: "c", model: "gpt-4o" })], + strategy: "swarm", + }); + expect( + swarmNoHandoffs.handoffs.length > 0 || swarmNoHandoffs.strategy === "swarm", + ).toBe(true); + + // Neither: no handoffs, not swarm → should NOT register + const neither = new Agent({ + name: "p", + model: "gpt-4o", + agents: [new Agent({ name: "c", model: "gpt-4o" })], + strategy: "sequential", + }); + expect(neither.handoffs.length > 0 || neither.strategy === "swarm").toBe(false); + }); + + it("old buggy condition (handoffs only) would miss SWARM without handoffs", () => { + // Simulates the Python bug: only checking handoffs + const swarmNoHandoffs = new Agent({ + name: "coder_qa_loop", + model: "gpt-4o", + agents: [ + new Agent({ name: "coder", model: "gpt-4o" }), + new Agent({ name: "qa", model: "gpt-4o" }), + ], + strategy: "swarm", + }); + + // OLD condition (the Python bug): only handoffs + const oldCondition = swarmNoHandoffs.handoffs.length > 0; + expect(oldCondition).toBe(false); // Would NOT register → deadlock! + + // NEW condition: handoffs OR swarm strategy + const newCondition = + swarmNoHandoffs.handoffs.length > 0 || swarmNoHandoffs.strategy === "swarm"; + expect(newCondition).toBe(true); // Correctly registers + }); + + it("issue fixer exact topology: handoffs on children, none on parent", () => { + const coder = new Agent({ + name: "coder", + model: "gpt-4o", + handoffs: [new OnTextMention({ target: "qa_agent", text: "HANDOFF_TO_QA" })], + }); + const qa = new Agent({ + name: "qa_agent", + model: "gpt-4o", + handoffs: [new OnTextMention({ target: "coder", text: "HANDOFF_TO_CODER" })], + }); + const loop = new Agent({ + name: "coder_qa_loop", + model: "gpt-4o", + agents: [coder, qa], + strategy: "swarm", + }); + + // Parent has no handoffs + expect(loop.handoffs.length).toBe(0); + // But children do + expect(coder.handoffs.length).toBe(1); + expect(qa.handoffs.length).toBe(1); + // Strategy is swarm + expect(loop.strategy).toBe("swarm"); + // Condition passes → handoff_check will be registered + expect(loop.handoffs.length > 0 || loop.strategy === "swarm").toBe(true); + }); +}); diff --git a/server/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java b/server/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java index d081cf783..8cfb408f9 100644 --- a/server/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java +++ b/server/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java @@ -201,72 +201,44 @@ void sanitizeMessages(ChatCompletion chatCompletion) { } /** - * Compact tool message history to reduce payload size. + * Compact tool message history. * - *

Applies three optimizations:

- *
    - *
  1. Truncate old tool results: Tool results older than the most recent - * {@code RECENT_TOOL_RESULTS_TO_KEEP} are truncated to {@code TOOL_RESULT_TRUNCATE_LENGTH} - * characters. The LLM already consumed these results in prior turns.
  2. - *
  3. Collapse write-only tools: Tools like {@code contextbook_write} produce - * confirmation messages ("wrote X chars") that add no value in history. - * Their results are replaced with a short acknowledgment.
  4. - *
  5. Keep only latest read per key: For tools like {@code contextbook_read}, - * only the most recent result per section argument is kept in full; - * older reads of the same section are truncated.
  6. - *
+ *

Tool result content is NEVER truncated. An earlier version of + * this method truncated any tool result older than the 3-6 most recent to + * 500 chars (with "...[truncated]" suffix). That caused the agent to + * lose context — a 5KB ``glob_find`` result kept only ~500 chars of file + * names, so on the next turn the agent re-issued the same ``glob_find`` + * with a different filter, then re-read the same files. Observed in + * workflow ``637d179b-e0b5-4efd-a33f-2b2811ccbc01`` where iter 14's + * ``glob_find`` result was clipped to ``...AgentspanAIMod...[truncated]`` + * and the agent kept reissuing nearly-identical queries trying to see + * more. + * + *

Token-budget pressure is handled separately by {@code condenseIfNeeded} + * which drops ENTIRE old messages — a much cleaner shape than partial + * truncation, since the agent either has full context for a message or + * doesn't see it at all. + * + *

The only remaining transformation in this method is collapsing + * write-only tool confirmations ({@code contextbook_write}, + * {@code contextbook_summary}) to a one-character ``[ok]`` acknowledgment. + * These results are pure ``"wrote N chars"`` confirmations that add no + * downstream value, and they're emitted by the agent itself so it can't + * lose information by forgetting them. */ - private static final int RECENT_TOOL_RESULTS_TO_KEEP = 6; - - private static final int TOOL_RESULT_TRUNCATE_LENGTH = 500; private static final Set WRITE_ONLY_TOOLS = Set.of("contextbook_write", "contextbook_summary"); void compactToolHistory(List messages) { - if (messages == null || messages.size() < 4) { - return; - } - - // 1. Find all tool response messages and their positions - List toolResponseIndices = new ArrayList<>(); - for (int i = 0; i < messages.size(); i++) { - ChatMessage msg = messages.get(i); - if (msg.getRole() == ChatMessage.Role.tool && msg.getToolCalls() != null) { - toolResponseIndices.add(i); - } - } - - if (toolResponseIndices.isEmpty()) { + if (messages == null || messages.isEmpty()) { return; } - // 2. Track the latest contextbook_read per section argument for dedup - Map latestReadBySection = new HashMap<>(); - for (int idx : toolResponseIndices) { - ChatMessage msg = messages.get(idx); - for (ToolCall tc : msg.getToolCalls()) { - String name = tc.getName(); - if (name != null && name.contains("contextbook_read")) { - Object section = tc.getInputParameters() != null - ? tc.getInputParameters().get("section") - : null; - String key = name + ":" + (section != null ? section.toString() : "toc"); - latestReadBySection.put(key, idx); - } + for (ChatMessage msg : messages) { + if (msg.getRole() != ChatMessage.Role.tool || msg.getToolCalls() == null) { + continue; } - } - - // 3. Compact: truncate old results, collapse writes, dedup reads - int recentCutoff = toolResponseIndices.size() - RECENT_TOOL_RESULTS_TO_KEEP; - - for (int ri = 0; ri < toolResponseIndices.size(); ri++) { - int idx = toolResponseIndices.get(ri); - ChatMessage msg = messages.get(idx); - boolean isRecent = ri >= recentCutoff; - for (ToolCall tc : msg.getToolCalls()) { String name = tc.getName() != null ? tc.getName() : ""; - - // Collapse write-only tools — result is just a confirmation if (WRITE_ONLY_TOOLS.stream().anyMatch(name::contains)) { msg.setMessage("[ok]"); if (tc.getOutput() != null) { @@ -274,46 +246,11 @@ void compactToolHistory(List messages) { compactedOutput.put("result", "[ok]"); tc.setOutput(compactedOutput); } - continue; - } - - // For contextbook_read: keep full only if it's the latest read for that section - if (name.contains("contextbook_read")) { - Object section = tc.getInputParameters() != null - ? tc.getInputParameters().get("section") - : null; - String key = name + ":" + (section != null ? section.toString() : "toc"); - Integer latestIdx = latestReadBySection.get(key); - if (latestIdx != null && latestIdx != idx) { - // Not the latest read of this section — truncate - truncateToolResult(msg, tc); - continue; - } - } - - // Truncate old tool results (not recent) - if (!isRecent) { - truncateToolResult(msg, tc); } } } } - private void truncateToolResult(ChatMessage msg, ToolCall tc) { - String text = msg.getMessage(); - if (text != null && text.length() > TOOL_RESULT_TRUNCATE_LENGTH) { - msg.setMessage(text.substring(0, TOOL_RESULT_TRUNCATE_LENGTH) + "...[truncated]"); - } - if (tc.getOutput() != null) { - Object result = tc.getOutput().get("result"); - if (result != null && result.toString().length() > TOOL_RESULT_TRUNCATE_LENGTH) { - Map output = new HashMap<>(tc.getOutput()); - output.put("result", result.toString().substring(0, TOOL_RESULT_TRUNCATE_LENGTH) + "...[truncated]"); - tc.setOutput(output); - } - } - } - void validateRunnableConversation(ChatCompletion chatCompletion) { List messages = chatCompletion.getMessages(); if (messages == null || messages.isEmpty()) {