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
agentforge/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """AgentForge — open-source plug-and-play framework for production AI agents.
2
+
3
+ This package is the default runtime. It ships:
4
+
5
+ - The `Agent` orchestrator (locked constructor surface per feat-001).
6
+ - `InMemoryStore` — process-local default `MemoryStore` so a fresh
7
+ agent has persistence wired without external infra.
8
+ - The configuration loader (`load_config`).
9
+ - The reasoning-strategy infrastructure: `RuntimeContext`,
10
+ `StrategyBase`, `get_runtime`, `ReActLoop` (feat-002).
11
+
12
+ For provider clients, persistence drivers, MCP, observability backends,
13
+ and safety modules, install the corresponding `agentforge-<X>` packages
14
+ or use the `agentforge[<extra>]` install (per ADR-0003).
15
+
16
+ See the project docs at `docs/README.md` (in the design workspace) and
17
+ the per-feature specs under `docs/features/`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from agentforge_core import FallbackChain
23
+
24
+ # feat-018: importing `agentforge.guardrails` here triggers the
25
+ # module-side `@register(...)` decorators on `PromptInjectionBasic`,
26
+ # `PIIRedactBasic`, `CapabilityCheck`, and `Allowlist` so they're
27
+ # resolvable by name from `agentforge.yaml` without an explicit
28
+ # import in the consumer.
29
+ import agentforge.guardrails # noqa: F401
30
+ from agentforge._tools import tool
31
+ from agentforge.agent import Agent
32
+ from agentforge.auth import EnvBearerAuth
33
+ from agentforge.config import AgentForgeConfig, load_config
34
+ from agentforge.findings import (
35
+ MultiSpanFinding,
36
+ NarrativeFinding,
37
+ Patch,
38
+ PatchFinding,
39
+ SimpleFinding,
40
+ Span,
41
+ )
42
+ from agentforge.memory import InMemoryGraphStore, InMemoryStore, InMemoryVectorStore
43
+ from agentforge.pipeline import (
44
+ Pipeline,
45
+ PipelineFailure,
46
+ PipelineFindingsTool,
47
+ PipelineResult,
48
+ Task,
49
+ )
50
+ from agentforge.renderers import (
51
+ MarkdownRenderer,
52
+ MissingRendererError,
53
+ PatchApplierRenderer,
54
+ RendererRegistry,
55
+ ScorecardRenderer,
56
+ SpanTableRenderer,
57
+ )
58
+ from agentforge.resolver_register import register_task
59
+ from agentforge.retrieval import Retriever
60
+ from agentforge.runtime import RUNTIME_KEY, RuntimeContext
61
+ from agentforge.strategies import (
62
+ MultiAgentSupervisor,
63
+ Plan,
64
+ PlanExecuteLoop,
65
+ PlanStep,
66
+ ReActLoop,
67
+ StrategyBase,
68
+ TreeOfThoughts,
69
+ get_runtime,
70
+ )
71
+
72
+ __version__ = "0.2.1"
73
+
74
+ __all__ = [
75
+ "RUNTIME_KEY",
76
+ "Agent",
77
+ "AgentForgeConfig",
78
+ "EnvBearerAuth",
79
+ "FallbackChain",
80
+ "InMemoryGraphStore",
81
+ "InMemoryStore",
82
+ "InMemoryVectorStore",
83
+ "MarkdownRenderer",
84
+ "MissingRendererError",
85
+ "MultiAgentSupervisor",
86
+ "MultiSpanFinding",
87
+ "NarrativeFinding",
88
+ "Patch",
89
+ "PatchApplierRenderer",
90
+ "PatchFinding",
91
+ "Pipeline",
92
+ "PipelineFailure",
93
+ "PipelineFindingsTool",
94
+ "PipelineResult",
95
+ "Plan",
96
+ "PlanExecuteLoop",
97
+ "PlanStep",
98
+ "ReActLoop",
99
+ "RendererRegistry",
100
+ "Retriever",
101
+ "RuntimeContext",
102
+ "ScorecardRenderer",
103
+ "SimpleFinding",
104
+ "Span",
105
+ "SpanTableRenderer",
106
+ "StrategyBase",
107
+ "Task",
108
+ "TreeOfThoughts",
109
+ "__version__",
110
+ "get_runtime",
111
+ "load_config",
112
+ "register_task",
113
+ "tool",
114
+ ]
@@ -0,0 +1,19 @@
1
+ """Local test helpers.
2
+
3
+ Private package (underscore prefix) — these helpers exist only to
4
+ support feat-002's tests (and other early features) until feat-016
5
+ ships the full public testing API at `agentforge.testing`.
6
+
7
+ Helpers here:
8
+
9
+ - `FakeLLMClient` — minimal scripted-response `LLMClient`. Replaced
10
+ by feat-016's `MockLLMClient` (richer: recording / replay /
11
+ capability simulation).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from agentforge._testing.fake_llm import FakeLLMClient, echo_response
17
+ from agentforge._testing.fake_tool import FakeTool
18
+
19
+ __all__ = ["FakeLLMClient", "FakeTool", "echo_response"]
@@ -0,0 +1,126 @@
1
+ """`FakeLLMClient` — minimal scripted-response `LLMClient` for unit tests.
2
+
3
+ Used by feat-002's strategy tests. Replaced by feat-016's full
4
+ `MockLLMClient` (which supports recording / replay / capability
5
+ simulation) when that lands; until then, this class is enough to
6
+ drive the four reasoning strategies through their unit and
7
+ integration tests.
8
+
9
+ Usage:
10
+
11
+ fake = FakeLLMClient(
12
+ responses=[
13
+ LLMResponse(content="thought 1", stop_reason="tool_use",
14
+ tool_calls=(ToolCall(id="t1", name="search",
15
+ arguments={"q": "x"}),),
16
+ usage=TokenUsage(input_tokens=10, output_tokens=5),
17
+ cost_usd=0.001, model="m", provider="p"),
18
+ LLMResponse(content="final answer", stop_reason="end_turn",
19
+ usage=TokenUsage(input_tokens=12, output_tokens=8),
20
+ cost_usd=0.002, model="m", provider="p"),
21
+ ],
22
+ )
23
+ # When the strategy under test calls fake.call(...) twice,
24
+ # it gets the two scripted responses in order.
25
+
26
+ Responses can also be callables (called with the call's args) for
27
+ dynamic scripting:
28
+
29
+ FakeLLMClient(responses=[lambda system, messages, tools=None: ...])
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Callable
35
+ from typing import Any
36
+
37
+ from agentforge_core.contracts.llm import LLMClient
38
+ from agentforge_core.values.messages import LLMResponse, Message, TokenUsage, ToolSpec
39
+
40
+ ResponseSpec = LLMResponse | Callable[..., LLMResponse]
41
+
42
+
43
+ class FakeLLMClient(LLMClient):
44
+ """Scripted-response `LLMClient` for tests.
45
+
46
+ Constructor takes a list of `LLMResponse` instances OR callables
47
+ that build a response from the call args. Each `.call()` returns
48
+ the next item in the list; raises if the list is exhausted.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ responses: list[ResponseSpec] | None = None,
54
+ *,
55
+ capabilities: set[str] | None = None,
56
+ ) -> None:
57
+ self._responses: list[ResponseSpec] = list(responses or [])
58
+ self._call_count: int = 0
59
+ self._captured: list[tuple[str, list[Message], list[ToolSpec] | None]] = []
60
+ self._capabilities: set[str] = set(capabilities or ())
61
+ self._closed: bool = False
62
+
63
+ @property
64
+ def call_count(self) -> int:
65
+ """Number of `.call()` invocations so far."""
66
+ return self._call_count
67
+
68
+ @property
69
+ def captured(
70
+ self,
71
+ ) -> list[tuple[str, list[Message], list[ToolSpec] | None]]:
72
+ """Every `(system, messages, tools)` triple seen by `.call()`,
73
+ in order. Useful for asserting the strategy sent the expected
74
+ messages."""
75
+ return list(self._captured)
76
+
77
+ async def call(
78
+ self,
79
+ system: str,
80
+ messages: list[Message],
81
+ tools: list[ToolSpec] | None = None,
82
+ ) -> LLMResponse:
83
+ if self._closed:
84
+ raise RuntimeError("FakeLLMClient.close() was already called")
85
+ if self._call_count >= len(self._responses):
86
+ raise RuntimeError(
87
+ f"FakeLLMClient exhausted after {self._call_count} calls; "
88
+ f"add more scripted responses or check the strategy's loop."
89
+ )
90
+ spec = self._responses[self._call_count]
91
+ self._captured.append((system, list(messages), tools))
92
+ self._call_count += 1
93
+ if callable(spec):
94
+ return spec(system=system, messages=messages, tools=tools)
95
+ return spec
96
+
97
+ async def close(self) -> None:
98
+ self._closed = True
99
+
100
+ def capabilities(self) -> set[str]:
101
+ return set(self._capabilities)
102
+
103
+
104
+ def echo_response(
105
+ *,
106
+ content: str = "ok",
107
+ stop_reason: str = "end_turn",
108
+ cost_usd: float = 0.0,
109
+ input_tokens: int = 1,
110
+ output_tokens: int = 1,
111
+ **_: Any,
112
+ ) -> LLMResponse:
113
+ """Convenience builder for an `LLMResponse` with sensible defaults.
114
+
115
+ Used by tests that only care about a single LLM call's output
116
+ shape and don't want to hand-write the full `LLMResponse`
117
+ construction every time.
118
+ """
119
+ return LLMResponse(
120
+ content=content,
121
+ stop_reason=stop_reason, # type: ignore[arg-type]
122
+ usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens),
123
+ cost_usd=cost_usd,
124
+ model="fake",
125
+ provider="fake",
126
+ )
@@ -0,0 +1,122 @@
1
+ """`FakeTool` — minimal scripted-response `Tool` for unit tests
2
+ (feat-004 chunk 5).
3
+
4
+ Replaces any tool with a stub during tests. Two construction forms:
5
+
6
+ from agentforge._testing import FakeTool
7
+
8
+ # 1. Static return value
9
+ web_search = FakeTool.fake("web_search", "stub result")
10
+
11
+ # 2. Callable that computes the response from the call args
12
+ web_search = FakeTool.fake(
13
+ "web_search",
14
+ lambda **kwargs: f"results for {kwargs['query']!r}",
15
+ )
16
+
17
+ The fake honours the same locked `Tool` ABC: it has a `name`,
18
+ `description`, `input_schema` (a permissive `dict`-shaped model that
19
+ accepts any kwargs), and a `run(**kwargs)` method. `Agent(tools=
20
+ [fake, ...])` works without other changes.
21
+
22
+ Replaced by feat-016's richer testing API; this is the minimum
23
+ surface to support feat-004 / feat-002 tests today.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ from collections.abc import Awaitable, Callable
30
+ from typing import Any, ClassVar
31
+
32
+ from agentforge_core.contracts.tool import Tool
33
+ from pydantic import BaseModel, ConfigDict
34
+
35
+
36
+ class _PermissiveInput(BaseModel):
37
+ """Input schema for `FakeTool` — accepts any kwargs.
38
+
39
+ Real `Tool` implementations declare a strict Pydantic model so
40
+ bad LLM tool-calls are rejected at the dispatch boundary; the
41
+ fake intentionally relaxes this so test code can pass arbitrary
42
+ kwargs without first defining a schema.
43
+ """
44
+
45
+ model_config = ConfigDict(extra="allow")
46
+
47
+
48
+ _FakeFn = Callable[..., Any] | Callable[..., Awaitable[Any]]
49
+
50
+
51
+ class FakeTool(Tool):
52
+ """Test-only `Tool` that returns scripted responses.
53
+
54
+ Construct via `FakeTool.fake(name, response_or_fn)` rather than
55
+ the bare class so the per-instance `name` / `description` work
56
+ without subclassing.
57
+ """
58
+
59
+ name: ClassVar[str] = "fake"
60
+ description: ClassVar[str] = "Test-only stub tool."
61
+ input_schema: ClassVar[type[BaseModel]] = _PermissiveInput
62
+ capabilities: ClassVar[frozenset[str]] = frozenset()
63
+ calls: list[dict[str, Any]]
64
+ """Per-instance recorded `run` invocation kwargs. Populated by
65
+ `fake()`-built instances; bare-class fallback keeps it empty."""
66
+
67
+ @classmethod
68
+ def fake(
69
+ cls,
70
+ name: str,
71
+ response: Any | _FakeFn,
72
+ *,
73
+ description: str | None = None,
74
+ capabilities: frozenset[str] | set[str] = frozenset(),
75
+ ) -> FakeTool:
76
+ """Build a fake tool with the given name and response.
77
+
78
+ `response` can be:
79
+ - A static value (returned as-is from every `run` call)
80
+ - A sync callable: `fn(**kwargs) -> Any`
81
+ - An async callable: `async fn(**kwargs) -> Any`
82
+
83
+ Records every call in `self.calls` for assertions.
84
+ """
85
+ # Synthesize a class so `name` / `description` / `capabilities`
86
+ # become per-fake. type() avoids subclass-scope dance from the
87
+ # @tool decorator.
88
+ is_async = _is_async_callable(response)
89
+ is_callable = callable(response) and not isinstance(response, type)
90
+
91
+ async def _run(self: FakeTool, **kwargs: Any) -> Any:
92
+ self.calls.append(dict(kwargs))
93
+ if is_callable:
94
+ if is_async:
95
+ return await response(**kwargs)
96
+ return response(**kwargs)
97
+ return response
98
+
99
+ cls_namespace: dict[str, Any] = {
100
+ "name": name,
101
+ "description": description or f"Fake {name} tool.",
102
+ "input_schema": _PermissiveInput,
103
+ "capabilities": frozenset(capabilities),
104
+ "run": _run,
105
+ "calls": [],
106
+ }
107
+ synthesized = type(f"Fake{name.title()}Tool", (cls,), cls_namespace)
108
+ instance: FakeTool = synthesized()
109
+ return instance
110
+
111
+ async def run(self, **kwargs: Any) -> Any: # noqa: ARG002 — bare-class fallback ignores kwargs
112
+ """Default `run` body — overridden by `fake()` instances; the
113
+ bare-class fallback returns the empty string."""
114
+ return ""
115
+
116
+
117
+ def _is_async_callable(obj: Any) -> bool:
118
+ """True for `async def` functions and partials wrapping them."""
119
+ return asyncio.iscoroutinefunction(obj)
120
+
121
+
122
+ __all__ = ["FakeTool"]
@@ -0,0 +1,14 @@
1
+ """Internal tooling module (feat-004).
2
+
3
+ The `@tool` decorator and the four shipped default tools
4
+ (`calculator`, `file_read`, `web_search`, `shell`) live under this
5
+ underscore-prefixed package; the public surface is re-exported from
6
+ `agentforge` (`from agentforge import tool`) and from
7
+ `agentforge.tools` (`from agentforge.tools import calculator`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from agentforge._tools.decorator import tool
13
+
14
+ __all__ = ["tool"]
@@ -0,0 +1,102 @@
1
+ """`calculator` — arithmetic expression tool (feat-004).
2
+
3
+ Evaluates pure-arithmetic expressions via Python's AST module
4
+ (`ast.parse` + recursive walker). **Does not use `eval()`** — only a
5
+ closed set of node types is allowed, so the tool can't be tricked
6
+ into running arbitrary Python.
7
+
8
+ Supported:
9
+ - Numeric literals (int, float)
10
+ - Binary ops: `+`, `-`, `*`, `/`, `//`, `%`, `**`
11
+ - Unary ops: `+`, `-`
12
+ - Parenthesisation
13
+
14
+ Rejected (raises `ValueError`):
15
+ - Names (variables), attribute access, function calls
16
+ - Subscripts, list / dict / set literals
17
+ - Comprehensions, lambdas, walrus, anything statement-y
18
+
19
+ Capabilities: empty (pure computation, no side effects).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ import operator
26
+ from collections.abc import Callable
27
+
28
+ from agentforge._tools.decorator import tool
29
+
30
+ _Number = int | float
31
+ _BinaryFn = Callable[[_Number, _Number], _Number]
32
+ _UnaryFn = Callable[[_Number], _Number]
33
+
34
+ _BINARY_OPS: dict[type[ast.operator], _BinaryFn] = {
35
+ ast.Add: operator.add,
36
+ ast.Sub: operator.sub,
37
+ ast.Mult: operator.mul,
38
+ ast.Div: operator.truediv,
39
+ ast.FloorDiv: operator.floordiv,
40
+ ast.Mod: operator.mod,
41
+ ast.Pow: operator.pow,
42
+ }
43
+ _UNARY_OPS: dict[type[ast.unaryop], _UnaryFn] = {
44
+ ast.UAdd: operator.pos,
45
+ ast.USub: operator.neg,
46
+ }
47
+
48
+
49
+ def _evaluate(node: ast.AST) -> _Number:
50
+ """Walk an AST node, evaluating only the closed set of allowed
51
+ arithmetic node types. Raise `ValueError` on anything else."""
52
+ if isinstance(node, ast.Expression):
53
+ return _evaluate(node.body)
54
+ if isinstance(node, ast.Constant):
55
+ if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
56
+ return node.value
57
+ msg = f"calculator: literal {node.value!r} is not a number"
58
+ raise ValueError(msg)
59
+ if isinstance(node, ast.BinOp):
60
+ bop_type = type(node.op)
61
+ bop_fn = _BINARY_OPS.get(bop_type)
62
+ if bop_fn is None:
63
+ msg = f"calculator: binary operator {bop_type.__name__!r} not allowed"
64
+ raise ValueError(msg)
65
+ return bop_fn(_evaluate(node.left), _evaluate(node.right))
66
+ if isinstance(node, ast.UnaryOp):
67
+ uop_type = type(node.op)
68
+ uop_fn = _UNARY_OPS.get(uop_type)
69
+ if uop_fn is None:
70
+ msg = f"calculator: unary operator {uop_type.__name__!r} not allowed"
71
+ raise ValueError(msg)
72
+ return uop_fn(_evaluate(node.operand))
73
+ msg = f"calculator: AST node {type(node).__name__!r} not allowed"
74
+ raise ValueError(msg)
75
+
76
+
77
+ @tool
78
+ def calculator(expression: str) -> float:
79
+ """Evaluate an arithmetic expression and return the result.
80
+
81
+ Supports `+`, `-`, `*`, `/`, `//`, `%`, `**` and parentheses.
82
+ Variables, function calls, and any non-arithmetic syntax are
83
+ rejected — this is a calculator, not a Python interpreter.
84
+
85
+ Args:
86
+ expression: The arithmetic expression to evaluate, e.g.
87
+ `"(1 + 2) * 3"` or `"2 ** 10"`.
88
+
89
+ Returns:
90
+ The numeric result as a float (int values are coerced to
91
+ float for a uniform return type).
92
+ """
93
+ try:
94
+ tree = ast.parse(expression, mode="eval")
95
+ except SyntaxError as exc:
96
+ msg = f"calculator: cannot parse expression {expression!r}: {exc.msg}"
97
+ raise ValueError(msg) from exc
98
+ result = _evaluate(tree)
99
+ return float(result)
100
+
101
+
102
+ __all__ = ["calculator"]