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