agentforge-py 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. agentforge/__init__.py +114 -0
  2. agentforge/_testing/__init__.py +19 -0
  3. agentforge/_testing/fake_llm.py +126 -0
  4. agentforge/_testing/fake_tool.py +122 -0
  5. agentforge/_tools/__init__.py +14 -0
  6. agentforge/_tools/calculator.py +102 -0
  7. agentforge/_tools/decorator.py +300 -0
  8. agentforge/_tools/file_read.py +112 -0
  9. agentforge/_tools/shell.py +134 -0
  10. agentforge/_tools/web_search.py +207 -0
  11. agentforge/agent.py +817 -0
  12. agentforge/auth.py +42 -0
  13. agentforge/cli/__init__.py +18 -0
  14. agentforge/cli/_build.py +323 -0
  15. agentforge/cli/_scaffold_state.py +250 -0
  16. agentforge/cli/_shared_scaffold.py +174 -0
  17. agentforge/cli/config_cmd.py +174 -0
  18. agentforge/cli/db_cmd.py +262 -0
  19. agentforge/cli/debug_cmd.py +168 -0
  20. agentforge/cli/docs_cmd.py +217 -0
  21. agentforge/cli/eval_cmd.py +181 -0
  22. agentforge/cli/health_cmd.py +139 -0
  23. agentforge/cli/list_modules.py +85 -0
  24. agentforge/cli/main.py +81 -0
  25. agentforge/cli/manifest_apply.py +368 -0
  26. agentforge/cli/module_cmd.py +247 -0
  27. agentforge/cli/new_cmd.py +171 -0
  28. agentforge/cli/run_cmd.py +234 -0
  29. agentforge/cli/upgrade_cmd.py +230 -0
  30. agentforge/config/__init__.py +45 -0
  31. agentforge/eval/__init__.py +18 -0
  32. agentforge/eval/consistency.py +107 -0
  33. agentforge/eval/coverage.py +100 -0
  34. agentforge/eval/format_compliance.py +107 -0
  35. agentforge/eval/regression.py +143 -0
  36. agentforge/findings.py +166 -0
  37. agentforge/guardrails/__init__.py +32 -0
  38. agentforge/guardrails/allowlist.py +49 -0
  39. agentforge/guardrails/capability_check.py +58 -0
  40. agentforge/guardrails/engine.py +289 -0
  41. agentforge/guardrails/pii_redact_basic.py +61 -0
  42. agentforge/guardrails/prompt_injection_basic.py +90 -0
  43. agentforge/memory/__init__.py +16 -0
  44. agentforge/memory/in_memory.py +130 -0
  45. agentforge/memory/in_memory_graph.py +262 -0
  46. agentforge/memory/in_memory_vector.py +167 -0
  47. agentforge/pipeline/__init__.py +26 -0
  48. agentforge/pipeline/engine.py +189 -0
  49. agentforge/pipeline/errors.py +19 -0
  50. agentforge/pipeline/tool.py +93 -0
  51. agentforge/py.typed +0 -0
  52. agentforge/recording.py +189 -0
  53. agentforge/renderers/__init__.py +28 -0
  54. agentforge/renderers/_defaults.py +32 -0
  55. agentforge/renderers/markdown.py +44 -0
  56. agentforge/renderers/patch_applier.py +46 -0
  57. agentforge/renderers/registry.py +108 -0
  58. agentforge/renderers/scorecard.py +59 -0
  59. agentforge/renderers/span_table.py +71 -0
  60. agentforge/replay.py +260 -0
  61. agentforge/resolver_register.py +41 -0
  62. agentforge/retrieval.py +410 -0
  63. agentforge/runtime.py +63 -0
  64. agentforge/strategies/__init__.py +27 -0
  65. agentforge/strategies/_base.py +280 -0
  66. agentforge/strategies/_plan.py +93 -0
  67. agentforge/strategies/multi_agent.py +541 -0
  68. agentforge/strategies/plan_execute.py +506 -0
  69. agentforge/strategies/react.py +237 -0
  70. agentforge/strategies/tot.py +472 -0
  71. agentforge/templates/_shared/.cursorrules +12 -0
  72. agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
  73. agentforge/templates/_shared/.gitkeep +0 -0
  74. agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
  75. agentforge/templates/_shared/CLAUDE.md +13 -0
  76. agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
  77. agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
  78. agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
  79. agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
  80. agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
  81. agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
  82. agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
  83. agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
  84. agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
  85. agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
  86. agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
  87. agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
  88. agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
  89. agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
  90. agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
  91. agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
  92. agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
  93. agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
  94. agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
  95. agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
  96. agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
  97. agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
  98. agentforge/templates/code-reviewer/.env.example +8 -0
  99. agentforge/templates/code-reviewer/.gitignore +7 -0
  100. agentforge/templates/code-reviewer/README.md +12 -0
  101. agentforge/templates/code-reviewer/agentforge.yaml +23 -0
  102. agentforge/templates/code-reviewer/copier.yml +34 -0
  103. agentforge/templates/code-reviewer/pyproject.toml +18 -0
  104. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  105. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  106. agentforge/templates/docs-qa/.env.example +8 -0
  107. agentforge/templates/docs-qa/.gitignore +7 -0
  108. agentforge/templates/docs-qa/README.md +14 -0
  109. agentforge/templates/docs-qa/agentforge.yaml +19 -0
  110. agentforge/templates/docs-qa/copier.yml +31 -0
  111. agentforge/templates/docs-qa/pyproject.toml +18 -0
  112. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  113. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  114. agentforge/templates/minimal/.env.example +11 -0
  115. agentforge/templates/minimal/.gitignore +10 -0
  116. agentforge/templates/minimal/README.md +28 -0
  117. agentforge/templates/minimal/agentforge.yaml +10 -0
  118. agentforge/templates/minimal/copier.yml +52 -0
  119. agentforge/templates/minimal/pyproject.toml +18 -0
  120. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  121. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
  122. agentforge/templates/patch-bot/.env.example +8 -0
  123. agentforge/templates/patch-bot/.gitignore +7 -0
  124. agentforge/templates/patch-bot/README.md +13 -0
  125. agentforge/templates/patch-bot/agentforge.yaml +15 -0
  126. agentforge/templates/patch-bot/copier.yml +31 -0
  127. agentforge/templates/patch-bot/pyproject.toml +18 -0
  128. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  129. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  130. agentforge/templates/research/.env.example +8 -0
  131. agentforge/templates/research/.gitignore +7 -0
  132. agentforge/templates/research/README.md +14 -0
  133. agentforge/templates/research/agentforge.yaml +17 -0
  134. agentforge/templates/research/copier.yml +31 -0
  135. agentforge/templates/research/pyproject.toml +18 -0
  136. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  137. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
  138. agentforge/templates/triage/.env.example +8 -0
  139. agentforge/templates/triage/.gitignore +7 -0
  140. agentforge/templates/triage/README.md +14 -0
  141. agentforge/templates/triage/agentforge.yaml +25 -0
  142. agentforge/templates/triage/copier.yml +31 -0
  143. agentforge/templates/triage/pyproject.toml +18 -0
  144. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  145. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
  146. agentforge/testing/__init__.py +69 -0
  147. agentforge/testing/conformance.py +40 -0
  148. agentforge/testing/factory.py +89 -0
  149. agentforge/testing/fixtures.py +42 -0
  150. agentforge/testing/llm.py +235 -0
  151. agentforge/testing/recording.py +177 -0
  152. agentforge/tools/__init__.py +41 -0
  153. agentforge_py-0.2.1.dist-info/METADATA +158 -0
  154. agentforge_py-0.2.1.dist-info/RECORD +157 -0
  155. agentforge_py-0.2.1.dist-info/WHEEL +4 -0
  156. agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
  157. agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,280 @@
