agentforge-py 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. agentforge/__init__.py +114 -0
  2. agentforge/_testing/__init__.py +19 -0
  3. agentforge/_testing/fake_llm.py +126 -0
  4. agentforge/_testing/fake_tool.py +122 -0
  5. agentforge/_tools/__init__.py +14 -0
  6. agentforge/_tools/calculator.py +102 -0
  7. agentforge/_tools/decorator.py +300 -0
  8. agentforge/_tools/file_read.py +112 -0
  9. agentforge/_tools/shell.py +134 -0
  10. agentforge/_tools/web_search.py +207 -0
  11. agentforge/agent.py +817 -0
  12. agentforge/auth.py +42 -0
  13. agentforge/cli/__init__.py +18 -0
  14. agentforge/cli/_build.py +323 -0
  15. agentforge/cli/_scaffold_state.py +250 -0
  16. agentforge/cli/_shared_scaffold.py +174 -0
  17. agentforge/cli/config_cmd.py +174 -0
  18. agentforge/cli/db_cmd.py +262 -0
  19. agentforge/cli/debug_cmd.py +168 -0
  20. agentforge/cli/docs_cmd.py +217 -0
  21. agentforge/cli/eval_cmd.py +181 -0
  22. agentforge/cli/health_cmd.py +139 -0
  23. agentforge/cli/list_modules.py +85 -0
  24. agentforge/cli/main.py +81 -0
  25. agentforge/cli/manifest_apply.py +368 -0
  26. agentforge/cli/module_cmd.py +247 -0
  27. agentforge/cli/new_cmd.py +171 -0
  28. agentforge/cli/run_cmd.py +234 -0
  29. agentforge/cli/upgrade_cmd.py +230 -0
  30. agentforge/config/__init__.py +45 -0
  31. agentforge/eval/__init__.py +18 -0
  32. agentforge/eval/consistency.py +107 -0
  33. agentforge/eval/coverage.py +100 -0
  34. agentforge/eval/format_compliance.py +107 -0
  35. agentforge/eval/regression.py +143 -0
  36. agentforge/findings.py +166 -0
  37. agentforge/guardrails/__init__.py +32 -0
  38. agentforge/guardrails/allowlist.py +49 -0
  39. agentforge/guardrails/capability_check.py +58 -0
  40. agentforge/guardrails/engine.py +289 -0
  41. agentforge/guardrails/pii_redact_basic.py +61 -0
  42. agentforge/guardrails/prompt_injection_basic.py +90 -0
  43. agentforge/memory/__init__.py +16 -0
  44. agentforge/memory/in_memory.py +130 -0
  45. agentforge/memory/in_memory_graph.py +262 -0
  46. agentforge/memory/in_memory_vector.py +167 -0
  47. agentforge/pipeline/__init__.py +26 -0
  48. agentforge/pipeline/engine.py +189 -0
  49. agentforge/pipeline/errors.py +19 -0
  50. agentforge/pipeline/tool.py +93 -0
  51. agentforge/py.typed +0 -0
  52. agentforge/recording.py +189 -0
  53. agentforge/renderers/__init__.py +28 -0
  54. agentforge/renderers/_defaults.py +32 -0
  55. agentforge/renderers/markdown.py +44 -0
  56. agentforge/renderers/patch_applier.py +46 -0
  57. agentforge/renderers/registry.py +108 -0
  58. agentforge/renderers/scorecard.py +59 -0
  59. agentforge/renderers/span_table.py +71 -0
  60. agentforge/replay.py +260 -0
  61. agentforge/resolver_register.py +41 -0
  62. agentforge/retrieval.py +410 -0
  63. agentforge/runtime.py +63 -0
  64. agentforge/strategies/__init__.py +27 -0
  65. agentforge/strategies/_base.py +280 -0
  66. agentforge/strategies/_plan.py +93 -0
  67. agentforge/strategies/multi_agent.py +541 -0
  68. agentforge/strategies/plan_execute.py +506 -0
  69. agentforge/strategies/react.py +237 -0
  70. agentforge/strategies/tot.py +472 -0
  71. agentforge/templates/_shared/.cursorrules +12 -0
  72. agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
  73. agentforge/templates/_shared/.gitkeep +0 -0
  74. agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
  75. agentforge/templates/_shared/CLAUDE.md +13 -0
  76. agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
  77. agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
  78. agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
  79. agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
  80. agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
  81. agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
  82. agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
  83. agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
  84. agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
  85. agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
  86. agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
  87. agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
  88. agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
  89. agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
  90. agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
  91. agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
  92. agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
  93. agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
  94. agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
  95. agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
  96. agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
  97. agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
  98. agentforge/templates/code-reviewer/.env.example +8 -0
  99. agentforge/templates/code-reviewer/.gitignore +7 -0
  100. agentforge/templates/code-reviewer/README.md +12 -0
  101. agentforge/templates/code-reviewer/agentforge.yaml +23 -0
  102. agentforge/templates/code-reviewer/copier.yml +34 -0
  103. agentforge/templates/code-reviewer/pyproject.toml +18 -0
  104. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  105. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  106. agentforge/templates/docs-qa/.env.example +8 -0
  107. agentforge/templates/docs-qa/.gitignore +7 -0
  108. agentforge/templates/docs-qa/README.md +14 -0
  109. agentforge/templates/docs-qa/agentforge.yaml +19 -0
  110. agentforge/templates/docs-qa/copier.yml +31 -0
  111. agentforge/templates/docs-qa/pyproject.toml +18 -0
  112. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  113. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  114. agentforge/templates/minimal/.env.example +11 -0
  115. agentforge/templates/minimal/.gitignore +10 -0
  116. agentforge/templates/minimal/README.md +28 -0
  117. agentforge/templates/minimal/agentforge.yaml +10 -0
  118. agentforge/templates/minimal/copier.yml +52 -0
  119. agentforge/templates/minimal/pyproject.toml +18 -0
  120. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  121. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
  122. agentforge/templates/patch-bot/.env.example +8 -0
  123. agentforge/templates/patch-bot/.gitignore +7 -0
  124. agentforge/templates/patch-bot/README.md +13 -0
  125. agentforge/templates/patch-bot/agentforge.yaml +15 -0
  126. agentforge/templates/patch-bot/copier.yml +31 -0
  127. agentforge/templates/patch-bot/pyproject.toml +18 -0
  128. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  129. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  130. agentforge/templates/research/.env.example +8 -0
  131. agentforge/templates/research/.gitignore +7 -0
  132. agentforge/templates/research/README.md +14 -0
  133. agentforge/templates/research/agentforge.yaml +17 -0
  134. agentforge/templates/research/copier.yml +31 -0
  135. agentforge/templates/research/pyproject.toml +18 -0
  136. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  137. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
  138. agentforge/templates/triage/.env.example +8 -0
  139. agentforge/templates/triage/.gitignore +7 -0
  140. agentforge/templates/triage/README.md +14 -0
  141. agentforge/templates/triage/agentforge.yaml +25 -0
  142. agentforge/templates/triage/copier.yml +31 -0
  143. agentforge/templates/triage/pyproject.toml +18 -0
  144. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  145. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
  146. agentforge/testing/__init__.py +69 -0
  147. agentforge/testing/conformance.py +40 -0
  148. agentforge/testing/factory.py +89 -0
  149. agentforge/testing/fixtures.py +42 -0
  150. agentforge/testing/llm.py +235 -0
  151. agentforge/testing/recording.py +177 -0
  152. agentforge/tools/__init__.py +41 -0
  153. agentforge_py-0.2.1.dist-info/METADATA +158 -0
  154. agentforge_py-0.2.1.dist-info/RECORD +157 -0
  155. agentforge_py-0.2.1.dist-info/WHEEL +4 -0
  156. agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
  157. agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,541 @@
