agentx-dev 3.2.0__tar.gz → 3.3.0__tar.gz
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.
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/CHANGELOG.md +68 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/PKG-INFO +1 -1
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Supervisor.py +599 -61
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/__init__.py +2 -1
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/PKG-INFO +1 -1
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/pyproject.toml +1 -1
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/AGENTX.md +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/CONTRIBUTING.md +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/LICENSE +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/MANIFEST.in +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/README.md +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Agents/Agent.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Agents/__init__.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/AsyncTools.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/AutoSetup.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Cache.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/ChatModel.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Compiler.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Config.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/DefaultTools.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Embeddings.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Evals.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Handoffs.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Loader.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/MCP.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Memory.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Observability.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Planner.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/AgentRun.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/AsyncAgentRun.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/__init__.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/promptTemplate.yaml +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Session.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Splitters.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Streaming.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Tools.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/__init__.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/chroma_store.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/pg_store.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/qdrant_store.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/WebTools.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/resources/__init__.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/resources/promptTemplate.yaml +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/SOURCES.txt +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/dependency_links.txt +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/requires.txt +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/top_level.txt +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/README.md +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/agentic_rag_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/async_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/async_quickstart.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/auto_features_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/caching_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/chatbot_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/complete_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/concurrent_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/concurrent_tool_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/file_agent_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/function_calling_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/mcp_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/mcp_github_triage_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/observability_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/orchestration_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/planner_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/robust_link_scraper.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/supervisor_codebase_analysis_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/supervisor_example.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/sync_quickstart.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/v3_1_1_features_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/v3_1_comprehensive_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/v3_1_features_demo.py +0 -0
- {agentx_dev-3.2.0 → agentx_dev-3.3.0}/setup.cfg +0 -0
|
@@ -4,6 +4,74 @@ All notable changes to `agentx-dev` are documented here. Format loosely
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/); versioning is
|
|
5
5
|
[Semver](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [3.3.0] - 2026-08-19
|
|
8
|
+
|
|
9
|
+
Dependency DAGs for the Supervisor. Plans declare which steps consume
|
|
10
|
+
which, and the executor derives ordering, parallelism, AND context
|
|
11
|
+
routing from those edges — unifying the old split where sequential
|
|
12
|
+
mode had threading but no parallelism and concurrent mode had
|
|
13
|
+
parallelism but no threading. Design: docs/design/3.3-depends-on-dag.md.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`depends_on` plan steps.** Every plan step now carries an `id`;
|
|
18
|
+
a step that consumes an earlier step's output lists that id in
|
|
19
|
+
`depends_on`. Sync `Supervisor` executes in stable topological
|
|
20
|
+
order; `AsyncSupervisor` runs a completion-driven scheduler where a
|
|
21
|
+
step starts the MOMENT its dependencies finish (not on wave
|
|
22
|
+
barriers) and independent steps run concurrently. In DAG mode each
|
|
23
|
+
step is threaded ONLY its direct dependencies' results — explicit
|
|
24
|
+
routing instead of "everything prior", which also stops the
|
|
25
|
+
per-entry context budget shrinking as plans grow.
|
|
26
|
+
|
|
27
|
+
- **Plan sanitization that never fails a run.** Missing/duplicate ids
|
|
28
|
+
auto-assigned, unknown dependencies dropped (a planner typo degrades
|
|
29
|
+
to a root step, not a dead run), self-deps dropped, cycles broken
|
|
30
|
+
deterministically (back-edge in plan order), spawn steps cannot be
|
|
31
|
+
depended on. If repairs were needed, the plan is re-requested once
|
|
32
|
+
(`max_plan_retries`, default 1) with the repair warnings appended;
|
|
33
|
+
the sanitized original is kept when the retry is no better.
|
|
34
|
+
|
|
35
|
+
- **Failure cascade + `skipped` flag.** A step whose dependency failed
|
|
36
|
+
(after retries / success-check) is skipped, transitively, with
|
|
37
|
+
`SubtaskResult.skipped=True` and an error naming the failed dep.
|
|
38
|
+
Independent branches keep running; synthesis runs over what
|
|
39
|
+
succeeded. Skipped steps never dispatch — no tokens burned
|
|
40
|
+
downstream of garbage.
|
|
41
|
+
|
|
42
|
+
- **`skip_when` conditional execution.** A step may declare
|
|
43
|
+
`{"step": <direct dep id>, "field": <typed output field>, "is": <value>}`;
|
|
44
|
+
evaluated in Python (no LLM call) against the dependency's 3.2
|
|
45
|
+
structured output, dotted paths supported, strictly FAIL-OPEN (any
|
|
46
|
+
doubt → the step runs). Condition-skips do NOT cascade — dependents
|
|
47
|
+
treat them as empty successes ("retrieval unnecessary" is not
|
|
48
|
+
"answering impossible").
|
|
49
|
+
|
|
50
|
+
- **`Specialist` registry entries.** `agents={}` now also accepts
|
|
51
|
+
`Specialist(description, runner, depends_on=[...names...],
|
|
52
|
+
output_schema=..., when_to_use=...)`. The extras render into the
|
|
53
|
+
planning catalog (`typically after:` / `returns: Schema(fields)` /
|
|
54
|
+
`use when:`) so the planner can write real dependency graphs and
|
|
55
|
+
`skip_when` conditions against actual field names. `depends_on`
|
|
56
|
+
here is a planner HINT, never an execution constraint (the same
|
|
57
|
+
specialist can appear twice in one plan; step-ids disambiguate).
|
|
58
|
+
Classic `(description, runner)` tuples keep working — they are
|
|
59
|
+
wrapped internally, and `Specialist` tuple-unpacks for older code.
|
|
60
|
+
|
|
61
|
+
- **`AsyncSupervisor(max_parallel=N)`.** Caps concurrent sub-tasks for
|
|
62
|
+
rate-limited deployments. `sequential=True` is now sugar for
|
|
63
|
+
`max_parallel=1` with all-prior threading.
|
|
64
|
+
|
|
65
|
+
- **`SubtaskResult.step_id` / `.depends_on` / `.skipped`** and a
|
|
66
|
+
`step_id` field on `dispatch` / `subtask_result` stream events.
|
|
67
|
+
|
|
68
|
+
### Backward compatibility
|
|
69
|
+
|
|
70
|
+
A plan where NO step declares `depends_on` runs with byte-identical
|
|
71
|
+
legacy semantics: sync + async-sequential thread all prior results in
|
|
72
|
+
plan order; async-concurrent runs everything at once with no
|
|
73
|
+
threading. Verified by regression tests against the 3.2 behaviour.
|
|
74
|
+
|
|
7
75
|
## [3.2.0] - 2026-08-13
|
|
8
76
|
|
|
9
77
|
Typed multi-agent pipelines. Specialists can now declare a Pydantic
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-dev
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: A production-grade Python framework for building LLM agents. Multi-provider chat, permission-gated tools, RAG (in-mem + Chroma/Qdrant/pgvector), semantic memory, Supervisor + Handoffs multi-agent orchestration with streaming, evals harness, prompt caching, Batch API, prompt-optimizer, MCP integration.
|
|
5
5
|
Author-email: Bruce-Arhin Shadrach <brucearhin098@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -220,6 +220,13 @@ class SubtaskResult(BaseModel):
|
|
|
220
220
|
structured data that downstream steps can consume without re-parsing
|
|
221
221
|
prose. Stays ``None`` for schema-less specialists, so existing
|
|
222
222
|
consumers of ``content`` are unaffected.
|
|
223
|
+
|
|
224
|
+
3.3 additions (all optional; absent in legacy plans):
|
|
225
|
+
``step_id`` is the plan step's id, ``depends_on`` the ids it consumed,
|
|
226
|
+
and ``skipped`` marks steps that never dispatched — either because a
|
|
227
|
+
dependency failed (``error`` explains the cascade) or because a
|
|
228
|
+
``skip_when`` condition matched (``error`` is None; the reason is in
|
|
229
|
+
``content``).
|
|
223
230
|
"""
|
|
224
231
|
|
|
225
232
|
agent: str
|
|
@@ -227,10 +234,63 @@ class SubtaskResult(BaseModel):
|
|
|
227
234
|
content: str
|
|
228
235
|
error: Optional[str] = None
|
|
229
236
|
output: Optional[Any] = None
|
|
237
|
+
step_id: Optional[str] = None
|
|
238
|
+
depends_on: List[str] = Field(default_factory=list)
|
|
239
|
+
skipped: bool = False
|
|
230
240
|
|
|
231
241
|
model_config = {"arbitrary_types_allowed": True}
|
|
232
242
|
|
|
233
243
|
|
|
244
|
+
@dataclass
|
|
245
|
+
class Specialist:
|
|
246
|
+
"""Registry entry for one specialist (3.3).
|
|
247
|
+
|
|
248
|
+
``agents={}`` accepts either the classic ``(description, runner)``
|
|
249
|
+
tuple or a ``Specialist``. Tuples are wrapped internally, so nothing
|
|
250
|
+
breaks; ``Specialist`` adds planner-facing metadata:
|
|
251
|
+
|
|
252
|
+
- ``depends_on``: names of specialists this one TYPICALLY follows.
|
|
253
|
+
A hint rendered into the planning catalog — never an execution
|
|
254
|
+
constraint (the same specialist can appear twice in one plan, so
|
|
255
|
+
name-level deps are ambiguous at runtime; step-ids are not).
|
|
256
|
+
- ``output_schema``: shown in the catalog so the planner can write
|
|
257
|
+
``skip_when`` conditions against real field names. Defaults from
|
|
258
|
+
``runner.output_schema`` when unset.
|
|
259
|
+
- ``when_to_use``: extra routing guidance for the planner.
|
|
260
|
+
|
|
261
|
+
Iterating a Specialist yields ``(description, runner)`` so existing
|
|
262
|
+
tuple-unpacking call sites keep working unchanged.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
description: str
|
|
266
|
+
runner: Any
|
|
267
|
+
depends_on: List[str] = field(default_factory=list)
|
|
268
|
+
output_schema: Optional[type] = None
|
|
269
|
+
when_to_use: str = ""
|
|
270
|
+
|
|
271
|
+
def __post_init__(self):
|
|
272
|
+
if self.output_schema is None:
|
|
273
|
+
self.output_schema = getattr(self.runner, "output_schema", None)
|
|
274
|
+
|
|
275
|
+
def __iter__(self):
|
|
276
|
+
# Tuple-compat: `desc, runner = specialist` and
|
|
277
|
+
# `for name, (desc, _) in agents.items()` both keep working.
|
|
278
|
+
return iter((self.description, self.runner))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _normalize_agents(agents: Dict[str, Any]) -> Dict[str, Specialist]:
|
|
282
|
+
"""Wrap classic ``(description, runner)`` tuples as ``Specialist``.
|
|
283
|
+
Existing Specialist values pass through untouched."""
|
|
284
|
+
out: Dict[str, Specialist] = {}
|
|
285
|
+
for name, entry in (agents or {}).items():
|
|
286
|
+
if isinstance(entry, Specialist):
|
|
287
|
+
out[name] = entry
|
|
288
|
+
else:
|
|
289
|
+
desc, runner = entry
|
|
290
|
+
out[name] = Specialist(description=desc, runner=runner)
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
234
294
|
class SupervisorResult(BaseModel):
|
|
235
295
|
"""Aggregate result produced by a Supervisor run."""
|
|
236
296
|
|
|
@@ -336,10 +396,13 @@ Rules for writing the plan (read these carefully — the shape of your plan matt
|
|
|
336
396
|
|
|
337
397
|
2. MERGE SEQUENTIAL WORK FOR THE SAME SPECIALIST. If two adjacent steps would both go to the same agent, they should almost always be ONE step. Bad: "write inspect.py" + "run inspect.py" + "report the output" (three python_agent calls). Good: "write inspect.py that scrapes X, run it, and report the title / link count / contacts it prints" (one python_agent call). The specialist's own reasoning loop handles the sequencing.
|
|
338
398
|
|
|
339
|
-
3.
|
|
340
|
-
|
|
341
|
-
-
|
|
342
|
-
-
|
|
399
|
+
3. DECLARE DEPENDENCIES WITH depends_on. Give every step an "id" (a short snake_case name). When a step CONSUMES an earlier step's output, list that step's id in its "depends_on". The dispatched step then receives exactly those steps' findings as "PRIOR SUB-TASK FINDINGS" context (structured JSON when the earlier specialist emits typed output, otherwise its answer text). Dependency CHAINS ARE GOOD design: intent-analysis feeding retrieval feeding reranking is a strong plan.
|
|
400
|
+
Rules:
|
|
401
|
+
- depends_on lists DIRECT dependencies only, but ALL of them: if a step reads BOTH the intent analysis AND the retrieval output, it depends on both ids — transitive context is NOT forwarded automatically.
|
|
402
|
+
- Do NOT chain independent steps. Steps with no dependency between them run IN PARALLEL — missing edges are what makes the plan fast. "Summarize topic A" and "summarize topic B" share no data: no depends_on between them.
|
|
403
|
+
- Steps that would go to the SAME specialist back-to-back should still be merged into one (rule 2) — chains hand work BETWEEN different specialists, they don't split one specialist's work.
|
|
404
|
+
- Write each dependent step's query as an instruction about what to DO with the prior findings ("using the intent analysis, retrieve the top 10 candidate passages"), never a request to re-state them.
|
|
405
|
+
- A step may be skipped conditionally with "skip_when": {{"step": "<direct dep id>", "field": "<field on that step's typed output>", "is": <value>}}. Use it to short-circuit unnecessary work (e.g. skip retrieval when the intent step returns needs_rag=false). Only reference a DIRECT dependency and only fields its specialist actually returns (the catalog lists them).
|
|
343
406
|
|
|
344
407
|
4. SKIP SPECULATIVE HOUSEKEEPING. Don't add a "list files first to see what's there" step just because it feels safer. Specialists handle their own preconditions internally. Bad plan: (a) list ./workspace, (b) delete files in ./workspace, (c) write inspect.py. Good plan: (a) clear ./workspace and write inspect.py.
|
|
345
408
|
|
|
@@ -353,10 +416,13 @@ Respond ONLY with valid JSON in this exact format (no code fences, no extra text
|
|
|
353
416
|
|
|
354
417
|
{{
|
|
355
418
|
"plan": [
|
|
356
|
-
{{"agent": "<agent_name>", "query": "<specific
|
|
419
|
+
{{"id": "<short_snake_case_id>", "agent": "<agent_name>", "query": "<specific sub-task>"}},
|
|
420
|
+
{{"id": "<id2>", "agent": "<agent_name>", "query": "<sub-task consuming id1's output>", "depends_on": ["<short_snake_case_id>"]}},
|
|
357
421
|
...
|
|
358
422
|
]
|
|
359
423
|
}}
|
|
424
|
+
|
|
425
|
+
"id" is required on every step; "depends_on" and "skip_when" only where rule 3 calls for them. Steps without depends_on are independent roots and may run in parallel.
|
|
360
426
|
"""
|
|
361
427
|
|
|
362
428
|
SUPERVISOR_SYNTHESIZE_PROMPT = """You are answering the user's question directly, using ONLY the facts the specialists explicitly reported.
|
|
@@ -531,6 +597,233 @@ def _evaluate_success_verdict(verdict) -> tuple:
|
|
|
531
597
|
return (True, "") if verdict else (False, generic)
|
|
532
598
|
|
|
533
599
|
|
|
600
|
+
# ----------------------------------------------------------------------------
|
|
601
|
+
# 3.3: plan-graph helpers (pure functions — unit-testable without a model)
|
|
602
|
+
# ----------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
def _render_agent_catalog(agents: Dict[str, Any]) -> str:
|
|
605
|
+
"""Render the planner-facing catalog. Specialist entries (3.3) get
|
|
606
|
+
their extra metadata lines; classic tuples render as before."""
|
|
607
|
+
lines: List[str] = []
|
|
608
|
+
for name, entry in agents.items():
|
|
609
|
+
if isinstance(entry, Specialist):
|
|
610
|
+
lines.append(f"- {name}: {entry.description}")
|
|
611
|
+
if entry.depends_on:
|
|
612
|
+
lines.append(f" typically after: {', '.join(entry.depends_on)}")
|
|
613
|
+
if entry.output_schema is not None:
|
|
614
|
+
try:
|
|
615
|
+
fields = ", ".join(entry.output_schema.model_fields.keys())
|
|
616
|
+
lines.append(f" returns: {entry.output_schema.__name__}({fields})")
|
|
617
|
+
except Exception:
|
|
618
|
+
lines.append(f" returns: {getattr(entry.output_schema, '__name__', 'typed output')}")
|
|
619
|
+
if entry.when_to_use:
|
|
620
|
+
lines.append(f" use when: {entry.when_to_use}")
|
|
621
|
+
else:
|
|
622
|
+
desc, _ = entry
|
|
623
|
+
lines.append(f"- {name}: {desc}")
|
|
624
|
+
return "\n".join(lines)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _plan_repair_note(repairs: List[str]) -> str:
|
|
628
|
+
"""Feedback block appended to the planning prompt on a replan after
|
|
629
|
+
sanitization had to fix the previous attempt's graph."""
|
|
630
|
+
bullet = "\n".join(f"- {r}" for r in repairs)
|
|
631
|
+
return (
|
|
632
|
+
"\n\nYOUR PREVIOUS PLAN HAD DEPENDENCY ERRORS that were auto-repaired:\n"
|
|
633
|
+
f"{bullet}\n"
|
|
634
|
+
"Write the plan again with a valid dependency graph: every "
|
|
635
|
+
"depends_on entry must name an existing step id, no step may "
|
|
636
|
+
"depend on itself or on a __spawn__ step, and the graph must "
|
|
637
|
+
"contain no cycles."
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _plan_uses_deps(plan: List[dict]) -> bool:
|
|
642
|
+
"""True when ANY step declares ``depends_on`` — the DAG-mode switch.
|
|
643
|
+
A dep-free plan keeps byte-identical legacy semantics."""
|
|
644
|
+
return any(
|
|
645
|
+
isinstance(step, dict) and step.get("depends_on")
|
|
646
|
+
for step in plan
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _sanitize_plan(plan: List[dict], verbose: bool = False) -> Tuple[List[dict], List[str]]:
|
|
651
|
+
"""Normalize a planner-emitted plan into a valid DAG. Deterministic;
|
|
652
|
+
never raises. Returns ``(plan, repairs)`` where ``repairs`` lists
|
|
653
|
+
every fix made (empty = the plan was already clean). The list feeds
|
|
654
|
+
the optional plan-repair replan loop.
|
|
655
|
+
|
|
656
|
+
Rules, in order (see docs/design/3.3-depends-on-dag.md §3.1):
|
|
657
|
+
1. auto-assign missing/duplicate ids as ``step_N`` (1-based position)
|
|
658
|
+
2. drop depends_on entries naming unknown step ids
|
|
659
|
+
3. drop self-dependencies
|
|
660
|
+
4. break cycles by dropping the back-edge in plan order
|
|
661
|
+
5. spawn steps cannot be depended on (such deps are dropped)
|
|
662
|
+
"""
|
|
663
|
+
repairs: List[str] = []
|
|
664
|
+
plan = [dict(step) for step in plan if isinstance(step, dict)]
|
|
665
|
+
|
|
666
|
+
# -- 1. ids ------------------------------------------------------------
|
|
667
|
+
seen: set = set()
|
|
668
|
+
for i, step in enumerate(plan, 1):
|
|
669
|
+
sid = step.get("id")
|
|
670
|
+
if not isinstance(sid, str) or not sid.strip() or sid in seen:
|
|
671
|
+
new_id = f"step_{i}"
|
|
672
|
+
# Extremely defensive: if the auto-name itself collides with a
|
|
673
|
+
# planner-chosen id, suffix until unique.
|
|
674
|
+
while new_id in seen:
|
|
675
|
+
new_id += "_"
|
|
676
|
+
if sid in seen:
|
|
677
|
+
repairs.append(f"duplicate id {sid!r} at position {i} renamed to {new_id!r}")
|
|
678
|
+
step["id"] = new_id
|
|
679
|
+
seen.add(step["id"])
|
|
680
|
+
|
|
681
|
+
ids = [s["id"] for s in plan]
|
|
682
|
+
id_pos = {sid: i for i, sid in enumerate(ids)}
|
|
683
|
+
spawn_ids = {s["id"] for s in plan if s.get("agent") == "__spawn__"}
|
|
684
|
+
|
|
685
|
+
# -- 2/3/5. dep validation ----------------------------------------------
|
|
686
|
+
for step in plan:
|
|
687
|
+
deps = step.get("depends_on") or []
|
|
688
|
+
if not isinstance(deps, list):
|
|
689
|
+
repairs.append(f"step {step['id']!r}: depends_on was not a list -- dropped")
|
|
690
|
+
step["depends_on"] = []
|
|
691
|
+
continue
|
|
692
|
+
clean: List[str] = []
|
|
693
|
+
for d in deps:
|
|
694
|
+
if d == step["id"]:
|
|
695
|
+
repairs.append(f"step {step['id']!r}: self-dependency dropped")
|
|
696
|
+
elif d not in id_pos:
|
|
697
|
+
repairs.append(f"step {step['id']!r}: unknown dependency {d!r} dropped")
|
|
698
|
+
elif d in spawn_ids:
|
|
699
|
+
repairs.append(
|
|
700
|
+
f"step {step['id']!r}: dependency on spawn step {d!r} dropped "
|
|
701
|
+
f"(spawn steps are bookkeeping, not data producers)"
|
|
702
|
+
)
|
|
703
|
+
elif d not in clean:
|
|
704
|
+
clean.append(d)
|
|
705
|
+
step["depends_on"] = clean
|
|
706
|
+
|
|
707
|
+
# -- 4. cycle breaking (Kahn's; drop the back-edge in plan order) -------
|
|
708
|
+
while True:
|
|
709
|
+
indeg = {sid: 0 for sid in ids}
|
|
710
|
+
dependents: Dict[str, List[str]] = {sid: [] for sid in ids}
|
|
711
|
+
for step in plan:
|
|
712
|
+
for d in step["depends_on"]:
|
|
713
|
+
indeg[step["id"]] += 1
|
|
714
|
+
dependents[d].append(step["id"])
|
|
715
|
+
queue = [sid for sid in ids if indeg[sid] == 0]
|
|
716
|
+
visited = 0
|
|
717
|
+
qi = 0
|
|
718
|
+
while qi < len(queue):
|
|
719
|
+
sid = queue[qi]; qi += 1
|
|
720
|
+
visited += 1
|
|
721
|
+
for dep_id in dependents[sid]:
|
|
722
|
+
indeg[dep_id] -= 1
|
|
723
|
+
if indeg[dep_id] == 0:
|
|
724
|
+
queue.append(dep_id)
|
|
725
|
+
if visited == len(ids):
|
|
726
|
+
break
|
|
727
|
+
# Cycle exists. Among cyclic nodes, find the edge whose SOURCE is
|
|
728
|
+
# latest in plan order and TARGET earliest — the back-edge — and
|
|
729
|
+
# drop it. Plan order is the planner's own statement of intended
|
|
730
|
+
# sequence, so the forward reading survives.
|
|
731
|
+
cyclic = {sid for sid in ids if indeg[sid] > 0}
|
|
732
|
+
back_edge = None # (source_dep, step_id) — step depends_on source
|
|
733
|
+
for step in plan:
|
|
734
|
+
if step["id"] not in cyclic:
|
|
735
|
+
continue
|
|
736
|
+
for d in step["depends_on"]:
|
|
737
|
+
if d in cyclic and id_pos[d] > id_pos[step["id"]]:
|
|
738
|
+
cand = (d, step["id"])
|
|
739
|
+
if back_edge is None or id_pos[d] > id_pos[back_edge[0]]:
|
|
740
|
+
back_edge = cand
|
|
741
|
+
if back_edge is None:
|
|
742
|
+
# Pure forward-edge cycle can't exist; belt-and-braces: drop
|
|
743
|
+
# the first cyclic step's first dep so the loop terminates.
|
|
744
|
+
for step in plan:
|
|
745
|
+
if step["id"] in cyclic and step["depends_on"]:
|
|
746
|
+
back_edge = (step["depends_on"][0], step["id"])
|
|
747
|
+
break
|
|
748
|
+
src, tgt = back_edge
|
|
749
|
+
plan[[s["id"] for s in plan].index(tgt)]["depends_on"].remove(src)
|
|
750
|
+
repairs.append(f"cycle broken: dropped dependency {src!r} from step {tgt!r}")
|
|
751
|
+
|
|
752
|
+
if repairs and verbose:
|
|
753
|
+
for r in repairs:
|
|
754
|
+
print(f"{_C_ERROR}[supervisor.plan] repaired: {r}{_C_RESET}")
|
|
755
|
+
return plan, repairs
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _topo_order(plan: List[dict]) -> List[int]:
|
|
759
|
+
"""Stable topological order over plan indices: a step never precedes
|
|
760
|
+
its dependencies, and ties break by plan position. Assumes the plan
|
|
761
|
+
has been through ``_sanitize_plan`` (acyclic, valid ids)."""
|
|
762
|
+
ids = [s["id"] for s in plan]
|
|
763
|
+
id_idx = {sid: i for i, sid in enumerate(ids)}
|
|
764
|
+
indeg = [len(s.get("depends_on") or []) for s in plan]
|
|
765
|
+
dependents: List[List[int]] = [[] for _ in plan]
|
|
766
|
+
for i, step in enumerate(plan):
|
|
767
|
+
for d in step.get("depends_on") or []:
|
|
768
|
+
dependents[id_idx[d]].append(i)
|
|
769
|
+
|
|
770
|
+
import heapq
|
|
771
|
+
ready = [i for i, deg in enumerate(indeg) if deg == 0]
|
|
772
|
+
heapq.heapify(ready)
|
|
773
|
+
order: List[int] = []
|
|
774
|
+
while ready:
|
|
775
|
+
i = heapq.heappop(ready) # smallest plan index first → stable
|
|
776
|
+
order.append(i)
|
|
777
|
+
for j in dependents[i]:
|
|
778
|
+
indeg[j] -= 1
|
|
779
|
+
if indeg[j] == 0:
|
|
780
|
+
heapq.heappush(ready, j)
|
|
781
|
+
return order
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _evaluate_skip_when(
|
|
785
|
+
cond: Any,
|
|
786
|
+
results_by_id: Dict[str, SubtaskResult],
|
|
787
|
+
step_deps: List[str],
|
|
788
|
+
verbose: bool = False,
|
|
789
|
+
) -> Tuple[bool, str]:
|
|
790
|
+
"""Evaluate a step's ``skip_when`` condition against a DIRECT
|
|
791
|
+
dependency's structured output. Returns ``(skip, reason)``.
|
|
792
|
+
|
|
793
|
+
FAIL-OPEN by design: malformed condition, non-dep step reference,
|
|
794
|
+
missing output, missing field, comparison error — every failure
|
|
795
|
+
path returns ``(False, ...)`` and the step RUNS. A skip must be
|
|
796
|
+
provably justified. Single operator: ``is`` (equality). Dotted
|
|
797
|
+
field paths supported (``"meta.confidence"``).
|
|
798
|
+
"""
|
|
799
|
+
if not isinstance(cond, dict):
|
|
800
|
+
return False, ""
|
|
801
|
+
ref = cond.get("step")
|
|
802
|
+
fld = cond.get("field")
|
|
803
|
+
if "is" not in cond or not isinstance(ref, str) or not isinstance(fld, str):
|
|
804
|
+
return False, ""
|
|
805
|
+
if ref not in step_deps:
|
|
806
|
+
if verbose:
|
|
807
|
+
print(f"{_C_ERROR}[supervisor.skip_when] step {ref!r} is not a "
|
|
808
|
+
f"direct dependency -- condition ignored (fail-open){_C_RESET}")
|
|
809
|
+
return False, ""
|
|
810
|
+
dep = results_by_id.get(ref)
|
|
811
|
+
if dep is None or dep.output is None:
|
|
812
|
+
return False, ""
|
|
813
|
+
try:
|
|
814
|
+
value: Any = dep.output
|
|
815
|
+
for part in fld.split("."):
|
|
816
|
+
if isinstance(value, dict):
|
|
817
|
+
value = value[part]
|
|
818
|
+
else:
|
|
819
|
+
value = getattr(value, part)
|
|
820
|
+
if value == cond["is"]:
|
|
821
|
+
return True, f"{ref}.{fld} == {cond['is']!r}"
|
|
822
|
+
except Exception:
|
|
823
|
+
return False, ""
|
|
824
|
+
return False, ""
|
|
825
|
+
|
|
826
|
+
|
|
534
827
|
# ANSI colors match the AgentRunner's verbose output so a mixed
|
|
535
828
|
# supervisor + inner-runner trace reads consistently.
|
|
536
829
|
_C_PLAN = "\x1B[1;34m" # blue bold — plan header + steps
|
|
@@ -594,11 +887,14 @@ class Supervisor:
|
|
|
594
887
|
spawn_config: Optional[SpawnConfig] = None,
|
|
595
888
|
max_subtask_retries: int = 1,
|
|
596
889
|
subtask_success_check: Optional[Callable[[SubtaskResult], Any]] = None,
|
|
890
|
+
max_plan_retries: int = 1,
|
|
597
891
|
):
|
|
598
892
|
"""
|
|
599
893
|
Args:
|
|
600
894
|
model: LLM used for planning and synthesis.
|
|
601
|
-
agents: Mapping of ``name -> (description, AgentRunner)
|
|
895
|
+
agents: Mapping of ``name -> (description, AgentRunner)`` or
|
|
896
|
+
``name -> Specialist`` (3.3). Tuples are wrapped as
|
|
897
|
+
Specialist internally.
|
|
602
898
|
max_subtasks: Hard upper bound on the number of planned sub-tasks.
|
|
603
899
|
verbose: When True (default), print colored progress markers for
|
|
604
900
|
each stage — the plan, each sub-task dispatch and result,
|
|
@@ -634,16 +930,26 @@ class Supervisor:
|
|
|
634
930
|
preserved). A predicate that itself raises is treated as
|
|
635
931
|
"accept" so a buggy check can't wedge the run. Default
|
|
636
932
|
``None`` keeps the exceptions-only behavior.
|
|
933
|
+
max_plan_retries: (3.3) When plan sanitization has to repair
|
|
934
|
+
the planner's graph (unknown dependency dropped, cycle
|
|
935
|
+
broken), the plan is re-requested up to this many times
|
|
936
|
+
with the repair warnings appended to the prompt. The
|
|
937
|
+
sanitized plan is kept as fallback if the retry is no
|
|
938
|
+
better. ``0`` disables replanning.
|
|
637
939
|
"""
|
|
638
940
|
self.model = model
|
|
639
|
-
#
|
|
640
|
-
|
|
941
|
+
# Normalize (and copy) so run-time spawns don't mutate the
|
|
942
|
+
# caller's dict. Accepts (description, runner) tuples or
|
|
943
|
+
# Specialist entries (3.3); everything is stored as Specialist,
|
|
944
|
+
# which still tuple-unpacks for older code.
|
|
945
|
+
self.agents = _normalize_agents(agents)
|
|
641
946
|
self.max_subtasks = max_subtasks
|
|
642
947
|
self.verbose = verbose
|
|
643
948
|
self.spawn_config = spawn_config or SpawnConfig(enabled=False)
|
|
644
949
|
self._spawns_this_run = 0
|
|
645
950
|
self.max_subtask_retries = max(0, int(max_subtask_retries))
|
|
646
951
|
self.subtask_success_check = subtask_success_check
|
|
952
|
+
self.max_plan_retries = max(0, int(max_plan_retries))
|
|
647
953
|
|
|
648
954
|
# -- internal helpers ----------------------------------------------------
|
|
649
955
|
|
|
@@ -662,12 +968,9 @@ class Supervisor:
|
|
|
662
968
|
return _evaluate_success_verdict(verdict)
|
|
663
969
|
|
|
664
970
|
def _build_agent_catalog(self) -> str:
|
|
665
|
-
|
|
666
|
-
for name, (desc, _) in self.agents.items():
|
|
667
|
-
lines.append(f"- {name}: {desc}")
|
|
668
|
-
return "\n".join(lines)
|
|
971
|
+
return _render_agent_catalog(self.agents)
|
|
669
972
|
|
|
670
|
-
def
|
|
973
|
+
def _plan_once(self, user_task: str, repair_note: str = "") -> List[dict]:
|
|
671
974
|
base_prompt = SUPERVISOR_PLAN_PROMPT.format(
|
|
672
975
|
agent_catalog=self._build_agent_catalog(),
|
|
673
976
|
user_task=user_task,
|
|
@@ -676,6 +979,8 @@ class Supervisor:
|
|
|
676
979
|
prompt = base_prompt
|
|
677
980
|
if self.spawn_config.enabled:
|
|
678
981
|
prompt = prompt + SUPERVISOR_SPAWN_INSTRUCTION
|
|
982
|
+
if repair_note:
|
|
983
|
+
prompt = prompt + repair_note
|
|
679
984
|
messages = [{"role": "user", "content": prompt}]
|
|
680
985
|
response = self.model.Initialize(messages=messages)
|
|
681
986
|
|
|
@@ -697,6 +1002,28 @@ class Supervisor:
|
|
|
697
1002
|
]
|
|
698
1003
|
return filtered[: self.max_subtasks]
|
|
699
1004
|
|
|
1005
|
+
def _plan(self, user_task: str) -> List[dict]:
|
|
1006
|
+
"""Plan, sanitize, and (3.3) replan once per repair budget when
|
|
1007
|
+
sanitization had to fix the graph. Returns a sanitized plan whose
|
|
1008
|
+
every step carries a valid ``id`` and acyclic ``depends_on``."""
|
|
1009
|
+
plan = self._plan_once(user_task)
|
|
1010
|
+
if not plan:
|
|
1011
|
+
return []
|
|
1012
|
+
sane, repairs = _sanitize_plan(plan, verbose=self.verbose)
|
|
1013
|
+
retries = self.max_plan_retries
|
|
1014
|
+
while repairs and retries > 0:
|
|
1015
|
+
retries -= 1
|
|
1016
|
+
note = _plan_repair_note(repairs)
|
|
1017
|
+
retry_plan = self._plan_once(user_task, repair_note=note)
|
|
1018
|
+
if not retry_plan:
|
|
1019
|
+
break
|
|
1020
|
+
retry_sane, retry_repairs = _sanitize_plan(retry_plan, verbose=self.verbose)
|
|
1021
|
+
if len(retry_repairs) < len(repairs):
|
|
1022
|
+
sane, repairs = retry_sane, retry_repairs
|
|
1023
|
+
else:
|
|
1024
|
+
break # retry was no better; keep the sanitized original
|
|
1025
|
+
return sane
|
|
1026
|
+
|
|
700
1027
|
# Map from SpawnRequest capability keyword -> the concrete tool names
|
|
701
1028
|
# a spawn would install. Kept in sync with _build_spawned_agent's
|
|
702
1029
|
# capability dispatch. Used by _find_existing_for_capabilities to
|
|
@@ -846,7 +1173,7 @@ class Supervisor:
|
|
|
846
1173
|
description, runner = _build_spawned_agent(
|
|
847
1174
|
req, model=self.model, allowed_paths=cfg.allowed_paths,
|
|
848
1175
|
)
|
|
849
|
-
self.agents[req.name] = (description, runner)
|
|
1176
|
+
self.agents[req.name] = Specialist(description=description, runner=runner)
|
|
850
1177
|
self._spawns_this_run += 1
|
|
851
1178
|
return req.name, None
|
|
852
1179
|
|
|
@@ -975,10 +1302,20 @@ class Supervisor:
|
|
|
975
1302
|
if self.verbose:
|
|
976
1303
|
_log_plan(plan)
|
|
977
1304
|
|
|
1305
|
+
# 3.3: DAG mode fires when ANY step declares depends_on. Dep-free
|
|
1306
|
+
# plans keep byte-identical legacy semantics (plan order, every
|
|
1307
|
+
# step sees ALL prior results).
|
|
1308
|
+
dag_mode = _plan_uses_deps(plan)
|
|
1309
|
+
order = _topo_order(plan) if dag_mode else list(range(len(plan)))
|
|
1310
|
+
|
|
978
1311
|
subtask_results: List[SubtaskResult] = []
|
|
1312
|
+
results_by_id: Dict[str, SubtaskResult] = {}
|
|
979
1313
|
spawn_rewrites: Dict[str, str] = {}
|
|
980
|
-
for step_idx
|
|
1314
|
+
for step_idx in order:
|
|
1315
|
+
item = plan[step_idx]
|
|
981
1316
|
agent_name = item.get("agent")
|
|
1317
|
+
step_id = item.get("id") or f"step_{step_idx + 1}"
|
|
1318
|
+
step_deps = list(item.get("depends_on") or [])
|
|
982
1319
|
|
|
983
1320
|
if agent_name == "__spawn__":
|
|
984
1321
|
spawned_name, rewrite_from = self._handle_spawn(item)
|
|
@@ -987,28 +1324,86 @@ class Supervisor:
|
|
|
987
1324
|
"rerouted_from": rewrite_from}
|
|
988
1325
|
if spawned_name and rewrite_from:
|
|
989
1326
|
spawn_rewrites[rewrite_from] = spawned_name
|
|
990
|
-
|
|
1327
|
+
sub_result = SubtaskResult(
|
|
991
1328
|
agent="__spawn__",
|
|
992
1329
|
query=f"spawn: {rewrite_from}",
|
|
993
1330
|
content=f"REROUTED: caps already covered by existing "
|
|
994
1331
|
f"specialist '{spawned_name}'. Subsequent "
|
|
995
1332
|
f"dispatches to '{rewrite_from}' will run "
|
|
996
1333
|
f"on '{spawned_name}'.",
|
|
997
|
-
|
|
1334
|
+
step_id=step_id,
|
|
1335
|
+
)
|
|
998
1336
|
elif spawned_name:
|
|
999
|
-
|
|
1337
|
+
sub_result = SubtaskResult(
|
|
1000
1338
|
agent="__spawn__",
|
|
1001
1339
|
query=f"spawn: {item.get('name', '?')}",
|
|
1002
1340
|
content=f"registered new specialist '{spawned_name}' with "
|
|
1003
1341
|
f"capabilities: {', '.join(item.get('capabilities', []))}",
|
|
1004
|
-
|
|
1342
|
+
step_id=step_id,
|
|
1343
|
+
)
|
|
1005
1344
|
else:
|
|
1006
|
-
|
|
1345
|
+
sub_result = SubtaskResult(
|
|
1007
1346
|
agent="__spawn__",
|
|
1008
1347
|
query=f"spawn: {item.get('name', '?')}",
|
|
1009
1348
|
content="",
|
|
1010
1349
|
error="spawn refused (see log for reason)",
|
|
1011
|
-
|
|
1350
|
+
step_id=step_id,
|
|
1351
|
+
)
|
|
1352
|
+
subtask_results.append(sub_result)
|
|
1353
|
+
results_by_id[step_id] = sub_result
|
|
1354
|
+
continue
|
|
1355
|
+
|
|
1356
|
+
# 3.3 failure cascade: a FAILED direct dependency (error set,
|
|
1357
|
+
# and not a mere condition-skip) skips this step. Condition-
|
|
1358
|
+
# skipped deps count as empty successes -- the step still runs.
|
|
1359
|
+
failed_dep = next(
|
|
1360
|
+
(d for d in step_deps
|
|
1361
|
+
if d in results_by_id
|
|
1362
|
+
and results_by_id[d].error
|
|
1363
|
+
), None,
|
|
1364
|
+
)
|
|
1365
|
+
if failed_dep is not None:
|
|
1366
|
+
sub_result = SubtaskResult(
|
|
1367
|
+
agent=agent_name or "<none>",
|
|
1368
|
+
query=item.get("query", ""),
|
|
1369
|
+
content="",
|
|
1370
|
+
error=(f"skipped: dependency '{failed_dep}' failed "
|
|
1371
|
+
f"({results_by_id[failed_dep].error})"),
|
|
1372
|
+
skipped=True,
|
|
1373
|
+
step_id=step_id,
|
|
1374
|
+
depends_on=step_deps,
|
|
1375
|
+
)
|
|
1376
|
+
if self.verbose:
|
|
1377
|
+
print(f"{_C_ERROR}[supervisor.skip -> {agent_name}] "
|
|
1378
|
+
f"dependency '{failed_dep}' failed{_C_RESET}")
|
|
1379
|
+
subtask_results.append(sub_result)
|
|
1380
|
+
results_by_id[step_id] = sub_result
|
|
1381
|
+
yield {"type": "subtask_result", "result": sub_result,
|
|
1382
|
+
"step": step_idx, "step_id": step_id}
|
|
1383
|
+
continue
|
|
1384
|
+
|
|
1385
|
+
# 3.3 conditional skip: evaluated against a direct dep's typed
|
|
1386
|
+
# output; fail-open. Does NOT cascade -- dependents treat this
|
|
1387
|
+
# step as an empty success.
|
|
1388
|
+
skip, reason = _evaluate_skip_when(
|
|
1389
|
+
item.get("skip_when"), results_by_id, step_deps, self.verbose,
|
|
1390
|
+
)
|
|
1391
|
+
if skip:
|
|
1392
|
+
sub_result = SubtaskResult(
|
|
1393
|
+
agent=agent_name or "<none>",
|
|
1394
|
+
query=item.get("query", ""),
|
|
1395
|
+
content=f"skipped: {reason}",
|
|
1396
|
+
skipped=True,
|
|
1397
|
+
step_id=step_id,
|
|
1398
|
+
depends_on=step_deps,
|
|
1399
|
+
)
|
|
1400
|
+
if self.verbose:
|
|
1401
|
+
print(f"{_C_DISPATCH}[supervisor.skip -> {agent_name}] "
|
|
1402
|
+
f"{reason}{_C_RESET}")
|
|
1403
|
+
subtask_results.append(sub_result)
|
|
1404
|
+
results_by_id[step_id] = sub_result
|
|
1405
|
+
yield {"type": "subtask_result", "result": sub_result,
|
|
1406
|
+
"step": step_idx, "step_id": step_id}
|
|
1012
1407
|
continue
|
|
1013
1408
|
|
|
1014
1409
|
if agent_name in spawn_rewrites:
|
|
@@ -1028,22 +1423,40 @@ class Supervisor:
|
|
|
1028
1423
|
query=item.get("query", ""),
|
|
1029
1424
|
content="",
|
|
1030
1425
|
error=f"specialist '{agent_name}' not in registry (spawn may have been refused)",
|
|
1426
|
+
step_id=step_id,
|
|
1427
|
+
depends_on=step_deps,
|
|
1031
1428
|
)
|
|
1032
1429
|
subtask_results.append(sub_result)
|
|
1033
|
-
|
|
1430
|
+
results_by_id[step_id] = sub_result
|
|
1431
|
+
yield {"type": "subtask_result", "result": sub_result,
|
|
1432
|
+
"step": step_idx, "step_id": step_id}
|
|
1034
1433
|
continue
|
|
1035
1434
|
|
|
1036
1435
|
sub_query = item["query"]
|
|
1037
1436
|
_, agent_runner = self.agents[agent_name]
|
|
1038
|
-
|
|
1039
|
-
|
|
1437
|
+
# Context routing: DAG mode threads DIRECT dependencies only
|
|
1438
|
+
# (explicit routing, focused context budget); legacy mode
|
|
1439
|
+
# threads everything prior, exactly as pre-3.3.
|
|
1440
|
+
if dag_mode:
|
|
1441
|
+
dep_results = [
|
|
1442
|
+
results_by_id[d] for d in step_deps if d in results_by_id
|
|
1443
|
+
]
|
|
1444
|
+
dispatched_query = _build_augmented_query(sub_query, dep_results)
|
|
1445
|
+
else:
|
|
1446
|
+
dispatched_query = _build_augmented_query(sub_query, subtask_results)
|
|
1447
|
+
yield {"type": "dispatch", "agent": agent_name, "query": sub_query,
|
|
1448
|
+
"step": step_idx, "step_id": step_id}
|
|
1040
1449
|
if self.verbose:
|
|
1041
1450
|
_log_dispatch(agent_name, sub_query)
|
|
1042
1451
|
sub_result = self._dispatch_with_retry(
|
|
1043
1452
|
agent_runner, agent_name, sub_query, dispatched_query,
|
|
1044
1453
|
)
|
|
1454
|
+
sub_result.step_id = step_id
|
|
1455
|
+
sub_result.depends_on = step_deps
|
|
1045
1456
|
subtask_results.append(sub_result)
|
|
1046
|
-
|
|
1457
|
+
results_by_id[step_id] = sub_result
|
|
1458
|
+
yield {"type": "subtask_result", "result": sub_result,
|
|
1459
|
+
"step": step_idx, "step_id": step_id}
|
|
1047
1460
|
if self.verbose:
|
|
1048
1461
|
_log_result(sub_result)
|
|
1049
1462
|
|
|
@@ -1104,20 +1517,24 @@ class AsyncSupervisor:
|
|
|
1104
1517
|
sequential: bool = False,
|
|
1105
1518
|
max_subtask_retries: int = 1,
|
|
1106
1519
|
subtask_success_check: Optional[Callable[[SubtaskResult], Any]] = None,
|
|
1520
|
+
max_parallel: Optional[int] = None,
|
|
1521
|
+
max_plan_retries: int = 1,
|
|
1107
1522
|
):
|
|
1108
1523
|
"""
|
|
1109
1524
|
Args:
|
|
1110
1525
|
model: LLM used for planning and synthesis.
|
|
1111
|
-
agents: Mapping of ``name -> (description, AgentRunner)
|
|
1526
|
+
agents: Mapping of ``name -> (description, AgentRunner)`` or
|
|
1527
|
+
``name -> Specialist`` (3.3).
|
|
1112
1528
|
max_subtasks: Hard upper bound on the number of planned sub-tasks.
|
|
1113
1529
|
verbose: Print colored progress markers for each stage.
|
|
1114
1530
|
sequential: When True, sub-tasks run one at a time in plan
|
|
1115
1531
|
order and each specialist receives the prior sub-tasks'
|
|
1116
|
-
findings as context
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1532
|
+
findings as context (sugar for ``max_parallel=1`` with
|
|
1533
|
+
all-prior threading). Default False: dep-free plans run
|
|
1534
|
+
fully concurrent as before; plans WITH ``depends_on``
|
|
1535
|
+
(3.3) run as a DAG — independent steps concurrent, each
|
|
1536
|
+
step threaded ONLY its direct dependencies' results, and
|
|
1537
|
+
a step starts the moment its dependencies complete.
|
|
1121
1538
|
max_subtask_retries: How many times a sub-task that RAISES is
|
|
1122
1539
|
re-dispatched (with the prior error appended) before the
|
|
1123
1540
|
Supervisor gives up on it. Default 1. See
|
|
@@ -1129,14 +1546,23 @@ class AsyncSupervisor:
|
|
|
1129
1546
|
rejects (ran fine but produced nothing useful). See
|
|
1130
1547
|
:class:`Supervisor` for the contract. Applies per sub-task
|
|
1131
1548
|
in both sequential and concurrent modes.
|
|
1549
|
+
max_parallel: (3.3) Cap on concurrently running sub-tasks.
|
|
1550
|
+
``None`` (default) = unbounded. Useful under provider
|
|
1551
|
+
rate limits. ``sequential=True`` forces this to 1.
|
|
1552
|
+
max_plan_retries: (3.3) Replans after sanitization repairs;
|
|
1553
|
+
see :class:`Supervisor`.
|
|
1132
1554
|
"""
|
|
1133
1555
|
self.model = model
|
|
1134
|
-
self.agents = agents
|
|
1556
|
+
self.agents = _normalize_agents(agents)
|
|
1135
1557
|
self.max_subtasks = max_subtasks
|
|
1136
1558
|
self.verbose = verbose
|
|
1137
1559
|
self.sequential = sequential
|
|
1138
1560
|
self.max_subtask_retries = max(0, int(max_subtask_retries))
|
|
1139
1561
|
self.subtask_success_check = subtask_success_check
|
|
1562
|
+
self.max_parallel = 1 if sequential else (
|
|
1563
|
+
max(1, int(max_parallel)) if max_parallel is not None else None
|
|
1564
|
+
)
|
|
1565
|
+
self.max_plan_retries = max(0, int(max_plan_retries))
|
|
1140
1566
|
|
|
1141
1567
|
def _evaluate_success(self, result: SubtaskResult) -> tuple:
|
|
1142
1568
|
"""See ``Supervisor._evaluate_success``."""
|
|
@@ -1152,10 +1578,7 @@ class AsyncSupervisor:
|
|
|
1152
1578
|
# -- internal helpers ----------------------------------------------------
|
|
1153
1579
|
|
|
1154
1580
|
def _build_agent_catalog(self) -> str:
|
|
1155
|
-
|
|
1156
|
-
for name, (desc, _) in self.agents.items():
|
|
1157
|
-
lines.append(f"- {name}: {desc}")
|
|
1158
|
-
return "\n".join(lines)
|
|
1581
|
+
return _render_agent_catalog(self.agents)
|
|
1159
1582
|
|
|
1160
1583
|
async def _call_model(self, messages: List[Dict[str, str]]) -> str:
|
|
1161
1584
|
"""Invoke the planning/synthesis model, preferring an async method."""
|
|
@@ -1166,12 +1589,14 @@ class AsyncSupervisor:
|
|
|
1166
1589
|
# Fall back to running the sync method in a thread.
|
|
1167
1590
|
return await asyncio.to_thread(self.model.Initialize, messages)
|
|
1168
1591
|
|
|
1169
|
-
async def
|
|
1592
|
+
async def _plan_once(self, user_task: str, repair_note: str = "") -> List[dict]:
|
|
1170
1593
|
prompt = SUPERVISOR_PLAN_PROMPT.format(
|
|
1171
1594
|
agent_catalog=self._build_agent_catalog(),
|
|
1172
1595
|
user_task=user_task,
|
|
1173
1596
|
max_subtasks=self.max_subtasks,
|
|
1174
1597
|
)
|
|
1598
|
+
if repair_note:
|
|
1599
|
+
prompt = prompt + repair_note
|
|
1175
1600
|
messages = [{"role": "user", "content": prompt}]
|
|
1176
1601
|
response = await self._call_model(messages)
|
|
1177
1602
|
|
|
@@ -1191,6 +1616,27 @@ class AsyncSupervisor:
|
|
|
1191
1616
|
]
|
|
1192
1617
|
return filtered[: self.max_subtasks]
|
|
1193
1618
|
|
|
1619
|
+
async def _plan(self, user_task: str) -> List[dict]:
|
|
1620
|
+
"""Plan + sanitize + (3.3) replan-on-repair. See Supervisor._plan."""
|
|
1621
|
+
plan = await self._plan_once(user_task)
|
|
1622
|
+
if not plan:
|
|
1623
|
+
return []
|
|
1624
|
+
sane, repairs = _sanitize_plan(plan, verbose=self.verbose)
|
|
1625
|
+
retries = self.max_plan_retries
|
|
1626
|
+
while repairs and retries > 0:
|
|
1627
|
+
retries -= 1
|
|
1628
|
+
retry_plan = await self._plan_once(
|
|
1629
|
+
user_task, repair_note=_plan_repair_note(repairs),
|
|
1630
|
+
)
|
|
1631
|
+
if not retry_plan:
|
|
1632
|
+
break
|
|
1633
|
+
retry_sane, retry_repairs = _sanitize_plan(retry_plan, verbose=self.verbose)
|
|
1634
|
+
if len(retry_repairs) < len(repairs):
|
|
1635
|
+
sane, repairs = retry_sane, retry_repairs
|
|
1636
|
+
else:
|
|
1637
|
+
break
|
|
1638
|
+
return sane
|
|
1639
|
+
|
|
1194
1640
|
async def _synthesize(
|
|
1195
1641
|
self, user_task: str, subtask_results: List[SubtaskResult]
|
|
1196
1642
|
) -> str:
|
|
@@ -1324,32 +1770,124 @@ class AsyncSupervisor:
|
|
|
1324
1770
|
"agent": item.get("agent"), "query": item.get("query", ""),
|
|
1325
1771
|
"step": step_idx}
|
|
1326
1772
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1773
|
+
# 3.3 unified completion-driven scheduler. Dependencies per step:
|
|
1774
|
+
# - DAG mode (any step has depends_on): the parsed edges.
|
|
1775
|
+
# - legacy sequential: every prior step (all-prior threading,
|
|
1776
|
+
# identical context to pre-3.3) — with max_parallel=1 this
|
|
1777
|
+
# reproduces the old sequential loop exactly.
|
|
1778
|
+
# - legacy concurrent: no deps — everything runs at once, no
|
|
1779
|
+
# threading, exactly the old asyncio.gather behavior.
|
|
1780
|
+
# Cascade + skip_when apply only in DAG mode (legacy plans never
|
|
1781
|
+
# skipped later steps on failure, and must not start now).
|
|
1782
|
+
dag_mode = _plan_uses_deps(plan)
|
|
1783
|
+
step_ids = [
|
|
1784
|
+
item.get("id") or f"step_{i + 1}" for i, item in enumerate(plan)
|
|
1785
|
+
]
|
|
1786
|
+
if dag_mode:
|
|
1787
|
+
deps_of: List[List[str]] = [
|
|
1788
|
+
list(item.get("depends_on") or []) for item in plan
|
|
1789
|
+
]
|
|
1790
|
+
elif self.sequential:
|
|
1791
|
+
deps_of = [step_ids[:i] for i in range(len(plan))]
|
|
1336
1792
|
else:
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1793
|
+
deps_of = [[] for _ in plan]
|
|
1794
|
+
|
|
1795
|
+
subtask_results: List[SubtaskResult] = []
|
|
1796
|
+
results_by_id: Dict[str, SubtaskResult] = {}
|
|
1797
|
+
done_ids: set = set()
|
|
1798
|
+
launched: set = set()
|
|
1799
|
+
running: Dict[asyncio.Task, int] = {}
|
|
1800
|
+
|
|
1801
|
+
def _ready() -> List[int]:
|
|
1802
|
+
out = []
|
|
1803
|
+
for i in range(len(plan)):
|
|
1804
|
+
if i in launched:
|
|
1805
|
+
continue
|
|
1806
|
+
if all(d in done_ids for d in deps_of[i] if d in step_ids):
|
|
1807
|
+
out.append(i)
|
|
1808
|
+
return out
|
|
1809
|
+
|
|
1810
|
+
def _record(i: int, r: SubtaskResult) -> SubtaskResult:
|
|
1811
|
+
r.step_id = step_ids[i]
|
|
1812
|
+
r.depends_on = deps_of[i]
|
|
1813
|
+
subtask_results.append(r)
|
|
1814
|
+
results_by_id[step_ids[i]] = r
|
|
1815
|
+
done_ids.add(step_ids[i])
|
|
1816
|
+
return r
|
|
1817
|
+
|
|
1818
|
+
while len(done_ids) < len(plan):
|
|
1819
|
+
# Launch everything ready (respecting max_parallel), resolving
|
|
1820
|
+
# cascades and condition-skips inline — those complete
|
|
1821
|
+
# instantly without dispatching.
|
|
1822
|
+
progressed = False
|
|
1823
|
+
for i in _ready():
|
|
1824
|
+
if dag_mode:
|
|
1825
|
+
failed_dep = next(
|
|
1826
|
+
(d for d in deps_of[i]
|
|
1827
|
+
if d in results_by_id and results_by_id[d].error),
|
|
1828
|
+
None,
|
|
1829
|
+
)
|
|
1830
|
+
if failed_dep is not None:
|
|
1831
|
+
launched.add(i)
|
|
1832
|
+
r = _record(i, SubtaskResult(
|
|
1833
|
+
agent=plan[i].get("agent", "<none>"),
|
|
1834
|
+
query=plan[i].get("query", ""),
|
|
1835
|
+
content="",
|
|
1836
|
+
error=(f"skipped: dependency '{failed_dep}' failed "
|
|
1837
|
+
f"({results_by_id[failed_dep].error})"),
|
|
1838
|
+
skipped=True,
|
|
1839
|
+
))
|
|
1840
|
+
yield {"type": "subtask_result", "result": r,
|
|
1841
|
+
"step": i, "step_id": step_ids[i]}
|
|
1842
|
+
progressed = True
|
|
1843
|
+
continue
|
|
1844
|
+
skip, reason = _evaluate_skip_when(
|
|
1845
|
+
plan[i].get("skip_when"), results_by_id,
|
|
1846
|
+
deps_of[i], self.verbose,
|
|
1847
|
+
)
|
|
1848
|
+
if skip:
|
|
1849
|
+
launched.add(i)
|
|
1850
|
+
r = _record(i, SubtaskResult(
|
|
1851
|
+
agent=plan[i].get("agent", "<none>"),
|
|
1852
|
+
query=plan[i].get("query", ""),
|
|
1853
|
+
content=f"skipped: {reason}",
|
|
1854
|
+
skipped=True,
|
|
1855
|
+
))
|
|
1856
|
+
yield {"type": "subtask_result", "result": r,
|
|
1857
|
+
"step": i, "step_id": step_ids[i]}
|
|
1858
|
+
progressed = True
|
|
1859
|
+
continue
|
|
1860
|
+
if self.max_parallel is not None and len(running) >= self.max_parallel:
|
|
1861
|
+
break
|
|
1862
|
+
launched.add(i)
|
|
1863
|
+
dep_results = [
|
|
1864
|
+
results_by_id[d] for d in deps_of[i] if d in results_by_id
|
|
1865
|
+
]
|
|
1866
|
+
task = asyncio.create_task(self._run_subtask(
|
|
1867
|
+
plan[i]["agent"], plan[i]["query"],
|
|
1868
|
+
prior_results=dep_results if dep_results else None,
|
|
1340
1869
|
))
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1870
|
+
running[task] = i
|
|
1871
|
+
progressed = True
|
|
1872
|
+
|
|
1873
|
+
if progressed and len(done_ids) >= len(plan):
|
|
1874
|
+
break
|
|
1875
|
+
if not running:
|
|
1876
|
+
if not progressed:
|
|
1877
|
+
# Nothing running, nothing launchable: only possible if
|
|
1878
|
+
# a dep id points at a step outside the plan (sanitizer
|
|
1879
|
+
# prevents this) — bail rather than spin.
|
|
1880
|
+
break
|
|
1881
|
+
continue
|
|
1882
|
+
|
|
1883
|
+
done, _ = await asyncio.wait(
|
|
1884
|
+
running.keys(), return_when=asyncio.FIRST_COMPLETED,
|
|
1885
|
+
)
|
|
1886
|
+
for t in done:
|
|
1887
|
+
i = running.pop(t)
|
|
1888
|
+
r = _record(i, t.result())
|
|
1889
|
+
yield {"type": "subtask_result", "result": r,
|
|
1890
|
+
"step": i, "step_id": step_ids[i]}
|
|
1353
1891
|
|
|
1354
1892
|
yield {"type": "synthesize_start"}
|
|
1355
1893
|
final = await self._synthesize(user_task, list(subtask_results))
|
|
@@ -64,7 +64,7 @@ from .Session import Session
|
|
|
64
64
|
from .WebTools import web_fetch_tool, web_search_tool
|
|
65
65
|
from .Supervisor import (
|
|
66
66
|
Supervisor, AsyncSupervisor,
|
|
67
|
-
SpawnConfig, SpawnRequest,
|
|
67
|
+
SpawnConfig, SpawnRequest, Specialist,
|
|
68
68
|
SupervisorResult, SubtaskResult,
|
|
69
69
|
)
|
|
70
70
|
|
|
@@ -218,6 +218,7 @@ __all__ = [
|
|
|
218
218
|
"Supervisor",
|
|
219
219
|
"AsyncSupervisor",
|
|
220
220
|
"SpawnConfig",
|
|
221
|
+
"Specialist",
|
|
221
222
|
"SpawnRequest",
|
|
222
223
|
"SupervisorResult",
|
|
223
224
|
"SubtaskResult",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-dev
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: A production-grade Python framework for building LLM agents. Multi-provider chat, permission-gated tools, RAG (in-mem + Chroma/Qdrant/pgvector), semantic memory, Supervisor + Handoffs multi-agent orchestration with streaming, evals harness, prompt caching, Batch API, prompt-optimizer, MCP integration.
|
|
5
5
|
Author-email: Bruce-Arhin Shadrach <brucearhin098@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
|
|
3
3
|
name = "agentx-dev"
|
|
4
|
-
version = "3.
|
|
4
|
+
version = "3.3.0"
|
|
5
5
|
description = "A production-grade Python framework for building LLM agents. Multi-provider chat, permission-gated tools, RAG (in-mem + Chroma/Qdrant/pgvector), semantic memory, Supervisor + Handoffs multi-agent orchestration with streaming, evals harness, prompt caching, Batch API, prompt-optimizer, MCP integration."
|
|
6
6
|
authors = [{ name = "Bruce-Arhin Shadrach", email = "brucearhin098@gmail.com" }]
|
|
7
7
|
# Best practice: link to the license file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|