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,506 @@
1
+ """`PlanExecuteLoop` — modern plan-and-solve strategy.
2
+
3
+ Per feat-002 §4.3:
4
+
5
+ PHASE 1 — PLAN
6
+ LLM call returns a typed `Plan(steps=[...])` as JSON. Validated
7
+ via Pydantic; cycles and dangling deps caught at parse time. On
8
+ invalid plan: optional re-plan with the error fed back, up to
9
+ `max_replans` retries.
10
+
11
+ PHASE 2 — EXECUTE (topological batches)
12
+ Steps grouped into batches by dependencies. Each batch runs
13
+ concurrently (asyncio.Semaphore caps at `max_parallel_steps`).
14
+ Tool steps invoke the named tool; "think" steps (tool=None) make
15
+ one LLM call about the step description.
16
+
17
+ PHASE 3 — SYNTHESIZE
18
+ Final LLM call: observations[] → final answer.
19
+
20
+ Modern: structured Pydantic plan instead of free-form JSON parsing.
21
+ The LLM is asked to emit JSON matching the schema; validation errors
22
+ are recoverable via re-plan rather than crashing the run.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import time
30
+ from collections.abc import AsyncIterator
31
+ from typing import Any
32
+
33
+ from agentforge_core.contracts.tool import Tool
34
+ from agentforge_core.observability.tracing import get_tracer
35
+ from agentforge_core.values.chat import StreamingEvent
36
+ from agentforge_core.values.messages import Message
37
+ from agentforge_core.values.state import AgentState
38
+ from pydantic import ValidationError
39
+
40
+ from agentforge.resolver_register import register_strategy
41
+ from agentforge.strategies._base import StrategyBase, _events_for_new_steps, get_runtime
42
+ from agentforge.strategies._plan import Plan, PlanStep, _topological_batches
43
+
44
+ PLAN_SYSTEM_PROMPT = (
45
+ "You are a planning assistant. Decompose the user's task into a "
46
+ "structured execution plan. Return ONLY a JSON object matching this "
47
+ "schema (no other text):\n\n"
48
+ ' {"steps": ['
49
+ '{"id": "<unique id>", '
50
+ '"description": "<what this step does>", '
51
+ '"tool": "<tool name or null>", '
52
+ '"arguments": {<keyword args for the tool>}, '
53
+ '"depends_on": [<earlier step ids>]'
54
+ "}, ...]}\n\n"
55
+ "Rules:\n"
56
+ "- Each step.id must be unique within the plan.\n"
57
+ "- depends_on must reference earlier step ids only.\n"
58
+ "- Steps with tool=null are think-only; an LLM will reason about "
59
+ "the description.\n"
60
+ "- Keep the plan concise; prefer parallelisable independent steps."
61
+ )
62
+
63
+ SYNTHESIS_SYSTEM_PROMPT = (
64
+ "You synthesize a final answer from a structured execution trace. "
65
+ "Read the user's task and the per-step observations, then produce "
66
+ "a clear, complete answer to the original task."
67
+ )
68
+
69
+
70
+ @register_strategy("plan-execute")
71
+ class PlanExecuteLoop(StrategyBase):
72
+ """Plan-and-solve loop with topological execution and re-planning.
73
+
74
+ Per feat-002 §4.2 the constructor surface is locked at v0.1:
75
+
76
+ Args:
77
+ max_parallel_steps: Max steps that may execute concurrently
78
+ within a batch. Default 4. Defends against tool fan-out
79
+ blowing past resource limits.
80
+ replan_on_failure: When a step raises during execution, ask
81
+ the LLM to re-plan from scratch with the error context.
82
+ Default True.
83
+ max_replans: Maximum number of full re-plan cycles. Default 1
84
+ (one initial plan + one re-plan).
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ *,
90
+ max_parallel_steps: int = 4,
91
+ replan_on_failure: bool = True,
92
+ max_replans: int = 1,
93
+ ) -> None:
94
+ if max_parallel_steps < 1:
95
+ raise ValueError("max_parallel_steps must be >= 1")
96
+ if max_replans < 0:
97
+ raise ValueError("max_replans must be >= 0")
98
+ self._max_parallel_steps = max_parallel_steps
99
+ self._replan_on_failure = replan_on_failure
100
+ self._max_replans = max_replans
101
+
102
+ async def run(self, state: AgentState) -> AgentState:
103
+ # Verify the runtime is bound (raises clear error otherwise);
104
+ # actual access happens in helpers via `get_runtime(state)`.
105
+ get_runtime(state)
106
+ replans = 0
107
+ plan_messages: list[Message] = [Message(role="user", content=state.task)]
108
+ tracer = get_tracer()
109
+
110
+ while True:
111
+ with tracer.start_as_current_span(
112
+ "strategy.iteration",
113
+ attributes={
114
+ "agentforge.iteration": replans,
115
+ "agentforge.strategy": "plan_execute",
116
+ },
117
+ ):
118
+ # PHASE 1 — Plan (with retry on parse / validation failure)
119
+ plan = await self._build_plan(state, plan_messages, replans_done=replans)
120
+ self._record_step(
121
+ state,
122
+ iteration=0,
123
+ kind="plan",
124
+ content={"steps": [step.model_dump() for step in plan.steps]},
125
+ )
126
+
127
+ # PHASE 2 — Execute
128
+ try:
129
+ observations = await self._execute_plan(state, plan)
130
+ break
131
+ except _StepFailure as failure:
132
+ if not self._replan_on_failure or replans >= self._max_replans:
133
+ # Surface the failure as a final observation; don't crash.
134
+ observations = failure.observations_so_far
135
+ self._record_step(
136
+ state,
137
+ iteration=len(plan.steps),
138
+ kind="observe",
139
+ content=(
140
+ f"Plan execution failed at step {failure.failed_step.id!r}: "
141
+ f"{failure.error}. No further re-plan attempts."
142
+ ),
143
+ )
144
+ break
145
+ replans += 1
146
+ # Feed the failure context back so the LLM can revise the plan.
147
+ plan_messages.append(Message(role="assistant", content=plan.model_dump_json()))
148
+ plan_messages.append(
149
+ Message(
150
+ role="user",
151
+ content=(
152
+ f"The plan failed at step {failure.failed_step.id!r} "
153
+ f"({failure.failed_step.description!r}): {failure.error}. "
154
+ f"Please re-plan."
155
+ ),
156
+ )
157
+ )
158
+
159
+ # PHASE 3 — Synthesize
160
+ synth_messages: list[Message] = [
161
+ Message(role="user", content=state.task),
162
+ Message(
163
+ role="assistant",
164
+ content=(
165
+ "Plan executed with the following observations:\n"
166
+ + "\n".join(f"- {step_id}: {obs}" for step_id, obs in observations.items())
167
+ ),
168
+ ),
169
+ ]
170
+ await self._call_llm(
171
+ state,
172
+ iteration=len(plan.steps) + 1,
173
+ system=SYNTHESIS_SYSTEM_PROMPT,
174
+ messages=synth_messages,
175
+ kind="synthesize",
176
+ )
177
+
178
+ return state
179
+
180
+ async def stream(self, state: AgentState) -> AsyncIterator[StreamingEvent]:
181
+ """Per-phase streaming override (feat-002 v0.3 polish).
182
+
183
+ Mirrors :meth:`run` but yields ``step`` `StreamingEvent`s
184
+ after each phase that records steps: ``plan`` after the plan
185
+ is built, ``observe`` after each batch of execute steps,
186
+ ``synthesize`` after the final synthesis call. Replan cycles
187
+ emit a fresh plan event per attempt.
188
+ """
189
+ get_runtime(state)
190
+ replans = 0
191
+ plan_messages: list[Message] = [Message(role="user", content=state.task)]
192
+ tracer = get_tracer()
193
+ runtime = get_runtime(state)
194
+ before = len(state.steps)
195
+
196
+ while True:
197
+ with tracer.start_as_current_span(
198
+ "strategy.iteration",
199
+ attributes={
200
+ "agentforge.iteration": replans,
201
+ "agentforge.strategy": "plan_execute",
202
+ },
203
+ ):
204
+ plan = await self._build_plan(state, plan_messages, replans_done=replans)
205
+ self._record_step(
206
+ state,
207
+ iteration=0,
208
+ kind="plan",
209
+ content={"steps": [step.model_dump() for step in plan.steps]},
210
+ )
211
+ for ev in _events_for_new_steps(state.steps, before):
212
+ yield ev
213
+ before = len(state.steps)
214
+
215
+ try:
216
+ observations = await self._execute_plan(state, plan)
217
+ for ev in _events_for_new_steps(state.steps, before):
218
+ yield ev
219
+ before = len(state.steps)
220
+ break
221
+ except _StepFailure as failure:
222
+ if not self._replan_on_failure or replans >= self._max_replans:
223
+ observations = failure.observations_so_far
224
+ self._record_step(
225
+ state,
226
+ iteration=len(plan.steps),
227
+ kind="observe",
228
+ content=(
229
+ f"Plan execution failed at step {failure.failed_step.id!r}: "
230
+ f"{failure.error}. No further re-plan attempts."
231
+ ),
232
+ )
233
+ for ev in _events_for_new_steps(state.steps, before):
234
+ yield ev
235
+ before = len(state.steps)
236
+ break
237
+ replans += 1
238
+ plan_messages.append(Message(role="assistant", content=plan.model_dump_json()))
239
+ plan_messages.append(
240
+ Message(
241
+ role="user",
242
+ content=(
243
+ f"The plan failed at step {failure.failed_step.id!r} "
244
+ f"({failure.failed_step.description!r}): {failure.error}. "
245
+ f"Please re-plan."
246
+ ),
247
+ )
248
+ )
249
+
250
+ # PHASE 3 — Synthesize
251
+ synth_messages: list[Message] = [
252
+ Message(role="user", content=state.task),
253
+ Message(
254
+ role="assistant",
255
+ content=(
256
+ "Plan executed with the following observations:\n"
257
+ + "\n".join(f"- {step_id}: {obs}" for step_id, obs in observations.items())
258
+ ),
259
+ ),
260
+ ]
261
+ await self._call_llm(
262
+ state,
263
+ iteration=len(plan.steps) + 1,
264
+ system=SYNTHESIS_SYSTEM_PROMPT,
265
+ messages=synth_messages,
266
+ kind="synthesize",
267
+ )
268
+ for ev in _events_for_new_steps(state.steps, before):
269
+ yield ev
270
+
271
+ yield StreamingEvent(
272
+ kind="done",
273
+ content={
274
+ "run_id": state.run_id,
275
+ "cost_usd": float(runtime.budget.spent_usd),
276
+ },
277
+ metadata={},
278
+ )
279
+
280
+ # ------------------------------------------------------------------
281
+ # Phase helpers
282
+ # ------------------------------------------------------------------
283
+
284
+ async def _build_plan(
285
+ self,
286
+ state: AgentState,
287
+ messages: list[Message],
288
+ *,
289
+ replans_done: int,
290
+ ) -> Plan:
291
+ """Ask the LLM for a Plan; validate; retry-on-error up to
292
+ `max_replans` total attempts."""
293
+ attempts_left = (self._max_replans - replans_done) + 1
294
+ last_error: str | None = None
295
+
296
+ for attempt in range(attempts_left):
297
+ response = await self._call_llm(
298
+ state,
299
+ iteration=0,
300
+ system=PLAN_SYSTEM_PROMPT,
301
+ messages=messages,
302
+ kind="think",
303
+ )
304
+ try:
305
+ return _parse_plan(response.content)
306
+ except (ValidationError, json.JSONDecodeError, ValueError) as exc:
307
+ last_error = str(exc)
308
+ if attempt >= attempts_left - 1:
309
+ break
310
+ # Feed the parse error back as user-role correction.
311
+ messages.append(Message(role="assistant", content=response.content))
312
+ messages.append(
313
+ Message(
314
+ role="user",
315
+ content=(
316
+ f"That plan was invalid: {exc}. "
317
+ f"Please return a valid JSON plan matching the schema."
318
+ ),
319
+ )
320
+ )
321
+
322
+ raise _PlanInvalidError(f"Plan invalid after retries: {last_error}")
323
+
324
+ async def _execute_plan(self, state: AgentState, plan: Plan) -> dict[str, str]:
325
+ """Execute the plan in topological batches.
326
+
327
+ Returns a mapping of `step_id -> observation`.
328
+ Raises `_StepFailure` (caught by `run`) on any step exception
329
+ when `replan_on_failure` is True; otherwise records the
330
+ failure as an observation and continues.
331
+ """
332
+ runtime = get_runtime(state)
333
+ batches = _topological_batches(plan.steps)
334
+ observations: dict[str, str] = {}
335
+ sem = asyncio.Semaphore(self._max_parallel_steps)
336
+
337
+ for batch_idx, batch in enumerate(batches, start=1):
338
+ self._check_guardrails(state)
339
+
340
+ async def execute_one(
341
+ step: PlanStep, *, batch_idx: int = batch_idx
342
+ ) -> tuple[PlanStep, str | Exception]:
343
+ async with sem:
344
+ try:
345
+ observation = await self._run_step(state, batch_idx=batch_idx, step=step)
346
+ except Exception as exc:
347
+ return step, exc
348
+ return step, observation
349
+
350
+ results = await asyncio.gather(*(execute_one(step) for step in batch))
351
+
352
+ for step, result in results:
353
+ if isinstance(result, Exception):
354
+ runtime.budget.record_error()
355
+ if self._replan_on_failure:
356
+ raise _StepFailure(
357
+ failed_step=step,
358
+ error=f"{type(result).__name__}: {result}",
359
+ observations_so_far=observations,
360
+ )
361
+ error_msg = f"Error: {type(result).__name__}: {result}"
362
+ self._record_step(
363
+ state,
364
+ iteration=batch_idx,
365
+ kind="observe",
366
+ content=error_msg,
367
+ )
368
+ observations[step.id] = error_msg
369
+ else:
370
+ runtime.budget.record_success()
371
+ observations[step.id] = result
372
+ self._record_step(
373
+ state,
374
+ iteration=batch_idx,
375
+ kind="observe",
376
+ content={"step_id": step.id, "observation": result},
377
+ )
378
+
379
+ return observations
380
+
381
+ async def _run_step(
382
+ self,
383
+ state: AgentState,
384
+ *,
385
+ batch_idx: int,
386
+ step: PlanStep,
387
+ ) -> str:
388
+ """Execute one PlanStep. Returns observation text."""
389
+ runtime = get_runtime(state)
390
+ started_ms = time.monotonic()
391
+
392
+ if step.tool is None:
393
+ # "Think" step — LLM call about the step's description.
394
+ response = await self._call_llm(
395
+ state,
396
+ iteration=batch_idx,
397
+ system=(
398
+ "You are reasoning about one step of a larger plan. "
399
+ "Provide a concise outcome for the step described."
400
+ ),
401
+ messages=[Message(role="user", content=step.description)],
402
+ kind="act",
403
+ )
404
+ return response.content
405
+
406
+ # Tool step
407
+ tool = _find_tool(runtime.tools, step.tool)
408
+ if tool is None:
409
+ raise _ToolNotFoundError(
410
+ f"tool {step.tool!r} (referenced by step {step.id!r}) is "
411
+ f"not registered on the agent"
412
+ )
413
+
414
+ # Record an `act` step for the tool dispatch.
415
+ self._record_step(
416
+ state,
417
+ iteration=batch_idx,
418
+ kind="act",
419
+ content={
420
+ "step_id": step.id,
421
+ "tool": step.tool,
422
+ "arguments": step.arguments,
423
+ },
424
+ )
425
+
426
+ observation = await self._dispatch_tool(tool, step.tool, dict(step.arguments))
427
+ duration_ms = int((time.monotonic() - started_ms) * 1000)
428
+ # Note duration is approximate (records before the observe step
429
+ # is appended); kept on the act step's metadata if needed.
430
+ del duration_ms
431
+ # plan-execute keeps its replan-on-failure semantics: if the
432
+ # dispatch helper produced an `Error:` observation (validation
433
+ # failure, timeout, or tool exception), bubble it up so the
434
+ # surrounding execute_one/_StepFailure machinery can decide
435
+ # whether to replan.
436
+ if observation.startswith("Error:"):
437
+ raise RuntimeError(observation)
438
+ return observation
439
+
440
+
441
+ def _parse_plan(content: str) -> Plan:
442
+ """Parse JSON-encoded plan from the LLM response."""
443
+ cleaned = _strip_code_fences(content)
444
+ return Plan.model_validate_json(cleaned)
445
+
446
+
447
+ def _strip_code_fences(content: str) -> str:
448
+ """Strip ```json ... ``` fences if the LLM wrapped the JSON in them."""
449
+ text = content.strip()
450
+ if text.startswith("```"):
451
+ # Remove leading fence (with optional language tag) and trailing fence.
452
+ first_newline = text.find("\n")
453
+ if first_newline == -1:
454
+ return text
455
+ body = text[first_newline + 1 :]
456
+ if body.endswith("```"):
457
+ body = body[: -len("```")]
458
+ return body.strip()
459
+ return text
460
+
461
+
462
+ def _find_tool(tools: tuple[Tool, ...], name: str) -> Tool | None:
463
+ for tool in tools:
464
+ if type(tool).name == name:
465
+ return tool
466
+ return None
467
+
468
+
469
+ # ----------------------------------------------------------------------
470
+ # Internal exceptions (caught by run; never raised to callers).
471
+ # ----------------------------------------------------------------------
472
+
473
+
474
+ class _PlanInvalidError(Exception):
475
+ """LLM produced an invalid plan after all retries."""
476
+
477
+
478
+ class _ToolNotFoundError(Exception):
479
+ """A plan step referenced an unregistered tool name."""
480
+
481
+
482
+ class _StepFailure(Exception): # noqa: N818 — internal sentinel, not user-visible
483
+ """Wraps a step's exception for the run-level retry logic."""
484
+
485
+ def __init__(
486
+ self,
487
+ *,
488
+ failed_step: PlanStep,
489
+ error: str,
490
+ observations_so_far: dict[str, str],
491
+ ) -> None:
492
+ super().__init__(error)
493
+ self.failed_step = failed_step
494
+ self.error = error
495
+ self.observations_so_far = observations_so_far
496
+
497
+
498
+ # ----------------------------------------------------------------------
499
+ # Public re-exports.
500
+ # ----------------------------------------------------------------------
501
+
502
+
503
+ __all__ = ["Plan", "PlanExecuteLoop", "PlanStep"]
504
+
505
+
506
+ _: Any = None # silence "unused import" for re-exports