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,300 @@
|
|
|
1
|
+
"""`@tool` — typed-function-to-`Tool` decorator (feat-004).
|
|
2
|
+
|
|
3
|
+
Wraps a typed function as a concrete `Tool` subclass:
|
|
4
|
+
|
|
5
|
+
from agentforge import tool
|
|
6
|
+
|
|
7
|
+
@tool
|
|
8
|
+
def lookup_user(user_id: str, include_email: bool = False) -> dict:
|
|
9
|
+
'''Fetch a user record.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
user_id: The internal user id (ULID).
|
|
13
|
+
include_email: When True, include the email field.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
A dict with name and signup_date.
|
|
17
|
+
'''
|
|
18
|
+
return db.get_user(user_id, with_email=include_email)
|
|
19
|
+
|
|
20
|
+
The decorator inspects the wrapped function and constructs:
|
|
21
|
+
|
|
22
|
+
- `name` from the function's `__name__` (or the
|
|
23
|
+
`name=` override argument).
|
|
24
|
+
- `description` from the docstring's summary line + Args
|
|
25
|
+
section, parsed Google-style. The first
|
|
26
|
+
non-blank non-arg line is the summary;
|
|
27
|
+
per-arg descriptions feed Pydantic field
|
|
28
|
+
descriptions.
|
|
29
|
+
- `input_schema` a Pydantic v2 model built from the
|
|
30
|
+
function's typed parameters. Required
|
|
31
|
+
parameters have no default; optional ones
|
|
32
|
+
carry the function's default.
|
|
33
|
+
- `run(**kwargs)` dispatches to the wrapped function (sync or
|
|
34
|
+
async). Returns whatever the function
|
|
35
|
+
returns; the dispatch path in strategies
|
|
36
|
+
validates kwargs before calling `run`.
|
|
37
|
+
|
|
38
|
+
Errors at decoration time:
|
|
39
|
+
|
|
40
|
+
- Missing type hint on a parameter → `ValueError`
|
|
41
|
+
- Variadic args (`*args`, `**kwargs`) → `ValueError`
|
|
42
|
+
- Positional-only parameters → `ValueError` (LLM
|
|
43
|
+
tool calls are
|
|
44
|
+
keyword-only over the
|
|
45
|
+
wire)
|
|
46
|
+
- `self` / class-method usage → not supported here;
|
|
47
|
+
subclass `Tool`
|
|
48
|
+
directly instead
|
|
49
|
+
|
|
50
|
+
Capabilities default to empty. Pass `capabilities={"network",
|
|
51
|
+
"filesystem"}` to declare them up front (used by the future safety
|
|
52
|
+
guardrails in feat-018).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import asyncio
|
|
58
|
+
import inspect
|
|
59
|
+
import re
|
|
60
|
+
from collections.abc import Callable, Iterable
|
|
61
|
+
from typing import Any, get_type_hints
|
|
62
|
+
|
|
63
|
+
from agentforge_core.contracts.tool import Tool
|
|
64
|
+
from pydantic import BaseModel, Field, create_model
|
|
65
|
+
|
|
66
|
+
# Sentinel for "no default" — distinguished from `None` (which is a
|
|
67
|
+
# legitimate default for `Optional[X] = None` parameters).
|
|
68
|
+
_NO_DEFAULT = inspect.Parameter.empty
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tool(
|
|
72
|
+
fn: Callable[..., Any] | None = None,
|
|
73
|
+
*,
|
|
74
|
+
name: str | None = None,
|
|
75
|
+
description: str | None = None,
|
|
76
|
+
capabilities: Iterable[str] = (),
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""Decorate a typed function as a `Tool`.
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
|
|
82
|
+
@tool
|
|
83
|
+
def my_func(x: int) -> str: ...
|
|
84
|
+
|
|
85
|
+
# or with explicit options:
|
|
86
|
+
@tool(name="custom_name", capabilities={"network"})
|
|
87
|
+
def my_func(x: int) -> str: ...
|
|
88
|
+
|
|
89
|
+
Returns a `Tool` *instance* (not a class). Pass the instance to
|
|
90
|
+
`Agent(tools=[...])` directly.
|
|
91
|
+
"""
|
|
92
|
+
# Bare-decorator form: `@tool` without parens.
|
|
93
|
+
if fn is not None and callable(fn) and not isinstance(fn, type):
|
|
94
|
+
return _build_tool(fn, name=None, description=None, capabilities=())
|
|
95
|
+
|
|
96
|
+
# Parameterised form: `@tool(name=..., ...)` — fn is None here;
|
|
97
|
+
# return a closure that takes the function on the next call.
|
|
98
|
+
def _decorate(real_fn: Callable[..., Any]) -> Tool:
|
|
99
|
+
return _build_tool(
|
|
100
|
+
real_fn,
|
|
101
|
+
name=name,
|
|
102
|
+
description=description,
|
|
103
|
+
capabilities=capabilities,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return _decorate
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_tool(
|
|
110
|
+
fn: Callable[..., Any],
|
|
111
|
+
*,
|
|
112
|
+
name: str | None,
|
|
113
|
+
description: str | None,
|
|
114
|
+
capabilities: Iterable[str],
|
|
115
|
+
) -> Tool:
|
|
116
|
+
"""Synthesize a concrete `Tool` subclass and instantiate it."""
|
|
117
|
+
sig = inspect.signature(fn)
|
|
118
|
+
type_hints = get_type_hints(fn)
|
|
119
|
+
|
|
120
|
+
fields = _build_pydantic_fields(fn, sig, type_hints)
|
|
121
|
+
parsed_doc = _parse_google_docstring(fn.__doc__ or "")
|
|
122
|
+
|
|
123
|
+
# Apply per-arg descriptions from the docstring's Args block by
|
|
124
|
+
# wrapping each field's default in `Field(default=..., description=...)`.
|
|
125
|
+
for field_name, arg_doc in parsed_doc.arg_descriptions.items():
|
|
126
|
+
if field_name not in fields or not arg_doc:
|
|
127
|
+
continue
|
|
128
|
+
annotation, default = fields[field_name]
|
|
129
|
+
if default is ...:
|
|
130
|
+
fields[field_name] = (annotation, Field(..., description=arg_doc))
|
|
131
|
+
else:
|
|
132
|
+
fields[field_name] = (annotation, Field(default=default, description=arg_doc))
|
|
133
|
+
|
|
134
|
+
schema_cls_name = _pascal_case(name or fn.__name__) + "Input"
|
|
135
|
+
# mypy can't verify keyword unpacking against `create_model`'s
|
|
136
|
+
# overloads; the runtime contract is exactly `create_model(name,
|
|
137
|
+
# **{field: (annotation, default), ...})`.
|
|
138
|
+
schema_cls: type[BaseModel] = create_model(schema_cls_name, **fields) # type: ignore[call-overload]
|
|
139
|
+
|
|
140
|
+
final_name = name or fn.__name__
|
|
141
|
+
final_description = description or parsed_doc.summary or fn.__name__
|
|
142
|
+
final_capabilities = frozenset(capabilities)
|
|
143
|
+
|
|
144
|
+
is_coroutine = asyncio.iscoroutinefunction(fn)
|
|
145
|
+
|
|
146
|
+
# Synthesize the Tool subclass dynamically. We use `type()`
|
|
147
|
+
# instead of a `class ...:` block so the closure-captured
|
|
148
|
+
# `schema_cls` is bound cleanly into the class namespace
|
|
149
|
+
# (Python's class-body scope rules don't see enclosing locals
|
|
150
|
+
# via plain `name = name` assignment shapes).
|
|
151
|
+
async def _run(self: Any, **kwargs: Any) -> Any: # noqa: ARG001 — bound method needs `self`
|
|
152
|
+
if is_coroutine:
|
|
153
|
+
return await fn(**kwargs)
|
|
154
|
+
return fn(**kwargs)
|
|
155
|
+
|
|
156
|
+
cls_namespace: dict[str, Any] = {
|
|
157
|
+
"name": final_name,
|
|
158
|
+
"description": final_description,
|
|
159
|
+
"input_schema": schema_cls,
|
|
160
|
+
"capabilities": final_capabilities,
|
|
161
|
+
"run": _run,
|
|
162
|
+
}
|
|
163
|
+
decorated_cls = type(
|
|
164
|
+
_pascal_case(final_name) + "Tool",
|
|
165
|
+
(Tool,),
|
|
166
|
+
cls_namespace,
|
|
167
|
+
)
|
|
168
|
+
instance: Tool = decorated_cls()
|
|
169
|
+
return instance
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _build_pydantic_fields(
|
|
173
|
+
fn: Callable[..., Any],
|
|
174
|
+
sig: inspect.Signature,
|
|
175
|
+
type_hints: dict[str, Any],
|
|
176
|
+
) -> dict[str, tuple[Any, Any]]:
|
|
177
|
+
"""Walk the function's parameters and produce `create_model`
|
|
178
|
+
field definitions (annotation + default).
|
|
179
|
+
|
|
180
|
+
Raises `ValueError` on:
|
|
181
|
+
- missing type hint
|
|
182
|
+
- variadic args (`*args`, `**kwargs`)
|
|
183
|
+
- positional-only parameters
|
|
184
|
+
- the `return` annotation slot (skipped silently — not a
|
|
185
|
+
field)
|
|
186
|
+
"""
|
|
187
|
+
fields: dict[str, tuple[Any, Any]] = {}
|
|
188
|
+
for param_name, param in sig.parameters.items():
|
|
189
|
+
# Disallow self / cls — decorator is for free functions.
|
|
190
|
+
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
|
|
191
|
+
msg = (
|
|
192
|
+
f"@tool: parameter {param_name!r} on {fn.__qualname__!r} is "
|
|
193
|
+
"positional-only. LLM tool calls bind by keyword; declare "
|
|
194
|
+
"the parameter as positional-or-keyword instead."
|
|
195
|
+
)
|
|
196
|
+
raise ValueError(msg)
|
|
197
|
+
if param.kind in (
|
|
198
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
199
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
200
|
+
):
|
|
201
|
+
msg = (
|
|
202
|
+
f"@tool: variadic parameter {param_name!r} on "
|
|
203
|
+
f"{fn.__qualname__!r} is not supported. Tools must declare "
|
|
204
|
+
"every input explicitly so the schema is complete."
|
|
205
|
+
)
|
|
206
|
+
raise ValueError(msg)
|
|
207
|
+
|
|
208
|
+
if param_name not in type_hints:
|
|
209
|
+
msg = (
|
|
210
|
+
f"@tool: parameter {param_name!r} on {fn.__qualname__!r} "
|
|
211
|
+
"is missing a type hint. Every parameter must be typed."
|
|
212
|
+
)
|
|
213
|
+
raise ValueError(msg)
|
|
214
|
+
|
|
215
|
+
annotation = type_hints[param_name]
|
|
216
|
+
default = param.default if param.default is not _NO_DEFAULT else ...
|
|
217
|
+
fields[param_name] = (annotation, default)
|
|
218
|
+
return fields
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ----------------------------------------------------------------------
|
|
222
|
+
# Google-style docstring parser
|
|
223
|
+
# ----------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _ParsedDoc(BaseModel):
|
|
227
|
+
summary: str
|
|
228
|
+
arg_descriptions: dict[str, str]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_ARGS_HEADER_RE = re.compile(r"^\s*Args\s*:\s*$", re.MULTILINE)
|
|
232
|
+
_ARG_LINE_RE = re.compile(r"^\s*([A-Za-z_]\w*)\s*(?:\(.*?\))?\s*:\s*(.*)$")
|
|
233
|
+
_SECTION_HEADERS = ("Returns:", "Raises:", "Yields:", "Example:", "Examples:", "Note:", "Notes:")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_google_docstring(doc: str) -> _ParsedDoc:
|
|
237
|
+
"""Parse a Google-style docstring.
|
|
238
|
+
|
|
239
|
+
Extracts:
|
|
240
|
+
- `summary`: the first non-blank line(s) before any section
|
|
241
|
+
header.
|
|
242
|
+
- `arg_descriptions`: per-arg one-line descriptions from the
|
|
243
|
+
`Args:` block. Multi-line arg descriptions concatenate into
|
|
244
|
+
one string.
|
|
245
|
+
"""
|
|
246
|
+
if not doc:
|
|
247
|
+
return _ParsedDoc(summary="", arg_descriptions={})
|
|
248
|
+
|
|
249
|
+
lines = inspect.cleandoc(doc).splitlines()
|
|
250
|
+
summary_lines: list[str] = []
|
|
251
|
+
arg_block: list[str] = []
|
|
252
|
+
in_args = False
|
|
253
|
+
for line in lines:
|
|
254
|
+
stripped = line.strip()
|
|
255
|
+
if not in_args and _ARGS_HEADER_RE.match(line):
|
|
256
|
+
in_args = True
|
|
257
|
+
continue
|
|
258
|
+
if in_args and any(stripped.startswith(h) for h in _SECTION_HEADERS):
|
|
259
|
+
in_args = False
|
|
260
|
+
continue
|
|
261
|
+
if in_args:
|
|
262
|
+
arg_block.append(line)
|
|
263
|
+
elif stripped:
|
|
264
|
+
# Stop summary if we hit a non-Args section header.
|
|
265
|
+
if any(stripped.startswith(h) for h in _SECTION_HEADERS):
|
|
266
|
+
break
|
|
267
|
+
summary_lines.append(stripped)
|
|
268
|
+
|
|
269
|
+
summary = " ".join(summary_lines).strip()
|
|
270
|
+
args = _parse_arg_block(arg_block)
|
|
271
|
+
return _ParsedDoc(summary=summary, arg_descriptions=args)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_arg_block(lines: list[str]) -> dict[str, str]:
|
|
275
|
+
"""Parse the body of a Google-style `Args:` block into
|
|
276
|
+
`{arg_name: description}`."""
|
|
277
|
+
out: dict[str, str] = {}
|
|
278
|
+
current_name: str | None = None
|
|
279
|
+
current_desc: list[str] = []
|
|
280
|
+
for line in lines:
|
|
281
|
+
m = _ARG_LINE_RE.match(line)
|
|
282
|
+
if m:
|
|
283
|
+
if current_name is not None:
|
|
284
|
+
out[current_name] = " ".join(current_desc).strip()
|
|
285
|
+
current_name = m.group(1)
|
|
286
|
+
current_desc = [m.group(2).strip()]
|
|
287
|
+
elif current_name is not None and line.strip():
|
|
288
|
+
current_desc.append(line.strip())
|
|
289
|
+
if current_name is not None:
|
|
290
|
+
out[current_name] = " ".join(current_desc).strip()
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _pascal_case(s: str) -> str:
|
|
295
|
+
"""Convert `snake_case` or `kebab-case` to `PascalCase`."""
|
|
296
|
+
parts = re.split(r"[_\-\s]+", s)
|
|
297
|
+
return "".join(p[:1].upper() + p[1:] for p in parts if p)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
__all__ = ["tool"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""`file_read` — sandboxed file-reading tool (feat-004).
|
|
2
|
+
|
|
3
|
+
Reads a file from a configurable working directory with a size cap.
|
|
4
|
+
The default instance is sandboxed to the process's current working
|
|
5
|
+
directory at import time and caps reads at 1 MiB; users who want
|
|
6
|
+
different limits construct their own:
|
|
7
|
+
|
|
8
|
+
custom = FileReadTool(work_dir="/srv/data", max_bytes=10 * 1024 * 1024)
|
|
9
|
+
agent = Agent(tools=[custom, ...])
|
|
10
|
+
|
|
11
|
+
Sandbox enforcement:
|
|
12
|
+
- Path is resolved against `work_dir` then checked: the resolved
|
|
13
|
+
real path must be inside `work_dir` (no `../` escape, no
|
|
14
|
+
absolute paths that escape the sandbox).
|
|
15
|
+
- Symlinks are followed, but the target must also be inside the
|
|
16
|
+
sandbox.
|
|
17
|
+
- Files larger than `max_bytes` raise `ValueError` before reading.
|
|
18
|
+
|
|
19
|
+
Capabilities: `{"filesystem"}`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, ClassVar
|
|
26
|
+
|
|
27
|
+
from agentforge_core.contracts.tool import Tool
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
|
|
30
|
+
_DEFAULT_MAX_BYTES = 1 * 1024 * 1024 # 1 MiB
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _FileReadInput(BaseModel):
|
|
34
|
+
"""Input schema for `file_read`."""
|
|
35
|
+
|
|
36
|
+
path: str = Field(
|
|
37
|
+
description=(
|
|
38
|
+
"Relative path inside the sandbox to read. "
|
|
39
|
+
"Absolute paths and `..` traversal are rejected."
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FileReadTool(Tool):
|
|
45
|
+
"""Read a file from a sandboxed working directory.
|
|
46
|
+
|
|
47
|
+
`work_dir` defaults to the process's CWD at construction time.
|
|
48
|
+
`max_bytes` defaults to 1 MiB.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name: ClassVar[str] = "file_read"
|
|
52
|
+
description: ClassVar[str] = (
|
|
53
|
+
"Read a UTF-8 text file from the sandbox. Returns the file's "
|
|
54
|
+
"contents as a string. Path must be relative and stay inside "
|
|
55
|
+
"the configured working directory."
|
|
56
|
+
)
|
|
57
|
+
input_schema: ClassVar[type[BaseModel]] = _FileReadInput
|
|
58
|
+
capabilities: ClassVar[frozenset[str]] = frozenset({"filesystem"})
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
work_dir: str | Path | None = None,
|
|
64
|
+
max_bytes: int = _DEFAULT_MAX_BYTES,
|
|
65
|
+
) -> None:
|
|
66
|
+
if max_bytes < 1:
|
|
67
|
+
msg = f"max_bytes must be >= 1, got {max_bytes}"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
# Resolve work_dir to an absolute, real path (follows symlinks)
|
|
70
|
+
# so containment checks compare apples to apples.
|
|
71
|
+
self._work_dir = Path(work_dir if work_dir is not None else Path.cwd()).resolve()
|
|
72
|
+
if not self._work_dir.is_dir():
|
|
73
|
+
msg = f"work_dir {self._work_dir!r} is not a directory"
|
|
74
|
+
raise ValueError(msg)
|
|
75
|
+
self._max_bytes = max_bytes
|
|
76
|
+
|
|
77
|
+
async def run(self, **kwargs: Any) -> str:
|
|
78
|
+
path_str = kwargs["path"]
|
|
79
|
+
# Reject explicitly absolute paths up front for a clearer
|
|
80
|
+
# error than the contained-path check would give.
|
|
81
|
+
if Path(path_str).is_absolute():
|
|
82
|
+
msg = f"file_read: absolute paths are not allowed (got {path_str!r})"
|
|
83
|
+
raise ValueError(msg)
|
|
84
|
+
|
|
85
|
+
candidate = (self._work_dir / path_str).resolve()
|
|
86
|
+
try:
|
|
87
|
+
candidate.relative_to(self._work_dir)
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
msg = (
|
|
90
|
+
f"file_read: path {path_str!r} resolves to {candidate!r}, "
|
|
91
|
+
f"which is outside the sandbox {self._work_dir!r}"
|
|
92
|
+
)
|
|
93
|
+
raise ValueError(msg) from exc
|
|
94
|
+
|
|
95
|
+
if not candidate.is_file():
|
|
96
|
+
msg = f"file_read: {path_str!r} is not a file"
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
|
|
99
|
+
size = candidate.stat().st_size
|
|
100
|
+
if size > self._max_bytes:
|
|
101
|
+
msg = f"file_read: {path_str!r} is {size} bytes; max_bytes={self._max_bytes}"
|
|
102
|
+
raise ValueError(msg)
|
|
103
|
+
|
|
104
|
+
text: str = candidate.read_text(encoding="utf-8")
|
|
105
|
+
return text
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Default instance — sandboxed to CWD at import time, 1 MiB cap.
|
|
109
|
+
file_read = FileReadTool()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = ["FileReadTool", "file_read"]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""`shell` — sandboxed subprocess tool (feat-004).
|
|
2
|
+
|
|
3
|
+
Executes a command as a list of arguments via `asyncio.create_subprocess_exec`
|
|
4
|
+
(`shell=False` equivalent — no shell interpretation, no glob expansion,
|
|
5
|
+
no env-var interpolation). Always reads input as `list[str]`, never as
|
|
6
|
+
a single string, so there is no shell-injection vector.
|
|
7
|
+
|
|
8
|
+
Capabilities: `{"shell", "destructive"}` — declared up front. Future
|
|
9
|
+
safety guardrails (feat-018) refuse to enable destructive tools without
|
|
10
|
+
explicit operator opt-in.
|
|
11
|
+
|
|
12
|
+
The default instance is constructed at import time with a 30-second
|
|
13
|
+
timeout and CWD as the sandbox. Users wanting different limits
|
|
14
|
+
construct their own:
|
|
15
|
+
|
|
16
|
+
custom = ShellTool(work_dir="/srv/jobs", timeout_s=120,
|
|
17
|
+
allowed_commands=("ls", "cat"))
|
|
18
|
+
agent = Agent(tools=[custom, ...])
|
|
19
|
+
|
|
20
|
+
Sandbox enforcement:
|
|
21
|
+
- `command` is a list; argv[0] is the executable.
|
|
22
|
+
- `allowed_commands` (optional) restricts argv[0] to a whitelist of
|
|
23
|
+
binary names. The default is `None` → no restriction (deploy with
|
|
24
|
+
care).
|
|
25
|
+
- `timeout_s` kills the subprocess if it runs too long.
|
|
26
|
+
- Working directory pinned to `work_dir`.
|
|
27
|
+
- Output truncated to `max_output_bytes` (default 64 KiB).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, ClassVar
|
|
35
|
+
|
|
36
|
+
from agentforge_core.contracts.tool import Tool
|
|
37
|
+
from pydantic import BaseModel, Field
|
|
38
|
+
|
|
39
|
+
_DEFAULT_TIMEOUT_S = 30.0
|
|
40
|
+
_DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 # 64 KiB
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _ShellInput(BaseModel):
|
|
44
|
+
"""Input schema for `shell`."""
|
|
45
|
+
|
|
46
|
+
command: list[str] = Field(
|
|
47
|
+
min_length=1,
|
|
48
|
+
description=(
|
|
49
|
+
"The command and arguments as a list, e.g. ['ls', '-la']. "
|
|
50
|
+
"Strings are not interpreted by a shell — no glob expansion, "
|
|
51
|
+
"no quoting, no env-var substitution. Pass each argument as "
|
|
52
|
+
"a separate list element."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ShellTool(Tool):
|
|
58
|
+
"""Run a sandboxed subprocess via `asyncio.create_subprocess_exec`.
|
|
59
|
+
|
|
60
|
+
`work_dir` defaults to CWD at construction time. `timeout_s`
|
|
61
|
+
defaults to 30s. `allowed_commands` defaults to None (any
|
|
62
|
+
command). `max_output_bytes` defaults to 64 KiB.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
name: ClassVar[str] = "shell"
|
|
66
|
+
description: ClassVar[str] = (
|
|
67
|
+
"Run a command as a list of arguments (no shell interpretation). "
|
|
68
|
+
"Returns combined stdout+stderr as a string. Capabilities: shell, "
|
|
69
|
+
"destructive — deploy with caution."
|
|
70
|
+
)
|
|
71
|
+
input_schema: ClassVar[type[BaseModel]] = _ShellInput
|
|
72
|
+
capabilities: ClassVar[frozenset[str]] = frozenset({"shell", "destructive"})
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
work_dir: str | Path | None = None,
|
|
78
|
+
timeout_s: float = _DEFAULT_TIMEOUT_S,
|
|
79
|
+
allowed_commands: tuple[str, ...] | None = None,
|
|
80
|
+
max_output_bytes: int = _DEFAULT_MAX_OUTPUT_BYTES,
|
|
81
|
+
) -> None:
|
|
82
|
+
if timeout_s <= 0:
|
|
83
|
+
msg = f"timeout_s must be > 0, got {timeout_s}"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
if max_output_bytes < 1:
|
|
86
|
+
msg = f"max_output_bytes must be >= 1, got {max_output_bytes}"
|
|
87
|
+
raise ValueError(msg)
|
|
88
|
+
self._work_dir = Path(work_dir if work_dir is not None else Path.cwd()).resolve()
|
|
89
|
+
if not self._work_dir.is_dir():
|
|
90
|
+
msg = f"work_dir {self._work_dir!r} is not a directory"
|
|
91
|
+
raise ValueError(msg)
|
|
92
|
+
self._timeout_s = timeout_s
|
|
93
|
+
self._allowed = allowed_commands
|
|
94
|
+
self._max_output_bytes = max_output_bytes
|
|
95
|
+
|
|
96
|
+
async def run(self, **kwargs: Any) -> str:
|
|
97
|
+
command: list[str] = list(kwargs["command"])
|
|
98
|
+
if not command:
|
|
99
|
+
msg = "shell: command list is empty"
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
if self._allowed is not None and command[0] not in self._allowed:
|
|
102
|
+
msg = f"shell: command {command[0]!r} is not in allowed_commands ({self._allowed!r})"
|
|
103
|
+
raise ValueError(msg)
|
|
104
|
+
|
|
105
|
+
# `subprocess_exec` takes argv as separate args (not a list);
|
|
106
|
+
# *command spreads it. shell=False is the default and only
|
|
107
|
+
# mode for create_subprocess_exec.
|
|
108
|
+
proc = await asyncio.create_subprocess_exec(
|
|
109
|
+
*command,
|
|
110
|
+
cwd=str(self._work_dir),
|
|
111
|
+
stdout=asyncio.subprocess.PIPE,
|
|
112
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
stdout_bytes, _ = await asyncio.wait_for(proc.communicate(), timeout=self._timeout_s)
|
|
116
|
+
except TimeoutError:
|
|
117
|
+
proc.kill()
|
|
118
|
+
await proc.wait()
|
|
119
|
+
msg = f"shell: command {command!r} exceeded timeout_s={self._timeout_s}; killed"
|
|
120
|
+
raise TimeoutError(msg) from None
|
|
121
|
+
|
|
122
|
+
if len(stdout_bytes) > self._max_output_bytes:
|
|
123
|
+
stdout_bytes = stdout_bytes[: self._max_output_bytes] + b"\n... [output truncated]"
|
|
124
|
+
text = stdout_bytes.decode("utf-8", errors="replace")
|
|
125
|
+
if proc.returncode != 0:
|
|
126
|
+
return f"[exit {proc.returncode}]\n{text}"
|
|
127
|
+
return text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Default instance — sandboxed to CWD, 30s timeout, no whitelist.
|
|
131
|
+
shell = ShellTool()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["ShellTool", "shell"]
|