agentkernel-cli 0.1.0__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.
- agentkernel/__init__.py +7 -0
- agentkernel/__main__.py +5 -0
- agentkernel/agent.py +311 -0
- agentkernel/approval/__init__.py +23 -0
- agentkernel/approval/base.py +34 -0
- agentkernel/approval/cli.py +129 -0
- agentkernel/approval/policy.py +58 -0
- agentkernel/approval/risk.py +91 -0
- agentkernel/approval/sandbox.py +201 -0
- agentkernel/budget.py +64 -0
- agentkernel/checkpoint.py +50 -0
- agentkernel/cli.py +1482 -0
- agentkernel/config.py +224 -0
- agentkernel/context/__init__.py +17 -0
- agentkernel/context/manager.py +216 -0
- agentkernel/context/truncate.py +35 -0
- agentkernel/cron.py +146 -0
- agentkernel/curation.py +183 -0
- agentkernel/doctor.py +141 -0
- agentkernel/embeddings.py +132 -0
- agentkernel/evaluation.py +186 -0
- agentkernel/improvement.py +133 -0
- agentkernel/insights.py +141 -0
- agentkernel/kanban.py +114 -0
- agentkernel/knowledge.py +383 -0
- agentkernel/loops.py +145 -0
- agentkernel/mcp/__init__.py +23 -0
- agentkernel/mcp/client.py +181 -0
- agentkernel/mcp/config.py +59 -0
- agentkernel/mcp/tools.py +96 -0
- agentkernel/memory.py +1208 -0
- agentkernel/paths.py +73 -0
- agentkernel/plugins.py +76 -0
- agentkernel/profiles.py +70 -0
- agentkernel/progress.py +89 -0
- agentkernel/providers/__init__.py +35 -0
- agentkernel/providers/_http.py +157 -0
- agentkernel/providers/anthropic.py +282 -0
- agentkernel/providers/base.py +38 -0
- agentkernel/providers/credentials.py +65 -0
- agentkernel/providers/local.py +34 -0
- agentkernel/providers/openai.py +260 -0
- agentkernel/redaction.py +77 -0
- agentkernel/semantic_index.py +139 -0
- agentkernel/semantic_memory.py +253 -0
- agentkernel/skills.py +268 -0
- agentkernel/subagent.py +161 -0
- agentkernel/telemetry.py +199 -0
- agentkernel/templates/README.md +35 -0
- agentkernel/templates/SKILL.md +28 -0
- agentkernel/templates/eval-suite.toml +22 -0
- agentkernel/templates/loop.toml +29 -0
- agentkernel/templates/mcp-servers.toml +22 -0
- agentkernel/templates/profile.toml +29 -0
- agentkernel/templates/tool_module.py +64 -0
- agentkernel/tools/__init__.py +5 -0
- agentkernel/tools/base.py +100 -0
- agentkernel/tools/builtin/__init__.py +37 -0
- agentkernel/tools/builtin/checkpoint_tool.py +33 -0
- agentkernel/tools/builtin/clarify.py +60 -0
- agentkernel/tools/builtin/files.py +221 -0
- agentkernel/tools/builtin/kanban_tool.py +100 -0
- agentkernel/tools/builtin/search.py +225 -0
- agentkernel/tools/builtin/shell.py +67 -0
- agentkernel/tools/builtin/todo.py +106 -0
- agentkernel/tui/__init__.py +50 -0
- agentkernel/tui/app.py +594 -0
- agentkernel/types.py +127 -0
- agentkernel/worktree.py +64 -0
- agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
- agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
- agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
- agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Eval suite template — a set of scored tasks for the agent.
|
|
2
|
+
# Run with: agentkernel eval --suite <this-file>.toml
|
|
3
|
+
# agentkernel eval --suite <this-file>.toml -o report.json # JSON report
|
|
4
|
+
# agentkernel eval --suite <this-file>.toml --case "*foo*" # subset
|
|
5
|
+
#
|
|
6
|
+
# Each case runs through the agent; a judge model then scores the answer against
|
|
7
|
+
# the rubric (0-1, pass/fail). The suite reports pass-rate and exits non-zero
|
|
8
|
+
# unless every case passes, so it doubles as a CI gate.
|
|
9
|
+
|
|
10
|
+
# Default rubric applied to any case that doesn't override it.
|
|
11
|
+
rubric = "The answer is correct, specific, and grounded in the actual files — not guessed."
|
|
12
|
+
|
|
13
|
+
[[cases]]
|
|
14
|
+
name = "example-case"
|
|
15
|
+
prompt = "<the task to give the agent>"
|
|
16
|
+
# Optional per-case rubric; overrides the suite default above.
|
|
17
|
+
rubric = "<what a correct answer must contain or do>"
|
|
18
|
+
|
|
19
|
+
[[cases]]
|
|
20
|
+
name = "another-case"
|
|
21
|
+
prompt = "<another task>"
|
|
22
|
+
# (no rubric here -> uses the suite default)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Loop template — a workflow the agent repeats until a stopping condition.
|
|
2
|
+
# Run with: agentkernel loop --file loops/<name>.toml
|
|
3
|
+
#
|
|
4
|
+
# Pattern: action -> check -> iterate -> stop. Each iteration runs `prompt`
|
|
5
|
+
# through a fresh agent; if `success_check` exits 0 it counts as a success, and
|
|
6
|
+
# the loop stops once it sees `success_streak` successes in a row (or hits
|
|
7
|
+
# max_iterations). Omit success_check to let the workflow itself decide when done.
|
|
8
|
+
name = "{{name}}"
|
|
9
|
+
description = "<one line describing what this loop accomplishes>"
|
|
10
|
+
|
|
11
|
+
prompt = """
|
|
12
|
+
<The instructions handed to the agent each iteration. Tell it what to do, how to
|
|
13
|
+
make the smallest correct change, and to stop after acting. Write it so that
|
|
14
|
+
repeating it converges — e.g. "fix the next failure", not "fix everything".>
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
max_iterations = 5
|
|
18
|
+
|
|
19
|
+
# Shell command run in the sandbox after each iteration; exit 0 == success.
|
|
20
|
+
# Remove this line for a loop with no programmatic check.
|
|
21
|
+
success_check = "<command that exits 0 when the work is done, e.g. 'uv run pytest -q'>"
|
|
22
|
+
|
|
23
|
+
# How many consecutive successes are required to stop (2+ guards against a fix in
|
|
24
|
+
# one round masking a regression in the next).
|
|
25
|
+
success_streak = 1
|
|
26
|
+
|
|
27
|
+
# Working directory for the success_check, and its timeout in seconds.
|
|
28
|
+
cwd = "."
|
|
29
|
+
check_timeout = 120
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# MCP servers template — paste these blocks into your agentkernel.toml.
|
|
2
|
+
#
|
|
3
|
+
# Each [[mcp_servers]] entry launches an MCP server over stdio; its tools are
|
|
4
|
+
# auto-discovered and registered into the same registry as the builtins, so the
|
|
5
|
+
# model uses them like any other tool. Read-only tools (advertising readOnlyHint)
|
|
6
|
+
# skip the approval gate; everything else is gated by default. Each server's
|
|
7
|
+
# stderr goes to mcp_log_dir/<name>.log.
|
|
8
|
+
|
|
9
|
+
[[mcp_servers]]
|
|
10
|
+
name = "filesystem"
|
|
11
|
+
command = "npx"
|
|
12
|
+
args = ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
13
|
+
timeout = 30 # per-request timeout in seconds
|
|
14
|
+
|
|
15
|
+
[[mcp_servers]]
|
|
16
|
+
name = "git"
|
|
17
|
+
command = "uvx"
|
|
18
|
+
args = ["mcp-server-git"]
|
|
19
|
+
timeout = 30
|
|
20
|
+
|
|
21
|
+
# On Windows, point `command` at the actual executable (e.g. "npx.cmd") since the
|
|
22
|
+
# client launches the process directly without a shell.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Profile template — a named parameterization of one run.
|
|
2
|
+
# Save as profiles/<name>.toml; the file name is the profile name.
|
|
3
|
+
# Load with: agentkernel --profile <name> run "..."
|
|
4
|
+
#
|
|
5
|
+
# A profile sets any of: system_prompt, tool_filter, model_override, rubric.
|
|
6
|
+
# The kernel honors system_prompt and tool_filter every run; model_override and
|
|
7
|
+
# rubric are used by the CLI and the eval harness. Every field is optional.
|
|
8
|
+
|
|
9
|
+
# Prepended to the system prompt for this run. Use it to set role and constraints.
|
|
10
|
+
system_prompt = """
|
|
11
|
+
<Describe the role and any hard constraints, e.g. "read-only — do not modify
|
|
12
|
+
files" or "produce a plan, do not act".>
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Restrict which tools are available this run, by tool name. OMIT this line to
|
|
16
|
+
# allow every registered tool. An empty list [] removes all tools (plan-only).
|
|
17
|
+
# Read-only builtins: read_file, list_dir, find_files, search_text, file_info.
|
|
18
|
+
# Mutating builtins: write_file, edit_file, bash.
|
|
19
|
+
tool_filter = ["read_file", "list_dir", "find_files", "search_text", "file_info"]
|
|
20
|
+
|
|
21
|
+
# Optional: override the model just for runs using this profile.
|
|
22
|
+
# model_override = "claude-haiku-4-5-20251001"
|
|
23
|
+
|
|
24
|
+
# Optional: default rubric used by `agentkernel eval` when this profile is active.
|
|
25
|
+
# rubric = "The answer is correct and complete."
|
|
26
|
+
|
|
27
|
+
# Optional: reasoning effort for providers that support it (OpenAI reasoning
|
|
28
|
+
# models, Anthropic extended thinking). Ignored by providers/models that don't.
|
|
29
|
+
# reasoning = "high" # low | medium | high
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Template for a custom tool module (agentkernel conventions).
|
|
2
|
+
|
|
3
|
+
A tool is a ToolSpec: a name, a model-facing description (write it like a prompt),
|
|
4
|
+
a JSON-Schema parameter object, and a handler. Build tools with a factory function
|
|
5
|
+
so any dependencies (a working dir, a client, a config value) are bound once and
|
|
6
|
+
the handler stays a pure function of its arguments — never reaching for globals.
|
|
7
|
+
|
|
8
|
+
Conventions to follow:
|
|
9
|
+
- Return a ToolResult; NEVER raise. Turn failures into ToolResult(is_error=True).
|
|
10
|
+
(The registry also catches stray exceptions, but handling them yourself gives a
|
|
11
|
+
clearer message to the model.)
|
|
12
|
+
- Set additionalProperties: False and list required fields in the schema.
|
|
13
|
+
- Flag mutations: pass requires_approval/mutates/runs_code so the loop gates them.
|
|
14
|
+
|
|
15
|
+
Register these with your runtime, e.g. in build_runtime or a plugin loader:
|
|
16
|
+
|
|
17
|
+
from your_module import my_tools
|
|
18
|
+
for spec in my_tools(working_dir="."):
|
|
19
|
+
registry.register(spec)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from agentkernel.tools.base import ToolSpec
|
|
25
|
+
from agentkernel.types import ToolResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def my_tools(working_dir: str = ".") -> list[ToolSpec]:
|
|
29
|
+
"""Build the toolset, binding any dependencies here."""
|
|
30
|
+
|
|
31
|
+
def greet(args: dict) -> ToolResult:
|
|
32
|
+
# args has already been validated against the schema below.
|
|
33
|
+
name = args["name"]
|
|
34
|
+
excited = bool(args.get("excited", False))
|
|
35
|
+
if not name.strip():
|
|
36
|
+
return ToolResult("", "name must not be empty", is_error=True)
|
|
37
|
+
greeting = f"Hello, {name}{'!' if excited else '.'}"
|
|
38
|
+
return ToolResult("", greeting)
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
ToolSpec(
|
|
42
|
+
name="greet",
|
|
43
|
+
description=(
|
|
44
|
+
"Return a friendly greeting for the given name. Use when the user "
|
|
45
|
+
"asks to greet or welcome someone."
|
|
46
|
+
),
|
|
47
|
+
parameters={
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {
|
|
50
|
+
"name": {"type": "string", "description": "Who to greet."},
|
|
51
|
+
"excited": {
|
|
52
|
+
"type": "boolean",
|
|
53
|
+
"description": "Use an exclamation mark.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"required": ["name"],
|
|
57
|
+
"additionalProperties": False,
|
|
58
|
+
},
|
|
59
|
+
handler=greet,
|
|
60
|
+
category="custom",
|
|
61
|
+
# For a tool that writes files / runs commands, gate it:
|
|
62
|
+
# mutates=True, requires_approval=True (or runs_code=True)
|
|
63
|
+
),
|
|
64
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tool definitions and the registry (design §6).
|
|
2
|
+
|
|
3
|
+
The registry is agnostic about a tool's origin: a native builtin and (later) an
|
|
4
|
+
MCP-backed tool register identically. This is the Phase-2 seam — nothing here is
|
|
5
|
+
special-cased per origin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import traceback
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import jsonschema
|
|
16
|
+
|
|
17
|
+
from agentkernel.types import ToolCall, ToolResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ToolSpec:
|
|
22
|
+
"""A registered tool. ``parameters`` is a JSON Schema (draft 2020-12) object.
|
|
23
|
+
|
|
24
|
+
Flags drive the approval/sandbox gate (design §10): any of ``requires_approval``,
|
|
25
|
+
``mutates``, or ``runs_code`` causes the loop to consult the ``Approver`` before
|
|
26
|
+
executing; ``runs_code`` additionally routes execution through the ``Sandbox``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
description: str # model-facing; write it like a prompt
|
|
31
|
+
parameters: dict[str, Any] # JSON Schema (draft 2020-12) object
|
|
32
|
+
handler: Callable[[dict], ToolResult]
|
|
33
|
+
requires_approval: bool = False
|
|
34
|
+
mutates: bool = False
|
|
35
|
+
runs_code: bool = False
|
|
36
|
+
category: str = "general"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def gated(self) -> bool:
|
|
40
|
+
"""True if this tool must pass the approver before executing."""
|
|
41
|
+
return self.requires_approval or self.mutates or self.runs_code
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ToolRegistry:
|
|
45
|
+
"""Holds tool specs and dispatches calls. See design §6.2.
|
|
46
|
+
|
|
47
|
+
Spec ordering is insertion order and is never re-sorted — the spec list is
|
|
48
|
+
part of the cacheable prefix (design §9.3), so reordering it between turns
|
|
49
|
+
would destroy prompt-cache hit-rate.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self._specs: dict[str, ToolSpec] = {}
|
|
54
|
+
|
|
55
|
+
def register(self, spec: ToolSpec) -> None:
|
|
56
|
+
if spec.name in self._specs:
|
|
57
|
+
raise ValueError(f"tool already registered: {spec.name!r}")
|
|
58
|
+
self._specs[spec.name] = spec
|
|
59
|
+
|
|
60
|
+
def spec(self, name: str) -> ToolSpec | None:
|
|
61
|
+
return self._specs.get(name)
|
|
62
|
+
|
|
63
|
+
def specs(self) -> list[ToolSpec]:
|
|
64
|
+
"""All specs in stable registration order (for the provider prefix)."""
|
|
65
|
+
return list(self._specs.values())
|
|
66
|
+
|
|
67
|
+
def validate(self, call: ToolCall) -> str | None:
|
|
68
|
+
"""Validate ``call.arguments`` against the tool's schema.
|
|
69
|
+
|
|
70
|
+
Returns an error string on failure (unknown tool or schema violation) or
|
|
71
|
+
``None`` if the call is valid. The loop turns a non-None result into a
|
|
72
|
+
``ToolResult(is_error=True)`` *instead of* executing, so the model can
|
|
73
|
+
correct itself.
|
|
74
|
+
"""
|
|
75
|
+
spec = self._specs.get(call.name)
|
|
76
|
+
if spec is None:
|
|
77
|
+
return f"Unknown tool: {call.name!r}"
|
|
78
|
+
try:
|
|
79
|
+
jsonschema.validate(call.arguments, spec.parameters)
|
|
80
|
+
except jsonschema.ValidationError as exc:
|
|
81
|
+
return f"Invalid arguments for {call.name!r}: {exc.message}"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def execute(self, call: ToolCall) -> ToolResult:
|
|
85
|
+
"""Dispatch to the handler. A handler exception becomes an error result.
|
|
86
|
+
|
|
87
|
+
Handlers receive only ``call.arguments`` and so cannot know the call id;
|
|
88
|
+
the registry stamps ``call_id`` onto the returned result here, which keeps
|
|
89
|
+
the §8 pairing contract the registry's responsibility, not the handler's.
|
|
90
|
+
"""
|
|
91
|
+
spec = self._specs.get(call.name)
|
|
92
|
+
if spec is None: # pragma: no cover - validate() runs first in the loop
|
|
93
|
+
return ToolResult(call.id, f"Unknown tool: {call.name!r}", is_error=True)
|
|
94
|
+
try:
|
|
95
|
+
result = spec.handler(call.arguments)
|
|
96
|
+
except Exception as exc: # noqa: BLE001 - errors become results, not raises
|
|
97
|
+
summary = f"{type(exc).__name__}: {exc}\n{traceback.format_exc(limit=3)}"
|
|
98
|
+
return ToolResult(call.id, summary, is_error=True)
|
|
99
|
+
result.call_id = call.id
|
|
100
|
+
return result
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Builtin tools the kernel ships (design §6.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from agentkernel.tools.builtin.files import file_tools
|
|
8
|
+
from agentkernel.tools.builtin.search import search_tools
|
|
9
|
+
from agentkernel.tools.builtin.shell import bash_tool
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from agentkernel.approval import Sandbox
|
|
13
|
+
from agentkernel.checkpoint import Checkpointer
|
|
14
|
+
from agentkernel.tools.base import ToolSpec
|
|
15
|
+
|
|
16
|
+
__all__ = ["file_tools", "search_tools", "bash_tool", "default_tools"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_tools(
|
|
20
|
+
sandbox: Sandbox,
|
|
21
|
+
working_dir: str = ".",
|
|
22
|
+
*,
|
|
23
|
+
max_result_tokens: int = 4096,
|
|
24
|
+
bash_timeout: int = 60,
|
|
25
|
+
checkpointer: Checkpointer | None = None,
|
|
26
|
+
) -> list[ToolSpec]:
|
|
27
|
+
"""The full builtin toolset: file + search tools + bash, bound to one working dir.
|
|
28
|
+
|
|
29
|
+
When ``checkpointer`` is set, the file tools record pre-edit state for
|
|
30
|
+
rollback; the ``rollback`` tool itself is registered by the runtime builder.
|
|
31
|
+
"""
|
|
32
|
+
tools = file_tools(
|
|
33
|
+
working_dir, max_result_tokens=max_result_tokens, checkpointer=checkpointer
|
|
34
|
+
)
|
|
35
|
+
tools += search_tools(working_dir, max_result_tokens=max_result_tokens)
|
|
36
|
+
tools.append(bash_tool(sandbox, working_dir, timeout=bash_timeout))
|
|
37
|
+
return tools
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The `rollback` tool — restore files to their pre-edit checkpoint (§18.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from agentkernel.tools.base import ToolSpec
|
|
8
|
+
from agentkernel.types import ToolResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from agentkernel.checkpoint import Checkpointer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def rollback_tool(checkpointer: Checkpointer) -> ToolSpec:
|
|
15
|
+
"""Build a `rollback` tool bound to ``checkpointer``."""
|
|
16
|
+
|
|
17
|
+
def rollback(_args: dict) -> ToolResult:
|
|
18
|
+
if checkpointer.pending() == 0:
|
|
19
|
+
return ToolResult("", "Nothing to roll back — no files have been modified.")
|
|
20
|
+
n = checkpointer.rollback()
|
|
21
|
+
return ToolResult("", f"Rolled back {n} file(s) to their pre-edit state.")
|
|
22
|
+
|
|
23
|
+
return ToolSpec(
|
|
24
|
+
name="rollback",
|
|
25
|
+
description=(
|
|
26
|
+
"Undo all file changes made this session, restoring every file the "
|
|
27
|
+
"file tools modified (and deleting any they created) to its state at "
|
|
28
|
+
"the start. Use this to recover after a wrong edit."
|
|
29
|
+
),
|
|
30
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
31
|
+
handler=rollback,
|
|
32
|
+
category="files",
|
|
33
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The `clarify` tool (design §18.4).
|
|
2
|
+
|
|
3
|
+
Lets the model ask the user a single focused question mid-run instead of guessing,
|
|
4
|
+
routed through the same terminal input channel the approver uses. In a
|
|
5
|
+
non-interactive run (no stdin), it degrades gracefully: the model is told no one
|
|
6
|
+
is available and to proceed with its best judgment, rather than blocking.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from agentkernel.tools.base import ToolSpec
|
|
14
|
+
from agentkernel.types import ToolResult
|
|
15
|
+
|
|
16
|
+
_NO_ANSWER = "No user is available to answer; proceed with your best judgment."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clarify_tool(
|
|
20
|
+
*,
|
|
21
|
+
input_fn: Callable[[str], str] = input,
|
|
22
|
+
output_fn: Callable[[str], None] = print,
|
|
23
|
+
) -> ToolSpec:
|
|
24
|
+
"""Build the `clarify` tool over a terminal input/output channel."""
|
|
25
|
+
|
|
26
|
+
def clarify(args: dict) -> ToolResult:
|
|
27
|
+
question = (args.get("question") or "").strip()
|
|
28
|
+
if not question:
|
|
29
|
+
return ToolResult("", "clarify requires a `question`.", is_error=True)
|
|
30
|
+
output_fn(f"\n[clarify] {question}")
|
|
31
|
+
try:
|
|
32
|
+
answer = input_fn("> your answer: ").strip()
|
|
33
|
+
except (EOFError, KeyboardInterrupt):
|
|
34
|
+
return ToolResult("", _NO_ANSWER)
|
|
35
|
+
if not answer:
|
|
36
|
+
return ToolResult("", "(no answer given) Proceed with your best judgment.")
|
|
37
|
+
return ToolResult("", f"User answered: {answer}")
|
|
38
|
+
|
|
39
|
+
return ToolSpec(
|
|
40
|
+
name="clarify",
|
|
41
|
+
description=(
|
|
42
|
+
"Ask the user one focused question when a requirement is genuinely "
|
|
43
|
+
"ambiguous and guessing would risk doing the wrong thing. Use "
|
|
44
|
+
"sparingly — prefer reasonable defaults. Returns the user's answer, or "
|
|
45
|
+
"tells you to proceed if no one is available."
|
|
46
|
+
),
|
|
47
|
+
parameters={
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {
|
|
50
|
+
"question": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "The single, specific question to ask the user.",
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"required": ["question"],
|
|
56
|
+
"additionalProperties": False,
|
|
57
|
+
},
|
|
58
|
+
handler=clarify,
|
|
59
|
+
category="interaction",
|
|
60
|
+
)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Filesystem tools: read_file, write_file, list_dir, edit_file (design §6.3).
|
|
2
|
+
|
|
3
|
+
All paths are confined to the configured working directory: ``..`` escapes and
|
|
4
|
+
absolute paths outside the root are rejected with an error result (never a
|
|
5
|
+
raise). ``read_file`` truncates large files via the shared §8.4/§9 mechanism.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from agentkernel.context.truncate import truncate_text
|
|
14
|
+
from agentkernel.tools.base import ToolSpec
|
|
15
|
+
from agentkernel.types import ToolResult
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from agentkernel.checkpoint import Checkpointer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_within(root: Path, path: str) -> Path:
|
|
22
|
+
"""Resolve ``path`` under ``root``, or raise ValueError if it escapes.
|
|
23
|
+
|
|
24
|
+
Shared by every working-dir-confined tool so the containment rule lives in
|
|
25
|
+
one place (design §6.3, §10.3).
|
|
26
|
+
"""
|
|
27
|
+
candidate = (root / path).resolve()
|
|
28
|
+
if candidate != root and root not in candidate.parents:
|
|
29
|
+
raise ValueError(f"path escapes working directory: {path!r}")
|
|
30
|
+
return candidate
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def file_tools(
|
|
34
|
+
working_dir: str = ".",
|
|
35
|
+
*,
|
|
36
|
+
max_result_tokens: int = 4096,
|
|
37
|
+
checkpointer: Checkpointer | None = None,
|
|
38
|
+
) -> list[ToolSpec]:
|
|
39
|
+
"""Build the file toolset bound to ``working_dir``.
|
|
40
|
+
|
|
41
|
+
Binding the root (and result cap) here keeps handlers pure functions of
|
|
42
|
+
their arguments — they never reach for global config (AGENT.md, design §7).
|
|
43
|
+
When a ``checkpointer`` is supplied, write_file/edit_file record a file's
|
|
44
|
+
pre-modification state so a ``rollback`` can undo the change (design §18.1).
|
|
45
|
+
"""
|
|
46
|
+
root = Path(working_dir).resolve()
|
|
47
|
+
|
|
48
|
+
def _resolve(path: str) -> Path:
|
|
49
|
+
return resolve_within(root, path)
|
|
50
|
+
|
|
51
|
+
def read_file(args: dict) -> ToolResult:
|
|
52
|
+
path = args["path"]
|
|
53
|
+
try:
|
|
54
|
+
target = _resolve(path)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
return ToolResult("", str(exc), is_error=True)
|
|
57
|
+
if not target.is_file():
|
|
58
|
+
return ToolResult("", f"Not a file: {path!r}", is_error=True)
|
|
59
|
+
text = target.read_text(encoding="utf-8", errors="replace")
|
|
60
|
+
return ToolResult("", truncate_text(text, max_result_tokens))
|
|
61
|
+
|
|
62
|
+
def write_file(args: dict) -> ToolResult:
|
|
63
|
+
path = args["path"]
|
|
64
|
+
content = args["content"]
|
|
65
|
+
try:
|
|
66
|
+
target = _resolve(path)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
return ToolResult("", str(exc), is_error=True)
|
|
69
|
+
if checkpointer is not None:
|
|
70
|
+
checkpointer.record(target)
|
|
71
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
target.write_text(content, encoding="utf-8")
|
|
73
|
+
return ToolResult("", f"Wrote {len(content)} bytes to {path}")
|
|
74
|
+
|
|
75
|
+
def list_dir(args: dict) -> ToolResult:
|
|
76
|
+
path = args.get("path", ".")
|
|
77
|
+
try:
|
|
78
|
+
target = _resolve(path)
|
|
79
|
+
except ValueError as exc:
|
|
80
|
+
return ToolResult("", str(exc), is_error=True)
|
|
81
|
+
if not target.is_dir():
|
|
82
|
+
return ToolResult("", f"Not a directory: {path!r}", is_error=True)
|
|
83
|
+
entries = sorted(
|
|
84
|
+
f"{p.name}/" if p.is_dir() else p.name for p in target.iterdir()
|
|
85
|
+
)
|
|
86
|
+
listing = "\n".join(entries) if entries else "(empty)"
|
|
87
|
+
return ToolResult("", truncate_text(listing, max_result_tokens))
|
|
88
|
+
|
|
89
|
+
def edit_file(args: dict) -> ToolResult:
|
|
90
|
+
path = args["path"]
|
|
91
|
+
old = args["old"]
|
|
92
|
+
new = args["new"]
|
|
93
|
+
replace_all = bool(args.get("replace_all", False))
|
|
94
|
+
try:
|
|
95
|
+
target = _resolve(path)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
return ToolResult("", str(exc), is_error=True)
|
|
98
|
+
if not target.is_file():
|
|
99
|
+
return ToolResult("", f"Not a file: {path!r}", is_error=True)
|
|
100
|
+
if old == new:
|
|
101
|
+
return ToolResult("", "`old` and `new` are identical; nothing to do.", is_error=True)
|
|
102
|
+
text = target.read_text(encoding="utf-8", errors="replace")
|
|
103
|
+
count = text.count(old)
|
|
104
|
+
if count == 0:
|
|
105
|
+
return ToolResult("", f"`old` text not found in {path!r}.", is_error=True)
|
|
106
|
+
if count > 1 and not replace_all:
|
|
107
|
+
return ToolResult(
|
|
108
|
+
"",
|
|
109
|
+
f"`old` text is not unique in {path!r} ({count} occurrences); "
|
|
110
|
+
"pass replace_all=true or include more surrounding context.",
|
|
111
|
+
is_error=True,
|
|
112
|
+
)
|
|
113
|
+
if checkpointer is not None:
|
|
114
|
+
checkpointer.record(target)
|
|
115
|
+
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
|
|
116
|
+
target.write_text(updated, encoding="utf-8")
|
|
117
|
+
replaced = count if replace_all else 1
|
|
118
|
+
return ToolResult("", f"Replaced {replaced} occurrence(s) in {path}.")
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
ToolSpec(
|
|
122
|
+
name="read_file",
|
|
123
|
+
description=(
|
|
124
|
+
"Read a UTF-8 text file within the working directory. Returns the "
|
|
125
|
+
"file contents; large files are truncated with a marker."
|
|
126
|
+
),
|
|
127
|
+
parameters={
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"path": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"description": "Path relative to the working directory.",
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"required": ["path"],
|
|
136
|
+
"additionalProperties": False,
|
|
137
|
+
},
|
|
138
|
+
handler=read_file,
|
|
139
|
+
category="files",
|
|
140
|
+
),
|
|
141
|
+
ToolSpec(
|
|
142
|
+
name="write_file",
|
|
143
|
+
description=(
|
|
144
|
+
"Write (creating or overwriting) a UTF-8 text file within the "
|
|
145
|
+
"working directory. Parent directories are created as needed. To "
|
|
146
|
+
"change part of an existing file, prefer edit_file."
|
|
147
|
+
),
|
|
148
|
+
parameters={
|
|
149
|
+
"type": "object",
|
|
150
|
+
"properties": {
|
|
151
|
+
"path": {"type": "string"},
|
|
152
|
+
"content": {"type": "string"},
|
|
153
|
+
},
|
|
154
|
+
"required": ["path", "content"],
|
|
155
|
+
"additionalProperties": False,
|
|
156
|
+
},
|
|
157
|
+
handler=write_file,
|
|
158
|
+
mutates=True,
|
|
159
|
+
requires_approval=True,
|
|
160
|
+
category="files",
|
|
161
|
+
),
|
|
162
|
+
ToolSpec(
|
|
163
|
+
name="list_dir",
|
|
164
|
+
description="List the entries of a directory within the working directory.",
|
|
165
|
+
parameters={
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"path": {
|
|
169
|
+
"type": "string",
|
|
170
|
+
"description": "Directory path relative to the working directory.",
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
"required": [],
|
|
174
|
+
"additionalProperties": False,
|
|
175
|
+
},
|
|
176
|
+
handler=list_dir,
|
|
177
|
+
category="files",
|
|
178
|
+
),
|
|
179
|
+
ToolSpec(
|
|
180
|
+
name="edit_file",
|
|
181
|
+
description=(
|
|
182
|
+
"Replace an exact substring in a text file within the working "
|
|
183
|
+
"directory — the surgical alternative to rewriting the whole file "
|
|
184
|
+
"with write_file. `old` must match the file byte-for-byte and be "
|
|
185
|
+
"unique unless replace_all is true. Fails (without writing) if "
|
|
186
|
+
"`old` is missing or ambiguous, so include enough surrounding "
|
|
187
|
+
"context to pin down the one spot you mean."
|
|
188
|
+
),
|
|
189
|
+
parameters={
|
|
190
|
+
"type": "object",
|
|
191
|
+
"properties": {
|
|
192
|
+
"path": {
|
|
193
|
+
"type": "string",
|
|
194
|
+
"description": "Path of the file to edit, relative to the working dir.",
|
|
195
|
+
},
|
|
196
|
+
"old": {
|
|
197
|
+
"type": "string",
|
|
198
|
+
"description": (
|
|
199
|
+
"Exact text to find; add surrounding lines to make it unique."
|
|
200
|
+
),
|
|
201
|
+
},
|
|
202
|
+
"new": {
|
|
203
|
+
"type": "string",
|
|
204
|
+
"description": "Replacement text.",
|
|
205
|
+
},
|
|
206
|
+
"replace_all": {
|
|
207
|
+
"type": "boolean",
|
|
208
|
+
"description": (
|
|
209
|
+
"Replace every occurrence instead of needing a unique match."
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
"required": ["path", "old", "new"],
|
|
214
|
+
"additionalProperties": False,
|
|
215
|
+
},
|
|
216
|
+
handler=edit_file,
|
|
217
|
+
mutates=True,
|
|
218
|
+
requires_approval=True,
|
|
219
|
+
category="files",
|
|
220
|
+
),
|
|
221
|
+
]
|