Jd Vijay commited on
Commit
86c1dfa
·
verified ·
1 Parent(s): 40e2abf

Upload app/agent/orchestrator.py

Browse files
Files changed (1) hide show
  1. app/agent/orchestrator.py +258 -0
app/agent/orchestrator.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ MultiAgentOrchestrator — DAG-based multi-role execution engine.
5
+ (v3.1 — fixed: mode forwarding to sub-agents, escalation handling)
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from typing import Callable, Optional
11
+
12
+ from app.agent.roles.base_role import RoleMessage, RoleMessageBus
13
+ from app.agent.roles.product_manager import ProductManagerRole
14
+ from app.agent.roles.architect import ArchitectRole
15
+ from app.agent.roles.engineer import EngineerRole
16
+ from app.agent.roles.qa import QARole
17
+ from app.db.session import SessionDB
18
+ from app.exceptions import OrchestratorError, PipelineCycleError
19
+ from app.logger import logger
20
+ from app.permissions.gate import AgentMode, PermissionGate
21
+ from app.schema import PipelineResult, PipelineStageResult
22
+
23
+
24
+ _DEFAULT_PIPELINE: list[str] = [
25
+ "product_manager",
26
+ "architect",
27
+ "engineer",
28
+ "qa",
29
+ ]
30
+
31
+ _DEFAULT_DEPS: dict[str, list[str]] = {
32
+ "product_manager": [],
33
+ "architect": ["product_manager"],
34
+ "engineer": ["architect"],
35
+ "qa": ["engineer"],
36
+ }
37
+
38
+
39
+ class MultiAgentOrchestrator:
40
+ """
41
+ Runs specialist roles in dependency order (topological sort via Kahn's algorithm).
42
+ Each role receives the accumulated context from all its upstream predecessors.
43
+
44
+ FIXED in v3.1:
45
+ - Mode is now forwarded to sub-agent roles
46
+ - Escalation from upstream roles skips downstream dependents
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ mode: AgentMode = AgentMode.BUILD,
52
+ pipeline: Optional[list[str]] = None,
53
+ deps: Optional[dict[str, list[str]]] = None,
54
+ timeout: int = 7200,
55
+ on_stage_start: Optional[Callable[..., object]] = None,
56
+ on_stage_complete: Optional[Callable[..., object]] = None,
57
+ on_stage_error: Optional[Callable[..., object]] = None,
58
+ ) -> None:
59
+ self.mode = mode
60
+ self.pipeline = pipeline or _DEFAULT_PIPELINE
61
+ self.deps = deps or _DEFAULT_DEPS
62
+ self.timeout = timeout
63
+ self.bus = RoleMessageBus()
64
+ self.db = SessionDB()
65
+ self.gate = PermissionGate(mode=mode)
66
+ self._on_stage_start = on_stage_start
67
+ self._on_stage_complete = on_stage_complete
68
+ self._on_stage_error = on_stage_error
69
+
70
+ async def run(self, goal: str) -> str:
71
+ result = await self.run_pipeline(goal)
72
+ return result.to_summary()
73
+
74
+ async def run_pipeline(self, goal: str) -> PipelineResult:
75
+ import uuid
76
+ pipeline_id = str(uuid.uuid4())[:8]
77
+
78
+ logger.info(
79
+ f"[Orchestrator:{pipeline_id}] â–¶ Multi-agent run "
80
+ f"| mode={self.mode.value} | goal={goal[:80]}"
81
+ )
82
+
83
+ session_id = await self.db.create_session(
84
+ goal, agent_name="orchestrator", mode=self.mode.value
85
+ )
86
+
87
+ try:
88
+ order = self._topological_sort()
89
+ except PipelineCycleError as e:
90
+ raise OrchestratorError(str(e), pipeline=self.pipeline) from e
91
+
92
+ logger.info(
93
+ f"[Orchestrator:{pipeline_id}] Execution order: {' → '.join(order)}"
94
+ )
95
+
96
+ pipeline_result = PipelineResult(pipeline_id=pipeline_id, goal=goal)
97
+ results: dict[str, str] = {}
98
+ # Track roles that escalated so downstream dependents can be SKIPPED
99
+ escalated_roles: set[str] = set()
100
+ t_pipeline = time.monotonic()
101
+
102
+ async def _run_stages() -> None:
103
+ for role_name in order:
104
+ # FIXED: skip roles whose upstream dependents escalated
105
+ upstream = self.deps.get(role_name, [])
106
+ if any(u in escalated_roles for u in upstream):
107
+ logger.warning(
108
+ f"[Orchestrator:{pipeline_id}] "
109
+ f"Skipping {role_name} — upstream role escalated."
110
+ )
111
+ pipeline_result.stages.append(PipelineStageResult(
112
+ role_name=role_name,
113
+ status="skipped",
114
+ output="Skipped — upstream role escalated.",
115
+ duration_s=0.0,
116
+ ))
117
+ escalated_roles.add(role_name)
118
+ continue
119
+
120
+ logger.info(f"[Orchestrator:{pipeline_id}] ── Role: {role_name} ──")
121
+ await self._fire_hook(self._on_stage_start, role_name)
122
+
123
+ role = self._build_role(role_name)
124
+ role_input = self._build_role_input(goal, role_name, results)
125
+ t0 = time.monotonic()
126
+
127
+ try:
128
+ output = await role.run(role_input)
129
+ results[role_name] = output
130
+ elapsed = time.monotonic() - t0
131
+
132
+ logger.info(
133
+ f"[Orchestrator:{pipeline_id}] "
134
+ f"{role_name} ✓ ({len(output)} chars, {elapsed:.1f}s)"
135
+ )
136
+ await self.db.log_message(session_id, role_name, output[:2048])
137
+
138
+ pipeline_result.stages.append(PipelineStageResult(
139
+ role_name=role_name,
140
+ status="completed",
141
+ output=output,
142
+ duration_s=round(elapsed, 2),
143
+ ))
144
+ await self._fire_hook(self._on_stage_complete, role_name, output)
145
+
146
+ except Exception as e:
147
+ elapsed = time.monotonic() - t0
148
+ err_msg = f"ERROR: {e}"
149
+ logger.error(f"[Orchestrator:{pipeline_id}] {role_name} ✗ {err_msg}")
150
+ results[role_name] = err_msg
151
+ await self.db.log_message(session_id, role_name, err_msg)
152
+
153
+ pipeline_result.stages.append(PipelineStageResult(
154
+ role_name=role_name,
155
+ status="error",
156
+ output=err_msg,
157
+ duration_s=round(elapsed, 2),
158
+ ))
159
+ await self._fire_hook(self._on_stage_error, role_name, err_msg)
160
+
161
+ try:
162
+ if hasattr(asyncio, "timeout"):
163
+ async with asyncio.timeout(self.timeout):
164
+ await _run_stages()
165
+ else:
166
+ await asyncio.wait_for(_run_stages(), timeout=self.timeout)
167
+ except asyncio.TimeoutError:
168
+ logger.warning(f"[Orchestrator:{pipeline_id}] Global timeout reached.")
169
+ pipeline_result.timed_out = True
170
+
171
+ pipeline_result.total_duration_s = round(time.monotonic() - t_pipeline, 2)
172
+ pipeline_result.verdict = self._derive_verdict(results, pipeline_result.timed_out)
173
+
174
+ final_state = "timeout" if pipeline_result.timed_out else "finished"
175
+ await self.db.close_session(session_id, state=final_state, step_count=len(order))
176
+
177
+ logger.info(
178
+ f"[Orchestrator:{pipeline_id}] â–  Pipeline complete. "
179
+ f"verdict={pipeline_result.verdict} "
180
+ f"duration={pipeline_result.total_duration_s:.1f}s"
181
+ )
182
+ return pipeline_result
183
+
184
+ def _topological_sort(self) -> list[str]:
185
+ in_degree = {r: len(self.deps.get(r, [])) for r in self.pipeline}
186
+ queue = [r for r in self.pipeline if in_degree[r] == 0]
187
+ order: list[str] = []
188
+ dependents: dict[str, list[str]] = {r: [] for r in self.pipeline}
189
+
190
+ for r, d_list in self.deps.items():
191
+ for d in d_list:
192
+ if d in dependents:
193
+ dependents[d].append(r)
194
+
195
+ while queue:
196
+ role = queue.pop(0)
197
+ order.append(role)
198
+ for dep in dependents.get(role, []):
199
+ in_degree[dep] -= 1
200
+ if in_degree[dep] == 0:
201
+ queue.append(dep)
202
+
203
+ if len(order) != len(self.pipeline):
204
+ raise PipelineCycleError(
205
+ "Dependency graph contains a cycle — cannot determine execution order.",
206
+ pipeline=self.pipeline,
207
+ )
208
+
209
+ return order
210
+
211
+ def _build_role(self, role_name: str):
212
+ from app.permissions.gate import AgentMode
213
+ role_map = {
214
+ "product_manager": ProductManagerRole,
215
+ "architect": ArchitectRole,
216
+ "engineer": EngineerRole,
217
+ "qa": QARole,
218
+ }
219
+ cls = role_map.get(role_name)
220
+ if cls is None:
221
+ raise OrchestratorError(f"Unknown role: '{role_name}'", pipeline=self.pipeline)
222
+ # FIXED: pass mode so sub-agent Manus runs respect plan/build mode
223
+ return cls(self.bus, mode=self.mode)
224
+
225
+ def _build_role_input(self, goal: str, role_name: str, results: dict[str, str]) -> str:
226
+ upstream = self.deps.get(role_name, [])
227
+ if not upstream:
228
+ return goal
229
+ parts = [f"ORIGINAL GOAL:\n{goal}"]
230
+ for up in upstream:
231
+ if up in results:
232
+ parts.append(f"\n{up.upper().replace('_', ' ')} OUTPUT:\n{results[up][:3000]}")
233
+ return "\n\n".join(parts)
234
+
235
+ @staticmethod
236
+ def _derive_verdict(results: dict[str, str], timed_out: bool) -> str:
237
+ if timed_out:
238
+ return "timeout"
239
+ qa_out = results.get("qa", "").upper()
240
+ if "APPROVED" in qa_out:
241
+ return "approved"
242
+ if "REWORK" in qa_out:
243
+ return "rework"
244
+ any_error = any(v.startswith("ERROR:") for v in results.values())
245
+ if any_error:
246
+ return "error"
247
+ return "unknown"
248
+
249
+ @staticmethod
250
+ async def _fire_hook(hook: Optional[Callable], *args: object) -> None:
251
+ if hook is None:
252
+ return
253
+ try:
254
+ coro = hook(*args)
255
+ if asyncio.iscoroutine(coro):
256
+ asyncio.create_task(coro)
257
+ except Exception as e:
258
+ logger.debug(f"[Orchestrator] Hook error (non-fatal): {e}")