minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Shell and subagent execution tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..errors import ToolError
|
|
9
|
+
from ..provider.anthropic_env import ANTHROPIC_ENV_PROVIDER
|
|
10
|
+
from ..shell import MAX_TIMEOUT_SECONDS, powershell_heredoc_delimiter, run_shell
|
|
11
|
+
from ..subagent import PREAMBLE as SUBAGENT_PREAMBLE
|
|
12
|
+
from ..subagent import build_command as build_subagent_command
|
|
13
|
+
from ..subagent import child_env as subagent_env
|
|
14
|
+
|
|
15
|
+
_POWERSHELL_KINDS = frozenset({"pwsh7", "windows_powershell"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _clamp_timeout(value: float) -> float:
|
|
19
|
+
"""Apply the ceiling `run_shell` enforces, so the reported value is real."""
|
|
20
|
+
return max(1.0, min(value, MAX_TIMEOUT_SECONDS))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProcessToolsMixin:
|
|
24
|
+
"""Run commands and nested agents inside the active sandbox policy."""
|
|
25
|
+
|
|
26
|
+
def _subagent_sandbox_options(self) -> tuple[list[str], list[tuple[str, str]]]:
|
|
27
|
+
"""Mirror the current in-memory sandbox policy onto the child command.
|
|
28
|
+
|
|
29
|
+
CLI flags are applied to ``settings.data`` in memory only; without this
|
|
30
|
+
the child reloads ``settings.json`` and silently drops ``--sandbox``,
|
|
31
|
+
``--sandbox-network``, and the other per-run overrides.
|
|
32
|
+
"""
|
|
33
|
+
policy = self.sandbox.policy
|
|
34
|
+
if not policy.enabled:
|
|
35
|
+
return ["--no-sandbox"], []
|
|
36
|
+
flags = ["--sandbox"]
|
|
37
|
+
options: list[tuple[str, str]] = [
|
|
38
|
+
("--sandbox-backend", policy.backend),
|
|
39
|
+
("--sandbox-network", policy.network_policy),
|
|
40
|
+
("--sandbox-timeout", f"{policy.timeout_seconds:g}"),
|
|
41
|
+
("--sandbox-max-output", str(policy.max_output_chars)),
|
|
42
|
+
]
|
|
43
|
+
for root in policy.allowed_roots:
|
|
44
|
+
options.append(("--sandbox-root", str(root)))
|
|
45
|
+
for host in policy.allowed_hosts:
|
|
46
|
+
options.append(("--sandbox-allow-host", host))
|
|
47
|
+
flags.append(
|
|
48
|
+
"--sandbox-allow-sensitive"
|
|
49
|
+
if policy.allow_sensitive_files
|
|
50
|
+
else "--no-sandbox-allow-sensitive"
|
|
51
|
+
)
|
|
52
|
+
return flags, options
|
|
53
|
+
|
|
54
|
+
def shell(
|
|
55
|
+
self, command: str, timeout: float | None = None, cwd: str = "."
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Run one command in the configured shell and workspace."""
|
|
58
|
+
if not isinstance(command, str):
|
|
59
|
+
raise ToolError("command must be a string")
|
|
60
|
+
if self.shell_spec.kind in _POWERSHELL_KINDS:
|
|
61
|
+
delimiter = powershell_heredoc_delimiter(command)
|
|
62
|
+
if delimiter is not None:
|
|
63
|
+
raise ToolError(
|
|
64
|
+
f"{self.shell_spec.label} does not support bash heredocs "
|
|
65
|
+
f"(<<{delimiter}). Write the payload with write/append_file "
|
|
66
|
+
"and run the file, use a PowerShell here-string (@'...'@), "
|
|
67
|
+
"or pass stdin; the same command will fail again."
|
|
68
|
+
)
|
|
69
|
+
workdir = self._path(cwd, operation="execute")
|
|
70
|
+
if not workdir.is_dir():
|
|
71
|
+
raise ToolError(f"working directory not found: {workdir}")
|
|
72
|
+
if timeout is not None and (
|
|
73
|
+
isinstance(timeout, bool) or not isinstance(timeout, (int, float))
|
|
74
|
+
):
|
|
75
|
+
raise ToolError("timeout must be a number")
|
|
76
|
+
try:
|
|
77
|
+
requested_timeout = float(
|
|
78
|
+
timeout if timeout is not None else self.shell_timeout
|
|
79
|
+
)
|
|
80
|
+
except (OverflowError, ValueError) as exc:
|
|
81
|
+
raise ToolError("timeout must be a number") from exc
|
|
82
|
+
if math.isnan(requested_timeout):
|
|
83
|
+
raise ToolError("timeout must be a number")
|
|
84
|
+
# The configured value is both the default and the hard ceiling.
|
|
85
|
+
effective_timeout = max(1.0, min(requested_timeout, self.shell_timeout))
|
|
86
|
+
return run_shell(
|
|
87
|
+
command,
|
|
88
|
+
shell=self.shell_spec,
|
|
89
|
+
cwd=workdir,
|
|
90
|
+
timeout=effective_timeout,
|
|
91
|
+
max_output_chars=self.max_output_chars,
|
|
92
|
+
sandbox=self.sandbox,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def subagent(
|
|
96
|
+
self,
|
|
97
|
+
task: str,
|
|
98
|
+
cwd: str = ".",
|
|
99
|
+
timeout: float | None = None,
|
|
100
|
+
max_turns: int | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Run a nested ``minimalcode -p`` process and return its answer."""
|
|
103
|
+
if not self.subagent_enabled:
|
|
104
|
+
raise ToolError(
|
|
105
|
+
"subagent is disabled here: set subagent.enabled or raise "
|
|
106
|
+
f"subagent.max_depth (currently {self.subagent_max_depth})"
|
|
107
|
+
)
|
|
108
|
+
if not isinstance(task, str) or not task.strip():
|
|
109
|
+
raise ToolError("task must be a non-empty string")
|
|
110
|
+
workdir = self._path(cwd, operation="execute")
|
|
111
|
+
if not workdir.is_dir():
|
|
112
|
+
raise ToolError(f"working directory not found: {workdir}")
|
|
113
|
+
if timeout is not None and (
|
|
114
|
+
isinstance(timeout, bool) or not isinstance(timeout, (int, float))
|
|
115
|
+
):
|
|
116
|
+
raise ToolError("timeout must be a number")
|
|
117
|
+
try:
|
|
118
|
+
requested_timeout = float(
|
|
119
|
+
timeout if timeout is not None else self.subagent_timeout
|
|
120
|
+
)
|
|
121
|
+
except (OverflowError, ValueError) as exc:
|
|
122
|
+
raise ToolError("timeout must be a number") from exc
|
|
123
|
+
if math.isnan(requested_timeout):
|
|
124
|
+
raise ToolError("timeout must be a number")
|
|
125
|
+
limits = [requested_timeout, self.subagent_timeout]
|
|
126
|
+
if self.sandbox.policy.enabled:
|
|
127
|
+
# Only an active sandbox caps the child's runtime.
|
|
128
|
+
limits.append(self.sandbox.policy.timeout_seconds)
|
|
129
|
+
effective_timeout = _clamp_timeout(min(limits))
|
|
130
|
+
turns = self.subagent_max_turns if max_turns is None else max_turns
|
|
131
|
+
if isinstance(turns, bool) or not isinstance(turns, int):
|
|
132
|
+
raise ToolError("max_turns must be an integer")
|
|
133
|
+
turns = max(1, min(turns, self.subagent_max_turns))
|
|
134
|
+
|
|
135
|
+
sandbox_flags, sandbox_options = self._subagent_sandbox_options()
|
|
136
|
+
extra_flags = list(sandbox_flags)
|
|
137
|
+
provider = self.provider_name
|
|
138
|
+
if provider == ANTHROPIC_ENV_PROVIDER:
|
|
139
|
+
# The env-derived provider exists only in this process; the child
|
|
140
|
+
# must rebuild it from the inherited ANTHROPIC_* environment.
|
|
141
|
+
extra_flags.append("--anthropic-env")
|
|
142
|
+
provider = None
|
|
143
|
+
command = build_subagent_command(
|
|
144
|
+
self.shell_spec,
|
|
145
|
+
cwd=workdir,
|
|
146
|
+
home=self.settings.home,
|
|
147
|
+
provider=provider,
|
|
148
|
+
model=self.model,
|
|
149
|
+
thinking=self.thinking,
|
|
150
|
+
max_turns=turns,
|
|
151
|
+
extra_flags=extra_flags,
|
|
152
|
+
extra_options=sandbox_options,
|
|
153
|
+
)
|
|
154
|
+
result = run_shell(
|
|
155
|
+
command,
|
|
156
|
+
shell=self.shell_spec,
|
|
157
|
+
cwd=workdir,
|
|
158
|
+
timeout=effective_timeout,
|
|
159
|
+
max_output_chars=self.max_output_chars,
|
|
160
|
+
env=subagent_env(self.subagent_depth + 1),
|
|
161
|
+
stdin_text=SUBAGENT_PREAMBLE + task.strip(),
|
|
162
|
+
sandbox=self.sandbox,
|
|
163
|
+
)
|
|
164
|
+
return {**result, "command": command, "timeout_seconds": effective_timeout}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Optional provider-backed tools: image understanding and web access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..errors import ToolError
|
|
8
|
+
from ..vision import SUPPORTED_IMAGE_FORMATS, image_media_type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RemoteToolsMixin:
|
|
12
|
+
"""Tools that delegate to a configured provider client."""
|
|
13
|
+
|
|
14
|
+
def read_image(self, path: str, prompt: str | None = None) -> dict[str, Any]:
|
|
15
|
+
"""Describe a workspace image through the provider's vision model."""
|
|
16
|
+
if not self.vision_enabled or self.vision_client is None:
|
|
17
|
+
raise ToolError("read_image is not enabled for the current provider")
|
|
18
|
+
if prompt is not None and not isinstance(prompt, str):
|
|
19
|
+
raise ToolError("prompt must be a string or null")
|
|
20
|
+
target = self._path(path, operation="read")
|
|
21
|
+
if not target.is_file():
|
|
22
|
+
raise ToolError(f"file not found: {target}")
|
|
23
|
+
try:
|
|
24
|
+
# Read one byte past the limit instead of trusting stat(): the file
|
|
25
|
+
# can grow between the check and the read.
|
|
26
|
+
with target.open("rb") as handle:
|
|
27
|
+
data = handle.read(self.vision_client.max_image_bytes + 1)
|
|
28
|
+
except OSError as exc:
|
|
29
|
+
raise ToolError(f"cannot read image: {target}: {exc}") from exc
|
|
30
|
+
if len(data) > self.vision_client.max_image_bytes:
|
|
31
|
+
raise ToolError(
|
|
32
|
+
f"image is too large: more than "
|
|
33
|
+
f"vision.max_image_bytes ({self.vision_client.max_image_bytes})"
|
|
34
|
+
)
|
|
35
|
+
media_type = image_media_type(data)
|
|
36
|
+
if media_type is None:
|
|
37
|
+
raise ToolError(
|
|
38
|
+
f"unsupported image format; expected {SUPPORTED_IMAGE_FORMATS}"
|
|
39
|
+
)
|
|
40
|
+
description = self.vision_client.describe(
|
|
41
|
+
media_type, data, question=prompt or None
|
|
42
|
+
)
|
|
43
|
+
return {
|
|
44
|
+
"status": "success",
|
|
45
|
+
"path": str(target),
|
|
46
|
+
"media_type": media_type,
|
|
47
|
+
"bytes": len(data),
|
|
48
|
+
"description": description,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def web_search(
|
|
52
|
+
self,
|
|
53
|
+
query: str,
|
|
54
|
+
count: int | None = None,
|
|
55
|
+
need_content: bool = False,
|
|
56
|
+
ori_question: str | None = None,
|
|
57
|
+
localization: str | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Search the web through the configured provider client."""
|
|
60
|
+
if not self.web_enabled or self.web_client is None:
|
|
61
|
+
raise ToolError("web tools are not enabled for the current provider")
|
|
62
|
+
return self.web_client.search(
|
|
63
|
+
query,
|
|
64
|
+
count=count,
|
|
65
|
+
need_content=need_content,
|
|
66
|
+
ori_question=ori_question,
|
|
67
|
+
localization=localization,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def web_fetch(self, url: str) -> dict[str, Any]:
|
|
71
|
+
"""Fetch one HTTP(S) page through the configured provider client."""
|
|
72
|
+
if not self.web_enabled or self.web_client is None:
|
|
73
|
+
raise ToolError("web tools are not enabled for the current provider")
|
|
74
|
+
return self.web_client.fetch(url)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The ToolRuntime facade: configuration, path policy, and dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..config import Settings
|
|
10
|
+
from ..errors import ConfigError, ToolError
|
|
11
|
+
from ..sandbox import SandboxRunner
|
|
12
|
+
from ..shell import ShellSpec
|
|
13
|
+
from ..subagent import depth_from_env as subagent_depth_from_env
|
|
14
|
+
from ..vision import ProviderVisionClient
|
|
15
|
+
from ..web import ProviderWebClient
|
|
16
|
+
from .files import FileToolsMixin
|
|
17
|
+
from .process import ProcessToolsMixin
|
|
18
|
+
from .remote import RemoteToolsMixin
|
|
19
|
+
from .schemas import READ_ONLY_TOOLS, TOOL_NAMES
|
|
20
|
+
from .search_tools import SearchToolsMixin
|
|
21
|
+
from .validation import check_argument_types, clip
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolRuntime(
|
|
25
|
+
FileToolsMixin,
|
|
26
|
+
SearchToolsMixin,
|
|
27
|
+
ProcessToolsMixin,
|
|
28
|
+
RemoteToolsMixin,
|
|
29
|
+
):
|
|
30
|
+
"""Execute model tool calls with workspace and sandbox enforcement.
|
|
31
|
+
|
|
32
|
+
The methods are grouped into mixins by concern; this class owns the shared
|
|
33
|
+
settings, path resolver, and dispatcher so every tool sees one policy.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
settings: Settings,
|
|
39
|
+
cwd: Path,
|
|
40
|
+
shell: ShellSpec,
|
|
41
|
+
*,
|
|
42
|
+
web_client: ProviderWebClient | None = None,
|
|
43
|
+
vision_client: ProviderVisionClient | None = None,
|
|
44
|
+
provider_name: str | None = None,
|
|
45
|
+
model: str | None = None,
|
|
46
|
+
thinking: str | None = None,
|
|
47
|
+
read_only: bool = False,
|
|
48
|
+
):
|
|
49
|
+
self.settings = settings
|
|
50
|
+
self.cwd = cwd.resolve()
|
|
51
|
+
self.shell_spec = shell
|
|
52
|
+
self.web_client = web_client
|
|
53
|
+
self.web_enabled = bool(web_client and web_client.enabled)
|
|
54
|
+
self.vision_client = vision_client
|
|
55
|
+
self.vision_enabled = bool(vision_client and vision_client.enabled)
|
|
56
|
+
self.provider_name = provider_name
|
|
57
|
+
self.model = model
|
|
58
|
+
self.thinking = thinking
|
|
59
|
+
|
|
60
|
+
# Subagent depth is inherited from the child environment so a nested
|
|
61
|
+
# process cannot recurse without bound.
|
|
62
|
+
self.subagent_depth = subagent_depth_from_env()
|
|
63
|
+
self.subagent_max_depth = int(settings.get("subagent.max_depth", 2))
|
|
64
|
+
self.subagent_enabled = (
|
|
65
|
+
bool(settings.get("subagent.enabled", True))
|
|
66
|
+
and self.subagent_depth < self.subagent_max_depth
|
|
67
|
+
)
|
|
68
|
+
self.subagent_timeout = float(settings.get("subagent.timeout_seconds", 900))
|
|
69
|
+
self.subagent_max_turns = int(settings.get("subagent.max_turns", 40))
|
|
70
|
+
self.subagent_max_parallel = max(
|
|
71
|
+
1, int(settings.get("subagent.max_parallel", 4))
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self.allow_outside = bool(settings.get("tools.allow_outside_workspace", False))
|
|
75
|
+
self.max_file_chars = int(settings.get("tools.max_file_chars", 160_000))
|
|
76
|
+
self.max_file_bytes = int(settings.get("tools.max_file_bytes", 5_000_000))
|
|
77
|
+
self.max_results = int(settings.get("tools.max_results", 200))
|
|
78
|
+
self.max_parallel = max(1, int(settings.get("tools.max_parallel", 4)))
|
|
79
|
+
# ``tools.allow`` and ``--read-only`` narrow the same set, so the two
|
|
80
|
+
# compose instead of the flag silently widening what the file allows.
|
|
81
|
+
self.read_only = bool(read_only)
|
|
82
|
+
self.allowed_tools = self._allowed_tool_names(
|
|
83
|
+
settings, read_only=self.read_only
|
|
84
|
+
)
|
|
85
|
+
self.shell_timeout = float(settings.get("shell.timeout_seconds", 120))
|
|
86
|
+
self.max_output_chars = int(settings.get("shell.max_output_chars", 60_000))
|
|
87
|
+
self.sandbox = SandboxRunner.from_settings(settings, self.cwd)
|
|
88
|
+
if self.sandbox.policy.enabled:
|
|
89
|
+
cap = self.sandbox.policy.timeout_seconds
|
|
90
|
+
if self.subagent_timeout > cap:
|
|
91
|
+
# A subagent is one sandboxed command, so the policy cap always
|
|
92
|
+
# wins. Clamp here so the value the tool reports is the value
|
|
93
|
+
# that actually applies, instead of silently killing the child
|
|
94
|
+
# at the sandbox timeout.
|
|
95
|
+
self.subagent_timeout = cap
|
|
96
|
+
self.sandbox.audit.note(
|
|
97
|
+
"subagent.timeout_seconds capped by sandbox.timeout_seconds "
|
|
98
|
+
f"({cap:g}s)"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _allowed_tool_names(settings: Settings, *, read_only: bool) -> frozenset[str]:
|
|
103
|
+
"""Resolve ``tools.allow`` and ``--read-only`` into one name set.
|
|
104
|
+
|
|
105
|
+
A typo in ``tools.allow`` would otherwise intersect down to an empty
|
|
106
|
+
set, silently leaving an agent that cannot do anything, so unknown
|
|
107
|
+
names are a configuration error rather than a no-op.
|
|
108
|
+
"""
|
|
109
|
+
configured = settings.get("tools.allow")
|
|
110
|
+
allowed = set(TOOL_NAMES)
|
|
111
|
+
if configured is not None:
|
|
112
|
+
if not isinstance(configured, list) or not configured:
|
|
113
|
+
raise ConfigError("tools.allow must be a non-empty list of tool names")
|
|
114
|
+
unknown = sorted(
|
|
115
|
+
str(name)
|
|
116
|
+
for name in configured
|
|
117
|
+
if not isinstance(name, str) or name not in TOOL_NAMES
|
|
118
|
+
)
|
|
119
|
+
if unknown:
|
|
120
|
+
raise ConfigError(
|
|
121
|
+
"tools.allow contains unknown tools: " + ", ".join(unknown)
|
|
122
|
+
)
|
|
123
|
+
allowed &= {name for name in configured if isinstance(name, str)}
|
|
124
|
+
if read_only:
|
|
125
|
+
allowed &= READ_ONLY_TOOLS
|
|
126
|
+
if not allowed:
|
|
127
|
+
raise ConfigError(
|
|
128
|
+
"no tools are enabled; widen tools.allow or drop --read-only"
|
|
129
|
+
)
|
|
130
|
+
return frozenset(allowed)
|
|
131
|
+
|
|
132
|
+
def _path(self, raw: str, *, operation: str = "read") -> Path:
|
|
133
|
+
"""Resolve a tool path through the sandbox's shared policy."""
|
|
134
|
+
return self.sandbox.resolve_path(
|
|
135
|
+
raw,
|
|
136
|
+
cwd=self.cwd,
|
|
137
|
+
operation=operation,
|
|
138
|
+
allow_outside=self.allow_outside,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def sandbox_summary(self) -> dict[str, Any]:
|
|
142
|
+
"""Return the serializable sandbox status used by the CLI."""
|
|
143
|
+
return self.sandbox.summary()
|
|
144
|
+
|
|
145
|
+
def dispatch(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
146
|
+
"""Validate and run one named tool call.
|
|
147
|
+
|
|
148
|
+
Lookup is explicit rather than ``getattr`` so a model cannot reach
|
|
149
|
+
helper methods or private attributes by name.
|
|
150
|
+
"""
|
|
151
|
+
handlers = {
|
|
152
|
+
"read": self.read,
|
|
153
|
+
"read_range": self.read_range,
|
|
154
|
+
"write": self.write,
|
|
155
|
+
"append_file": self.append_file,
|
|
156
|
+
"edit": self.edit,
|
|
157
|
+
"shell": self.shell,
|
|
158
|
+
"subagent": self.subagent,
|
|
159
|
+
"grep": self.grep,
|
|
160
|
+
"find": self.find,
|
|
161
|
+
"read_image": self.read_image,
|
|
162
|
+
"web_search": self.web_search,
|
|
163
|
+
"web_fetch": self.web_fetch,
|
|
164
|
+
}
|
|
165
|
+
handler = handlers.get(name)
|
|
166
|
+
if handler is None:
|
|
167
|
+
raise ToolError(f"unknown tool: {name}")
|
|
168
|
+
if name not in self.allowed_tools:
|
|
169
|
+
# The model can only ask for what was advertised, but a replayed
|
|
170
|
+
# session or a provider that ignores the schema list must still be
|
|
171
|
+
# stopped here: this is the enforcement point, not the schema.
|
|
172
|
+
reason = (
|
|
173
|
+
"--read-only"
|
|
174
|
+
if self.read_only and name not in READ_ONLY_TOOLS
|
|
175
|
+
else "tools.allow"
|
|
176
|
+
)
|
|
177
|
+
raise ToolError(f"tool {name!r} is disabled by {reason}")
|
|
178
|
+
if not isinstance(arguments, dict):
|
|
179
|
+
raise ToolError("tool arguments must be an object")
|
|
180
|
+
try:
|
|
181
|
+
inspect.signature(handler).bind(**arguments)
|
|
182
|
+
except TypeError as exc:
|
|
183
|
+
raise ToolError(f"invalid arguments for {name}: {exc}") from exc
|
|
184
|
+
check_argument_types(name, handler, arguments)
|
|
185
|
+
return handler(**arguments)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def activity(name: str, arguments: dict[str, Any]) -> str:
|
|
189
|
+
"""Return a short one-line preview for the terminal activity stream."""
|
|
190
|
+
if name == "shell":
|
|
191
|
+
detail = clip(str(arguments.get("command", "")))
|
|
192
|
+
elif name == "subagent":
|
|
193
|
+
detail = clip(str(arguments.get("task", "")))
|
|
194
|
+
elif name in {
|
|
195
|
+
"read",
|
|
196
|
+
"read_range",
|
|
197
|
+
"write",
|
|
198
|
+
"append_file",
|
|
199
|
+
"edit",
|
|
200
|
+
"read_image",
|
|
201
|
+
}:
|
|
202
|
+
detail = clip(str(arguments.get("path", "")))
|
|
203
|
+
elif name == "grep":
|
|
204
|
+
detail = (
|
|
205
|
+
f"{clip(str(arguments.get('pattern', '')))} in "
|
|
206
|
+
f"{clip(str(arguments.get('path', '.')))}"
|
|
207
|
+
)
|
|
208
|
+
elif name == "find":
|
|
209
|
+
detail = (
|
|
210
|
+
f"{clip(str(arguments.get('pattern', '*')))} in "
|
|
211
|
+
f"{clip(str(arguments.get('path', '.')))}"
|
|
212
|
+
)
|
|
213
|
+
elif name == "web_search":
|
|
214
|
+
detail = clip(str(arguments.get("query", "")))
|
|
215
|
+
elif name == "web_fetch":
|
|
216
|
+
detail = clip(str(arguments.get("url", "")))
|
|
217
|
+
else:
|
|
218
|
+
detail = ""
|
|
219
|
+
return f"[{name}] {detail}".rstrip()
|