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,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
+ ]