1
+ """`StrategyBase` — shared helpers every shipped strategy inherits from.
2
+
3
+ Concrete strategies (ReAct, Plan-Execute, Tree-of-Thoughts,
4
+ Multi-Agent) inherit `StrategyBase` and implement `run(state)`.
5
+ The base class provides the three operations every strategy
6
+ performs at LLM-call boundaries:
7
+
8
+ - `_check_guardrails(state)` — `BudgetPolicy.check()` plus
9
+ iteration accounting. Required before every LLM call by the
10
+ `ReasoningStrategy` invariant; the conformance suite verifies
11
+ via AST inspection that every shipped strategy calls this
12
+ inside its main loop.
13
+ - `_record_step(state, kind, content, **kwargs)` — append a
14
+ `Step` to `state.steps` with consistent shape.
15
+ - `_call_llm(state, system, messages, tools=None)` —
16
+ guardrail-check → LLM call → record cost on the budget →
17
+ record `think` step → return `LLMResponse`.
18
+
19
+ The class is named `StrategyBase` (no underscore) because it is
20
+ part of the framework's *internal* public surface — strategy
21
+ authors who write custom loops are encouraged to inherit from it
22
+ to get conformance for free. (External-to-framework name without
23
+ the underscore makes it discoverable; the class is exported from
24
+ `agentforge.strategies`.)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import logging
31
+ import time
32
+ from abc import abstractmethod
33
+ from typing import Any
34
+
35
+ from agentforge_core.contracts.llm import LLMClient
36
+ from agentforge_core.contracts.strategy import ReasoningStrategy
37
+ from agentforge_core.contracts.tool import Tool
38
+ from agentforge_core.observability.tracing import get_tracer
39
+ from agentforge_core.values.chat import StreamingEvent
40
+ from agentforge_core.values.messages import LLMResponse, Message, ToolSpec
41
+ from agentforge_core.values.state import AgentState, Step, StepKind
42
+ from pydantic import ValidationError
43
+
44
+ from agentforge.runtime import RUNTIME_KEY, RuntimeContext
45
+
46
+ DEFAULT_TOOL_TIMEOUT_S = 30.0
47
+ """Default per-tool execution timeout. Overridable per call to
48
+ `_dispatch_tool` and (eventually, per feat-004 §4.5) via
49
+ `agent.tool_options.timeout_s` in `agentforge.yaml`."""
50
+
51
+ log = logging.getLogger(__name__)
52
+
53
+
54
+ def _events_for_new_steps(steps: list[Step], before: int) -> list[StreamingEvent]:
55
+ """Build ``step`` `StreamingEvent`s for every step appended since
56
+ the ``before`` index. Keeps the streaming-side rendering of state
57
+ deltas separate from the strategy's recording semantics."""
58
+ return [
59
+ StreamingEvent(
60
+ kind="step",
61
+ content=step.content,
62
+ metadata={
63
+ "iteration": step.iteration,
64
+ "kind": step.kind,
65
+ },
66
+ )
67
+ for step in steps[before:]
68
+ ]
69
+
70
+
71
+ def get_runtime(state: AgentState) -> RuntimeContext:
72
+ """Return the `RuntimeContext` bound on `state.metadata`.
73
+
74
+ `Agent.run()` populates this before calling `strategy.run(state)`.
75
+ Strategies that bypass `Agent.run()` (e.g. unit tests) must bind
76
+ a context manually via `state.metadata[RUNTIME_KEY] = ...`.
77
+
78
+ Raises:
79
+ RuntimeError: no runtime context is bound (the strategy was
80
+ invoked outside of `Agent.run()` and no test fixture set
81
+ one up).
82
+ """
83
+ rt = state.metadata.get(RUNTIME_KEY)
84
+ if rt is None:
85
+ raise RuntimeError(
86
+ "AgentState has no RuntimeContext bound. Strategies should "
87
+ "be invoked via Agent.run(); tests that drive strategy.run() "
88
+ "directly must set state.metadata[RUNTIME_KEY] = "
89
+ "RuntimeContext(...) first."
90
+ )
91
+ if not isinstance(rt, RuntimeContext):
92
+ raise TypeError(
93
+ f"state.metadata[{RUNTIME_KEY}!r] is not a RuntimeContext: got {type(rt).__name__}"
94
+ )
95
+ return rt
96
+
97
+
98
+ class StrategyBase(ReasoningStrategy):
99
+ """Abstract base for shipped reasoning strategies.
100
+
101
+ Provides the budget-aware LLM-call helper, step recording, and
102
+ guardrail-check primitive used by every strategy. Subclasses
103
+ implement `run(state)`.
104
+ """
105
+
106
+ @abstractmethod
107
+ async def run(self, state: AgentState) -> AgentState: ...
108
+
109
+ # ----------------------------------------------------------------
110
+ # Helpers — every concrete strategy uses these.
111
+ # ----------------------------------------------------------------
112
+
113
+ def _check_guardrails(self, state: AgentState) -> None:
114
+ """Run all per-iteration guardrail checks.
115
+
116
+ Called before every LLM call. Raises `BudgetExceeded` /
117
+ `GuardrailViolation` if a cap is breached. The conformance
118
+ suite verifies via AST inspection that every concrete
119
+ strategy class invokes this method inside its main loop.
120
+ """
121
+ runtime = get_runtime(state)
122
+ runtime.budget.check()
123
+
124
+ def _record_step(
125
+ self,
126
+ state: AgentState,
127
+ *,
128
+ iteration: int,
129
+ kind: StepKind,
130
+ content: str | dict[str, object],
131
+ tokens_in: int = 0,
132
+ tokens_out: int = 0,
133
+ cost_usd: float = 0.0,
134
+ duration_ms: int = 0,
135
+ tool_call: object | None = None,
136
+ ) -> Step:
137
+ """Append a `Step` to `state.steps` with consistent shape."""
138
+ step = Step(
139
+ iteration=iteration,
140
+ kind=kind,
141
+ content=content,
142
+ tokens_in=tokens_in,
143
+ tokens_out=tokens_out,
144
+ cost_usd=cost_usd,
145
+ duration_ms=duration_ms,
146
+ tool_call=tool_call, # type: ignore[arg-type]
147
+ )
148
+ state.steps.append(step)
149
+ return step
150
+
151
+ async def _call_llm(
152
+ self,
153
+ state: AgentState,
154
+ *,
155
+ iteration: int,
156
+ system: str,
157
+ messages: list[Message],
158
+ tools: list[ToolSpec] | None = None,
159
+ kind: StepKind = "think",
160
+ ) -> LLMResponse:
161
+ """Guardrail-check → LLM call → record cost → record step.
162
+
163
+ Centralises the four operations every shipped strategy
164
+ performs at LLM-call boundaries. The conformance suite
165
+ treats a class that uses this helper as having satisfied
166
+ the guardrail-call invariant.
167
+ """
168
+ self._check_guardrails(state)
169
+ runtime = get_runtime(state)
170
+ llm: LLMClient = runtime.llm
171
+
172
+ tracer = get_tracer()
173
+ started_ms = time.monotonic()
174
+ with tracer.start_as_current_span(
175
+ "llm.call",
176
+ attributes={
177
+ "agentforge.iteration": iteration,
178
+ "agentforge.llm.has_tools": tools is not None and len(tools) > 0,
179
+ },
180
+ ) as llm_span:
181
+ response = await llm.call(system=system, messages=messages, tools=tools)
182
+ llm_span.set_attribute("agentforge.llm.provider", response.provider)
183
+ llm_span.set_attribute("agentforge.llm.model", response.model)
184
+ llm_span.set_attribute("agentforge.llm.tokens_in", response.usage.input_tokens)
185
+ llm_span.set_attribute("agentforge.llm.tokens_out", response.usage.output_tokens)
186
+ llm_span.set_attribute("agentforge.llm.cost_usd", float(response.cost_usd))
187
+ llm_span.set_attribute(
188
+ "agentforge.llm.stop_reason",
189
+ str(response.stop_reason),
190
+ )
191
+ duration_ms = int((time.monotonic() - started_ms) * 1000)
192
+
193
+ # Record cost on the shared BudgetPolicy.
194
+ runtime.budget.commit(response.cost_usd, response.usage.total)
195
+ runtime.budget.increment_iteration()
196
+
197
+ # Deliberately do NOT call record_success() here — the
198
+ # error_streak tracks tool-execution failures across
199
+ # iterations, not LLM-call success. Resetting on every LLM
200
+ # call would make the streak counter useless: every iteration
201
+ # would start with a streak reset, so a broken tool that
202
+ # always errors would never accumulate enough errors to trip
203
+ # the cap. Strategies call record_success() / record_error()
204
+ # around tool dispatches themselves.
205
+
206
+ # Append the LLM-call step to the trace.
207
+ self._record_step(
208
+ state,
209
+ iteration=iteration,
210
+ kind=kind,
211
+ content=response.content,
212
+ tokens_in=response.usage.input_tokens,
213
+ tokens_out=response.usage.output_tokens,
214
+ cost_usd=response.cost_usd,
215
+ duration_ms=duration_ms,
216
+ )
217
+
218
+ return response
219
+
220
+ async def _dispatch_tool(
221
+ self,
222
+ tool: Tool | None,
223
+ tool_name: str,
224
+ arguments: dict[str, Any],
225
+ *,
226
+ timeout_s: float | None = DEFAULT_TOOL_TIMEOUT_S,
227
+ ) -> str:
228
+ """Validate args, run a tool with a timeout, capture errors.
229
+
230
+ Centralises the tool-call dispatch path per feat-004 §4.3:
231
+
232
+ 1. Tool not registered → observation explaining "not registered".
233
+ 2. ValidationError on `input_schema.model_validate(arguments)`
234
+ → observation showing what's wrong (LLM sees the validation
235
+ error, not a stack trace, so it can self-correct on the next
236
+ iteration).
237
+ 3. `await tool.run(**validated)` wrapped in
238
+ `asyncio.wait_for(timeout=timeout_s)` (None disables).
239
+ 4. Any exception from the tool body → observation prefixed
240
+ `Error:`. Tools should raise rather than catch — the
241
+ strategy turns the raise into the LLM's observation.
242
+
243
+ Returns the observation string. The caller decides whether to
244
+ record a Step / increment the budget's success/error streak /
245
+ forward to the LLM as a `tool` message.
246
+ """
247
+ if tool is None:
248
+ return f"Error: tool {tool_name!r} is not registered on this agent."
249
+ try:
250
+ validated = tool.input_schema.model_validate(arguments)
251
+ except ValidationError as exc:
252
+ return f"Error: invalid arguments for {tool_name!r}: {exc}"
253
+ kwargs = validated.model_dump()
254
+
255
+ tracer = get_tracer()
256
+ started_ms = time.monotonic()
257
+ with tracer.start_as_current_span(
258
+ f"tool.{tool_name}",
259
+ attributes={
260
+ "agentforge.tool.name": tool_name,
261
+ "agentforge.tool.timeout_s": (float(timeout_s) if timeout_s is not None else -1.0),
262
+ },
263
+ ) as tool_span:
264
+ try:
265
+ if timeout_s is None:
266
+ raw = await tool.run(**kwargs)
267
+ else:
268
+ raw = await asyncio.wait_for(tool.run(**kwargs), timeout=timeout_s)
269
+ except TimeoutError:
270
+ tool_span.set_attribute("agentforge.tool.error", "TimeoutError")
271
+ return f"Error: tool {tool_name!r} exceeded timeout_s={timeout_s}."
272
+ except Exception as exc:
273
+ tool_span.set_attribute("agentforge.tool.error", type(exc).__name__)
274
+ return f"Error: {type(exc).__name__}: {exc}"
275
+ finally:
276
+ tool_span.set_attribute(
277
+ "agentforge.tool.duration_ms",
278
+ int((time.monotonic() - started_ms) * 1000),
279
+ )
280
+ return raw if isinstance(raw, str) else str(raw)
@@ -0,0 +1,93 @@
1
+ """`Plan` + `PlanStep` value types and topological-sort helper.
2
+
3
+ Used by `PlanExecuteLoop` (feat-002 chunk 3). The plan is a typed
4
+ Pydantic model so the LLM's JSON output is validated at parse time;
5
+ cycle and dangling-dependency detection are enforced by a
6
+ `model_validator` and `_topological_batches`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
14
+
15
+
16
+ class PlanStep(BaseModel):
17
+ """One step in a `Plan`.
18
+
19
+ Attributes:
20
+ id: Stable identifier; `depends_on` references this.
21
+ description: Natural-language description of what the step does.
22
+ tool: Name of a registered tool to invoke. `None` means a
23
+ "think" step — an LLM call reasoning about `description`
24
+ in the context of prior step outputs.
25
+ arguments: Keyword arguments passed to the tool's `run()`. Only
26
+ used when `tool` is not `None`.
27
+ depends_on: List of `PlanStep.id`s that must complete before
28
+ this step executes. Empty list = step is independent.
29
+ """
30
+
31
+ model_config = ConfigDict(frozen=True, strict=True)
32
+
33
+ id: str = Field(min_length=1)
34
+ description: str
35
+ tool: str | None = None
36
+ arguments: dict[str, Any] = Field(default_factory=dict)
37
+ depends_on: list[str] = Field(default_factory=list)
38
+
39
+
40
+ class Plan(BaseModel):
41
+ """A topologically valid execution plan."""
42
+
43
+ model_config = ConfigDict(frozen=True, strict=True)
44
+
45
+ steps: list[PlanStep] = Field(min_length=1)
46
+
47
+ @model_validator(mode="after")
48
+ def _validate(self) -> Plan:
49
+ ids = [step.id for step in self.steps]
50
+ if len(ids) != len(set(ids)):
51
+ raise ValueError("Plan step ids must be unique")
52
+ id_set = set(ids)
53
+ for step in self.steps:
54
+ for dep in step.depends_on:
55
+ if dep not in id_set:
56
+ raise ValueError(
57
+ f"step {step.id!r} depends_on={dep!r} which is not a plan step id"
58
+ )
59
+ if step.id in step.depends_on:
60
+ raise ValueError(f"step {step.id!r} depends on itself")
61
+ # Cycle detection — _topological_batches raises if a cycle exists.
62
+ _topological_batches(self.steps)
63
+ return self
64
+
65
+
66
+ def _topological_batches(steps: list[PlanStep]) -> list[list[PlanStep]]:
67
+ """Group `steps` into a list of batches.
68
+
69
+ Each batch contains steps whose `depends_on` set is fully covered
70
+ by completed (earlier-batch) steps. Steps within a batch have no
71
+ dependencies on each other and may run concurrently.
72
+
73
+ Raises:
74
+ ValueError: a cycle exists in the dependency graph.
75
+ """
76
+ by_id = {step.id: step for step in steps}
77
+ remaining: dict[str, set[str]] = {s.id: set(s.depends_on) for s in steps}
78
+ completed: set[str] = set()
79
+ batches: list[list[PlanStep]] = []
80
+
81
+ while remaining:
82
+ ready_ids = [sid for sid, deps in remaining.items() if deps.issubset(completed)]
83
+ if not ready_ids:
84
+ unsorted = sorted(remaining.keys())
85
+ raise ValueError(
86
+ f"Cycle in plan dependencies; cannot order remaining steps: {unsorted}"
87
+ )
88
+ batches.append([by_id[sid] for sid in ready_ids])
89
+ completed.update(ready_ids)
90
+ for sid in ready_ids:
91
+ del remaining[sid]
92
+
93
+ return batches