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.
Files changed (72) hide show
  1. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/CHANGELOG.md +68 -0
  2. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/PKG-INFO +1 -1
  3. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Supervisor.py +599 -61
  4. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/__init__.py +2 -1
  5. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/PKG-INFO +1 -1
  6. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/pyproject.toml +1 -1
  7. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/AGENTX.md +0 -0
  8. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/CONTRIBUTING.md +0 -0
  9. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/LICENSE +0 -0
  10. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/MANIFEST.in +0 -0
  11. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/README.md +0 -0
  12. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Agents/Agent.py +0 -0
  13. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Agents/__init__.py +0 -0
  14. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/AsyncTools.py +0 -0
  15. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/AutoSetup.py +0 -0
  16. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Cache.py +0 -0
  17. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/ChatModel.py +0 -0
  18. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Compiler.py +0 -0
  19. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Config.py +0 -0
  20. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/DefaultTools.py +0 -0
  21. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Embeddings.py +0 -0
  22. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Evals.py +0 -0
  23. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Handoffs.py +0 -0
  24. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Loader.py +0 -0
  25. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/MCP.py +0 -0
  26. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Memory.py +0 -0
  27. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Observability.py +0 -0
  28. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Planner.py +0 -0
  29. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/AgentRun.py +0 -0
  30. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/AsyncAgentRun.py +0 -0
  31. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/__init__.py +0 -0
  32. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Runner/promptTemplate.yaml +0 -0
  33. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Session.py +0 -0
  34. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Splitters.py +0 -0
  35. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Streaming.py +0 -0
  36. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/Tools.py +0 -0
  37. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/__init__.py +0 -0
  38. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/chroma_store.py +0 -0
  39. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/pg_store.py +0 -0
  40. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/VectorStores/qdrant_store.py +0 -0
  41. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/WebTools.py +0 -0
  42. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/resources/__init__.py +0 -0
  43. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev/resources/promptTemplate.yaml +0 -0
  44. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/SOURCES.txt +0 -0
  45. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/dependency_links.txt +0 -0
  46. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/requires.txt +0 -0
  47. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/agentx_dev.egg-info/top_level.txt +0 -0
  48. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/README.md +0 -0
  49. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/agentic_rag_demo.py +0 -0
  50. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/async_example.py +0 -0
  51. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/async_quickstart.py +0 -0
  52. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/auto_features_example.py +0 -0
  53. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/caching_example.py +0 -0
  54. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/chatbot_example.py +0 -0
  55. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/complete_example.py +0 -0
  56. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/concurrent_example.py +0 -0
  57. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/concurrent_tool_example.py +0 -0
  58. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/file_agent_demo.py +0 -0
  59. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/function_calling_demo.py +0 -0
  60. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/mcp_demo.py +0 -0
  61. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/mcp_github_triage_demo.py +0 -0
  62. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/observability_example.py +0 -0
  63. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/orchestration_demo.py +0 -0
  64. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/planner_example.py +0 -0
  65. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/robust_link_scraper.py +0 -0
  66. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/supervisor_codebase_analysis_demo.py +0 -0
  67. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/supervisor_example.py +0 -0
  68. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/sync_quickstart.py +0 -0
  69. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/v3_1_1_features_demo.py +0 -0
  70. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/v3_1_comprehensive_demo.py +0 -0
  71. {agentx_dev-3.2.0 → agentx_dev-3.3.0}/examples/v3_1_features_demo.py +0 -0
  72. {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.2.0
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. SEQUENTIAL STEPS SEE PRIOR RESULTS. Each dispatched step automatically receives the findings of every earlier completed step as "PRIOR SUB-TASK FINDINGS" context (structured JSON when the earlier specialist emits typed output, otherwise its answer text). So dependency CHAINS ARE FINE and often the right design: an intent-analysis step feeding a retrieval step feeding a reranking step is a good plan, because each later specialist reads the earlier one's output directly.
340
- Two rules still apply:
341
- - Steps that would go to the SAME specialist back-to-back should still be merged into one (rule 2) — the chain is for handing work BETWEEN different specialists, not for splitting one specialist's work.
342
- - Write each step's query as an instruction about what to DO with the prior findings ("using the intent analysis above, retrieve the top 10 candidate passages"), not a request to re-state them ("report what the previous step found" is a wasted step — the synthesis phase already does that).
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, self-contained sub-task>"}},
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
- # Copy so run-time spawns don't mutate the caller's dict.
640
- self.agents = dict(agents)
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
- lines = []
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 _plan(self, user_task: str) -> List[dict]:
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, item in enumerate(plan):
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
- subtask_results.append(SubtaskResult(
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
- subtask_results.append(SubtaskResult(
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
- subtask_results.append(SubtaskResult(
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
- yield {"type": "subtask_result", "result": sub_result, "step": step_idx}
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
- dispatched_query = _build_augmented_query(sub_query, subtask_results)
1039
- yield {"type": "dispatch", "agent": agent_name, "query": sub_query, "step": step_idx}
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
- yield {"type": "subtask_result", "result": sub_result, "step": step_idx}
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. Use when steps depend on earlier
1117
- steps' output (e.g. researcher then writer). Default
1118
- False keeps the classic concurrent behavior via
1119
- asyncio.gather — faster but each specialist runs in
1120
- isolation and can't see other specialists' results.
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
- lines = []
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 _plan(self, user_task: str) -> List[dict]:
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
- subtask_results: List[SubtaskResult] = []
1328
- if self.sequential:
1329
- for step_idx, item in enumerate(plan):
1330
- r = await self._run_subtask(
1331
- item["agent"], item["query"],
1332
- prior_results=subtask_results,
1333
- )
1334
- subtask_results.append(r)
1335
- yield {"type": "subtask_result", "result": r, "step": step_idx}
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
- tasks = [
1338
- (step_idx, asyncio.create_task(
1339
- self._run_subtask(item["agent"], item["query"])
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
- for step_idx, item in enumerate(plan)
1342
- ]
1343
- pending = {t: idx for idx, t in tasks}
1344
- while pending:
1345
- done, _ = await asyncio.wait(
1346
- pending.keys(), return_when=asyncio.FIRST_COMPLETED,
1347
- )
1348
- for t in done:
1349
- idx = pending.pop(t)
1350
- r = t.result()
1351
- subtask_results.append(r)
1352
- yield {"type": "subtask_result", "result": r, "step": idx}
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.2.0
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.2.0"
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