From 8b1eec54a19b2150c442050f05c09f5e42b7626d Mon Sep 17 00:00:00 2001 From: rishu685 Date: Sun, 26 Jul 2026 10:55:43 +0530 Subject: [PATCH 1/2] fix(orchestrator): load both system and orchestrator prompt files orchestrator is the required routing prompt. system is the optional persona prefix prepended when present. When orchestrator is absent, fall back to description for backward compatibility. Closes #38 --- src/agent_engine/engine/langgraph/nodes.py | 12 ++- tests/engine/test_engine_flow.py | 115 +++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/agent_engine/engine/langgraph/nodes.py b/src/agent_engine/engine/langgraph/nodes.py index 50071593..4f5f3441 100644 --- a/src/agent_engine/engine/langgraph/nodes.py +++ b/src/agent_engine/engine/langgraph/nodes.py @@ -535,7 +535,17 @@ def __init__( async def __call__(self, state: GraphState) -> dict[str, object]: candidates = self._filter_children(state) - base_prompt = load_file(self._base_dir, self._spec.prompts.system) or self._spec.description + # orchestrator is the required routing prompt; system is an optional persona prefix. + orchestrator_content = load_file(self._base_dir, self._spec.prompts.orchestrator) + if orchestrator_content: + system_content = load_file(self._base_dir, self._spec.prompts.system) + base_prompt = ( + f"{system_content}\n\n{orchestrator_content}" + if system_content + else orchestrator_content + ) + else: + base_prompt = self._spec.description system_prompt = f"{base_prompt}\n{_ORCHESTRATOR_CONTRACT}" return await self._run(system_prompt, candidates, state) diff --git a/tests/engine/test_engine_flow.py b/tests/engine/test_engine_flow.py index 6a45413a..ac5e945c 100644 --- a/tests/engine/test_engine_flow.py +++ b/tests/engine/test_engine_flow.py @@ -538,3 +538,118 @@ async def test_orchestrator_fallback_model_execution(tmp_path: Path, model_facto result = await run_message(spec, tmp_path, model_factory, "hello child") assert result.visited == ["root", "root/child"] + + +async def test_orchestrator_loads_both_prompts(tmp_path: Path) -> None: + from langchain_core.messages import AIMessage + + from agent_engine.core.spec import OrchestratorPromptSet, OrchestratorSpec + from agent_engine.engine.langgraph.nodes import _ORCHESTRATOR_CONTRACT, OrchestratorNode + + # Write prompt files + sys_path = tmp_path / "system.md" + orch_path = tmp_path / "orchestrator.md" + sys_path.write_text("System persona content", encoding="utf-8") + orch_path.write_text("Orchestrator routing content", encoding="utf-8") + + class CapturingModel: + def __init__(self) -> None: + self.captured_prompt = None + + def bind_tools(self, tools: list[Any]) -> Any: + return self + + async def ainvoke(self, messages: list[Any]) -> AIMessage: + self.captured_prompt = messages[0].content + return AIMessage(content="done") + + # 1. Test both prompts loaded + spec_both = OrchestratorSpec( + id="orch", + name="orch", + description="orch desc", + model=_MODEL, + prompts=OrchestratorPromptSet( + system="system.md", + orchestrator="orchestrator.md", + ), + ) + model_both = CapturingModel() + node_both = OrchestratorNode( + spec=spec_both, + node_path="root", + model=cast(Any, model_both), + children=[], + filters=[], + base_dir=tmp_path, + ) + await node_both({"message": "hi", "visited": [], "used_tools": []}) + expected_both = ( + f"System persona content\n\nOrchestrator routing content\n{_ORCHESTRATOR_CONTRACT}" + ) + assert model_both.captured_prompt == expected_both + + # 2. Test system prompt only (no orchestrator) — falls back to description because + # orchestrator is required; system alone is not enough to build the prompt. + spec_sys = OrchestratorSpec( + id="orch", + name="orch", + description="orch desc", + model=_MODEL, + prompts=OrchestratorPromptSet( + system="system.md", + ), + ) + model_sys = CapturingModel() + node_sys = OrchestratorNode( + spec=spec_sys, + node_path="root", + model=cast(Any, model_sys), + children=[], + filters=[], + base_dir=tmp_path, + ) + await node_sys({"message": "hi", "visited": [], "used_tools": []}) + assert model_sys.captured_prompt == f"orch desc\n{_ORCHESTRATOR_CONTRACT}" + + # 3. Test orchestrator prompt only + spec_orch = OrchestratorSpec( + id="orch", + name="orch", + description="orch desc", + model=_MODEL, + prompts=OrchestratorPromptSet( + orchestrator="orchestrator.md", + ), + ) + model_orch = CapturingModel() + node_orch = OrchestratorNode( + spec=spec_orch, + node_path="root", + model=cast(Any, model_orch), + children=[], + filters=[], + base_dir=tmp_path, + ) + await node_orch({"message": "hi", "visited": [], "used_tools": []}) + assert model_orch.captured_prompt == f"Orchestrator routing content\n{_ORCHESTRATOR_CONTRACT}" + + # 4. Test fallback to description + spec_desc = OrchestratorSpec( + id="orch", + name="orch", + description="orch desc", + model=_MODEL, + prompts=OrchestratorPromptSet(), + ) + model_desc = CapturingModel() + node_desc = OrchestratorNode( + spec=spec_desc, + node_path="root", + model=cast(Any, model_desc), + children=[], + filters=[], + base_dir=tmp_path, + ) + await node_desc({"message": "hi", "visited": [], "used_tools": []}) + assert model_desc.captured_prompt == f"orch desc\n{_ORCHESTRATOR_CONTRACT}" From 677cca231f493ce320887c5779b426c33b8d0623 Mon Sep 17 00:00:00 2001 From: rishu685 Date: Mon, 27 Jul 2026 09:15:22 +0530 Subject: [PATCH 2/2] fix(orchestrator): load both system and orchestrator prompt files Always join system and orchestrator prompts directly without an conditional check, satisfying the reviewer feedback. Closes #38 --- src/agent_engine/engine/langgraph/nodes.py | 12 ++---------- tests/engine/test_engine_flow.py | 15 +++++++++------ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/agent_engine/engine/langgraph/nodes.py b/src/agent_engine/engine/langgraph/nodes.py index 4f5f3441..c77ec1da 100644 --- a/src/agent_engine/engine/langgraph/nodes.py +++ b/src/agent_engine/engine/langgraph/nodes.py @@ -535,17 +535,9 @@ def __init__( async def __call__(self, state: GraphState) -> dict[str, object]: candidates = self._filter_children(state) - # orchestrator is the required routing prompt; system is an optional persona prefix. + base_prompt = load_file(self._base_dir, self._spec.prompts.system) or self._spec.description orchestrator_content = load_file(self._base_dir, self._spec.prompts.orchestrator) - if orchestrator_content: - system_content = load_file(self._base_dir, self._spec.prompts.system) - base_prompt = ( - f"{system_content}\n\n{orchestrator_content}" - if system_content - else orchestrator_content - ) - else: - base_prompt = self._spec.description + base_prompt = f"{base_prompt}\n\n{orchestrator_content}" system_prompt = f"{base_prompt}\n{_ORCHESTRATOR_CONTRACT}" return await self._run(system_prompt, candidates, state) diff --git a/tests/engine/test_engine_flow.py b/tests/engine/test_engine_flow.py index ac5e945c..635d3e5a 100644 --- a/tests/engine/test_engine_flow.py +++ b/tests/engine/test_engine_flow.py @@ -589,8 +589,7 @@ async def ainvoke(self, messages: list[Any]) -> AIMessage: ) assert model_both.captured_prompt == expected_both - # 2. Test system prompt only (no orchestrator) — falls back to description because - # orchestrator is required; system alone is not enough to build the prompt. + # 2. Test system prompt only (no orchestrator) — uses system as the base prompt. spec_sys = OrchestratorSpec( id="orch", name="orch", @@ -610,9 +609,11 @@ async def ainvoke(self, messages: list[Any]) -> AIMessage: base_dir=tmp_path, ) await node_sys({"message": "hi", "visited": [], "used_tools": []}) - assert model_sys.captured_prompt == f"orch desc\n{_ORCHESTRATOR_CONTRACT}" + expected_sys = f"System persona content\n\n\n{_ORCHESTRATOR_CONTRACT}" + assert model_sys.captured_prompt == expected_sys - # 3. Test orchestrator prompt only + # 3. Test orchestrator prompt only (no system) — + # description is used as base, then orchestrator is appended. spec_orch = OrchestratorSpec( id="orch", name="orch", @@ -632,7 +633,8 @@ async def ainvoke(self, messages: list[Any]) -> AIMessage: base_dir=tmp_path, ) await node_orch({"message": "hi", "visited": [], "used_tools": []}) - assert model_orch.captured_prompt == f"Orchestrator routing content\n{_ORCHESTRATOR_CONTRACT}" + expected_orch = f"orch desc\n\nOrchestrator routing content\n{_ORCHESTRATOR_CONTRACT}" + assert model_orch.captured_prompt == expected_orch # 4. Test fallback to description spec_desc = OrchestratorSpec( @@ -652,4 +654,5 @@ async def ainvoke(self, messages: list[Any]) -> AIMessage: base_dir=tmp_path, ) await node_desc({"message": "hi", "visited": [], "used_tools": []}) - assert model_desc.captured_prompt == f"orch desc\n{_ORCHESTRATOR_CONTRACT}" + expected_desc = f"orch desc\n\n\n{_ORCHESTRATOR_CONTRACT}" + assert model_desc.captured_prompt == expected_desc