1
+ """`MultiAgentSupervisor` — supervisor delegates to worker strategies.
2
+
3
+ Per feat-002 §4.4:
4
+
5
+ PHASE 1 — DELEGATE
6
+ Supervisor LLM call decides which workers to invoke and what
7
+ subtask each should solve. Returns a typed `_DelegationPlan`
8
+ (Pydantic) listing one `_WorkerAssignment(worker, task)` per
9
+ invocation. Unknown worker names are dropped with a logged
10
+ warning rather than crashing the run.
11
+
12
+ PHASE 2 — EXECUTE WORKERS
13
+ Workers run concurrently (asyncio.Semaphore caps at
14
+ `max_parallel_workers`). Each worker receives:
15
+ - A fresh `AgentState` with a derived `run_id`
16
+ (`"{parent_run_id}/{worker}-{idx}"`) and its own
17
+ sub-`task` from the assignment
18
+ - A sub-`RuntimeContext` with a *proportional* `BudgetPolicy`:
19
+ the parent's *remaining* USD is split evenly among workers
20
+ in this batch (so collective worker spend cannot exceed the
21
+ parent's remaining budget)
22
+ - The shared parent `MemoryStore` (workers can publish
23
+ findings to the same store)
24
+
25
+ Per-worker spend is committed back to the parent budget after
26
+ the worker finishes (`parent.budget.commit(worker_spend)`),
27
+ keeping accounting accurate at the supervisor level.
28
+
29
+ Worker exceptions are caught and recorded as a `delegate` step
30
+ with `metadata={"error": ...}`; the supervisor continues with
31
+ surviving worker outputs.
32
+
33
+ PHASE 3 — AGGREGATE
34
+ Supervisor LLM call synthesises worker outputs into the final
35
+ answer. If zero workers produced output (all errored, or
36
+ delegation plan was empty), the supervisor synthesises directly
37
+ from the original task.
38
+
39
+ Modern: structured Pydantic delegation plan (no free-form parsing);
40
+ proportional budget split with parent-budget reconciliation;
41
+ graceful degradation on worker failure.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import json
48
+ import logging
49
+ from collections.abc import AsyncIterator
50
+
51
+ from agentforge_core.contracts.strategy import ReasoningStrategy
52
+ from agentforge_core.observability.tracing import get_tracer
53
+ from agentforge_core.production.budget import BudgetPolicy
54
+ from agentforge_core.values.chat import StreamingEvent
55
+ from agentforge_core.values.messages import Message
56
+ from agentforge_core.values.state import AgentState
57
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
58
+
59
+ from agentforge.resolver_register import register_strategy
60
+ from agentforge.runtime import RUNTIME_KEY, RuntimeContext
61
+ from agentforge.strategies._base import StrategyBase, _events_for_new_steps, get_runtime
62
+
63
+ log = logging.getLogger(__name__)
64
+
65
+ DELEGATE_SYSTEM_PROMPT = (
66
+ "You are a supervisor coordinating specialist worker agents. The "
67
+ "available workers are listed below with short descriptions. Decide "
68
+ "which workers to invoke and what subtask each should solve. Return "
69
+ "ONLY a JSON object matching this schema (no other text):\n\n"
70
+ ' {{"assignments": [{{"worker": "<name>", "task": "<subtask text>"}}, ...]}}\n\n'
71
+ "Workers available:\n{worker_catalog}\n\n"
72
+ "Rules:\n"
73
+ "- Only assign workers from the list above (case-sensitive).\n"
74
+ "- Each subtask should be self-contained; workers do not see each "
75
+ "other's outputs.\n"
76
+ "- Prefer parallel independent subtasks. You may assign the same "
77
+ "worker more than once if it makes sense."
78
+ )
79
+
80
+ AGGREGATE_SYSTEM_PROMPT = (
81
+ "You are a supervisor synthesising the final answer from your "
82
+ "workers' outputs. Read the user's task and the per-worker results "
83
+ "below, then produce a clear, complete answer to the original task. "
84
+ "Do not introduce claims unsupported by the worker outputs."
85
+ )
86
+
87
+
88
+ # ----------------------------------------------------------------------
89
+ # LLM I/O schemas
90
+ # ----------------------------------------------------------------------
91
+
92
+
93
+ class _WorkerAssignment(BaseModel):
94
+ model_config = ConfigDict(frozen=True, strict=True)
95
+ worker: str = Field(min_length=1)
96
+ task: str = Field(min_length=1)
97
+
98
+
99
+ class _DelegationPlan(BaseModel):
100
+ model_config = ConfigDict(frozen=True, strict=True)
101
+ assignments: list[_WorkerAssignment] = Field(default_factory=list)
102
+
103
+
104
+ # ----------------------------------------------------------------------
105
+ # Worker output
106
+ # ----------------------------------------------------------------------
107
+
108
+
109
+ class _WorkerResult(BaseModel):
110
+ """Result of one worker invocation, recorded as a `delegate` step."""
111
+
112
+ model_config = ConfigDict(frozen=True, strict=True)
113
+ worker: str
114
+ task: str
115
+ output: str = ""
116
+ error: str | None = None
117
+ cost_usd: float = 0.0
118
+
119
+
120
+ # ----------------------------------------------------------------------
121
+ # Strategy
122
+ # ----------------------------------------------------------------------
123
+
124
+
125
+ @register_strategy("multi-agent")
126
+ class MultiAgentSupervisor(StrategyBase):
127
+ """Supervisor that delegates subtasks to worker strategies.
128
+
129
+ Per feat-002 §4.2 the constructor surface is locked at v0.1:
130
+
131
+ Args:
132
+ workers: Mapping from worker name (used in the delegation
133
+ plan) to a `ReasoningStrategy` instance that solves the
134
+ subtask. The same strategy class can appear under
135
+ different names (e.g. `"researcher"` and `"summariser"`
136
+ both `ReActLoop`s with different system prompts in
137
+ future). Required and non-empty.
138
+ max_parallel_workers: Max workers that may run concurrently.
139
+ Default 4.
140
+ max_rounds: Number of delegation rounds. Default 1 (one
141
+ delegation + one aggregation). Multi-round delegation
142
+ (supervisor revises plan after seeing partial results)
143
+ is reserved for v0.2.
144
+ worker_descriptions: Optional human-readable descriptions
145
+ shown to the supervisor LLM in the delegation prompt.
146
+ Maps worker name → description. Workers not in the dict
147
+ get a generic "general-purpose worker" description.
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ *,
153
+ workers: dict[str, ReasoningStrategy],
154
+ max_parallel_workers: int = 4,
155
+ max_rounds: int = 1,
156
+ worker_descriptions: dict[str, str] | None = None,
157
+ ) -> None:
158
+ if not workers:
159
+ raise ValueError("workers must be a non-empty dict")
160
+ for name in workers:
161
+ if not name or not isinstance(name, str):
162
+ raise ValueError(f"worker name must be a non-empty string, got {name!r}")
163
+ if max_parallel_workers < 1:
164
+ raise ValueError("max_parallel_workers must be >= 1")
165
+ if max_rounds < 1:
166
+ raise ValueError("max_rounds must be >= 1")
167
+ self._workers = dict(workers)
168
+ self._max_parallel_workers = max_parallel_workers
169
+ self._max_rounds = max_rounds
170
+ self._worker_descriptions = dict(worker_descriptions or {})
171
+
172
+ async def run(self, state: AgentState) -> AgentState:
173
+ runtime = get_runtime(state)
174
+ round_idx = 0
175
+ all_results: list[_WorkerResult] = []
176
+ tracer = get_tracer()
177
+
178
+ while round_idx < self._max_rounds:
179
+ with tracer.start_as_current_span(
180
+ "strategy.iteration",
181
+ attributes={
182
+ "agentforge.iteration": round_idx,
183
+ "agentforge.strategy": "multi_agent",
184
+ },
185
+ ):
186
+ new_results, should_break = await self._iterate_round(
187
+ state, runtime, round_idx, all_results
188
+ )
189
+ all_results.extend(new_results)
190
+ round_idx += 1
191
+ if should_break:
192
+ break
193
+
194
+ # PHASE 3 — AGGREGATE
195
+ await self._aggregate(state, round_idx + 1, all_results)
196
+ return state
197
+
198
+ async def stream(self, state: AgentState) -> AsyncIterator[StreamingEvent]:
199
+ """Per-round streaming override (feat-002 v0.3 polish).
200
+
201
+ Mirrors :meth:`run` but yields a ``step`` `StreamingEvent`
202
+ for each step recorded inside a round (delegate plan +
203
+ per-worker outputs; flushed after the round completes),
204
+ then the final aggregate step, then the terminal ``done``.
205
+ """
206
+ runtime = get_runtime(state)
207
+ round_idx = 0
208
+ all_results: list[_WorkerResult] = []
209
+ tracer = get_tracer()
210
+ before = len(state.steps)
211
+
212
+ while round_idx < self._max_rounds:
213
+ with tracer.start_as_current_span(
214
+ "strategy.iteration",
215
+ attributes={
216
+ "agentforge.iteration": round_idx,
217
+ "agentforge.strategy": "multi_agent",
218
+ },
219
+ ):
220
+ new_results, should_break = await self._iterate_round(
221
+ state, runtime, round_idx, all_results
222
+ )
223
+ for ev in _events_for_new_steps(state.steps, before):
224
+ yield ev
225
+ before = len(state.steps)
226
+ all_results.extend(new_results)
227
+ round_idx += 1
228
+ if should_break:
229
+ break
230
+
231
+ # PHASE 3 — AGGREGATE
232
+ await self._aggregate(state, round_idx + 1, all_results)
233
+ for ev in _events_for_new_steps(state.steps, before):
234
+ yield ev
235
+
236
+ yield StreamingEvent(
237
+ kind="done",
238
+ content={
239
+ "run_id": state.run_id,
240
+ "cost_usd": float(runtime.budget.spent_usd),
241
+ },
242
+ metadata={},
243
+ )
244
+
245
+ async def _iterate_round(
246
+ self,
247
+ state: AgentState,
248
+ runtime: RuntimeContext,
249
+ round_idx: int,
250
+ prior_results: list[_WorkerResult],
251
+ ) -> tuple[list[_WorkerResult], bool]:
252
+ """Run one delegation round.
253
+
254
+ Returns ``(new_results, should_break)``. ``should_break`` is
255
+ True when the supervisor cannot make further progress this
256
+ run — either the delegation plan was empty or every worker
257
+ errored. The caller is responsible for extending the
258
+ aggregate result list and incrementing ``round_idx``.
259
+ """
260
+ self._check_guardrails(state)
261
+
262
+ # PHASE 1 — DELEGATE
263
+ plan = await self._delegate(state, round_idx, prior=prior_results)
264
+ assignments = self._filter_assignments(plan.assignments)
265
+ if not assignments:
266
+ log.info(
267
+ "MultiAgentSupervisor: round %d produced no valid "
268
+ "assignments; proceeding to aggregation.",
269
+ round_idx,
270
+ )
271
+ return [], True
272
+
273
+ # PHASE 2 — EXECUTE WORKERS (proportional budget split)
274
+ results = await self._execute_workers(state, runtime, assignments, round_idx=round_idx)
275
+
276
+ if not any(r.error is None for r in results):
277
+ # Every worker failed in this round; nothing useful to
278
+ # iterate on. Bail out to aggregation rather than
279
+ # spinning the supervisor on the same failure.
280
+ log.warning(
281
+ "MultiAgentSupervisor: every worker errored in round "
282
+ "%d; aggregating with no successful outputs.",
283
+ round_idx,
284
+ )
285
+ return results, True
286
+
287
+ return results, False
288
+
289
+ # ------------------------------------------------------------------
290
+ # Phase 1 — delegate
291
+ # ------------------------------------------------------------------
292
+
293
+ async def _delegate(
294
+ self,
295
+ state: AgentState,
296
+ round_idx: int,
297
+ *,
298
+ prior: list[_WorkerResult],
299
+ ) -> _DelegationPlan:
300
+ """Ask the supervisor LLM for a delegation plan."""
301
+ catalog = "\n".join(
302
+ f" - {name}: {self._worker_descriptions.get(name, 'general-purpose worker')}"
303
+ for name in self._workers
304
+ )
305
+ prompt = DELEGATE_SYSTEM_PROMPT.format(worker_catalog=catalog)
306
+ messages: list[Message] = [Message(role="user", content=state.task)]
307
+ if prior:
308
+ prior_text = "\n".join(
309
+ f"- {r.worker}: {r.output if r.error is None else f'ERROR: {r.error}'}"
310
+ for r in prior
311
+ )
312
+ messages.append(
313
+ Message(
314
+ role="assistant",
315
+ content=f"Prior worker outputs from earlier rounds:\n{prior_text}",
316
+ )
317
+ )
318
+ response = await self._call_llm(
319
+ state,
320
+ iteration=round_idx + 1,
321
+ system=prompt,
322
+ messages=messages,
323
+ kind="plan",
324
+ )
325
+ try:
326
+ return _DelegationPlan.model_validate_json(_strip_code_fences(response.content))
327
+ except (ValidationError, json.JSONDecodeError, ValueError) as exc:
328
+ log.warning(
329
+ "MultiAgentSupervisor: delegation plan parse failed at round %d: %s",
330
+ round_idx,
331
+ exc,
332
+ )
333
+ return _DelegationPlan(assignments=[])
334
+
335
+ def _filter_assignments(self, assignments: list[_WorkerAssignment]) -> list[_WorkerAssignment]:
336
+ """Drop assignments referring to unknown workers (logged)."""
337
+ kept: list[_WorkerAssignment] = []
338
+ for a in assignments:
339
+ if a.worker in self._workers:
340
+ kept.append(a)
341
+ else:
342
+ log.warning(
343
+ "MultiAgentSupervisor: dropping assignment for unknown worker %r",
344
+ a.worker,
345
+ )
346
+ return kept
347
+
348
+ # ------------------------------------------------------------------
349
+ # Phase 2 — execute workers
350
+ # ------------------------------------------------------------------
351
+
352
+ async def _execute_workers(
353
+ self,
354
+ state: AgentState,
355
+ runtime: RuntimeContext,
356
+ assignments: list[_WorkerAssignment],
357
+ *,
358
+ round_idx: int,
359
+ ) -> list[_WorkerResult]:
360
+ """Run all assigned workers in parallel under a semaphore."""
361
+ per_worker_budget = self._derive_per_worker_budget(runtime.budget, len(assignments))
362
+ sem = asyncio.Semaphore(self._max_parallel_workers)
363
+
364
+ async def _one(idx: int, assignment: _WorkerAssignment) -> _WorkerResult:
365
+ async with sem:
366
+ return await self._run_worker(
367
+ state,
368
+ runtime,
369
+ assignment,
370
+ sub_budget_usd=per_worker_budget,
371
+ idx=idx,
372
+ )
373
+
374
+ results = await asyncio.gather(
375
+ *(_one(i, a) for i, a in enumerate(assignments)),
376
+ return_exceptions=False,
377
+ )
378
+ # Record one `delegate` step per worker outcome.
379
+ for r in results:
380
+ content: dict[str, object] = {
381
+ "worker": r.worker,
382
+ "task": r.task,
383
+ "output": r.output,
384
+ "cost_usd": r.cost_usd,
385
+ }
386
+ if r.error is not None:
387
+ content["error"] = r.error
388
+ self._record_step(
389
+ state,
390
+ iteration=round_idx + 1,
391
+ kind="delegate",
392
+ content=content,
393
+ cost_usd=r.cost_usd,
394
+ )
395
+ return list(results)
396
+
397
+ def _derive_per_worker_budget(self, parent: BudgetPolicy, n_workers: int) -> float:
398
+ """Split the parent's *remaining* USD evenly among workers."""
399
+ if n_workers <= 0:
400
+ return 0.0
401
+ return parent.remaining_usd() / n_workers
402
+
403
+ async def _run_worker(
404
+ self,
405
+ state: AgentState,
406
+ runtime: RuntimeContext,
407
+ assignment: _WorkerAssignment,
408
+ *,
409
+ sub_budget_usd: float,
410
+ idx: int,
411
+ ) -> _WorkerResult:
412
+ """Run a single worker with a sub-RuntimeContext.
413
+
414
+ On exception, returns a `_WorkerResult` with `error` populated
415
+ rather than propagating — the supervisor's contract is to keep
416
+ running with surviving workers.
417
+ """
418
+ worker = self._workers[assignment.worker]
419
+ sub_budget = BudgetPolicy(
420
+ usd=sub_budget_usd,
421
+ max_tokens=runtime.budget.max_tokens,
422
+ max_iterations=runtime.budget.max_iterations,
423
+ error_streak_limit=runtime.budget.error_streak_limit,
424
+ )
425
+ sub_runtime = RuntimeContext(
426
+ llm=runtime.llm,
427
+ tools=runtime.tools,
428
+ memory=runtime.memory,
429
+ budget=sub_budget,
430
+ system_prompt=runtime.system_prompt,
431
+ )
432
+ sub_run_id = f"{state.run_id}/{assignment.worker}-{idx}"
433
+ sub_state = AgentState(
434
+ run_id=sub_run_id,
435
+ task=assignment.task,
436
+ metadata={RUNTIME_KEY: sub_runtime},
437
+ )
438
+ try:
439
+ await worker.run(sub_state)
440
+ except Exception as exc:
441
+ # Roll the worker's spend back into the parent budget even on failure.
442
+ runtime.budget.commit(sub_budget.spent_usd, sub_budget.consumed_tokens)
443
+ log.warning(
444
+ "MultiAgentSupervisor: worker %r failed on subtask %r: %s",
445
+ assignment.worker,
446
+ assignment.task,
447
+ exc,
448
+ )
449
+ return _WorkerResult(
450
+ worker=assignment.worker,
451
+ task=assignment.task,
452
+ output="",
453
+ error=f"{type(exc).__name__}: {exc}",
454
+ cost_usd=sub_budget.spent_usd,
455
+ )
456
+
457
+ # Reconcile the sub-budget's spend into the parent budget. The
458
+ # _call_llm helper already committed against `sub_budget`; we
459
+ # mirror that into the parent so the supervisor's accounting is
460
+ # accurate (and so subsequent rounds see the spend).
461
+ runtime.budget.commit(sub_budget.spent_usd, sub_budget.consumed_tokens)
462
+
463
+ output = _extract_output(sub_state)
464
+ return _WorkerResult(
465
+ worker=assignment.worker,
466
+ task=assignment.task,
467
+ output=output,
468
+ error=None,
469
+ cost_usd=sub_budget.spent_usd,
470
+ )
471
+
472
+ # ------------------------------------------------------------------
473
+ # Phase 3 — aggregate
474
+ # ------------------------------------------------------------------
475
+
476
+ async def _aggregate(
477
+ self,
478
+ state: AgentState,
479
+ iteration: int,
480
+ results: list[_WorkerResult],
481
+ ) -> None:
482
+ """Synthesise worker outputs into a final answer."""
483
+ if results:
484
+ results_text = "\n\n".join(
485
+ f"--- {r.worker} ({'OK' if r.error is None else 'ERROR'}) ---\n"
486
+ f"Task: {r.task}\n"
487
+ f"Output: {r.output if r.error is None else r.error}"
488
+ for r in results
489
+ )
490
+ assistant_msg = Message(role="assistant", content=f"Worker outputs:\n{results_text}")
491
+ messages: list[Message] = [
492
+ Message(role="user", content=state.task),
493
+ assistant_msg,
494
+ ]
495
+ else:
496
+ messages = [Message(role="user", content=state.task)]
497
+
498
+ await self._call_llm(
499
+ state,
500
+ iteration=iteration,
501
+ system=AGGREGATE_SYSTEM_PROMPT,
502
+ messages=messages,
503
+ kind="synthesize",
504
+ )
505
+
506
+
507
+ # ----------------------------------------------------------------------
508
+ # Helpers
509
+ # ----------------------------------------------------------------------
510
+
511
+
512
+ def _extract_output(sub_state: AgentState) -> str:
513
+ """Pull the final text output from a worker's sub-state.
514
+
515
+ Workers terminate with a `synthesize` or `think` step; we take the
516
+ last step's textual content as the worker's output.
517
+ """
518
+ if not sub_state.steps:
519
+ return ""
520
+ last = sub_state.steps[-1]
521
+ content = last.content
522
+ if isinstance(content, str):
523
+ return content
524
+ return json.dumps(content)
525
+
526
+
527
+ def _strip_code_fences(content: str) -> str:
528
+ """Strip ```json ... ``` fences if the LLM wrapped the JSON in them."""
529
+ text = content.strip()
530
+ if text.startswith("```"):
531
+ first_newline = text.find("\n")
532
+ if first_newline == -1:
533
+ return text
534
+ body = text[first_newline + 1 :]
535
+ if body.endswith("```"):
536
+ body = body[: -len("```")]
537
+ return body.strip()
538
+ return text
539
+
540
+
541
+ __all__ = ["MultiAgentSupervisor"]