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.
- agentforge/__init__.py +114 -0
- agentforge/_testing/__init__.py +19 -0
- agentforge/_testing/fake_llm.py +126 -0
- agentforge/_testing/fake_tool.py +122 -0
- agentforge/_tools/__init__.py +14 -0
- agentforge/_tools/calculator.py +102 -0
- agentforge/_tools/decorator.py +300 -0
- agentforge/_tools/file_read.py +112 -0
- agentforge/_tools/shell.py +134 -0
- agentforge/_tools/web_search.py +207 -0
- agentforge/agent.py +817 -0
- agentforge/auth.py +42 -0
- agentforge/cli/__init__.py +18 -0
- agentforge/cli/_build.py +323 -0
- agentforge/cli/_scaffold_state.py +250 -0
- agentforge/cli/_shared_scaffold.py +174 -0
- agentforge/cli/config_cmd.py +174 -0
- agentforge/cli/db_cmd.py +262 -0
- agentforge/cli/debug_cmd.py +168 -0
- agentforge/cli/docs_cmd.py +217 -0
- agentforge/cli/eval_cmd.py +181 -0
- agentforge/cli/health_cmd.py +139 -0
- agentforge/cli/list_modules.py +85 -0
- agentforge/cli/main.py +81 -0
- agentforge/cli/manifest_apply.py +368 -0
- agentforge/cli/module_cmd.py +247 -0
- agentforge/cli/new_cmd.py +171 -0
- agentforge/cli/run_cmd.py +234 -0
- agentforge/cli/upgrade_cmd.py +230 -0
- agentforge/config/__init__.py +45 -0
- agentforge/eval/__init__.py +18 -0
- agentforge/eval/consistency.py +107 -0
- agentforge/eval/coverage.py +100 -0
- agentforge/eval/format_compliance.py +107 -0
- agentforge/eval/regression.py +143 -0
- agentforge/findings.py +166 -0
- agentforge/guardrails/__init__.py +32 -0
- agentforge/guardrails/allowlist.py +49 -0
- agentforge/guardrails/capability_check.py +58 -0
- agentforge/guardrails/engine.py +289 -0
- agentforge/guardrails/pii_redact_basic.py +61 -0
- agentforge/guardrails/prompt_injection_basic.py +90 -0
- agentforge/memory/__init__.py +16 -0
- agentforge/memory/in_memory.py +130 -0
- agentforge/memory/in_memory_graph.py +262 -0
- agentforge/memory/in_memory_vector.py +167 -0
- agentforge/pipeline/__init__.py +26 -0
- agentforge/pipeline/engine.py +189 -0
- agentforge/pipeline/errors.py +19 -0
- agentforge/pipeline/tool.py +93 -0
- agentforge/py.typed +0 -0
- agentforge/recording.py +189 -0
- agentforge/renderers/__init__.py +28 -0
- agentforge/renderers/_defaults.py +32 -0
- agentforge/renderers/markdown.py +44 -0
- agentforge/renderers/patch_applier.py +46 -0
- agentforge/renderers/registry.py +108 -0
- agentforge/renderers/scorecard.py +59 -0
- agentforge/renderers/span_table.py +71 -0
- agentforge/replay.py +260 -0
- agentforge/resolver_register.py +41 -0
- agentforge/retrieval.py +410 -0
- agentforge/runtime.py +63 -0
- agentforge/strategies/__init__.py +27 -0
- agentforge/strategies/_base.py +280 -0
- agentforge/strategies/_plan.py +93 -0
- agentforge/strategies/multi_agent.py +541 -0
- agentforge/strategies/plan_execute.py +506 -0
- agentforge/strategies/react.py +237 -0
- agentforge/strategies/tot.py +472 -0
- agentforge/templates/_shared/.cursorrules +12 -0
- agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
- agentforge/templates/_shared/.gitkeep +0 -0
- agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
- agentforge/templates/_shared/CLAUDE.md +13 -0
- agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
- agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
- agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
- agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
- agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
- agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
- agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
- agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
- agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
- agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
- agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
- agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
- agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
- agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
- agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
- agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
- agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
- agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
- agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
- agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
- agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
- agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
- agentforge/templates/code-reviewer/.env.example +8 -0
- agentforge/templates/code-reviewer/.gitignore +7 -0
- agentforge/templates/code-reviewer/README.md +12 -0
- agentforge/templates/code-reviewer/agentforge.yaml +23 -0
- agentforge/templates/code-reviewer/copier.yml +34 -0
- agentforge/templates/code-reviewer/pyproject.toml +18 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/docs-qa/.env.example +8 -0
- agentforge/templates/docs-qa/.gitignore +7 -0
- agentforge/templates/docs-qa/README.md +14 -0
- agentforge/templates/docs-qa/agentforge.yaml +19 -0
- agentforge/templates/docs-qa/copier.yml +31 -0
- agentforge/templates/docs-qa/pyproject.toml +18 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/minimal/.env.example +11 -0
- agentforge/templates/minimal/.gitignore +10 -0
- agentforge/templates/minimal/README.md +28 -0
- agentforge/templates/minimal/agentforge.yaml +10 -0
- agentforge/templates/minimal/copier.yml +52 -0
- agentforge/templates/minimal/pyproject.toml +18 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
- agentforge/templates/patch-bot/.env.example +8 -0
- agentforge/templates/patch-bot/.gitignore +7 -0
- agentforge/templates/patch-bot/README.md +13 -0
- agentforge/templates/patch-bot/agentforge.yaml +15 -0
- agentforge/templates/patch-bot/copier.yml +31 -0
- agentforge/templates/patch-bot/pyproject.toml +18 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/research/.env.example +8 -0
- agentforge/templates/research/.gitignore +7 -0
- agentforge/templates/research/README.md +14 -0
- agentforge/templates/research/agentforge.yaml +17 -0
- agentforge/templates/research/copier.yml +31 -0
- agentforge/templates/research/pyproject.toml +18 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
- agentforge/templates/triage/.env.example +8 -0
- agentforge/templates/triage/.gitignore +7 -0
- agentforge/templates/triage/README.md +14 -0
- agentforge/templates/triage/agentforge.yaml +25 -0
- agentforge/templates/triage/copier.yml +31 -0
- agentforge/templates/triage/pyproject.toml +18 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
- agentforge/testing/__init__.py +69 -0
- agentforge/testing/conformance.py +40 -0
- agentforge/testing/factory.py +89 -0
- agentforge/testing/fixtures.py +42 -0
- agentforge/testing/llm.py +235 -0
- agentforge/testing/recording.py +177 -0
- agentforge/tools/__init__.py +41 -0
- agentforge_py-0.2.1.dist-info/METADATA +158 -0
- agentforge_py-0.2.1.dist-info/RECORD +157 -0
- agentforge_py-0.2.1.dist-info/WHEEL +4 -0
- agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
- 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"]
|