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