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,69 @@
|
|
|
1
|
+
"""Public testing API (feat-016).
|
|
2
|
+
|
|
3
|
+
`agentforge.testing` is the v0.1 public test-helper surface. It
|
|
4
|
+
ships scripted-response mocks, fake tools, an `agent_factory`
|
|
5
|
+
helper, pytest fixtures, conformance harnesses re-exported from
|
|
6
|
+
`agentforge_core.testing`, and recording / replay helpers.
|
|
7
|
+
|
|
8
|
+
Typical usage:
|
|
9
|
+
|
|
10
|
+
from agentforge.testing import (
|
|
11
|
+
MockLLMClient,
|
|
12
|
+
FakeTool,
|
|
13
|
+
agent_factory,
|
|
14
|
+
run_memory_conformance,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
async def test_population_lookup() -> None:
|
|
18
|
+
llm = MockLLMClient.from_script([
|
|
19
|
+
{"text": "I need to search.",
|
|
20
|
+
"tool_calls": [{"name": "search", "args": {"q": "x"}}]},
|
|
21
|
+
{"text": "47.5M", "stop_reason": "end_turn"},
|
|
22
|
+
])
|
|
23
|
+
web = FakeTool.fake("search", lambda **kw: "47.5M")
|
|
24
|
+
agent = agent_factory(model=llm, tools=[web])
|
|
25
|
+
result = await agent.run("How many people live in Spain?")
|
|
26
|
+
assert "47.5M" in result.output
|
|
27
|
+
assert llm.call_count == 2
|
|
28
|
+
|
|
29
|
+
The private `agentforge._testing` namespace is preserved as a
|
|
30
|
+
compatibility shim for the framework's own pre-feat-016 internal
|
|
31
|
+
tests; new code should import from `agentforge.testing`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from agentforge._testing.fake_llm import FakeLLMClient, echo_response
|
|
37
|
+
from agentforge._testing.fake_tool import FakeTool
|
|
38
|
+
from agentforge.testing.conformance import (
|
|
39
|
+
run_hybrid_search_conformance,
|
|
40
|
+
run_input_validator_conformance,
|
|
41
|
+
run_memory_conformance,
|
|
42
|
+
run_output_validator_conformance,
|
|
43
|
+
run_strategy_conformance,
|
|
44
|
+
run_task_conformance,
|
|
45
|
+
run_tool_gate_conformance,
|
|
46
|
+
run_vector_conformance,
|
|
47
|
+
)
|
|
48
|
+
from agentforge.testing.factory import agent_factory
|
|
49
|
+
from agentforge.testing.llm import MockLLMClient, ScriptedResponse
|
|
50
|
+
from agentforge.testing.recording import load_recording, record_llm
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"FakeLLMClient",
|
|
54
|
+
"FakeTool",
|
|
55
|
+
"MockLLMClient",
|
|
56
|
+
"ScriptedResponse",
|
|
57
|
+
"agent_factory",
|
|
58
|
+
"echo_response",
|
|
59
|
+
"load_recording",
|
|
60
|
+
"record_llm",
|
|
61
|
+
"run_hybrid_search_conformance",
|
|
62
|
+
"run_input_validator_conformance",
|
|
63
|
+
"run_memory_conformance",
|
|
64
|
+
"run_output_validator_conformance",
|
|
65
|
+
"run_strategy_conformance",
|
|
66
|
+
"run_task_conformance",
|
|
67
|
+
"run_tool_gate_conformance",
|
|
68
|
+
"run_vector_conformance",
|
|
69
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Conformance harnesses (feat-016).
|
|
2
|
+
|
|
3
|
+
Re-exports the ABC-conformance functions from
|
|
4
|
+
`agentforge_core.testing.conformance` so users have one canonical
|
|
5
|
+
import path. The actual harnesses live in `agentforge-core` because
|
|
6
|
+
they exercise contracts defined there.
|
|
7
|
+
|
|
8
|
+
Typical usage:
|
|
9
|
+
|
|
10
|
+
from agentforge.testing import run_memory_conformance
|
|
11
|
+
from my_pkg import MyMemoryStore
|
|
12
|
+
|
|
13
|
+
async def test_my_driver() -> None:
|
|
14
|
+
async with MyMemoryStore.from_url("...") as store:
|
|
15
|
+
await run_memory_conformance(store)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from agentforge_core.testing.conformance import (
|
|
21
|
+
run_hybrid_search_conformance,
|
|
22
|
+
run_input_validator_conformance,
|
|
23
|
+
run_memory_conformance,
|
|
24
|
+
run_output_validator_conformance,
|
|
25
|
+
run_strategy_conformance,
|
|
26
|
+
run_task_conformance,
|
|
27
|
+
run_tool_gate_conformance,
|
|
28
|
+
run_vector_conformance,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"run_hybrid_search_conformance",
|
|
33
|
+
"run_input_validator_conformance",
|
|
34
|
+
"run_memory_conformance",
|
|
35
|
+
"run_output_validator_conformance",
|
|
36
|
+
"run_strategy_conformance",
|
|
37
|
+
"run_task_conformance",
|
|
38
|
+
"run_tool_gate_conformance",
|
|
39
|
+
"run_vector_conformance",
|
|
40
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`agent_factory` — safe Agent constructor for unit tests (feat-016).
|
|
2
|
+
|
|
3
|
+
Defaults: `MockLLMClient.deterministic("ok")`, no tools, in-memory
|
|
4
|
+
store, low budget (0.10 USD), low iteration cap (3). Override any
|
|
5
|
+
kwarg explicitly. The intent is fast, deterministic, isolated tests
|
|
6
|
+
— no network, no secrets, < 100 ms per case.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentforge_core.contracts.llm import LLMClient
|
|
14
|
+
from agentforge_core.contracts.strategy import ReasoningStrategy
|
|
15
|
+
from agentforge_core.contracts.tool import Tool
|
|
16
|
+
from agentforge_core.values.state import AgentState, Step
|
|
17
|
+
|
|
18
|
+
from agentforge.agent import Agent
|
|
19
|
+
from agentforge.memory import InMemoryStore
|
|
20
|
+
from agentforge.testing.llm import MockLLMClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SingleStepStrategy(ReasoningStrategy):
|
|
24
|
+
"""Trivial strategy: makes a single LLM call, returns its content.
|
|
25
|
+
|
|
26
|
+
The factory wires this in when no `strategy` override is passed
|
|
27
|
+
so tests don't need to install feat-002's strategies just to
|
|
28
|
+
exercise `Agent.run`. Real production agents pass an explicit
|
|
29
|
+
strategy.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
async def run(self, state: AgentState) -> AgentState:
|
|
33
|
+
from agentforge.strategies._base import get_runtime # noqa: PLC0415
|
|
34
|
+
|
|
35
|
+
runtime = get_runtime(state)
|
|
36
|
+
response = await runtime.llm.call(
|
|
37
|
+
system="",
|
|
38
|
+
messages=[],
|
|
39
|
+
tools=None,
|
|
40
|
+
)
|
|
41
|
+
state.steps.append(
|
|
42
|
+
Step(
|
|
43
|
+
iteration=0,
|
|
44
|
+
kind="observe",
|
|
45
|
+
content=response.content,
|
|
46
|
+
cost_usd=response.cost_usd,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
return state
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def agent_factory(
|
|
53
|
+
*,
|
|
54
|
+
model: str | LLMClient | None = None,
|
|
55
|
+
tools: list[Tool] | None = None,
|
|
56
|
+
strategy: str | ReasoningStrategy | None = None,
|
|
57
|
+
**overrides: Any,
|
|
58
|
+
) -> Agent:
|
|
59
|
+
"""Construct an `Agent` with safe test defaults.
|
|
60
|
+
|
|
61
|
+
Equivalent to::
|
|
62
|
+
|
|
63
|
+
Agent(
|
|
64
|
+
model=model or MockLLMClient.deterministic("ok"),
|
|
65
|
+
tools=tools or [],
|
|
66
|
+
strategy=strategy or _SingleStepStrategy(),
|
|
67
|
+
memory=InMemoryStore(),
|
|
68
|
+
budget_usd=0.10,
|
|
69
|
+
max_iterations=3,
|
|
70
|
+
install_log_filter=False,
|
|
71
|
+
**overrides,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
The `install_log_filter=False` default keeps test runs from
|
|
75
|
+
mutating root-logger handlers across the suite.
|
|
76
|
+
"""
|
|
77
|
+
return Agent(
|
|
78
|
+
model=model if model is not None else MockLLMClient.deterministic("ok"),
|
|
79
|
+
tools=tools if tools is not None else [],
|
|
80
|
+
strategy=strategy if strategy is not None else _SingleStepStrategy(),
|
|
81
|
+
memory=overrides.pop("memory", None) or InMemoryStore(),
|
|
82
|
+
budget_usd=overrides.pop("budget_usd", 0.10),
|
|
83
|
+
max_iterations=overrides.pop("max_iterations", 3),
|
|
84
|
+
install_log_filter=overrides.pop("install_log_filter", False),
|
|
85
|
+
**overrides,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["agent_factory"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Pytest fixtures (feat-016).
|
|
2
|
+
|
|
3
|
+
Importing this module registers the fixtures into the active
|
|
4
|
+
session via `pytest.fixture` — typical usage is to re-export the
|
|
5
|
+
two fixtures from a project's `conftest.py`:
|
|
6
|
+
|
|
7
|
+
# tests/conftest.py
|
|
8
|
+
from agentforge.testing.fixtures import mock_llm, temp_memory_store
|
|
9
|
+
|
|
10
|
+
The fixtures are framework-agnostic in spirit but the decorator is
|
|
11
|
+
pytest-specific. Other test runners can construct the helpers
|
|
12
|
+
themselves via `MockLLMClient.deterministic(...)` and
|
|
13
|
+
`InMemoryStore()` directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from agentforge.memory import InMemoryStore
|
|
23
|
+
from agentforge.testing.llm import MockLLMClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def mock_llm() -> MockLLMClient:
|
|
28
|
+
"""A fresh `MockLLMClient.deterministic("ok")` per test."""
|
|
29
|
+
return MockLLMClient.deterministic("ok")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
async def temp_memory_store() -> AsyncIterator[InMemoryStore]:
|
|
34
|
+
"""An `InMemoryStore` that's cleared after the test."""
|
|
35
|
+
store = InMemoryStore()
|
|
36
|
+
try:
|
|
37
|
+
yield store
|
|
38
|
+
finally:
|
|
39
|
+
await store.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["mock_llm", "temp_memory_store"]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""`MockLLMClient` — the v0.1 public scripted-response `LLMClient`.
|
|
2
|
+
|
|
3
|
+
Richer than the private `_testing.FakeLLMClient`:
|
|
4
|
+
|
|
5
|
+
- `MockLLMClient.from_script([...])` accepts a list of dict
|
|
6
|
+
"scripted responses" with text / tool_calls / stop_reason / usage
|
|
7
|
+
— far less boilerplate than constructing `LLMResponse` by hand.
|
|
8
|
+
- `MockLLMClient.deterministic("answer")` always returns the same
|
|
9
|
+
short response, perfect for the `agent_factory` default.
|
|
10
|
+
- `MockLLMClient.from_recording(path)` (added in chunk 3) replays
|
|
11
|
+
a recorded transcript.
|
|
12
|
+
- Tracks `call_count` and `tool_calls_observed` (a list of every
|
|
13
|
+
`(tool_name, arguments)` pair the script emitted) so tests can
|
|
14
|
+
assert on what the agent asked the LLM to call.
|
|
15
|
+
|
|
16
|
+
Implementation defers as much as possible to the existing
|
|
17
|
+
`FakeLLMClient` so we share its captured-call ergonomics; the
|
|
18
|
+
public surface is what changed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from agentforge_core.contracts.llm import LLMClient
|
|
28
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
29
|
+
from agentforge_core.values.messages import (
|
|
30
|
+
LLMResponse,
|
|
31
|
+
Message,
|
|
32
|
+
StopReason,
|
|
33
|
+
TokenUsage,
|
|
34
|
+
ToolCall,
|
|
35
|
+
ToolSpec,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
ScriptedResponse = dict[str, Any]
|
|
39
|
+
"""Lightweight scripted response.
|
|
40
|
+
|
|
41
|
+
Keys (all optional except `text`):
|
|
42
|
+
- ``text``: str — the assistant's content.
|
|
43
|
+
- ``tool_calls``: list[{name, args, id?}] — emitted tool calls.
|
|
44
|
+
- ``stop_reason``: "end_turn" | "tool_use" | ... — defaults to
|
|
45
|
+
"tool_use" when tool_calls are present, "end_turn" otherwise.
|
|
46
|
+
- ``usage``: {input_tokens, output_tokens, ...} — defaults to a
|
|
47
|
+
small non-zero usage.
|
|
48
|
+
- ``cost_usd``, ``model``, ``provider`` — defaults to 0.0 / "mock"
|
|
49
|
+
/ "mock".
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MockLLMClient(LLMClient):
|
|
54
|
+
"""Scripted-response `LLMClient`. Public test helper."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
responses: list[LLMResponse],
|
|
60
|
+
provider: str = "mock",
|
|
61
|
+
model: str = "mock",
|
|
62
|
+
) -> None:
|
|
63
|
+
self._responses = list(responses)
|
|
64
|
+
self._cursor = 0
|
|
65
|
+
self._observed_tool_calls: list[tuple[str, dict[str, Any]]] = []
|
|
66
|
+
self._provider = provider
|
|
67
|
+
self._model = model
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Factories
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_script(
|
|
75
|
+
cls,
|
|
76
|
+
responses: list[ScriptedResponse],
|
|
77
|
+
*,
|
|
78
|
+
provider: str = "mock",
|
|
79
|
+
model: str = "mock",
|
|
80
|
+
) -> MockLLMClient:
|
|
81
|
+
"""Build a mock from a list of lightweight dict responses."""
|
|
82
|
+
return cls(
|
|
83
|
+
responses=[_scripted_to_llm_response(r, provider, model) for r in responses],
|
|
84
|
+
provider=provider,
|
|
85
|
+
model=model,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_recording(
|
|
90
|
+
cls,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
provider: str = "mock",
|
|
94
|
+
model: str = "mock",
|
|
95
|
+
) -> MockLLMClient:
|
|
96
|
+
"""Replay a recording produced by `record_llm`.
|
|
97
|
+
|
|
98
|
+
Responses are returned in on-disk order — same as the
|
|
99
|
+
original real-provider sequence. Matching by request hash
|
|
100
|
+
is exposed for callers that need it; the default replay
|
|
101
|
+
is sequential because tests usually exercise the same flow
|
|
102
|
+
as the recording.
|
|
103
|
+
"""
|
|
104
|
+
from agentforge.testing.recording import load_recording # noqa: PLC0415
|
|
105
|
+
|
|
106
|
+
_, entries = load_recording(path)
|
|
107
|
+
responses: list[LLMResponse] = []
|
|
108
|
+
for entry in entries:
|
|
109
|
+
resp = dict(entry["response"])
|
|
110
|
+
# Pydantic strict mode requires tuples for tuple-typed
|
|
111
|
+
# fields; JSON serialisation lowered them to lists.
|
|
112
|
+
if isinstance(resp.get("tool_calls"), list):
|
|
113
|
+
resp["tool_calls"] = tuple(resp["tool_calls"])
|
|
114
|
+
responses.append(LLMResponse.model_validate(resp))
|
|
115
|
+
return cls(responses=responses, provider=provider, model=model)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def deterministic(
|
|
119
|
+
cls,
|
|
120
|
+
response: str,
|
|
121
|
+
*,
|
|
122
|
+
provider: str = "mock",
|
|
123
|
+
model: str = "mock",
|
|
124
|
+
) -> MockLLMClient:
|
|
125
|
+
"""Always returns the same single-response transcript.
|
|
126
|
+
|
|
127
|
+
Useful for `agent_factory()` defaults: a one-shot reply
|
|
128
|
+
with no tool calls, no cost, that lets `Agent.run` complete
|
|
129
|
+
cleanly in unit tests.
|
|
130
|
+
"""
|
|
131
|
+
return cls.from_script(
|
|
132
|
+
[{"text": response, "stop_reason": "end_turn"}],
|
|
133
|
+
provider=provider,
|
|
134
|
+
model=model,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Observation surface
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def call_count(self) -> int:
|
|
143
|
+
"""Number of `.call()` invocations so far."""
|
|
144
|
+
return self._cursor
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def tool_calls_observed(self) -> list[tuple[str, dict[str, Any]]]:
|
|
148
|
+
"""Every (tool_name, arguments) pair the script has emitted.
|
|
149
|
+
|
|
150
|
+
Tests use this to assert on the agent's tool-call sequence
|
|
151
|
+
without having to inspect each `LLMResponse` manually.
|
|
152
|
+
"""
|
|
153
|
+
return list(self._observed_tool_calls)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# LLMClient contract
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def call(
|
|
160
|
+
self,
|
|
161
|
+
system: str,
|
|
162
|
+
messages: list[Message],
|
|
163
|
+
tools: list[ToolSpec] | None = None,
|
|
164
|
+
) -> LLMResponse:
|
|
165
|
+
del system, messages, tools
|
|
166
|
+
if self._cursor >= len(self._responses):
|
|
167
|
+
msg = (
|
|
168
|
+
f"MockLLMClient exhausted after {self._cursor} call(s); "
|
|
169
|
+
f"add more scripted responses or check the strategy loop."
|
|
170
|
+
)
|
|
171
|
+
raise ModuleError(msg)
|
|
172
|
+
response = self._responses[self._cursor]
|
|
173
|
+
self._cursor += 1
|
|
174
|
+
for tc in response.tool_calls:
|
|
175
|
+
self._observed_tool_calls.append((tc.name, dict(tc.arguments)))
|
|
176
|
+
return response
|
|
177
|
+
|
|
178
|
+
async def close(self) -> None:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _scripted_to_llm_response(
|
|
183
|
+
spec: ScriptedResponse,
|
|
184
|
+
provider: str,
|
|
185
|
+
model: str,
|
|
186
|
+
) -> LLMResponse:
|
|
187
|
+
"""Normalise a `ScriptedResponse` dict into an `LLMResponse`."""
|
|
188
|
+
text = spec.get("text", "")
|
|
189
|
+
raw_tool_calls = spec.get("tool_calls", []) or []
|
|
190
|
+
tool_calls = tuple(
|
|
191
|
+
ToolCall(
|
|
192
|
+
id=tc.get("id") or _synth_tool_id(tc),
|
|
193
|
+
name=tc["name"],
|
|
194
|
+
arguments=dict(tc.get("args", tc.get("arguments", {}))),
|
|
195
|
+
)
|
|
196
|
+
for tc in raw_tool_calls
|
|
197
|
+
)
|
|
198
|
+
stop_reason: StopReason = spec.get(
|
|
199
|
+
"stop_reason",
|
|
200
|
+
"tool_use" if tool_calls else "end_turn",
|
|
201
|
+
)
|
|
202
|
+
usage_raw: dict[str, Any] = spec.get("usage") or {}
|
|
203
|
+
usage = TokenUsage(
|
|
204
|
+
input_tokens=int(usage_raw.get("input_tokens", 1)),
|
|
205
|
+
output_tokens=int(usage_raw.get("output_tokens", 1)),
|
|
206
|
+
cache_read_tokens=int(usage_raw.get("cache_read_tokens", 0)),
|
|
207
|
+
cache_write_tokens=int(usage_raw.get("cache_write_tokens", 0)),
|
|
208
|
+
thinking_tokens=int(usage_raw.get("thinking_tokens", 0)),
|
|
209
|
+
)
|
|
210
|
+
return LLMResponse(
|
|
211
|
+
content=text,
|
|
212
|
+
tool_calls=tool_calls,
|
|
213
|
+
stop_reason=stop_reason,
|
|
214
|
+
usage=usage,
|
|
215
|
+
cost_usd=float(spec.get("cost_usd", 0.0)),
|
|
216
|
+
model=spec.get("model", model),
|
|
217
|
+
provider=spec.get("provider", provider),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _synth_tool_id(tc: dict[str, Any]) -> str:
|
|
222
|
+
"""Synthesize a stable tool-call id from name + arguments.
|
|
223
|
+
|
|
224
|
+
Real providers always set their own id; the mock fills one in
|
|
225
|
+
when the script omits it so downstream code that keys on
|
|
226
|
+
ToolCall.id keeps working.
|
|
227
|
+
"""
|
|
228
|
+
serialised = json.dumps(
|
|
229
|
+
{"name": tc["name"], "args": tc.get("args", tc.get("arguments", {}))},
|
|
230
|
+
sort_keys=True,
|
|
231
|
+
)
|
|
232
|
+
return "mock-" + hashlib.sha256(serialised.encode("utf-8")).hexdigest()[:12]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["MockLLMClient", "ScriptedResponse"]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""LLM recording + replay (feat-016 chunk 3).
|
|
2
|
+
|
|
3
|
+
`record_llm(real_client, path)` returns a wrapper that proxies
|
|
4
|
+
every `.call(...)` to the real provider and appends one JSON line
|
|
5
|
+
to `path` recording `{request_hash, request, response}`. Tests
|
|
6
|
+
replay via `MockLLMClient.from_recording(path)` which matches by
|
|
7
|
+
request hash so reordered calls still work.
|
|
8
|
+
|
|
9
|
+
JSONL format (versioned):
|
|
10
|
+
|
|
11
|
+
{"format_version": 1, "redactions": ["api_key", "authorization"]}
|
|
12
|
+
{"request_hash": "...", "request": {...}, "response": {...}}
|
|
13
|
+
{"request_hash": "...", "request": {...}, "response": {...}}
|
|
14
|
+
|
|
15
|
+
Header always at line 0. Recordings made under format_version 1
|
|
16
|
+
remain loadable when the version bumps.
|
|
17
|
+
|
|
18
|
+
Redaction applies to system / messages / metadata fields whose
|
|
19
|
+
key (case-insensitive) matches one of the redactions list. Values
|
|
20
|
+
are replaced with `"<redacted>"` before write. By default,
|
|
21
|
+
`api_key` and `authorization` are redacted; pass `redactions=[...]`
|
|
22
|
+
to extend or replace.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from agentforge_core.contracts.llm import LLMClient
|
|
33
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
34
|
+
from agentforge_core.values.messages import LLMResponse, Message, ToolSpec
|
|
35
|
+
|
|
36
|
+
_FORMAT_VERSION = 1
|
|
37
|
+
_DEFAULT_REDACTIONS: tuple[str, ...] = ("api_key", "authorization", "bearer")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _RecordingLLMClient(LLMClient):
|
|
41
|
+
"""Wraps a real `LLMClient`; records each call to a JSONL path."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
real: LLMClient,
|
|
47
|
+
path: Path,
|
|
48
|
+
redactions: tuple[str, ...],
|
|
49
|
+
) -> None:
|
|
50
|
+
self._real = real
|
|
51
|
+
self._path = path
|
|
52
|
+
self._redactions = redactions
|
|
53
|
+
self._initialised = False
|
|
54
|
+
|
|
55
|
+
async def call(
|
|
56
|
+
self,
|
|
57
|
+
system: str,
|
|
58
|
+
messages: list[Message],
|
|
59
|
+
tools: list[ToolSpec] | None = None,
|
|
60
|
+
) -> LLMResponse:
|
|
61
|
+
response = await self._real.call(system, messages, tools)
|
|
62
|
+
self._ensure_header()
|
|
63
|
+
request = _redact(
|
|
64
|
+
{
|
|
65
|
+
"system": system,
|
|
66
|
+
"messages": [m.model_dump(mode="json") for m in messages],
|
|
67
|
+
"tools": [t.model_dump(mode="json", by_alias=True) for t in (tools or [])],
|
|
68
|
+
},
|
|
69
|
+
self._redactions,
|
|
70
|
+
)
|
|
71
|
+
record = {
|
|
72
|
+
"request_hash": _hash_request(request),
|
|
73
|
+
"request": request,
|
|
74
|
+
"response": response.model_dump(mode="json"),
|
|
75
|
+
}
|
|
76
|
+
with self._path.open("a", encoding="utf-8") as f:
|
|
77
|
+
f.write(json.dumps(record) + "\n")
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
async def close(self) -> None:
|
|
81
|
+
await self._real.close()
|
|
82
|
+
|
|
83
|
+
def _ensure_header(self) -> None:
|
|
84
|
+
if self._initialised:
|
|
85
|
+
return
|
|
86
|
+
# Header is written exactly once. Re-recording over an
|
|
87
|
+
# existing file replaces the file outright; appending without
|
|
88
|
+
# a header would corrupt format detection.
|
|
89
|
+
if not self._path.exists() or self._path.stat().st_size == 0:
|
|
90
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
with self._path.open("w", encoding="utf-8") as f:
|
|
92
|
+
f.write(
|
|
93
|
+
json.dumps(
|
|
94
|
+
{
|
|
95
|
+
"format_version": _FORMAT_VERSION,
|
|
96
|
+
"redactions": list(self._redactions),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
+ "\n"
|
|
100
|
+
)
|
|
101
|
+
self._initialised = True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def record_llm(
|
|
105
|
+
real: LLMClient,
|
|
106
|
+
path: str | Path,
|
|
107
|
+
*,
|
|
108
|
+
redactions: tuple[str, ...] | None = None,
|
|
109
|
+
) -> LLMClient:
|
|
110
|
+
"""Return an `LLMClient` that proxies `real` and records to `path`.
|
|
111
|
+
|
|
112
|
+
Replay via `MockLLMClient.from_recording(path)`.
|
|
113
|
+
|
|
114
|
+
Redactions default to `("api_key", "authorization", "bearer")`.
|
|
115
|
+
Pass an empty tuple to disable redaction entirely (only for
|
|
116
|
+
truly local recordings).
|
|
117
|
+
"""
|
|
118
|
+
return _RecordingLLMClient(
|
|
119
|
+
real=real,
|
|
120
|
+
path=Path(path),
|
|
121
|
+
redactions=redactions if redactions is not None else _DEFAULT_REDACTIONS,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_recording(path: str | Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
126
|
+
"""Parse a recording file into `(header, entries)`.
|
|
127
|
+
|
|
128
|
+
`header` carries `format_version` + `redactions`. `entries` is
|
|
129
|
+
the list of `{request_hash, request, response}` records in
|
|
130
|
+
on-disk order.
|
|
131
|
+
"""
|
|
132
|
+
p = Path(path)
|
|
133
|
+
if not p.exists():
|
|
134
|
+
msg = f"recording {p!r} does not exist."
|
|
135
|
+
raise ModuleError(msg)
|
|
136
|
+
lines = [line for line in p.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
137
|
+
if not lines:
|
|
138
|
+
msg = f"recording {p!r} is empty."
|
|
139
|
+
raise ModuleError(msg)
|
|
140
|
+
header = json.loads(lines[0])
|
|
141
|
+
if header.get("format_version") != _FORMAT_VERSION:
|
|
142
|
+
msg = (
|
|
143
|
+
f"recording {p!r}: format_version={header.get('format_version')!r} "
|
|
144
|
+
f"not supported (this build accepts version {_FORMAT_VERSION})."
|
|
145
|
+
)
|
|
146
|
+
raise ModuleError(msg)
|
|
147
|
+
entries = [json.loads(line) for line in lines[1:]]
|
|
148
|
+
return header, entries
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _hash_request(request: dict[str, Any]) -> str:
|
|
152
|
+
"""Stable hash of a redacted request for replay lookup.
|
|
153
|
+
|
|
154
|
+
Hashing happens AFTER redaction so the cassette is portable
|
|
155
|
+
across environments with different api_key values.
|
|
156
|
+
"""
|
|
157
|
+
blob = json.dumps(request, sort_keys=True).encode("utf-8")
|
|
158
|
+
return hashlib.sha256(blob).hexdigest()[:16]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _redact(payload: Any, keys: tuple[str, ...]) -> Any:
|
|
162
|
+
"""Recursively replace any dict-value whose key matches one of
|
|
163
|
+
`keys` (case-insensitive) with `"<redacted>"`."""
|
|
164
|
+
if isinstance(payload, dict):
|
|
165
|
+
result: dict[str, Any] = {}
|
|
166
|
+
for k, v in payload.items():
|
|
167
|
+
if isinstance(k, str) and k.lower() in keys:
|
|
168
|
+
result[k] = "<redacted>"
|
|
169
|
+
else:
|
|
170
|
+
result[k] = _redact(v, keys)
|
|
171
|
+
return result
|
|
172
|
+
if isinstance(payload, list):
|
|
173
|
+
return [_redact(item, keys) for item in payload]
|
|
174
|
+
return payload
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = ["load_recording", "record_llm"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Default tools shipped with `agentforge` (feat-004).
|
|
2
|
+
|
|
3
|
+
Public surface — pre-built `Tool` instances ready to pass into
|
|
4
|
+
`Agent(tools=[...])`. Implementations live under
|
|
5
|
+
`agentforge._tools/`; this module re-exports them.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from agentforge import Agent
|
|
9
|
+
from agentforge.tools import calculator, file_read
|
|
10
|
+
|
|
11
|
+
agent = Agent(model="...", tools=[calculator, file_read])
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For tools that take configuration (e.g., `FileReadTool` with a
|
|
15
|
+
custom sandbox and size cap), import the class directly:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from agentforge.tools import FileReadTool
|
|
19
|
+
|
|
20
|
+
custom = FileReadTool(work_dir="/srv/data", max_bytes=10_485_760)
|
|
21
|
+
agent = Agent(model="...", tools=[custom])
|
|
22
|
+
```
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from agentforge._tools.calculator import calculator
|
|
28
|
+
from agentforge._tools.file_read import FileReadTool, file_read
|
|
29
|
+
from agentforge._tools.shell import ShellTool, shell
|
|
30
|
+
from agentforge._tools.web_search import SearchResult, WebSearchTool, web_search
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"FileReadTool",
|
|
34
|
+
"SearchResult",
|
|
35
|
+
"ShellTool",
|
|
36
|
+
"WebSearchTool",
|
|
37
|
+
"calculator",
|
|
38
|
+
"file_read",
|
|
39
|
+
"shell",
|
|
40
|
+
"web_search",
|
|
41
|
+
]
|