otto-cli-agent 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.
- agent/README.md +22 -0
- agent/__init__.py +0 -0
- agent/cli/README.md +77 -0
- agent/cli/__init__.py +0 -0
- agent/cli/art.py +371 -0
- agent/cli/chat.py +309 -0
- agent/cli/clipboard.py +106 -0
- agent/cli/context.py +56 -0
- agent/cli/doctor.py +60 -0
- agent/cli/errors.py +58 -0
- agent/cli/eval.py +130 -0
- agent/cli/eval_claw.py +414 -0
- agent/cli/eval_compaction.py +106 -0
- agent/cli/eval_hle.py +92 -0
- agent/cli/eval_memory.py +253 -0
- agent/cli/eval_swe.py +172 -0
- agent/cli/lessons.py +97 -0
- agent/cli/main.py +67 -0
- agent/cli/modals.py +570 -0
- agent/cli/models.py +64 -0
- agent/cli/output.py +54 -0
- agent/cli/route.py +78 -0
- agent/cli/sessions.py +125 -0
- agent/cli/setup_screen.py +562 -0
- agent/cli/shell.py +548 -0
- agent/cli/tui.py +1807 -0
- agent/cli/ui.py +14 -0
- agent/cli/usage_panel.py +159 -0
- agent/config/README.md +7 -0
- agent/config/__init__.py +0 -0
- agent/config/envfile.py +76 -0
- agent/eval/README.md +76 -0
- agent/eval/__init__.py +0 -0
- agent/eval/claw_bench.py +1031 -0
- agent/eval/compaction_bench.py +229 -0
- agent/eval/data/README.md +10 -0
- agent/eval/data/claw/README.md +108 -0
- agent/eval/data/claw/llm_judge-gemini.patch +57 -0
- agent/eval/data/claw/otto.yaml +35 -0
- agent/eval/failures.py +276 -0
- agent/eval/golden/README.md +33 -0
- agent/eval/golden/code_01.json +6 -0
- agent/eval/golden/code_02.json +6 -0
- agent/eval/golden/code_03.json +6 -0
- agent/eval/golden/code_04.json +6 -0
- agent/eval/golden/code_05.json +6 -0
- agent/eval/golden/code_06.json +6 -0
- agent/eval/golden/math_01.json +6 -0
- agent/eval/golden/math_02.json +6 -0
- agent/eval/golden/math_03.json +6 -0
- agent/eval/golden/math_04.json +6 -0
- agent/eval/golden/math_05.json +6 -0
- agent/eval/golden/math_06.json +6 -0
- agent/eval/golden/nphard_gcp_01.json +6 -0
- agent/eval/golden/nphard_ksp_01.json +6 -0
- agent/eval/golden/nphard_math_binpacking_01.json +6 -0
- agent/eval/golden/nphard_math_clique_01.json +6 -0
- agent/eval/golden/nphard_math_setcover_01.json +6 -0
- agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
- agent/eval/golden/nphard_tsp_01.json +6 -0
- agent/eval/golden/nphard_tsp_02.json +6 -0
- agent/eval/hle_bench.py +273 -0
- agent/eval/langfuse_sync.py +172 -0
- agent/eval/memory_bench.py +538 -0
- agent/eval/runner.py +174 -0
- agent/eval/single_agent.py +120 -0
- agent/eval/swe_bench.py +604 -0
- agent/eval/terminal_bench.py +345 -0
- agent/memory/README.md +102 -0
- agent/memory/__init__.py +42 -0
- agent/memory/embeddings.py +302 -0
- agent/memory/hashing.py +15 -0
- agent/memory/lessons.py +483 -0
- agent/memory/queue.py +531 -0
- agent/memory/retrieval.py +493 -0
- agent/memory/session.py +60 -0
- agent/memory/sessions.py +436 -0
- agent/memory/store.py +429 -0
- agent/memory/tokens.py +60 -0
- agent/memory/wiring.py +146 -0
- agent/pipeline/README.md +135 -0
- agent/pipeline/__init__.py +0 -0
- agent/pipeline/browsing.py +609 -0
- agent/pipeline/budget.py +403 -0
- agent/pipeline/codemap.py +254 -0
- agent/pipeline/evidence.py +325 -0
- agent/pipeline/execution.py +67 -0
- agent/pipeline/modes.py +137 -0
- agent/pipeline/native.py +1137 -0
- agent/pipeline/nodes.py +3644 -0
- agent/pipeline/pricing.py +209 -0
- agent/pipeline/progress.py +139 -0
- agent/pipeline/rag.py +139 -0
- agent/pipeline/research.py +1325 -0
- agent/pipeline/run.py +528 -0
- agent/pipeline/screen.py +77 -0
- agent/pipeline/state.py +220 -0
- agent/pipeline/toolkit.py +328 -0
- agent/pipeline/tools.py +1990 -0
- agent/pipeline/tracing.py +147 -0
- agent/pipeline/usage.py +251 -0
- agent/pipeline/vision.py +84 -0
- agent/pipeline/walkthrough.py +735 -0
- agent/pipeline/workspace.py +229 -0
- agent/router/README.md +60 -0
- agent/router/__init__.py +0 -0
- agent/router/automap.py +114 -0
- agent/router/health.py +229 -0
- agent/router/llm_provider/README.md +38 -0
- agent/router/llm_provider/__init__.py +202 -0
- agent/router/llm_provider/anthropic_provider.py +128 -0
- agent/router/llm_provider/base.py +507 -0
- agent/router/llm_provider/custom.py +152 -0
- agent/router/llm_provider/gemini_provider.py +122 -0
- agent/router/llm_provider/inception_provider.py +687 -0
- agent/router/llm_provider/openai_provider.py +151 -0
- agent/router/llm_provider/retired.py +145 -0
- agent/router/llm_provider/temperature.py +371 -0
- agent/router/mapping.py +579 -0
- agent/router/outcomes.py +363 -0
- agent/router/overrides.py +389 -0
- agent/router/reload.py +28 -0
- agent/router/router.py +413 -0
- agent/router/setup.py +123 -0
- otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
- otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
- otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/pipeline/tools.py
ADDED
|
@@ -0,0 +1,1990 @@
|
|
|
1
|
+
"""The agent's tool registry, tiered by reversibility.
|
|
2
|
+
|
|
3
|
+
Every tool is registered with an explicit tier (READ_ONLY or MUTATING).
|
|
4
|
+
Every node in this graph (router excluded -- it only ever dispatches, see
|
|
5
|
+
nodes.py) may only ever be handed READ_ONLY tools, via TOOL_DISPATCH below:
|
|
6
|
+
a role node's or the evaluator's output has not been judged yet while it is
|
|
7
|
+
still iterating, and an irreversible action taken before judgment can't be
|
|
8
|
+
undone if the evaluator later rejects that attempt. Same reasoning the
|
|
9
|
+
retired swarm pipeline's identical module docstring gave for its workers.
|
|
10
|
+
|
|
11
|
+
Eleven tools exist (2026-09-10, replacing the swarm pipeline's execute_python
|
|
12
|
+
tool as this graph's whole tool box, per the router/planner/solver/
|
|
13
|
+
summarizer/finder/evaluator design):
|
|
14
|
+
|
|
15
|
+
execute_python -- real. Runs in the bound workspace if there is one,
|
|
16
|
+
otherwise in a throwaway temp dir as it always did.
|
|
17
|
+
execute_bash -- real: a generic shell command, same sandboxing bar as
|
|
18
|
+
execute_python (see its own docstring for the honest
|
|
19
|
+
limits of that bar), and the same workspace behaviour.
|
|
20
|
+
read_file -- real, WORKSPACE tier. Reads a file from the bound
|
|
21
|
+
list_files workspace; lists what is in it. Both READ_ONLY.
|
|
22
|
+
write_file -- real, WORKSPACE tier. Creates/overwrites a file, and
|
|
23
|
+
edit_file replaces an exact unique snippet in one. These are the
|
|
24
|
+
first tools in this repo that change anything on disk
|
|
25
|
+
the caller didn't hand them, which is why the tier
|
|
26
|
+
exists -- see WORKSPACE's own note below, and
|
|
27
|
+
agent/pipeline/workspace.py's module docstring for what
|
|
28
|
+
the confinement is and is not worth.
|
|
29
|
+
web_search -- STUBBED. No real search integration yet -- deliberate
|
|
30
|
+
scope cut (2026-09-10 design call: prove the graph
|
|
31
|
+
skeleton with Mercury-only routing and stub tools
|
|
32
|
+
first, add a real search API once that's working).
|
|
33
|
+
Always fails cleanly with a legible reason, so a
|
|
34
|
+
caller's tool-loop can fall back to its own knowledge
|
|
35
|
+
instead of crashing or silently returning nothing.
|
|
36
|
+
rag -- STUBBED, same reasoning as web_search -- no vector
|
|
37
|
+
store / knowledge base exists yet to query.
|
|
38
|
+
complete_code -- real, added later the same day once the graph skeleton
|
|
39
|
+
above was proven: Mercury's FIM endpoint
|
|
40
|
+
(Task.CODE_COMPLETE / mercury-edit-2, mapping.py),
|
|
41
|
+
unused until now because nothing in this graph had
|
|
42
|
+
called into it. Fills in code given what's already
|
|
43
|
+
written -- see its own docstring for its CODE: format
|
|
44
|
+
(an optional "---SUFFIX---" split).
|
|
45
|
+
predict_edit -- real, same day/reason as complete_code: Mercury's edit
|
|
46
|
+
endpoint (Task.CODE_EDIT / mercury-edit-2). Genuinely
|
|
47
|
+
different in kind from every other tool here -- it
|
|
48
|
+
takes NO instruction, only code (optionally with a
|
|
49
|
+
`<|cursor|>` marker) and predicts whatever edit comes
|
|
50
|
+
next from that alone. Good for "what's the obvious
|
|
51
|
+
next fix/continuation here", useless for "make this
|
|
52
|
+
specific change" -- see its own docstring.
|
|
53
|
+
recall_memory -- real, added later the same day, Phase 2 of
|
|
54
|
+
docs/design/tiered-memory.md: semantic search
|
|
55
|
+
(agent/memory/retrieval.py's recall()) over THIS
|
|
56
|
+
session's own compacted-away conversation history --
|
|
57
|
+
what a caller reaches for when something from earlier
|
|
58
|
+
in a long conversation got summarized away and the
|
|
59
|
+
summary alone isn't enough. Takes a plain query
|
|
60
|
+
string, nothing else. Reads whichever MemoryStore
|
|
61
|
+
agent/pipeline/run.py bound for this run
|
|
62
|
+
(agent/memory/session.py's `current_store()`) -- None
|
|
63
|
+
bound (a caller that never wired memory in at all,
|
|
64
|
+
e.g. agent/eval/'s golden runner) is not an error,
|
|
65
|
+
same "fail clean, not crash" shape as web_search/rag.
|
|
66
|
+
|
|
67
|
+
Both new tools go through the SAME `Router` instance's `.fim()`/
|
|
68
|
+
`.code_edit()` (agent/router/router.py) that already served CODE_COMPLETE/
|
|
69
|
+
CODE_EDIT before this graph existed -- lazily constructed (`_get_router()`
|
|
70
|
+
below) rather than at import time, unlike nodes.py's module-level `ROUTER`:
|
|
71
|
+
this module is also imported by agent/eval/runner.py for its offline,
|
|
72
|
+
zero-network golden-checker execution (execute_python), and that path must
|
|
73
|
+
keep working without INCEPTION_API_KEY set at all. A `ProviderError` from
|
|
74
|
+
either call (no key, no viable route, a live API failure) is caught and
|
|
75
|
+
turned into a normal failing ToolResult, exactly like web_search/rag's
|
|
76
|
+
stub failure -- never a crash that takes down the whole tool loop.
|
|
77
|
+
|
|
78
|
+
This is NOT a security sandbox -- it is a subprocess with a timeout, not a
|
|
79
|
+
seccomp/container jail. It has no network block and no memory/CPU limit
|
|
80
|
+
beyond the timeout. That is an acceptable bar for code/commands this
|
|
81
|
+
graph's own nodes generate to check their own work, not for untrusted
|
|
82
|
+
input. execute_bash widens that gap versus execute_python specifically: an
|
|
83
|
+
arbitrary shell command reaches any binary on PATH, not just the Python
|
|
84
|
+
interpreter. If a mutating tool (writing a real file, calling a paid API,
|
|
85
|
+
sending something) is ever added, it must be registered MUTATING here, and
|
|
86
|
+
nothing in a role/evaluator node's reach may call it.
|
|
87
|
+
"""
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import base64
|
|
91
|
+
import binascii
|
|
92
|
+
import json
|
|
93
|
+
import logging
|
|
94
|
+
import os
|
|
95
|
+
import re
|
|
96
|
+
import shlex
|
|
97
|
+
import subprocess
|
|
98
|
+
import sys
|
|
99
|
+
import tempfile
|
|
100
|
+
from contextlib import contextmanager
|
|
101
|
+
from dataclasses import dataclass
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
from typing import Callable, Iterator
|
|
104
|
+
|
|
105
|
+
from agent.memory.retrieval import (
|
|
106
|
+
EVICTED_KIND, HISTORY_KIND, NOTHING_COMPACTED, recall, recall_chunks,
|
|
107
|
+
)
|
|
108
|
+
from agent.memory.session import current_store
|
|
109
|
+
from agent.pipeline import codemap as _codemap
|
|
110
|
+
from agent.pipeline import browsing
|
|
111
|
+
from agent.pipeline import screen as screening
|
|
112
|
+
from agent.pipeline import walkthrough
|
|
113
|
+
from agent.pipeline.execution import current_command_runner
|
|
114
|
+
from agent.pipeline.vision import describe_image, sniff_media_type
|
|
115
|
+
from langchain_core.messages import HumanMessage
|
|
116
|
+
from agent.pipeline.workspace import (
|
|
117
|
+
OutsideWorkspace,
|
|
118
|
+
current_workspace,
|
|
119
|
+
resolve_in_workspace,
|
|
120
|
+
)
|
|
121
|
+
from agent.router.llm_provider.base import ProviderError
|
|
122
|
+
from agent.router.router import Router
|
|
123
|
+
|
|
124
|
+
logger = logging.getLogger(__name__)
|
|
125
|
+
|
|
126
|
+
READ_ONLY = "read_only"
|
|
127
|
+
MUTATING = "mutating"
|
|
128
|
+
#: Writes, but only ever inside the directory a caller deliberately bound as
|
|
129
|
+
#: this run's workspace (agent/pipeline/workspace.py), and never anywhere
|
|
130
|
+
#: else. Not READ_ONLY -- it changes real files, and pretending otherwise
|
|
131
|
+
#: would make the tier meaningless. Not MUTATING either, in the sense that
|
|
132
|
+
#: tier was defined for: what makes an irreversible action unsafe before the
|
|
133
|
+
#: evaluator has judged an attempt is that it cannot be taken back, and a
|
|
134
|
+
#: write into a workspace the caller created and will delete can be. A run
|
|
135
|
+
#: that binds no workspace -- every ordinary chat turn -- cannot reach these
|
|
136
|
+
#: at all, so this widens nothing for the case the tier system was protecting.
|
|
137
|
+
WORKSPACE = "workspace"
|
|
138
|
+
|
|
139
|
+
#: Tail length kept from stdout/stderr -- long enough to carry a real
|
|
140
|
+
#: traceback, short enough not to blow out a prompt on a runaway print loop.
|
|
141
|
+
_TAIL = 4000
|
|
142
|
+
|
|
143
|
+
#: How long a shell command or a Python snippet may run, in seconds.
|
|
144
|
+
#:
|
|
145
|
+
#: These used to be one hardcoded `timeout: float = 10.0` on execute_bash and
|
|
146
|
+
#: execute_python, and nothing could change it: nodes.py's loops call a tool as
|
|
147
|
+
#: `dispatch[name](body)`, one positional argument, so the keyword was
|
|
148
|
+
#: unreachable from every caller that actually exists. Ten seconds is right for
|
|
149
|
+
#: what those two were built for -- a node checking its own arithmetic in a
|
|
150
|
+
#: throwaway temp dir -- and wrong by an order of magnitude for the case a
|
|
151
|
+
#: workspace exists for. `pytest` on a real repository does not finish in ten
|
|
152
|
+
#: seconds, and what the agent reads back is not "this is slow", it is
|
|
153
|
+
#: `[timed out]` with a returncode of -1, which is indistinguishable from a
|
|
154
|
+
#: suite that hung. It then "fixes" a failure that never happened.
|
|
155
|
+
#:
|
|
156
|
+
#: So the default is a function of whether a workspace is bound, because that
|
|
157
|
+
#: is exactly the distinction: no workspace means a self-check, a workspace
|
|
158
|
+
#: means somebody's build. OTTO_COMMAND_TIMEOUT overrides both -- a monorepo
|
|
159
|
+
#: whose suite takes four minutes is a real case and not one to guess at.
|
|
160
|
+
THROWAWAY_TIMEOUT_S = 10.0
|
|
161
|
+
WORKSPACE_TIMEOUT_S = 120.0
|
|
162
|
+
COMMAND_TIMEOUT_ENV = "OTTO_COMMAND_TIMEOUT"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def default_timeout() -> float:
|
|
166
|
+
"""Seconds a command gets when its caller does not say. See the constants
|
|
167
|
+
above. A malformed or non-positive OTTO_COMMAND_TIMEOUT is logged and
|
|
168
|
+
ignored rather than raising -- a typo in an env var must not be the reason
|
|
169
|
+
a run dies, and silently treating "" or "abc" as zero would make every
|
|
170
|
+
command time out instantly."""
|
|
171
|
+
raw = os.environ.get(COMMAND_TIMEOUT_ENV, "").strip()
|
|
172
|
+
if raw:
|
|
173
|
+
try:
|
|
174
|
+
seconds = float(raw)
|
|
175
|
+
except ValueError:
|
|
176
|
+
logger.warning("%s=%r is not a number; ignoring it", COMMAND_TIMEOUT_ENV, raw)
|
|
177
|
+
else:
|
|
178
|
+
if seconds > 0:
|
|
179
|
+
return seconds
|
|
180
|
+
logger.warning("%s=%r is not positive; ignoring it", COMMAND_TIMEOUT_ENV, raw)
|
|
181
|
+
return WORKSPACE_TIMEOUT_S if current_workspace() is not None else THROWAWAY_TIMEOUT_S
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class ToolResult:
|
|
186
|
+
stdout: str
|
|
187
|
+
stderr: str
|
|
188
|
+
returncode: int
|
|
189
|
+
timed_out: bool = False
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def ok(self) -> bool:
|
|
193
|
+
return self.returncode == 0 and not self.timed_out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def execute_python(code: str, *, timeout: float | None = None) -> ToolResult:
|
|
197
|
+
"""Run `code` as a standalone script in a fresh process, in the bound
|
|
198
|
+
workspace if there is one and otherwise in a fresh throwaway temp dir.
|
|
199
|
+
|
|
200
|
+
Used by every role node/the evaluator as their ACTION/execute_python
|
|
201
|
+
self-check before committing to a FINAL answer or a verdict (nodes.py's
|
|
202
|
+
_tool_loop) -- the same role this had in the retired swarm pipeline,
|
|
203
|
+
just no longer also doubling as evaluate()'s only verification strategy
|
|
204
|
+
(that domain-specific branch is gone; the evaluator now checks things
|
|
205
|
+
for real via this same tool instead of a bespoke code path).
|
|
206
|
+
|
|
207
|
+
`timeout=None` means "whatever default_timeout() says for this run" --
|
|
208
|
+
which is what every real caller gets, since nodes.py's loops invoke a tool
|
|
209
|
+
as `dispatch[name](body)` with no keywords at all.
|
|
210
|
+
"""
|
|
211
|
+
timeout = default_timeout() if timeout is None else timeout
|
|
212
|
+
remote = current_command_runner()
|
|
213
|
+
if remote is not None:
|
|
214
|
+
# base64 rather than a heredoc: a heredoc is only safe until the
|
|
215
|
+
# snippet contains a line equal to the delimiter, and the snippet is
|
|
216
|
+
# model-written text that nothing constrains.
|
|
217
|
+
stdout, stderr, code = remote(
|
|
218
|
+
f"echo {_b64(code)} | base64 -d | python3 -", timeout,
|
|
219
|
+
)
|
|
220
|
+
return ToolResult(
|
|
221
|
+
stdout=_clip(stdout), stderr=_clip(stderr),
|
|
222
|
+
returncode=code, timed_out=(code == -1),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
with _run_dir() as tmp, tempfile.TemporaryDirectory() as holder:
|
|
226
|
+
# The script itself lives OUTSIDE the run dir, always. When the run dir
|
|
227
|
+
# is a throwaway temp dir it makes no difference, but when it is a real
|
|
228
|
+
# workspace, dropping a snippet.py into the repo the agent is editing
|
|
229
|
+
# would show up in `git status` and in its own next listing -- a file
|
|
230
|
+
# nobody wrote, indistinguishable from one the task asked for.
|
|
231
|
+
script = Path(holder) / "snippet.py"
|
|
232
|
+
script.write_text(utf8_clean(code), encoding="utf-8")
|
|
233
|
+
try:
|
|
234
|
+
# errors="replace" on the way back: a script that prints half of
|
|
235
|
+
# a multi-byte character (a `head -c`, a slice of a UTF-8 string
|
|
236
|
+
# handled as bytes) must come back as text with a `?` in it, not
|
|
237
|
+
# as a UnicodeDecodeError that kills the turn.
|
|
238
|
+
proc = subprocess.run(
|
|
239
|
+
[sys.executable, str(script)],
|
|
240
|
+
cwd=tmp,
|
|
241
|
+
env=_env_with_pythonpath(tmp),
|
|
242
|
+
capture_output=True,
|
|
243
|
+
text=True,
|
|
244
|
+
errors="replace",
|
|
245
|
+
timeout=timeout,
|
|
246
|
+
)
|
|
247
|
+
return ToolResult(
|
|
248
|
+
stdout=_clip(proc.stdout),
|
|
249
|
+
stderr=_clip(proc.stderr),
|
|
250
|
+
returncode=proc.returncode,
|
|
251
|
+
)
|
|
252
|
+
except subprocess.TimeoutExpired as exc:
|
|
253
|
+
return ToolResult(
|
|
254
|
+
stdout=_clip(_as_text(exc.stdout)),
|
|
255
|
+
stderr=_clip(_as_text(exc.stderr) + "\n[timed out]"),
|
|
256
|
+
returncode=-1,
|
|
257
|
+
timed_out=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _as_text(stream: "str | bytes | None") -> str:
|
|
262
|
+
"""A TimeoutExpired's captured output as text.
|
|
263
|
+
|
|
264
|
+
subprocess.run(text=True) decodes what it returns normally, but the output
|
|
265
|
+
hung off a TimeoutExpired can still be bytes -- so the timeout branch,
|
|
266
|
+
which is exactly the branch nobody exercises until a real command hangs,
|
|
267
|
+
raised TypeError instead of reporting the timeout. Found when a benchmark
|
|
268
|
+
task ran a command long enough to hit it.
|
|
269
|
+
"""
|
|
270
|
+
if stream is None:
|
|
271
|
+
return ""
|
|
272
|
+
return stream.decode(errors="replace") if isinstance(stream, bytes) else stream
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _env_with_pythonpath(run_dir: str) -> dict[str, str]:
|
|
276
|
+
"""The child's environment with `run_dir` prepended to PYTHONPATH.
|
|
277
|
+
|
|
278
|
+
Needed because the script itself lives outside the run dir (see
|
|
279
|
+
execute_python), and Python seeds sys.path from the SCRIPT's directory,
|
|
280
|
+
not from cwd -- so without this a snippet could no longer `import` a
|
|
281
|
+
module sitting in the workspace next to it, which is most of the reason
|
|
282
|
+
to run Python in a workspace at all.
|
|
283
|
+
"""
|
|
284
|
+
env = dict(os.environ)
|
|
285
|
+
existing = env.get("PYTHONPATH")
|
|
286
|
+
env["PYTHONPATH"] = f"{run_dir}{os.pathsep}{existing}" if existing else run_dir
|
|
287
|
+
return env
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@contextmanager
|
|
291
|
+
def _run_dir() -> Iterator[str]:
|
|
292
|
+
"""Where execute_bash/execute_python actually run: the bound workspace if
|
|
293
|
+
there is one, otherwise a fresh temp dir deleted on the way out.
|
|
294
|
+
|
|
295
|
+
The temp dir is the old behaviour and stays the default, because it is the
|
|
296
|
+
right one for what these two were built for -- a node checking its own
|
|
297
|
+
snippet, leaving nothing behind. A workspace is the opposite case on
|
|
298
|
+
purpose: a task that edits files needs the NEXT command to see what the
|
|
299
|
+
last one did, which a per-call temp dir can never provide.
|
|
300
|
+
"""
|
|
301
|
+
workspace = current_workspace()
|
|
302
|
+
if workspace is not None:
|
|
303
|
+
yield str(workspace)
|
|
304
|
+
return
|
|
305
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
306
|
+
yield tmp
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
#: Shell constructs that detach a command from the call that started it.
|
|
310
|
+
#: `&` at the end of a line backgrounds it; nohup/setsid/disown outlive the
|
|
311
|
+
#: shell entirely.
|
|
312
|
+
_DETACHING = re.compile(
|
|
313
|
+
r"(?:^|\s)(?:nohup|setsid|disown)(?:\s|$)" # explicitly detaching
|
|
314
|
+
r"|(?<!&)&\s*$" # trailing & on the command
|
|
315
|
+
r"|(?<!&)&\s*\n", # trailing & on any line
|
|
316
|
+
re.MULTILINE,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
BACKGROUNDING_REFUSED = (
|
|
320
|
+
"execute_bash: this command backgrounds or detaches part of its work "
|
|
321
|
+
"({why}), so it would return before that work has done anything and you "
|
|
322
|
+
"would be told it succeeded without ever seeing what happened. Run it in "
|
|
323
|
+
"the foreground instead, so its real output comes back to you. If it is "
|
|
324
|
+
"something long-running that never exits on its own, run it with a "
|
|
325
|
+
"timeout or a bounded amount of work (for example `timeout 10 ...`) so it "
|
|
326
|
+
"still returns something you can read."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _detaching_reason(command: str) -> str | None:
|
|
331
|
+
"""Why `command` would run detached, or None if it runs to completion.
|
|
332
|
+
|
|
333
|
+
Every tool call in this graph is synchronous by design (agent/pipeline/
|
|
334
|
+
nodes.py's _tool_loop runs exactly one, waits for it, and feeds the result
|
|
335
|
+
back), and that is the property the whole loop depends on: the model
|
|
336
|
+
decides what to do next from what the last thing actually printed. A
|
|
337
|
+
backgrounded command breaks it silently -- the call returns instantly with
|
|
338
|
+
an empty stdout and exit 0, which reads as "it worked" for work that has
|
|
339
|
+
not started. So this is refused rather than quietly allowed.
|
|
340
|
+
"""
|
|
341
|
+
match = _DETACHING.search(command)
|
|
342
|
+
if not match:
|
|
343
|
+
return None
|
|
344
|
+
token = match.group(0).strip()
|
|
345
|
+
return "nohup/setsid/disown" if token.isalpha() else "a trailing &"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def execute_bash(command: str, *, timeout: float | None = None) -> ToolResult:
|
|
349
|
+
"""Run `command` as a shell command, in the bound workspace if there is
|
|
350
|
+
one and otherwise in a fresh throwaway temp dir.
|
|
351
|
+
|
|
352
|
+
Same timeout/capture-output shape as execute_python, but `shell=True` over
|
|
353
|
+
the raw command text rather than a Python script -- see the module
|
|
354
|
+
docstring's note on the wider threat-model surface this implies (any
|
|
355
|
+
binary on PATH, not just the Python interpreter), and workspace.py's own
|
|
356
|
+
docstring for why a shell in a workspace is a blast-radius argument
|
|
357
|
+
rather than a sandbox.
|
|
358
|
+
|
|
359
|
+
`timeout=None` means "whatever default_timeout() says for this run", which
|
|
360
|
+
is 10s with no workspace and 120s with one -- see default_timeout() for
|
|
361
|
+
why a build being cut off at ten seconds is worse than it sounds.
|
|
362
|
+
"""
|
|
363
|
+
if (why := _detaching_reason(command)) is not None:
|
|
364
|
+
return ToolResult(
|
|
365
|
+
stdout="", stderr=BACKGROUNDING_REFUSED.format(why=why), returncode=1,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
timeout = default_timeout() if timeout is None else timeout
|
|
369
|
+
remote = current_command_runner()
|
|
370
|
+
if remote is not None:
|
|
371
|
+
stdout, stderr, code = remote(command, timeout)
|
|
372
|
+
return ToolResult(
|
|
373
|
+
stdout=_clip(stdout), stderr=_clip(stderr),
|
|
374
|
+
returncode=code, timed_out=(code == -1),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
with _run_dir() as tmp:
|
|
378
|
+
try:
|
|
379
|
+
# errors="replace": a command whose output cuts a multi-byte
|
|
380
|
+
# character in half (`head -c 4` on a file of chess glyphs, which
|
|
381
|
+
# is how this was found) is a command that printed something, and
|
|
382
|
+
# a `?` in its output is what it printed. Strict decoding raised
|
|
383
|
+
# instead, and a UnicodeDecodeError is not something a tool loop
|
|
384
|
+
# catches -- the whole turn died on it.
|
|
385
|
+
proc = subprocess.run(
|
|
386
|
+
utf8_clean(command),
|
|
387
|
+
shell=True,
|
|
388
|
+
cwd=tmp,
|
|
389
|
+
capture_output=True,
|
|
390
|
+
text=True,
|
|
391
|
+
errors="replace",
|
|
392
|
+
timeout=timeout,
|
|
393
|
+
)
|
|
394
|
+
return ToolResult(
|
|
395
|
+
stdout=_clip(proc.stdout),
|
|
396
|
+
stderr=_clip(proc.stderr),
|
|
397
|
+
returncode=proc.returncode,
|
|
398
|
+
)
|
|
399
|
+
except subprocess.TimeoutExpired as exc:
|
|
400
|
+
return ToolResult(
|
|
401
|
+
stdout=_clip(_as_text(exc.stdout)),
|
|
402
|
+
stderr=_clip(_as_text(exc.stderr) + "\n[timed out]"),
|
|
403
|
+
returncode=-1,
|
|
404
|
+
timed_out=True,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _clip(text: str) -> str:
|
|
409
|
+
"""`text` bounded to _TAIL, keeping BOTH ends when it has to cut.
|
|
410
|
+
|
|
411
|
+
It used to keep only the tail, which throws away the most useful part of
|
|
412
|
+
exactly the outputs that overflow: a compiler prints its first and most
|
|
413
|
+
informative error at the top and then cascades, a test run names the
|
|
414
|
+
failure before the summary, and a long `find` says what it is doing before
|
|
415
|
+
it says how it ended. Losing the head means the model is told what
|
|
416
|
+
happened last instead of what went wrong first.
|
|
417
|
+
"""
|
|
418
|
+
if len(text) <= _TAIL:
|
|
419
|
+
return text
|
|
420
|
+
head, tail = _TAIL // 2, _TAIL - _TAIL // 2
|
|
421
|
+
cut = len(text) - head - tail
|
|
422
|
+
return f"{text[:head]}\n\n[... {cut} characters omitted ...]\n\n{text[-tail:]}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
#: Longest file listing / read this returns before truncating -- the same
|
|
426
|
+
#: "don't blow the caller's context" bar _TAIL sets for command output.
|
|
427
|
+
_MAX_LIST_ENTRIES = 400
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _workspace_failure(tool: str, detail: str) -> ToolResult:
|
|
431
|
+
"""The refusal every workspace tool gives when there is no workspace to
|
|
432
|
+
act in -- an ordinary `otto chat` turn, or any caller that never opened
|
|
433
|
+
one. A failed ToolResult, never a raise: same "fail clean" shape as
|
|
434
|
+
web_search/rag/recall_memory, so a tool loop reads it as a normal
|
|
435
|
+
unsuccessful call and can say so instead of crashing."""
|
|
436
|
+
return ToolResult(stdout="", stderr=f"{tool}: {detail}", returncode=1)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _remote_paths_are_the_containers_own(tool: str, path: str) -> ToolResult | None:
|
|
440
|
+
"""Reject an empty path for a remote (container) file operation.
|
|
441
|
+
|
|
442
|
+
Deliberately the ONLY check in remote mode, where the local
|
|
443
|
+
resolve_in_workspace() confinement does not apply and should not be
|
|
444
|
+
imitated: the container IS the sandbox, its whole filesystem is the task's
|
|
445
|
+
subject, and a task that says "fix /etc/nginx/nginx.conf" means exactly
|
|
446
|
+
that. Confining to a subdirectory there would break real tasks while
|
|
447
|
+
protecting nothing the container boundary does not already protect.
|
|
448
|
+
"""
|
|
449
|
+
if not path.strip():
|
|
450
|
+
return _workspace_failure(tool, "the path must not be empty")
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _b64(text: str) -> str:
|
|
455
|
+
"""Text as base64, for shipping into a container through a shell command
|
|
456
|
+
without a quoting story -- content with quotes, backslashes, newlines or a
|
|
457
|
+
line that happens to read `OTTO_EOF` all survive unchanged."""
|
|
458
|
+
return base64.b64encode(utf8_clean(text).encode()).decode()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def utf8_clean(text: str) -> str:
|
|
462
|
+
"""`text` as something UTF-8 can actually carry.
|
|
463
|
+
|
|
464
|
+
A model's reply arrives through JSON, and JSON can hold a lone surrogate
|
|
465
|
+
(`\\udce2`) that no encoder will write: `Path.write_text` raises
|
|
466
|
+
UnicodeEncodeError on it, and that is not an OSError, so it unwound the
|
|
467
|
+
whole turn -- the person saw a codec traceback instead of a failed tool
|
|
468
|
+
call. Seen live, alongside the decode half (`execute_bash` below), on a
|
|
469
|
+
task whose file was full of three-byte chess glyphs. Replacing the
|
|
470
|
+
character is the honest fix: the model wrote something unrepresentable,
|
|
471
|
+
and a `?` where it stood is what every other tool would have shown.
|
|
472
|
+
"""
|
|
473
|
+
try:
|
|
474
|
+
text.encode("utf-8")
|
|
475
|
+
except UnicodeEncodeError:
|
|
476
|
+
return text.encode("utf-8", "replace").decode("utf-8")
|
|
477
|
+
return text
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def read_file(body: str) -> ToolResult:
|
|
481
|
+
"""Read a file from the bound workspace. CODE: body is the path, alone,
|
|
482
|
+
optionally suffixed `:START-END` for a 1-based inclusive line range
|
|
483
|
+
("src/app.py:40-80") -- worth having because the whole point of reading
|
|
484
|
+
is to spend context on the part that matters.
|
|
485
|
+
|
|
486
|
+
Output is line-numbered, which costs a few characters and buys the two
|
|
487
|
+
things that follow a read: quoting a location back, and knowing which
|
|
488
|
+
lines an edit is actually replacing.
|
|
489
|
+
"""
|
|
490
|
+
spec = body.strip()
|
|
491
|
+
line_range = None
|
|
492
|
+
if ":" in spec:
|
|
493
|
+
head, _, tail = spec.rpartition(":")
|
|
494
|
+
if head and re.fullmatch(r"\d+-\d+", tail):
|
|
495
|
+
spec, line_range = head, tuple(int(n) for n in tail.split("-"))
|
|
496
|
+
remote = current_command_runner()
|
|
497
|
+
if remote is not None:
|
|
498
|
+
if (bad := _remote_paths_are_the_containers_own("read_file", spec)) is not None:
|
|
499
|
+
return bad
|
|
500
|
+
sed = f"sed -n '{line_range[0]},{line_range[1]}p'" if line_range else "cat"
|
|
501
|
+
start = line_range[0] if line_range else 1
|
|
502
|
+
stdout, stderr, code = remote(
|
|
503
|
+
f"{sed} {shlex.quote(spec)} | nl -ba -v {start} -w6 -s'\t'", 20.0,
|
|
504
|
+
)
|
|
505
|
+
if code != 0:
|
|
506
|
+
return _workspace_failure("read_file", stderr.strip() or f"could not read {spec!r}")
|
|
507
|
+
return ToolResult(stdout=_clip(stdout), stderr="", returncode=0)
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
path = resolve_in_workspace(spec)
|
|
511
|
+
except OutsideWorkspace as exc:
|
|
512
|
+
return _workspace_failure("read_file", str(exc))
|
|
513
|
+
if not path.is_file():
|
|
514
|
+
return _workspace_failure("read_file", f"{spec!r} is not a file in the workspace")
|
|
515
|
+
# Signpost, not a silent fallback. Reading an image as text returns pages
|
|
516
|
+
# of line-numbered mojibake -- it burns context and teaches the agent
|
|
517
|
+
# nothing, least of all that a tool exists for this. Naming that tool at
|
|
518
|
+
# the moment it is needed is what makes it discoverable, and it costs no
|
|
519
|
+
# network call.
|
|
520
|
+
if (kind := sniff_media_type(path.read_bytes()[:16])) is not None:
|
|
521
|
+
return _workspace_failure(
|
|
522
|
+
"read_file",
|
|
523
|
+
f"{spec!r} is a {kind} image, not text -- use view_image to look at it",
|
|
524
|
+
)
|
|
525
|
+
try:
|
|
526
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
527
|
+
except OSError as exc:
|
|
528
|
+
return _workspace_failure("read_file", f"could not read {spec!r}: {exc}")
|
|
529
|
+
|
|
530
|
+
start, end = (1, len(lines)) if line_range is None else line_range
|
|
531
|
+
start, end = max(1, start), min(len(lines), end)
|
|
532
|
+
numbered = "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1))
|
|
533
|
+
return ToolResult(stdout=_clip(numbered), stderr="", returncode=0)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _split_write_body(body: str) -> tuple[str, str]:
|
|
537
|
+
"""(path, content) from a write_file body.
|
|
538
|
+
|
|
539
|
+
The documented form is the path on the first line and the content after
|
|
540
|
+
it. A JSON object with "path"/"content" keys is also accepted, because
|
|
541
|
+
that is what a model actually reaches for when it has to put two values
|
|
542
|
+
in one string: observed in a Terminal-Bench transcript, where a JSON body
|
|
543
|
+
was taken literally and created a file named `{`. The prompts now spell
|
|
544
|
+
the real format out, so this is a safety net rather than a second
|
|
545
|
+
supported format -- accepting it costs nothing and the alternative is
|
|
546
|
+
writing rubbish to a plausible-looking path.
|
|
547
|
+
"""
|
|
548
|
+
stripped = body.strip()
|
|
549
|
+
if stripped.startswith("{"):
|
|
550
|
+
try:
|
|
551
|
+
parsed = json.loads(stripped)
|
|
552
|
+
except ValueError:
|
|
553
|
+
parsed = None
|
|
554
|
+
if isinstance(parsed, dict) and "path" in parsed and "content" in parsed:
|
|
555
|
+
return str(parsed["path"]), str(parsed["content"])
|
|
556
|
+
head, _, content = body.partition("\n")
|
|
557
|
+
return head, content
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def write_file(body: str) -> ToolResult:
|
|
561
|
+
"""Create or overwrite a file in the bound workspace. CODE: body is the
|
|
562
|
+
path on its OWN FIRST LINE, and everything after that first newline is
|
|
563
|
+
the file's content, verbatim.
|
|
564
|
+
|
|
565
|
+
No separator line between the two, deliberately: any delimiter that could
|
|
566
|
+
be typed is a delimiter that can appear in real file content, and a
|
|
567
|
+
write that silently truncates at a `---` inside a Markdown document or a
|
|
568
|
+
Python docstring is a far worse failure than a slightly plainer format.
|
|
569
|
+
Missing parent directories are created -- a model that writes
|
|
570
|
+
"pkg/mod/x.py" means for it to exist.
|
|
571
|
+
"""
|
|
572
|
+
head, content = _split_write_body(body)
|
|
573
|
+
remote = current_command_runner()
|
|
574
|
+
if remote is not None:
|
|
575
|
+
if (bad := _remote_paths_are_the_containers_own("write_file", head)) is not None:
|
|
576
|
+
return bad
|
|
577
|
+
target = shlex.quote(head.strip())
|
|
578
|
+
stdout, stderr, code = remote(
|
|
579
|
+
f"mkdir -p \"$(dirname {target})\" && "
|
|
580
|
+
f"echo {_b64(content)} | base64 -d > {target}", 30.0,
|
|
581
|
+
)
|
|
582
|
+
if code != 0:
|
|
583
|
+
return _workspace_failure("write_file", stderr.strip() or f"could not write {head.strip()!r}")
|
|
584
|
+
return ToolResult(
|
|
585
|
+
stdout=f"wrote {head.strip()} ({len(content.splitlines())} lines)",
|
|
586
|
+
stderr="", returncode=0,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
path = resolve_in_workspace(head)
|
|
591
|
+
except OutsideWorkspace as exc:
|
|
592
|
+
return _workspace_failure("write_file", str(exc))
|
|
593
|
+
if not head.strip():
|
|
594
|
+
return _workspace_failure("write_file", "first line must be the file path")
|
|
595
|
+
try:
|
|
596
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
597
|
+
path.write_text(utf8_clean(content), encoding="utf-8")
|
|
598
|
+
except OSError as exc:
|
|
599
|
+
return _workspace_failure("write_file", f"could not write {head.strip()!r}: {exc}")
|
|
600
|
+
return ToolResult(
|
|
601
|
+
stdout=f"wrote {head.strip()} ({len(content.splitlines())} lines)",
|
|
602
|
+
stderr="", returncode=0,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
#: How `edit_file` locates the text to replace, in order. The first pass that
|
|
607
|
+
#: finds the snippet EXACTLY ONCE wins.
|
|
608
|
+
#:
|
|
609
|
+
#: Exact match alone was the whole implementation, and it is the single
|
|
610
|
+
#: cheapest thing to fix in a coding agent: models quote `old_text` with
|
|
611
|
+
#: whitespace drift -- a tab that became spaces, a trailing space that did not
|
|
612
|
+
#: survive the read, an indentation level lost when the snippet was echoed back.
|
|
613
|
+
#: The answer the field converged on is a cascade of progressively looser
|
|
614
|
+
#: matches rather than a smarter model.
|
|
615
|
+
#:
|
|
616
|
+
#: Every pass still requires a UNIQUE hit. A looser match that finds two
|
|
617
|
+
#: candidates is not a licence to pick one -- which of them was meant is
|
|
618
|
+
#: genuinely unknown, and guessing writes the edit into the wrong place, which
|
|
619
|
+
#: is worse than refusing. Looseness buys tolerance of how the text was quoted,
|
|
620
|
+
#: never tolerance of ambiguity about where it goes.
|
|
621
|
+
def _match_exact(haystack: str, needle: str) -> list[int]:
|
|
622
|
+
out, i = [], haystack.find(needle)
|
|
623
|
+
while i != -1:
|
|
624
|
+
out.append(i)
|
|
625
|
+
i = haystack.find(needle, i + 1)
|
|
626
|
+
return out
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _trailing_space_insensitive(haystack: str, needle: str) -> list[tuple[int, int]]:
|
|
630
|
+
"""Match ignoring trailing whitespace on every line -- the most common
|
|
631
|
+
drift, and invisible in a diff."""
|
|
632
|
+
pattern = r"[ \t]*\n".join(
|
|
633
|
+
re.escape(line.rstrip()) for line in needle.split("\n")
|
|
634
|
+
)
|
|
635
|
+
return [(m.start(), m.end()) for m in re.finditer(pattern, haystack)]
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _indent_insensitive(haystack: str, needle: str) -> list[tuple[int, int]]:
|
|
639
|
+
"""Match ignoring how much each line is indented, but not the text itself.
|
|
640
|
+
Catches a snippet re-quoted at a different nesting level."""
|
|
641
|
+
lines = [line.strip() for line in needle.split("\n")]
|
|
642
|
+
if not any(lines):
|
|
643
|
+
return []
|
|
644
|
+
pattern = r"\n[ \t]*".join(re.escape(line) for line in lines)
|
|
645
|
+
pattern = r"[ \t]*" + pattern
|
|
646
|
+
return [(m.start(), m.end()) for m in re.finditer(pattern, haystack)]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _anchored(haystack: str, needle: str) -> list[tuple[int, int]]:
|
|
650
|
+
"""Match on the first and last lines only, taking everything between them.
|
|
651
|
+
|
|
652
|
+
For a snippet whose middle was elided or misremembered. Deliberately last
|
|
653
|
+
and deliberately narrow: it needs at least three lines, both anchors must
|
|
654
|
+
be unique on their own, and the span it takes is whatever lies between --
|
|
655
|
+
so a wrong anchor pair is a visibly wrong edit rather than a subtle one.
|
|
656
|
+
"""
|
|
657
|
+
lines = [line for line in needle.split("\n") if line.strip()]
|
|
658
|
+
if len(lines) < 3:
|
|
659
|
+
return []
|
|
660
|
+
head, tail = lines[0].strip(), lines[-1].strip()
|
|
661
|
+
heads = _match_exact(haystack, head)
|
|
662
|
+
tails = _match_exact(haystack, tail)
|
|
663
|
+
if len(heads) != 1 or len(tails) != 1 or tails[0] < heads[0]:
|
|
664
|
+
return []
|
|
665
|
+
return [(heads[0], tails[0] + len(tail))]
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
#: (name shown to the model, finder). Order is the cascade.
|
|
669
|
+
_EDIT_PASSES = (
|
|
670
|
+
("exact", lambda h, n: [(i, i + len(n)) for i in _match_exact(h, n)]),
|
|
671
|
+
("ignoring trailing whitespace", _trailing_space_insensitive),
|
|
672
|
+
("ignoring indentation", _indent_insensitive),
|
|
673
|
+
("anchored on its first and last lines", _anchored),
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _locate(original: str, old_text: str) -> tuple[int, int, str] | str:
|
|
678
|
+
"""Where `old_text` sits in `original`, or why it could not be placed.
|
|
679
|
+
|
|
680
|
+
Returns `(start, end, how)` or an explanatory string for the caller to
|
|
681
|
+
hand back as a failed ToolResult.
|
|
682
|
+
"""
|
|
683
|
+
for how, find in _EDIT_PASSES:
|
|
684
|
+
spans = find(original, old_text)
|
|
685
|
+
if len(spans) == 1:
|
|
686
|
+
return spans[0][0], spans[0][1], how
|
|
687
|
+
if len(spans) > 1:
|
|
688
|
+
return (
|
|
689
|
+
f"the ---OLD--- text appears {len(spans)} times ({how}) -- "
|
|
690
|
+
"it must appear exactly once; quote more surrounding lines to "
|
|
691
|
+
"make it unique"
|
|
692
|
+
)
|
|
693
|
+
return (
|
|
694
|
+
"the ---OLD--- text never appears, even ignoring whitespace and "
|
|
695
|
+
"indentation -- read the file and quote the lines you actually see"
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
#: Ceiling on a file edited inside a container, since editing it means moving
|
|
700
|
+
#: it out and back. Generous for source; a refusal names the size, so a caller
|
|
701
|
+
#: that meant to edit a 50MB log learns why rather than waiting.
|
|
702
|
+
MAX_EDIT_BYTES = 4 * 1024 * 1024
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def edit_file(body: str) -> ToolResult:
|
|
706
|
+
"""Replace an exact snippet in a workspace file. CODE: body is
|
|
707
|
+
|
|
708
|
+
path/to/file.py
|
|
709
|
+
---OLD---
|
|
710
|
+
the exact text to replace
|
|
711
|
+
---NEW---
|
|
712
|
+
what to replace it with
|
|
713
|
+
|
|
714
|
+
The old text must appear EXACTLY ONCE. Not zero times (the model is
|
|
715
|
+
editing something it misremembers, and a silently-skipped edit is the
|
|
716
|
+
kind of failure that surfaces later as an inexplicable test result), and
|
|
717
|
+
not several times (which of them was meant is genuinely unknown, and
|
|
718
|
+
guessing is worse than saying so). Both cases come back as a failed
|
|
719
|
+
ToolResult naming the count, which is a thing a tool loop can act on --
|
|
720
|
+
read the file again, quote more surrounding context, retry.
|
|
721
|
+
|
|
722
|
+
This exists alongside write_file because rewriting a whole file to change
|
|
723
|
+
three lines is how an agent destroys the parts of it nobody asked about.
|
|
724
|
+
"""
|
|
725
|
+
head, _, rest = body.partition("\n")
|
|
726
|
+
remote = current_command_runner()
|
|
727
|
+
if remote is not None:
|
|
728
|
+
return _remote_edit(remote, head, rest)
|
|
729
|
+
|
|
730
|
+
# Workspace first, body format second: "there is nowhere to write" is the
|
|
731
|
+
# more fundamental refusal, and reporting a format complaint to a caller
|
|
732
|
+
# that could never have written anything anyway just misdirects it.
|
|
733
|
+
try:
|
|
734
|
+
path = resolve_in_workspace(head)
|
|
735
|
+
except OutsideWorkspace as exc:
|
|
736
|
+
return _workspace_failure("edit_file", str(exc))
|
|
737
|
+
if "---OLD---" not in rest or "---NEW---" not in rest:
|
|
738
|
+
return _workspace_failure(
|
|
739
|
+
"edit_file", "body must be: path, then ---OLD---, then ---NEW---",
|
|
740
|
+
)
|
|
741
|
+
old_part, _, new_part = rest.partition("---NEW---")
|
|
742
|
+
old_text = old_part.split("---OLD---", 1)[1].strip("\n")
|
|
743
|
+
new_text = new_part.strip("\n")
|
|
744
|
+
if not path.is_file():
|
|
745
|
+
return _workspace_failure("edit_file", f"{head.strip()!r} is not a file in the workspace")
|
|
746
|
+
|
|
747
|
+
original = path.read_text(encoding="utf-8", errors="replace")
|
|
748
|
+
located = _locate(original, old_text)
|
|
749
|
+
if isinstance(located, str):
|
|
750
|
+
return _workspace_failure("edit_file", f"{located} (in {head.strip()})")
|
|
751
|
+
start, end, how = located
|
|
752
|
+
path.write_text(utf8_clean(original[:start] + new_text + original[end:]), encoding="utf-8")
|
|
753
|
+
# Naming the pass that matched is not decoration: it tells the model its
|
|
754
|
+
# quote was off, and how, so the next one is closer.
|
|
755
|
+
note = "" if how == "exact" else f" (matched {how})"
|
|
756
|
+
return ToolResult(stdout=f"edited {head.strip()}{note}", stderr="", returncode=0)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
#: The exact-once replacement edit_file performs, as a script to run inside a
|
|
760
|
+
#: container. Same contract as the local branch -- refuse at zero matches and
|
|
761
|
+
#: refuse at several, rather than guessing -- expressed once here so the two
|
|
762
|
+
#: modes can't drift into disagreeing about what an edit means. Both texts
|
|
763
|
+
#: arrive base64-encoded, so no quoting of the model's content is involved.
|
|
764
|
+
#: Read a file out of the container, and write one back. Two halves of the
|
|
765
|
+
#: same trip, because the MATCHING happens here rather than in there.
|
|
766
|
+
#:
|
|
767
|
+
#: It used to happen in there, as a second copy of the exact-match logic. That
|
|
768
|
+
#: copy would now have to grow its own cascade and stay in step with this one
|
|
769
|
+
#: forever -- which is exactly how `_tool_loop` and `_agent_loop` came to
|
|
770
|
+
#: differ on the single line that mattered. One implementation, reached twice.
|
|
771
|
+
_REMOTE_READ_SCRIPT = """
|
|
772
|
+
import base64, sys
|
|
773
|
+
try:
|
|
774
|
+
data = open(sys.argv[1], 'rb').read()
|
|
775
|
+
except OSError as exc:
|
|
776
|
+
print(f"cannot read {sys.argv[1]}: {exc}", file=sys.stderr); sys.exit(2)
|
|
777
|
+
if len(data) > {cap}:
|
|
778
|
+
print(f"{sys.argv[1]} is {len(data)} bytes, too large to edit this way",
|
|
779
|
+
file=sys.stderr); sys.exit(4)
|
|
780
|
+
print(base64.b64encode(data).decode())
|
|
781
|
+
"""
|
|
782
|
+
|
|
783
|
+
_REMOTE_WRITE_SCRIPT = """
|
|
784
|
+
import base64, sys
|
|
785
|
+
open(sys.argv[1], 'wb').write(base64.b64decode(sys.argv[2]))
|
|
786
|
+
print(f"edited {sys.argv[1]}")
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _remote_edit(remote, head: str, rest: str) -> ToolResult:
|
|
791
|
+
"""edit_file against a container, via the bound command runner."""
|
|
792
|
+
if (bad := _remote_paths_are_the_containers_own("edit_file", head)) is not None:
|
|
793
|
+
return bad
|
|
794
|
+
if "---OLD---" not in rest or "---NEW---" not in rest:
|
|
795
|
+
return _workspace_failure(
|
|
796
|
+
"edit_file", "body must be: path, then ---OLD---, then ---NEW---",
|
|
797
|
+
)
|
|
798
|
+
old_part, _, new_part = rest.partition("---NEW---")
|
|
799
|
+
old_text = old_part.split("---OLD---", 1)[1].strip("\n")
|
|
800
|
+
new_text = new_part.strip("\n")
|
|
801
|
+
path = head.strip()
|
|
802
|
+
|
|
803
|
+
script = _REMOTE_READ_SCRIPT.replace("{cap}", str(MAX_EDIT_BYTES))
|
|
804
|
+
stdout, stderr, code = remote(
|
|
805
|
+
f"python3 -c {shlex.quote(script)} {shlex.quote(path)}", 30.0,
|
|
806
|
+
)
|
|
807
|
+
if code != 0:
|
|
808
|
+
return _workspace_failure("edit_file", stderr.strip() or f"cannot read {path!r}")
|
|
809
|
+
try:
|
|
810
|
+
original = base64.b64decode(stdout.strip(), validate=True).decode(errors="replace")
|
|
811
|
+
except (ValueError, binascii.Error) as exc:
|
|
812
|
+
return _workspace_failure("edit_file", f"{path!r} did not transfer cleanly: {exc}")
|
|
813
|
+
|
|
814
|
+
located = _locate(original, old_text)
|
|
815
|
+
if isinstance(located, str):
|
|
816
|
+
return _workspace_failure("edit_file", f"{located} (in {path})")
|
|
817
|
+
start, end, how = located
|
|
818
|
+
edited = original[:start] + new_text + original[end:]
|
|
819
|
+
|
|
820
|
+
stdout, stderr, code = remote(
|
|
821
|
+
f"python3 -c {shlex.quote(_REMOTE_WRITE_SCRIPT)} "
|
|
822
|
+
f"{shlex.quote(path)} {_b64(edited)}", 30.0,
|
|
823
|
+
)
|
|
824
|
+
if code != 0:
|
|
825
|
+
return _workspace_failure("edit_file", stderr.strip() or "the edit could not be written back")
|
|
826
|
+
note = "" if how == "exact" else f" (matched {how})"
|
|
827
|
+
return ToolResult(stdout=f"edited {path}{note}", stderr="", returncode=0)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def list_files(body: str) -> ToolResult:
|
|
831
|
+
"""List files under a workspace directory, recursively. CODE: body is the
|
|
832
|
+
directory (empty means the workspace root).
|
|
833
|
+
|
|
834
|
+
Skips the directories that are always noise and sometimes enormous --
|
|
835
|
+
.git, __pycache__, .venv and friends -- because the first thing an agent
|
|
836
|
+
does in an unfamiliar repo is list it, and a listing dominated by
|
|
837
|
+
thousands of object files teaches it nothing while costing everything.
|
|
838
|
+
"""
|
|
839
|
+
spec = body.strip() or "."
|
|
840
|
+
remote = current_command_runner()
|
|
841
|
+
if remote is not None:
|
|
842
|
+
pruned = " ".join(f"-name {shlex.quote(d)} -o" for d in sorted(_LISTING_SKIP))
|
|
843
|
+
stdout, stderr, code = remote(
|
|
844
|
+
f"find {shlex.quote(spec)} \\( {pruned} -false \\) -prune -o -print "
|
|
845
|
+
f"| head -n {_MAX_LIST_ENTRIES}", 30.0,
|
|
846
|
+
)
|
|
847
|
+
if code != 0:
|
|
848
|
+
return _workspace_failure("list_files", stderr.strip() or f"could not list {spec!r}")
|
|
849
|
+
return ToolResult(stdout=_clip(stdout) or "(empty)", stderr="", returncode=0)
|
|
850
|
+
|
|
851
|
+
try:
|
|
852
|
+
root = resolve_in_workspace(spec)
|
|
853
|
+
except OutsideWorkspace as exc:
|
|
854
|
+
return _workspace_failure("list_files", str(exc))
|
|
855
|
+
if not root.is_dir():
|
|
856
|
+
return _workspace_failure("list_files", f"{spec!r} is not a directory in the workspace")
|
|
857
|
+
|
|
858
|
+
base = current_workspace()
|
|
859
|
+
entries = []
|
|
860
|
+
for path in sorted(root.rglob("*")):
|
|
861
|
+
if any(part in _LISTING_SKIP for part in path.relative_to(root).parts):
|
|
862
|
+
continue
|
|
863
|
+
# `as_posix`, not `str`: the remote branch above lists through `find`
|
|
864
|
+
# and always returns forward slashes, so a local listing on Windows
|
|
865
|
+
# would describe the same tree in a different vocabulary from the
|
|
866
|
+
# containerised one -- and the agent types these paths back into
|
|
867
|
+
# read_file and edit_file.
|
|
868
|
+
entries.append(path.relative_to(base).as_posix() + ("/" if path.is_dir() else ""))
|
|
869
|
+
if len(entries) > _MAX_LIST_ENTRIES:
|
|
870
|
+
entries.append(f"... (truncated at {_MAX_LIST_ENTRIES} entries)")
|
|
871
|
+
break
|
|
872
|
+
return ToolResult(stdout=_clip("\n".join(entries)) or "(empty)", stderr="", returncode=0)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
#: Directories a listing never descends into -- version-control internals,
|
|
876
|
+
#: build/dependency trees, and caches. Never source, so skipping them loses
|
|
877
|
+
#: an agent nothing it would have wanted to read.
|
|
878
|
+
_LISTING_SKIP = frozenset({
|
|
879
|
+
".git", ".hg", ".svn", "__pycache__", ".venv", "venv", "node_modules",
|
|
880
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", "dist", "build",
|
|
881
|
+
".eggs", ".idea", ".vscode", "target",
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
#: Ceiling on an image Otto will ship to a vision model, overridable because
|
|
886
|
+
#: the right number depends on the vendor and the wallet rather than on
|
|
887
|
+
#: anything this file knows. Over it, the agent is told it can downscale the
|
|
888
|
+
#: file itself with a shell command -- deliberately no Pillow dependency, and
|
|
889
|
+
#: in a container the file is not on this machine to resize anyway.
|
|
890
|
+
MAX_IMAGE_BYTES = int(os.environ.get("OTTO_MAX_IMAGE_BYTES", 8 * 1024 * 1024))
|
|
891
|
+
|
|
892
|
+
#: Reads the file and base64s it in one command, size-checked first so an
|
|
893
|
+
#: enormous file is refused rather than transferred. `base64 < file` rather
|
|
894
|
+
#: than `base64 -w0 file`: `-w` is GNU coreutils and missing on BusyBox and
|
|
895
|
+
#: BSD, while reading stdin and stripping newlines works everywhere.
|
|
896
|
+
_REMOTE_IMAGE_SCRIPT = (
|
|
897
|
+
'sz=$(wc -c < {path}) || exit 2; '
|
|
898
|
+
'[ "$sz" -le {cap} ] || {{ echo "too big: $sz bytes" >&2; exit 3; }}; '
|
|
899
|
+
"base64 < {path} | tr -d '\\n'"
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _translated(llm, exc: Exception) -> ProviderError:
|
|
904
|
+
"""A vendor SDK exception as one of Otto's own, recording a permanent
|
|
905
|
+
unusability against the right catalogue on the way through.
|
|
906
|
+
|
|
907
|
+
The tools that call `.invoke()` directly bypass nodes.py's `_call`, which
|
|
908
|
+
is where this translation normally happens, so they would otherwise let a
|
|
909
|
+
raw `google.genai` or `anthropic` error escape with no vendor attached.
|
|
910
|
+
"""
|
|
911
|
+
from agent.router.llm_provider.base import translate_unknown
|
|
912
|
+
|
|
913
|
+
provider = getattr(llm, "_otto_provider", "") if llm is not None else ""
|
|
914
|
+
model_id = ""
|
|
915
|
+
for attr in ("model", "model_name", "model_id"):
|
|
916
|
+
value = getattr(llm, attr, None) if llm is not None else None
|
|
917
|
+
if isinstance(value, str):
|
|
918
|
+
model_id = value
|
|
919
|
+
break
|
|
920
|
+
return translate_unknown(exc, provider=provider, model_id=model_id)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def view_image(body: str) -> ToolResult:
|
|
925
|
+
"""Look at an image file and answer a question about it.
|
|
926
|
+
|
|
927
|
+
CODE: body is the path on the FIRST line, and the question after it:
|
|
928
|
+
|
|
929
|
+
fixtures/Canon1.png
|
|
930
|
+
transcribe the top staff bar by bar, with pitch and duration
|
|
931
|
+
|
|
932
|
+
The question matters more than it looks. A vision model returns words, and
|
|
933
|
+
those words are all the reasoning model ever sees -- for "reproduce this
|
|
934
|
+
score as SVG" a generic caption is worthless, while a narrow question
|
|
935
|
+
answered twice is close to being able to look. Ask again to narrow; the
|
|
936
|
+
repeat detector in agent/pipeline/nodes.py knows this tool is different and
|
|
937
|
+
keys on the question too, not just the path.
|
|
938
|
+
|
|
939
|
+
The image never enters the conversation -- see agent/pipeline/vision.py for
|
|
940
|
+
why that is a deliberate ceiling. One consequence worth knowing: a
|
|
941
|
+
description can be summarised away by memory compaction, and the recovery
|
|
942
|
+
is simply to call this again, since the file is still on disk.
|
|
943
|
+
"""
|
|
944
|
+
head, _, question = body.partition("\n")
|
|
945
|
+
path = head.strip()
|
|
946
|
+
if not path:
|
|
947
|
+
return _workspace_failure("view_image", "first line must be the image path")
|
|
948
|
+
|
|
949
|
+
remote = current_command_runner()
|
|
950
|
+
if remote is not None:
|
|
951
|
+
command = _REMOTE_IMAGE_SCRIPT.format(path=shlex.quote(path), cap=MAX_IMAGE_BYTES)
|
|
952
|
+
stdout, stderr, code = remote(command, 60.0)
|
|
953
|
+
if code != 0:
|
|
954
|
+
return _workspace_failure("view_image", stderr.strip() or f"could not read {path!r}")
|
|
955
|
+
try:
|
|
956
|
+
# Never _clip this: clipping base64 yields silently corrupt image
|
|
957
|
+
# bytes, which surface as a baffling answer rather than an error.
|
|
958
|
+
# Whitespace stripped rather than trusted away: `base64 -w0` should
|
|
959
|
+
# produce none, but a container whose base64 predates that flag
|
|
960
|
+
# would fail strict decoding on a line break -- which is exactly
|
|
961
|
+
# how this failed the first time it ran.
|
|
962
|
+
data = base64.b64decode("".join(stdout.split()), validate=True)
|
|
963
|
+
except (ValueError, binascii.Error) as exc:
|
|
964
|
+
return _workspace_failure("view_image", f"{path!r} did not transfer cleanly: {exc}")
|
|
965
|
+
else:
|
|
966
|
+
try:
|
|
967
|
+
resolved = resolve_in_workspace(path)
|
|
968
|
+
except OutsideWorkspace as exc:
|
|
969
|
+
return _workspace_failure("view_image", str(exc))
|
|
970
|
+
if not resolved.is_file():
|
|
971
|
+
return _workspace_failure("view_image", f"{path!r} is not a file in the workspace")
|
|
972
|
+
data = resolved.read_bytes()
|
|
973
|
+
if len(data) > MAX_IMAGE_BYTES:
|
|
974
|
+
return _workspace_failure(
|
|
975
|
+
"view_image",
|
|
976
|
+
f"{path!r} is {len(data)} bytes, over the {MAX_IMAGE_BYTES}-byte limit -- "
|
|
977
|
+
"downscale it first (for example with a shell command) and try again",
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
media_type = sniff_media_type(data)
|
|
981
|
+
if media_type is None:
|
|
982
|
+
return _workspace_failure(
|
|
983
|
+
"view_image", f"{path!r} is not an image this can read (checked its first bytes)",
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
llm = None
|
|
987
|
+
try:
|
|
988
|
+
# Imported here, not at module level, for the same reason _get_router()
|
|
989
|
+
# is lazy: agent/eval/runner.py imports this module for offline golden
|
|
990
|
+
# checking with no keys set, and that path must keep working.
|
|
991
|
+
from agent.router.mapping import Task
|
|
992
|
+
|
|
993
|
+
llm = _get_router().chat_model(Task.VISION)
|
|
994
|
+
answer = describe_image(
|
|
995
|
+
llm, base64.b64encode(data).decode(), media_type, question.strip(),
|
|
996
|
+
)
|
|
997
|
+
except ProviderError as exc:
|
|
998
|
+
# No GEMINI_API_KEY means Task.VISION has no viable route, and that
|
|
999
|
+
# arrives here as a ProviderError. It must degrade like any other
|
|
1000
|
+
# failing tool, never take down a benchmark run.
|
|
1001
|
+
return _workspace_failure("view_image", f"could not look at {path!r}: {exc}")
|
|
1002
|
+
except Exception as exc:
|
|
1003
|
+
# This path calls .invoke() directly, so it meets the vendor's own SDK
|
|
1004
|
+
# exceptions rather than anything Otto owns -- translated here for the
|
|
1005
|
+
# same reason nodes.py's _call translates them, and so a model the
|
|
1006
|
+
# vendor says is permanently unusable is remembered rather than picked
|
|
1007
|
+
# again by the next capability fallback in this run.
|
|
1008
|
+
return _workspace_failure(
|
|
1009
|
+
"view_image",
|
|
1010
|
+
f"could not look at {path!r}: {_translated(llm, exc)}",
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
header = (
|
|
1014
|
+
f"view_image: a vision model looked at {path} "
|
|
1015
|
+
f"({media_type}, {len(data)} bytes) and reports:"
|
|
1016
|
+
)
|
|
1017
|
+
return ToolResult(stdout=_clip(f"{header}\n{answer}"), stderr="", returncode=0)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
#: Anthropic's server-side web search runs on their infrastructure and returns
|
|
1021
|
+
#: its results as content blocks in the same response, which is why this fits
|
|
1022
|
+
#: Otto's tool contract exactly: a query string in, text out, no change to the
|
|
1023
|
+
#: ACTION/CODE protocol and no search-API key for Otto to hold.
|
|
1024
|
+
#:
|
|
1025
|
+
#: Two variants exist and the newer one is NOT a superset: `web_search_20260209`
|
|
1026
|
+
#: (dynamic filtering) requires Claude 4.6 or later and is rejected with a 400
|
|
1027
|
+
#: by the cheap tier this routes to, while `web_search_20250305` is accepted
|
|
1028
|
+
#: everywhere. Verified against the live API rather than assumed. Raise the
|
|
1029
|
+
#: WEB route's pin past 4.6 and this should move with it.
|
|
1030
|
+
WEB_SEARCH_TOOL = {"type": "web_search_20250305", "name": "web_search", "max_uses": 5}
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def look(question: str) -> ToolResult:
|
|
1034
|
+
"""Look at the screen and answer a question about it -- the container's
|
|
1035
|
+
desktop, or, with no container, the page the last `browse` call
|
|
1036
|
+
rendered. CODE: body is the question, alone:
|
|
1037
|
+
|
|
1038
|
+
which window is in front, and what does its title bar say?
|
|
1039
|
+
roughly where is the OK button?
|
|
1040
|
+
are all sixteen black pieces drawn, and do they differ from white?
|
|
1041
|
+
|
|
1042
|
+
What comes back is WORDS. The capture goes through the vision model exactly
|
|
1043
|
+
as `view_image` does and the image never enters the conversation --
|
|
1044
|
+
agent/pipeline/vision.py argues for that ceiling at length. Which is why
|
|
1045
|
+
this takes a question rather than returning a picture.
|
|
1046
|
+
|
|
1047
|
+
Reach for this only when a command cannot do the job. Agents that prefer
|
|
1048
|
+
the code path take about a third fewer steps at a higher score, and the
|
|
1049
|
+
desktop image carries the same shell you already have.
|
|
1050
|
+
"""
|
|
1051
|
+
remote = current_command_runner()
|
|
1052
|
+
if remote is None:
|
|
1053
|
+
# No desktop here. What there can be is the page the last local
|
|
1054
|
+
# `browse` call rendered -- the driver leaves a screenshot of it --
|
|
1055
|
+
# which is the picture a run building a page actually needs.
|
|
1056
|
+
workspace = current_workspace()
|
|
1057
|
+
shot = browsing.last_screenshot(workspace) if workspace is not None else None
|
|
1058
|
+
if shot is None or not shot.is_file():
|
|
1059
|
+
return _workspace_failure(
|
|
1060
|
+
"look", "no container is bound for this run, so there is no screen "
|
|
1061
|
+
"to look at -- open a page with `browse open <path or URL>` first "
|
|
1062
|
+
"and look shows what it rendered",
|
|
1063
|
+
)
|
|
1064
|
+
if not question.strip():
|
|
1065
|
+
return _workspace_failure("look", "say what you want to know about the page")
|
|
1066
|
+
data = shot.read_bytes()
|
|
1067
|
+
else:
|
|
1068
|
+
if not question.strip():
|
|
1069
|
+
return _workspace_failure("look", "say what you want to know about the screen")
|
|
1070
|
+
stdout, stderr, code = remote(screening.CAPTURE, 30.0)
|
|
1071
|
+
if code != 0 or not stdout.strip():
|
|
1072
|
+
return _workspace_failure(
|
|
1073
|
+
"look", stderr.strip() or "could not capture the screen -- is a desktop running?",
|
|
1074
|
+
)
|
|
1075
|
+
try:
|
|
1076
|
+
# Whitespace stripped rather than trusted away: `base64 -w0` should
|
|
1077
|
+
# produce none, but base64 wraps at 76 columns without it and
|
|
1078
|
+
# strict decoding rejects the newline. That is exactly how this
|
|
1079
|
+
# failed the first time it ran against a real desktop.
|
|
1080
|
+
data = base64.b64decode("".join(stdout.split()), validate=True)
|
|
1081
|
+
except (ValueError, binascii.Error) as exc:
|
|
1082
|
+
return _workspace_failure("look", f"the capture did not transfer cleanly: {exc}")
|
|
1083
|
+
if len(data) > screening.MAX_CAPTURE_BYTES:
|
|
1084
|
+
return _workspace_failure("look", f"the capture is {len(data)} bytes, too large to read")
|
|
1085
|
+
|
|
1086
|
+
media_type = sniff_media_type(data)
|
|
1087
|
+
if media_type is None:
|
|
1088
|
+
return _workspace_failure("look", "the capture was not a readable image")
|
|
1089
|
+
try:
|
|
1090
|
+
from agent.router.mapping import Task
|
|
1091
|
+
|
|
1092
|
+
llm = _get_router().chat_model(Task.VISION)
|
|
1093
|
+
answer = describe_image(
|
|
1094
|
+
llm, base64.b64encode(data).decode(), media_type, question.strip(),
|
|
1095
|
+
)
|
|
1096
|
+
except ProviderError as exc:
|
|
1097
|
+
return _workspace_failure("look", f"could not look at the screen: {exc}")
|
|
1098
|
+
except Exception as exc:
|
|
1099
|
+
# The same clause view_image has, and for the same reason: this path
|
|
1100
|
+
# reaches the vendor's own SDK, so it meets exceptions Otto does not
|
|
1101
|
+
# own. Without it a vendor 400 -- "unable to process input image",
|
|
1102
|
+
# which is what a capture of a blank or half-drawn screen gets --
|
|
1103
|
+
# unwound the whole tool loop instead of arriving as one failed call.
|
|
1104
|
+
# Translating rather than swallowing is what lets a model the vendor
|
|
1105
|
+
# says is permanently unusable be remembered instead of picked again
|
|
1106
|
+
# by the next fallback in this run.
|
|
1107
|
+
return _workspace_failure(
|
|
1108
|
+
"look", f"could not look at the screen: {_translated(llm, exc)}",
|
|
1109
|
+
)
|
|
1110
|
+
return ToolResult(stdout=_clip(answer), stderr="", returncode=0)
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def look_act(body: str) -> ToolResult:
|
|
1114
|
+
"""Click or type on the container's desktop. CODE: body is one operation:
|
|
1115
|
+
|
|
1116
|
+
click 840 512
|
|
1117
|
+
type the text to enter wherever focus is
|
|
1118
|
+
|
|
1119
|
+
Separate from `look` because clicking changes something and looking does
|
|
1120
|
+
not, which is what puts this behind the same hold that covers sending a
|
|
1121
|
+
message.
|
|
1122
|
+
"""
|
|
1123
|
+
remote = current_command_runner()
|
|
1124
|
+
if remote is None:
|
|
1125
|
+
return _workspace_failure("look_act", "no container is bound for this run")
|
|
1126
|
+
parsed = screening.parse_act(body)
|
|
1127
|
+
if isinstance(parsed, str):
|
|
1128
|
+
return _workspace_failure("look_act", parsed)
|
|
1129
|
+
op, argument = parsed
|
|
1130
|
+
|
|
1131
|
+
if op == "click":
|
|
1132
|
+
point = screening.parse_point(argument)
|
|
1133
|
+
if isinstance(point, str):
|
|
1134
|
+
return _workspace_failure("look_act", point)
|
|
1135
|
+
command, done = screening.click_command(*point), f"clicked {point[0]},{point[1]}"
|
|
1136
|
+
else:
|
|
1137
|
+
if not argument:
|
|
1138
|
+
return _workspace_failure("look_act", "type needs something to type")
|
|
1139
|
+
command, done = screening.type_command(argument), f"typed {len(argument)} characters"
|
|
1140
|
+
|
|
1141
|
+
stdout, stderr, code = remote(command, 30.0)
|
|
1142
|
+
if code != 0:
|
|
1143
|
+
return _workspace_failure("look_act", stderr.strip() or f"{op} failed")
|
|
1144
|
+
return ToolResult(stdout=done, stderr="", returncode=0)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _browse(body: str, allowed: tuple[str, ...], tool: str) -> ToolResult:
|
|
1148
|
+
"""One browser operation, in the container Otto is already working in.
|
|
1149
|
+
|
|
1150
|
+
Split into a reading half and an acting half so the mutation gate covers
|
|
1151
|
+
the acting one for free: clicking a button or submitting a form on a live
|
|
1152
|
+
site is the irreversible kind of action, and a benchmark of interrupted web
|
|
1153
|
+
tasks found that persistent state changes are exactly where agents break.
|
|
1154
|
+
Reading a page is not, and gating it would tax every step.
|
|
1155
|
+
"""
|
|
1156
|
+
remote = current_command_runner()
|
|
1157
|
+
# No container: the driver runs HERE, against the workspace, through
|
|
1158
|
+
# whatever local interpreter has Playwright -- see browsing.py's module
|
|
1159
|
+
# docstring for the chess game that made this necessary. No workspace
|
|
1160
|
+
# either is the same refusal shape as the file tools: nothing to act in.
|
|
1161
|
+
workspace = current_workspace() if remote is None else None
|
|
1162
|
+
if remote is None and workspace is None:
|
|
1163
|
+
return _workspace_failure(
|
|
1164
|
+
tool, "no container and no workspace is bound for this run, so there "
|
|
1165
|
+
"is no browser to drive",
|
|
1166
|
+
)
|
|
1167
|
+
parsed = browsing.parse_op(body, allowed)
|
|
1168
|
+
if isinstance(parsed, str):
|
|
1169
|
+
return _workspace_failure(tool, parsed)
|
|
1170
|
+
op, argument = parsed
|
|
1171
|
+
if op in ("open", "click", "type", "find") and not argument:
|
|
1172
|
+
return _workspace_failure(tool, f"{op} needs something to act on")
|
|
1173
|
+
if op == "open":
|
|
1174
|
+
if remote is None and not _looks_like_url(argument):
|
|
1175
|
+
# A path in the workspace, opened as a file: URL. Confined the way
|
|
1176
|
+
# every file tool is, so `../../etc/passwd` is refused here and
|
|
1177
|
+
# `file:` typed directly is still refused by check_url below.
|
|
1178
|
+
try:
|
|
1179
|
+
page = resolve_in_workspace(argument)
|
|
1180
|
+
except OutsideWorkspace as exc:
|
|
1181
|
+
return _workspace_failure(tool, str(exc))
|
|
1182
|
+
if not page.is_file():
|
|
1183
|
+
return _workspace_failure(
|
|
1184
|
+
tool, f"{argument!r} is not a file in the workspace -- give a "
|
|
1185
|
+
"path to a page here, or an http(s) URL",
|
|
1186
|
+
)
|
|
1187
|
+
argument = page.as_uri()
|
|
1188
|
+
# Checked HERE, before the driver script is built, so a refused URL
|
|
1189
|
+
# never reaches the container. The agent chooses this URL and the
|
|
1190
|
+
# agent reads web pages, so a page it already opened can steer the
|
|
1191
|
+
# next request -- which is indirect prompt injection with a network
|
|
1192
|
+
# call on the end of it. See agent/pipeline/browsing.py's check_url.
|
|
1193
|
+
elif why := browsing.check_url(argument):
|
|
1194
|
+
return _workspace_failure(tool, why)
|
|
1195
|
+
|
|
1196
|
+
limits = {
|
|
1197
|
+
"chars": browsing.MAX_DIGEST_CHARS, "links": browsing.MAX_LINKS,
|
|
1198
|
+
"fields": browsing.MAX_FIELDS, "headings": browsing.MAX_HEADINGS,
|
|
1199
|
+
"errors": browsing.MAX_ERRORS,
|
|
1200
|
+
}
|
|
1201
|
+
if remote is not None:
|
|
1202
|
+
stdout, stderr, code = remote(
|
|
1203
|
+
f"python3 -c {shlex.quote(browsing.DRIVER)} {shlex.quote(op)} "
|
|
1204
|
+
f"{shlex.quote(argument)} {shlex.quote(json.dumps(limits))}",
|
|
1205
|
+
90.0,
|
|
1206
|
+
)
|
|
1207
|
+
else:
|
|
1208
|
+
# The screenshot is what a local `look` shows afterwards.
|
|
1209
|
+
limits["screenshot"] = str(browsing.last_screenshot(workspace))
|
|
1210
|
+
stdout, stderr, code = browsing.run_local(
|
|
1211
|
+
op, argument, json.dumps(limits), workspace=workspace,
|
|
1212
|
+
)
|
|
1213
|
+
if code != 0:
|
|
1214
|
+
return _workspace_failure(tool, stderr.strip() or f"{op} failed")
|
|
1215
|
+
# A page of the agent's own -- a file in the workspace -- that threw is a
|
|
1216
|
+
# failed call, so `browse open index.html` is a command that fails when
|
|
1217
|
+
# the page is broken (agent/pipeline/evidence.py's `is_check`). Somebody
|
|
1218
|
+
# else's site throwing is reported in the digest and is not this run's
|
|
1219
|
+
# problem: half the web logs an error on load.
|
|
1220
|
+
errors = browsing.page_errors(stdout)
|
|
1221
|
+
if errors and stdout.startswith("url: file:"):
|
|
1222
|
+
return ToolResult(
|
|
1223
|
+
stdout=_clip(stdout),
|
|
1224
|
+
stderr=f"{tool}: the page threw -- " + "; ".join(errors[:3]),
|
|
1225
|
+
returncode=1,
|
|
1226
|
+
)
|
|
1227
|
+
return ToolResult(stdout=_clip(stdout), stderr="", returncode=0)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _looks_like_url(argument: str) -> bool:
|
|
1231
|
+
"""Whether `open`'s argument names a URL rather than a workspace path."""
|
|
1232
|
+
return re.match(r"[A-Za-z][A-Za-z0-9+.-]*://", argument.strip()) is not None
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def exercise(body: str) -> ToolResult:
|
|
1236
|
+
"""USE what you built and get every step back as the machine saw it.
|
|
1237
|
+
CODE: body is one step per line, and the FIRST says what kind of thing
|
|
1238
|
+
it is:
|
|
1239
|
+
|
|
1240
|
+
open index.html a page, in a real browser
|
|
1241
|
+
count css .piece = 32
|
|
1242
|
+
click css .square:nth-child(53)
|
|
1243
|
+
expect Computer is thinking
|
|
1244
|
+
expect not Checkmate
|
|
1245
|
+
|
|
1246
|
+
serve npm run dev -- --port 5173 an app: its server, then its page
|
|
1247
|
+
open http://127.0.0.1:5173/
|
|
1248
|
+
click Sign in
|
|
1249
|
+
type Email = a@b.c
|
|
1250
|
+
|
|
1251
|
+
run python3 tool.py --count 3 a command line
|
|
1252
|
+
expect 3 items
|
|
1253
|
+
exit = 0
|
|
1254
|
+
run python3 tool.py --bogus
|
|
1255
|
+
exit = 2
|
|
1256
|
+
|
|
1257
|
+
serve uvicorn app:app --port 8000 an API
|
|
1258
|
+
request GET http://127.0.0.1:8000/health
|
|
1259
|
+
status = 200
|
|
1260
|
+
expect ok
|
|
1261
|
+
|
|
1262
|
+
tty python3 app.py a program in a terminal
|
|
1263
|
+
expect Name?
|
|
1264
|
+
type otto
|
|
1265
|
+
press Enter
|
|
1266
|
+
expect Hello, otto
|
|
1267
|
+
screen
|
|
1268
|
+
|
|
1269
|
+
`browse` loads a page and `look` shows it; neither can tell you the
|
|
1270
|
+
thing WORKS, because each of their calls is a fresh page. This runs the
|
|
1271
|
+
whole sequence in one page, one shell, or one terminal, stops at the
|
|
1272
|
+
first step that fails, and names it. A page that throws fails the
|
|
1273
|
+
call too.
|
|
1274
|
+
|
|
1275
|
+
The report is written by code -- what the browser found, what the
|
|
1276
|
+
command printed, what was on the terminal -- which is why the judge is
|
|
1277
|
+
shown it as evidence. Write steps that would FAIL if the feature were
|
|
1278
|
+
broken: a count that must hold, a text that must appear after a click,
|
|
1279
|
+
an exit code that must be what it is.
|
|
1280
|
+
|
|
1281
|
+
Pages come from the workspace and servers from commands run in it, so
|
|
1282
|
+
this never acts on a live site: that is `browse_act` and its hold.
|
|
1283
|
+
"""
|
|
1284
|
+
walk = walkthrough.parse_walk(body)
|
|
1285
|
+
if isinstance(walk, str):
|
|
1286
|
+
return _workspace_failure("exercise", walk)
|
|
1287
|
+
remote = current_command_runner()
|
|
1288
|
+
workspace = current_workspace() if remote is None else None
|
|
1289
|
+
if remote is None and workspace is None:
|
|
1290
|
+
return _workspace_failure(
|
|
1291
|
+
"exercise", "no container and no workspace is bound for this run, so "
|
|
1292
|
+
"there is nothing to use it in",
|
|
1293
|
+
)
|
|
1294
|
+
if walk.backend == "shell":
|
|
1295
|
+
return _exercise_shell(walk, remote, workspace)
|
|
1296
|
+
if walk.backend == "tty":
|
|
1297
|
+
return _exercise_tty(walk, remote, workspace)
|
|
1298
|
+
if walk.backend in walkthrough.DEVICE_KINDS:
|
|
1299
|
+
return _exercise_device(walk, remote, workspace)
|
|
1300
|
+
return _exercise_page(walk, remote, workspace)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _exercise_device(walk, remote, workspace) -> ToolResult:
|
|
1304
|
+
"""An app on a device or a desktop -- agent/pipeline/native.py. Local
|
|
1305
|
+
only: the tools (adb, simctl, System Events, xdotool, pywinauto) are on
|
|
1306
|
+
this machine, and a container has no screen of that kind."""
|
|
1307
|
+
from agent.pipeline import native
|
|
1308
|
+
|
|
1309
|
+
if remote is not None:
|
|
1310
|
+
return _workspace_failure(
|
|
1311
|
+
"exercise", f"a {walk.backend} app is driven on this machine, not in the "
|
|
1312
|
+
"container -- run without a container, or drive the container's desktop with look_act",
|
|
1313
|
+
)
|
|
1314
|
+
shot = str(browsing.last_screenshot(workspace))
|
|
1315
|
+
if walk.backend == "windows":
|
|
1316
|
+
limits = json.dumps({"screenshot": shot, "rows": 40})
|
|
1317
|
+
stdout, stderr, code = native.run_windows("\n".join(walk.lines), limits, cwd=str(workspace))
|
|
1318
|
+
return _walk_result(stdout, stderr, code, page=False)
|
|
1319
|
+
try:
|
|
1320
|
+
device = native.device_for(walk.backend, cwd=str(workspace))
|
|
1321
|
+
except native.DeviceError as exc:
|
|
1322
|
+
return _workspace_failure("exercise", str(exc))
|
|
1323
|
+
report, failed = native.run_native(walk, device, screenshot=shot)
|
|
1324
|
+
return _walk_result(report, "", 6 if failed else 0, page=False)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _exercise_page(walk, remote, workspace) -> ToolResult:
|
|
1328
|
+
"""A page in the workspace, or a served app, in the browser driver."""
|
|
1329
|
+
lines = walk.lines
|
|
1330
|
+
first = walk.steps[0]
|
|
1331
|
+
typed = ""
|
|
1332
|
+
if first.verb == "open":
|
|
1333
|
+
if remote is not None:
|
|
1334
|
+
if (bad := _remote_paths_are_the_containers_own("exercise", first.argument)) is not None:
|
|
1335
|
+
return bad
|
|
1336
|
+
else:
|
|
1337
|
+
try:
|
|
1338
|
+
page = resolve_in_workspace(first.argument)
|
|
1339
|
+
except OutsideWorkspace as exc:
|
|
1340
|
+
return _workspace_failure("exercise", str(exc))
|
|
1341
|
+
if not page.is_file():
|
|
1342
|
+
return _workspace_failure(
|
|
1343
|
+
"exercise", f"{first.argument!r} is not a file in the workspace",
|
|
1344
|
+
)
|
|
1345
|
+
typed = first.argument
|
|
1346
|
+
lines[0] = f"open {page.as_uri()}"
|
|
1347
|
+
script = "\n".join(lines)
|
|
1348
|
+
limits = {
|
|
1349
|
+
"chars": browsing.MAX_DIGEST_CHARS, "links": browsing.MAX_LINKS,
|
|
1350
|
+
"fields": browsing.MAX_FIELDS, "headings": browsing.MAX_HEADINGS,
|
|
1351
|
+
"errors": browsing.MAX_ERRORS, "serve_timeout": walkthrough.SERVE_TIMEOUT_S,
|
|
1352
|
+
}
|
|
1353
|
+
if remote is not None:
|
|
1354
|
+
stdout, stderr, code = remote(
|
|
1355
|
+
f"python3 -c {shlex.quote(browsing.DRIVER)} walk "
|
|
1356
|
+
f"{shlex.quote(script)} {shlex.quote(json.dumps(limits))}",
|
|
1357
|
+
180.0,
|
|
1358
|
+
)
|
|
1359
|
+
else:
|
|
1360
|
+
limits["screenshot"] = str(browsing.last_screenshot(workspace))
|
|
1361
|
+
stdout, stderr, code = browsing.run_local(
|
|
1362
|
+
"walk", script, json.dumps(limits), workspace=workspace, timeout=180.0,
|
|
1363
|
+
)
|
|
1364
|
+
if typed and remote is None:
|
|
1365
|
+
# The report names the page the way the step did, not as the file
|
|
1366
|
+
# URL it became: that line is the action record's, and the judge
|
|
1367
|
+
# should recognise the path it saw written.
|
|
1368
|
+
stdout = stdout.replace(lines[0][len("open "):], typed)
|
|
1369
|
+
return _walk_result(stdout, stderr, code, page=True)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _exercise_shell(walk, remote, workspace) -> ToolResult:
|
|
1373
|
+
"""Commands, and a server if asked, where execute_bash runs."""
|
|
1374
|
+
shell = (walkthrough.RemoteShell(remote) if remote is not None
|
|
1375
|
+
else walkthrough.LocalShell(str(workspace)))
|
|
1376
|
+
report, failed = walkthrough.run_shell(walk, shell)
|
|
1377
|
+
return _walk_result(report, "", 6 if failed else 0, page=False)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _exercise_tty(walk, remote, workspace) -> ToolResult:
|
|
1381
|
+
"""A program in a pseudo-terminal, through the terminal driver."""
|
|
1382
|
+
script = "\n".join(walk.lines)
|
|
1383
|
+
limits = json.dumps({"cols": 100, "rows": 30})
|
|
1384
|
+
if remote is not None:
|
|
1385
|
+
stdout, stderr, code = remote(
|
|
1386
|
+
f"python3 -c {shlex.quote(walkthrough.PTY_DRIVER)} "
|
|
1387
|
+
f"{shlex.quote(script)} {shlex.quote(limits)}",
|
|
1388
|
+
150.0,
|
|
1389
|
+
)
|
|
1390
|
+
else:
|
|
1391
|
+
stdout, stderr, code = walkthrough.run_tty_local(script, limits, cwd=str(workspace))
|
|
1392
|
+
return _walk_result(stdout, stderr, code, page=False)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _walk_result(stdout: str, stderr: str, code: int, *, page: bool) -> ToolResult:
|
|
1396
|
+
"""One shape for every backend's outcome.
|
|
1397
|
+
|
|
1398
|
+
6 is a driver's "a step failed": the report was printed and is the thing
|
|
1399
|
+
worth reading. Anything else non-zero is the walkthrough not having run
|
|
1400
|
+
at all. A page that threw fails even a walkthrough whose steps all held.
|
|
1401
|
+
"""
|
|
1402
|
+
if code not in (0, 6):
|
|
1403
|
+
return _workspace_failure(
|
|
1404
|
+
"exercise", stderr.strip() or "the walkthrough could not run",
|
|
1405
|
+
)
|
|
1406
|
+
if code == 6:
|
|
1407
|
+
return ToolResult(
|
|
1408
|
+
stdout=_clip(stdout),
|
|
1409
|
+
stderr="exercise: " + (walkthrough.failed_step(stdout) or "a step failed"),
|
|
1410
|
+
returncode=1,
|
|
1411
|
+
)
|
|
1412
|
+
if page:
|
|
1413
|
+
errors = browsing.page_errors(stdout)
|
|
1414
|
+
if errors:
|
|
1415
|
+
return ToolResult(
|
|
1416
|
+
stdout=_clip(stdout),
|
|
1417
|
+
stderr="exercise: every step passed but the page threw -- " + "; ".join(errors[:3]),
|
|
1418
|
+
returncode=1,
|
|
1419
|
+
)
|
|
1420
|
+
return ToolResult(stdout=_clip(stdout), stderr="", returncode=0)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def browse(body: str) -> ToolResult:
|
|
1424
|
+
"""Look at a web page. CODE: body is one operation:
|
|
1425
|
+
|
|
1426
|
+
open https://example.com/search?q=widgets
|
|
1427
|
+
open index.html
|
|
1428
|
+
read
|
|
1429
|
+
find the pricing table
|
|
1430
|
+
back
|
|
1431
|
+
|
|
1432
|
+
What comes back is a DIGEST -- url, title, headings, links, form fields and
|
|
1433
|
+
clipped text -- not the page. That is the lever rather than a nicety:
|
|
1434
|
+
refining only the observation and action space, with no planner or critic
|
|
1435
|
+
or tree search, beat every scaffolding trick tried against it by +9.8
|
|
1436
|
+
points.
|
|
1437
|
+
|
|
1438
|
+
Whatever the page THREW comes back too, and a page in the workspace that
|
|
1439
|
+
threw is a failed call: `open index.html` is the command that fails if
|
|
1440
|
+
the page is broken, which is what makes it a check rather than a look.
|
|
1441
|
+
`look` afterwards shows what it rendered.
|
|
1442
|
+
|
|
1443
|
+
Reach for this only when code cannot do the job. An agent that prefers
|
|
1444
|
+
calling an API or a script over driving a UI takes 32% fewer steps, and on
|
|
1445
|
+
the web an API-plus-browser agent beats browsing alone by 24 absolute
|
|
1446
|
+
points. If the site has an endpoint, use execute_bash.
|
|
1447
|
+
"""
|
|
1448
|
+
return _browse(body, browsing.READ_OPS, "browse")
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def browse_act(body: str) -> ToolResult:
|
|
1452
|
+
"""Act on the page you are looking at. CODE: body is one operation:
|
|
1453
|
+
|
|
1454
|
+
click Add to basket
|
|
1455
|
+
type Email = someone@example.com
|
|
1456
|
+
submit
|
|
1457
|
+
|
|
1458
|
+
Separate from `browse` because these change something on a live site and
|
|
1459
|
+
reading does not -- which is what puts this behind the same hold that
|
|
1460
|
+
covers sending a message. Persistent state changes are where web agents
|
|
1461
|
+
measurably break.
|
|
1462
|
+
"""
|
|
1463
|
+
return _browse(body, browsing.ACT_OPS, "browse_act")
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def web_search(query: str) -> ToolResult:
|
|
1467
|
+
"""Search the live web and return what the search found, with sources.
|
|
1468
|
+
|
|
1469
|
+
CODE: body is the query, in plain words -- not a URL and not a shell
|
|
1470
|
+
command. The model may decide a query needs no search (asked for a fact it
|
|
1471
|
+
already knows, it will just answer); that is a successful result, not a
|
|
1472
|
+
failure.
|
|
1473
|
+
|
|
1474
|
+
Unconfigured is a clean failure, not a crash: with no ANTHROPIC_API_KEY the
|
|
1475
|
+
WEB route has no viable candidate and no fallback -- deliberately, since
|
|
1476
|
+
falling through to a model with no web access would answer from memory
|
|
1477
|
+
while looking like a search -- and that arrives here as a ProviderError.
|
|
1478
|
+
"""
|
|
1479
|
+
if not query.strip():
|
|
1480
|
+
return ToolResult(stdout="", stderr="web_search: the query must not be empty", returncode=1)
|
|
1481
|
+
try:
|
|
1482
|
+
from agent.router.mapping import Task
|
|
1483
|
+
|
|
1484
|
+
llm = _get_router().chat_model(Task.WEB).bind_tools([WEB_SEARCH_TOOL])
|
|
1485
|
+
reply = llm.invoke([HumanMessage(
|
|
1486
|
+
f"Search the web and answer this, citing the sources you used:\n{query}"
|
|
1487
|
+
)])
|
|
1488
|
+
except ProviderError as exc:
|
|
1489
|
+
return ToolResult(stdout="", stderr=f"web_search failed: {exc}", returncode=1)
|
|
1490
|
+
except Exception as exc: # raw vendor SDK error -- see _translated
|
|
1491
|
+
return ToolResult(
|
|
1492
|
+
stdout="", stderr=f"web_search failed: {_translated(None, exc)}", returncode=1,
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
text = _blocks_to_text(reply.content)
|
|
1496
|
+
if not text.strip():
|
|
1497
|
+
return ToolResult(
|
|
1498
|
+
stdout="", stderr=f"web_search returned nothing for {query!r}", returncode=1,
|
|
1499
|
+
)
|
|
1500
|
+
return ToolResult(stdout=_clip(text), stderr="", returncode=0)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _blocks_to_text(content) -> str:
|
|
1504
|
+
"""The readable text of a reply that may be a plain string or a list of
|
|
1505
|
+
blocks. A web-search reply interleaves `server_tool_use` and
|
|
1506
|
+
`web_search_tool_result` blocks with the text ones; only the text is worth
|
|
1507
|
+
handing back, since the search results are already summarised into it."""
|
|
1508
|
+
if isinstance(content, str):
|
|
1509
|
+
return content
|
|
1510
|
+
parts = []
|
|
1511
|
+
for block in content or []:
|
|
1512
|
+
if isinstance(block, str):
|
|
1513
|
+
parts.append(block)
|
|
1514
|
+
elif isinstance(block, dict) and block.get("type") == "text":
|
|
1515
|
+
parts.append(block.get("text", ""))
|
|
1516
|
+
return "".join(parts)
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
#: One indexed corpus per (workspace, fingerprint). Re-indexing on every query
|
|
1520
|
+
#: would re-embed the whole tree each time; keying on the fingerprint means an
|
|
1521
|
+
#: edited file is picked up on the next query and an unchanged tree is not.
|
|
1522
|
+
_RAG_INDEXES: dict[tuple[str, str], "MemoryStore"] = {}
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
#: A query that is one bare identifier, path or quoted string rather than a
|
|
1526
|
+
#: question -- `FALLOFF_RATIO`, `agent/pipeline/budget.py`, `"exit 3"`.
|
|
1527
|
+
_LITERAL = re.compile(r'^[\'\"]?[A-Za-z_][A-Za-z0-9_.:/-]*[\'\"]?$')
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def looks_like_a_literal(query: str) -> bool:
|
|
1531
|
+
"""Whether this is a string to find rather than a question to answer.
|
|
1532
|
+
|
|
1533
|
+
Measured on Otto's own `agent/` tree, four exact identifiers against four
|
|
1534
|
+
questions about the same code:
|
|
1535
|
+
|
|
1536
|
+
query type grep semantic
|
|
1537
|
+
exact token 4/4 0.2s 4/4 41.6s
|
|
1538
|
+
conceptual 0/4 0.3s 4/4 2.5s
|
|
1539
|
+
|
|
1540
|
+
They are complementary, not competing. On an identifier the two find the
|
|
1541
|
+
same files and grep is two orders of magnitude faster -- the semantic path
|
|
1542
|
+
has to embed the whole workspace first, which is forty seconds nobody
|
|
1543
|
+
needed. On a question grep finds NOTHING, because the words in the
|
|
1544
|
+
question are not the words in the code.
|
|
1545
|
+
|
|
1546
|
+
The `rag` docstring used to say roughly this as advice, which left the
|
|
1547
|
+
choice to the model on every call. This makes the tool decide, since the
|
|
1548
|
+
query itself says which it is.
|
|
1549
|
+
|
|
1550
|
+
Deliberately narrow: one token, no spaces. Anything with a space is a
|
|
1551
|
+
question, and anything ambiguous falls through to the semantic path, which
|
|
1552
|
+
is slower but never wrong in the way grep is wrong here.
|
|
1553
|
+
"""
|
|
1554
|
+
return bool(_LITERAL.match(query.strip()))
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def _grep_workspace(needle: str) -> ToolResult | None:
|
|
1558
|
+
"""Files containing `needle`, or None if grep could not be used.
|
|
1559
|
+
|
|
1560
|
+
Goes through the command runner when one is bound, so this works in the
|
|
1561
|
+
container the same way every other tool does. None rather than a failure
|
|
1562
|
+
on any trouble, so the caller falls through to the semantic path -- a slow
|
|
1563
|
+
answer beats an error.
|
|
1564
|
+
"""
|
|
1565
|
+
quoted = shlex.quote(needle.strip("'\""))
|
|
1566
|
+
remote = current_command_runner()
|
|
1567
|
+
if remote is not None:
|
|
1568
|
+
try:
|
|
1569
|
+
stdout, _, code = remote(f"grep -rln -e {quoted} . | head -n 40", 30.0)
|
|
1570
|
+
except Exception: # noqa: BLE001
|
|
1571
|
+
return None
|
|
1572
|
+
if code not in (0, 1):
|
|
1573
|
+
return None
|
|
1574
|
+
return _literal_result(needle, stdout)
|
|
1575
|
+
|
|
1576
|
+
root = current_workspace()
|
|
1577
|
+
if root is None:
|
|
1578
|
+
return None
|
|
1579
|
+
try:
|
|
1580
|
+
proc = subprocess.run(
|
|
1581
|
+
["grep", "-rln", "-e", needle.strip("'\""), "."],
|
|
1582
|
+
cwd=root, capture_output=True, text=True, timeout=30,
|
|
1583
|
+
)
|
|
1584
|
+
except (OSError, subprocess.SubprocessError):
|
|
1585
|
+
return None
|
|
1586
|
+
if proc.returncode not in (0, 1):
|
|
1587
|
+
return None
|
|
1588
|
+
return _literal_result(needle, proc.stdout)
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def _literal_result(needle: str, stdout: str) -> ToolResult:
|
|
1592
|
+
files = [line for line in stdout.splitlines() if line.strip()][:40]
|
|
1593
|
+
if not files:
|
|
1594
|
+
return ToolResult(
|
|
1595
|
+
stdout="",
|
|
1596
|
+
stderr=(f"rag: nothing in the workspace contains {needle!r}. If you "
|
|
1597
|
+
"were asking a question rather than looking for that exact "
|
|
1598
|
+
"string, ask it in words and this will search by meaning."),
|
|
1599
|
+
returncode=1,
|
|
1600
|
+
)
|
|
1601
|
+
return ToolResult(
|
|
1602
|
+
stdout=f"files containing {needle!r}:\n" + "\n".join(files),
|
|
1603
|
+
stderr="", returncode=0,
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def rag(query: str) -> ToolResult:
|
|
1608
|
+
"""Search the CONTENTS of the files in this workspace, semantically.
|
|
1609
|
+
|
|
1610
|
+
CODE: body is a plain question, not a path and not a shell command --
|
|
1611
|
+
"where is the retry budget configured", not "grep -r retry".
|
|
1612
|
+
|
|
1613
|
+
Distinct from `recall_memory`, which searches what this CONVERSATION said
|
|
1614
|
+
earlier and then compacted away. This searches what is WRITTEN IN THE
|
|
1615
|
+
FILES.
|
|
1616
|
+
|
|
1617
|
+
Hand it either kind of query. A bare identifier or path is grepped, which
|
|
1618
|
+
is exact and immediate; a question is answered by meaning. You do not have
|
|
1619
|
+
to pick -- see `looks_like_a_literal` for the measurement that made this
|
|
1620
|
+
the tool's job rather than yours.
|
|
1621
|
+
|
|
1622
|
+
Requires a bound workspace, and returns a clean failure without one.
|
|
1623
|
+
"""
|
|
1624
|
+
if not query.strip():
|
|
1625
|
+
return ToolResult(stdout="", stderr="rag: the query must not be empty", returncode=1)
|
|
1626
|
+
if looks_like_a_literal(query):
|
|
1627
|
+
# Grep it instead. See `looks_like_a_literal` for the measurement --
|
|
1628
|
+
# on an exact identifier the two find the same files and grep is two
|
|
1629
|
+
# orders of magnitude faster, while on a question grep finds nothing
|
|
1630
|
+
# at all.
|
|
1631
|
+
found = _grep_workspace(query.strip())
|
|
1632
|
+
if found is not None:
|
|
1633
|
+
return found
|
|
1634
|
+
root = current_workspace()
|
|
1635
|
+
if root is None:
|
|
1636
|
+
return ToolResult(
|
|
1637
|
+
stdout="",
|
|
1638
|
+
stderr="rag: no workspace is bound for this run, so there are no files to search",
|
|
1639
|
+
returncode=1,
|
|
1640
|
+
)
|
|
1641
|
+
try:
|
|
1642
|
+
from agent.memory.retrieval import (
|
|
1643
|
+
EVICTED_KIND, HISTORY_KIND, NOTHING_COMPACTED, recall, recall_chunks,
|
|
1644
|
+
)
|
|
1645
|
+
from agent.memory.store import MemoryStore
|
|
1646
|
+
from agent.pipeline.rag import corpus_fingerprint, index_corpus
|
|
1647
|
+
|
|
1648
|
+
key = (str(root), corpus_fingerprint(root))
|
|
1649
|
+
store = _RAG_INDEXES.get(key)
|
|
1650
|
+
if store is None:
|
|
1651
|
+
store = MemoryStore(Path(tempfile.gettempdir()) / f"otto-rag-{key[1]}.db")
|
|
1652
|
+
if index_corpus(store, "corpus", root) == 0:
|
|
1653
|
+
return ToolResult(
|
|
1654
|
+
stdout="",
|
|
1655
|
+
stderr=f"rag: no indexable text files under {root}",
|
|
1656
|
+
returncode=1,
|
|
1657
|
+
)
|
|
1658
|
+
_RAG_INDEXES[key] = store
|
|
1659
|
+
return ToolResult(stdout=_clip(recall(store, "corpus", query)), stderr="", returncode=0)
|
|
1660
|
+
except Exception as exc: # indexing or embedding trouble must not crash the loop
|
|
1661
|
+
return ToolResult(stdout="", stderr=f"rag failed: {exc}", returncode=1)
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def recall_memory(query: str) -> ToolResult:
|
|
1665
|
+
"""Semantic search (agent/memory/retrieval.py) over what this session has
|
|
1666
|
+
compacted away: the CONVERSATION (kind="history", summarised into bullets
|
|
1667
|
+
by agent/memory/queue.py) and the TOOL OUTPUT evicted from this run's own
|
|
1668
|
+
transcript (kind="context", written by agent/pipeline/nodes.py's
|
|
1669
|
+
`_compact`). `query` is a plain search string, nothing else -- not code,
|
|
1670
|
+
not a shell command.
|
|
1671
|
+
|
|
1672
|
+
The second of those is why `_compact` is no longer one-way. A result older
|
|
1673
|
+
than the recent tail used to be replaced by its one-line summary and the
|
|
1674
|
+
bytes were simply gone; the honest thing to tell the model was "run it
|
|
1675
|
+
again", which is true, rather than "search for it", which was not.
|
|
1676
|
+
|
|
1677
|
+
Two distinct ways this can come back empty-handed, both reported as a
|
|
1678
|
+
normal failing ToolResult rather than a crash (same shape as web_search/
|
|
1679
|
+
rag's stub failure, so a caller's tool loop reacts to it the same way):
|
|
1680
|
+
no store bound at all for this run (agent/memory/session.py's
|
|
1681
|
+
current_store() returns None -- e.g. agent/eval/'s golden runner, which
|
|
1682
|
+
never wires memory in), or a store that IS bound but genuinely has
|
|
1683
|
+
nothing compacted yet (recall()'s own "nothing to recall yet" message,
|
|
1684
|
+
which still comes back as ok=True -- that's a real, if unhelpful,
|
|
1685
|
+
answer, not a failure).
|
|
1686
|
+
"""
|
|
1687
|
+
store = current_store()
|
|
1688
|
+
if store is None:
|
|
1689
|
+
return ToolResult(
|
|
1690
|
+
stdout="",
|
|
1691
|
+
stderr=f"recall_memory: no session memory is bound for this run (query={query!r})",
|
|
1692
|
+
returncode=1,
|
|
1693
|
+
)
|
|
1694
|
+
try:
|
|
1695
|
+
# purpose="acting": this is asked mid-task, while the agent is doing
|
|
1696
|
+
# something, which is the read that measurably wants to be narrow --
|
|
1697
|
+
# see agent/memory/retrieval.py's PROCEDURAL_TOP_K.
|
|
1698
|
+
history = recall(store, HISTORY_KIND, query, purpose="acting")
|
|
1699
|
+
evicted = recall_chunks(store, EVICTED_KIND, query)
|
|
1700
|
+
except Exception as exc: # a memory-layer bug must not crash the tool loop
|
|
1701
|
+
return ToolResult(stdout="", stderr=f"recall_memory failed: {exc}", returncode=1)
|
|
1702
|
+
|
|
1703
|
+
# Labelled, because they are different KINDS of thing and an agent acting
|
|
1704
|
+
# on them should know which is which: one is what was said, the other is
|
|
1705
|
+
# what a command printed. Unlabelled and concatenated, a tool result reads
|
|
1706
|
+
# as something the person told it.
|
|
1707
|
+
parts = []
|
|
1708
|
+
if history and history != NOTHING_COMPACTED:
|
|
1709
|
+
parts.append("FROM THE CONVERSATION:\n" + history)
|
|
1710
|
+
if evicted:
|
|
1711
|
+
parts.append("FROM EARLIER TOOL OUTPUT IN THIS RUN:\n" + evicted)
|
|
1712
|
+
if not parts:
|
|
1713
|
+
return ToolResult(stdout=NOTHING_COMPACTED, stderr="", returncode=0)
|
|
1714
|
+
return ToolResult(stdout="\n\n".join(parts), stderr="", returncode=0)
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
#: Lazily constructed, NOT at import time (unlike nodes.py's module-level
|
|
1718
|
+
#: ROUTER) -- see the module docstring's note on why: this module is also
|
|
1719
|
+
#: imported for its zero-network golden-checker path (agent/eval/runner.py's
|
|
1720
|
+
#: execute_python), which must keep working with no INCEPTION_API_KEY set.
|
|
1721
|
+
_router: Router | None = None
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def _get_router() -> Router:
|
|
1725
|
+
global _router
|
|
1726
|
+
if _router is None:
|
|
1727
|
+
_router = Router()
|
|
1728
|
+
return _router
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def complete_code(body: str) -> ToolResult:
|
|
1732
|
+
"""Fill-in-the-middle via Task.CODE_COMPLETE (Mercury's real FIM
|
|
1733
|
+
endpoint, agent/router/mapping.py) -- routed through the same `Router`
|
|
1734
|
+
class every chat call goes through, just its `.fim()` method instead of
|
|
1735
|
+
`.chat_model()`.
|
|
1736
|
+
|
|
1737
|
+
`body` is the FIM "prefix" -- the code already written, completion
|
|
1738
|
+
picks up from its end. To also pin what must follow the completion
|
|
1739
|
+
(the FIM "suffix"), put a line containing exactly "---SUFFIX---"
|
|
1740
|
+
between the two; most completions (finishing a function to its natural
|
|
1741
|
+
end, say) have no suffix at all, which is why that's the plain,
|
|
1742
|
+
unmarked case.
|
|
1743
|
+
"""
|
|
1744
|
+
prefix, _, suffix = body.partition("\n---SUFFIX---\n")
|
|
1745
|
+
try:
|
|
1746
|
+
text = _get_router().fim(prefix, suffix)
|
|
1747
|
+
except ProviderError as exc:
|
|
1748
|
+
return ToolResult(stdout="", stderr=f"complete_code failed: {exc}", returncode=1)
|
|
1749
|
+
return ToolResult(stdout=text, stderr="", returncode=0)
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def predict_edit(body: str) -> ToolResult:
|
|
1753
|
+
"""Next-edit prediction via Task.CODE_EDIT (Mercury's edit endpoint).
|
|
1754
|
+
|
|
1755
|
+
Genuinely different in kind from every other tool here: `body` is not
|
|
1756
|
+
an instruction, because this endpoint has none (agent/router/router.py's
|
|
1757
|
+
`code_edit()` docstring) -- it predicts whatever edit comes next purely
|
|
1758
|
+
from the code itself, optionally with a `<|cursor|>` marker placed
|
|
1759
|
+
where attention should focus (placed at the end automatically if you
|
|
1760
|
+
don't include one). Good for "what's the obvious next
|
|
1761
|
+
fix/continuation here" -- e.g. after execute_python surfaces a
|
|
1762
|
+
traceback, handing this the code with the cursor near the failing line.
|
|
1763
|
+
Useless for "make this specific change": there is nowhere to say what
|
|
1764
|
+
the change should be, so a role that wants an instructed edit should
|
|
1765
|
+
just write the new code directly instead of reaching for this tool.
|
|
1766
|
+
"""
|
|
1767
|
+
try:
|
|
1768
|
+
text = _get_router().code_edit(body)
|
|
1769
|
+
except ProviderError as exc:
|
|
1770
|
+
return ToolResult(stdout="", stderr=f"predict_edit failed: {exc}", returncode=1)
|
|
1771
|
+
return ToolResult(stdout=text, stderr="", returncode=0)
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
TOOL_TIERS: dict[str, str] = {
|
|
1775
|
+
"execute_python": READ_ONLY,
|
|
1776
|
+
"execute_bash": READ_ONLY,
|
|
1777
|
+
"read_file": READ_ONLY,
|
|
1778
|
+
"view_image": READ_ONLY,
|
|
1779
|
+
"list_files": READ_ONLY,
|
|
1780
|
+
"write_file": WORKSPACE,
|
|
1781
|
+
"edit_file": WORKSPACE,
|
|
1782
|
+
"browse": READ_ONLY,
|
|
1783
|
+
# Only ever opens a file in the workspace and drives that one page, so
|
|
1784
|
+
# nothing outside this run can be changed by it. A page's own script may
|
|
1785
|
+
# reach the network, exactly as a snippet under execute_python may.
|
|
1786
|
+
"exercise": READ_ONLY,
|
|
1787
|
+
"look": READ_ONLY,
|
|
1788
|
+
"look_act": MUTATING,
|
|
1789
|
+
"browse_act": MUTATING,
|
|
1790
|
+
"web_search": READ_ONLY,
|
|
1791
|
+
"rag": READ_ONLY,
|
|
1792
|
+
"code_map": READ_ONLY,
|
|
1793
|
+
"complete_code": READ_ONLY,
|
|
1794
|
+
"predict_edit": READ_ONLY,
|
|
1795
|
+
"recall_memory": READ_ONLY,
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
#: Tools whose output is content Otto did not write and the user did not say.
|
|
1799
|
+
#:
|
|
1800
|
+
#: A web page, a search result, a screenshot of somebody's UI, a chunk pulled
|
|
1801
|
+
#: out of a repository Otto was pointed at -- all of it arrives in the same
|
|
1802
|
+
#: `HumanMessage(f"TOOL RESULT:\n{...}")` envelope as the user's own task did.
|
|
1803
|
+
#: Framed identically, read identically: text inside a page saying "ignore
|
|
1804
|
+
#: your previous instructions" is sitting in the same role as the instruction
|
|
1805
|
+
#: it is trying to override.
|
|
1806
|
+
#:
|
|
1807
|
+
#: Marking it does not make the model immune. What it does is make the
|
|
1808
|
+
#: distinction available at all, at the point of delivery, which is the one
|
|
1809
|
+
#: place that knows it. The structural defences do not depend on this: the
|
|
1810
|
+
#: mutation gate is code and never reads tool output, and the rubric is
|
|
1811
|
+
#: written before any output is visible.
|
|
1812
|
+
#:
|
|
1813
|
+
#: Every ExtraTool is third-party too -- those are supplied per run by a
|
|
1814
|
+
#: benchmark task file or a caller, so their output has the same provenance
|
|
1815
|
+
#: as a web page. They are not listed here because they are not known here;
|
|
1816
|
+
#: nodes.py adds them by asking current_extra_tools().
|
|
1817
|
+
THIRD_PARTY: frozenset[str] = frozenset({
|
|
1818
|
+
"browse", "browse_act", "web_search", "rag", "look",
|
|
1819
|
+
})
|
|
1820
|
+
|
|
1821
|
+
#: What each tool needs bound before it can do anything at all.
|
|
1822
|
+
#:
|
|
1823
|
+
#: Declared beside TOOL_TIERS and for the same reason: a fact about a tool
|
|
1824
|
+
#: belongs next to the tool, so the next one added says its own preconditions
|
|
1825
|
+
#: instead of being discovered missing.
|
|
1826
|
+
#:
|
|
1827
|
+
#: These are not preferences. Each value is read off the tool's own first
|
|
1828
|
+
#: refusal -- `rag` says "no workspace is bound", `look` says "no container is
|
|
1829
|
+
#: bound" -- so the table cannot drift from the behaviour without a test
|
|
1830
|
+
#: noticing.
|
|
1831
|
+
ANYWHERE = "anywhere"
|
|
1832
|
+
NEEDS_CONTAINER = "container"
|
|
1833
|
+
NEEDS_WORKSPACE = "workspace_only"
|
|
1834
|
+
NEEDS_EITHER = "workspace_or_container"
|
|
1835
|
+
#: A container, or a workspace on a machine where some Python can import
|
|
1836
|
+
#: Playwright (browsing.local_interpreter). The browser tools' own first
|
|
1837
|
+
#: refusal names both halves.
|
|
1838
|
+
NEEDS_BROWSER = "container_or_local_browser"
|
|
1839
|
+
|
|
1840
|
+
TOOL_NEEDS: dict[str, str] = {
|
|
1841
|
+
"execute_python": ANYWHERE,
|
|
1842
|
+
"execute_bash": ANYWHERE,
|
|
1843
|
+
"web_search": ANYWHERE,
|
|
1844
|
+
"complete_code": ANYWHERE,
|
|
1845
|
+
"predict_edit": ANYWHERE,
|
|
1846
|
+
"recall_memory": ANYWHERE,
|
|
1847
|
+
# Each of these checks the command runner first and falls back to the
|
|
1848
|
+
# workspace, so either one is enough.
|
|
1849
|
+
"read_file": NEEDS_EITHER,
|
|
1850
|
+
"write_file": NEEDS_EITHER,
|
|
1851
|
+
"edit_file": NEEDS_EITHER,
|
|
1852
|
+
"list_files": NEEDS_EITHER,
|
|
1853
|
+
"view_image": NEEDS_EITHER,
|
|
1854
|
+
# The browser lives in the container, or runs here against the workspace
|
|
1855
|
+
# when a local interpreter has Playwright. `look` follows it: locally it
|
|
1856
|
+
# shows the page the last `browse` rendered. Clicking a DESKTOP has no
|
|
1857
|
+
# local path and never will -- see agent/pipeline/screen.py.
|
|
1858
|
+
"browse": NEEDS_BROWSER,
|
|
1859
|
+
"browse_act": NEEDS_BROWSER,
|
|
1860
|
+
# The shell backend needs nothing installed; the page and terminal
|
|
1861
|
+
# backends say what is missing when it is.
|
|
1862
|
+
"exercise": NEEDS_EITHER,
|
|
1863
|
+
"look": NEEDS_BROWSER,
|
|
1864
|
+
"look_act": NEEDS_CONTAINER,
|
|
1865
|
+
# Both index files on this machine and have no remote branch.
|
|
1866
|
+
"rag": NEEDS_WORKSPACE,
|
|
1867
|
+
"code_map": NEEDS_WORKSPACE,
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def reachable_tools() -> dict[str, str]:
|
|
1872
|
+
"""The standing tools that could actually do something in this run.
|
|
1873
|
+
|
|
1874
|
+
Used to decide what the PROMPT advertises. It is deliberately NOT used to
|
|
1875
|
+
filter `dispatch_table()`: a model that names a tool left out of the menu
|
|
1876
|
+
still reaches it and still gets that tool's own refusal, exactly as today.
|
|
1877
|
+
That is what makes this free -- being wrong about reachability costs the
|
|
1878
|
+
same as being right about it does now, so there is no new failure mode and
|
|
1879
|
+
no extra round trip.
|
|
1880
|
+
"""
|
|
1881
|
+
has_workspace = current_workspace() is not None
|
|
1882
|
+
has_container = current_command_runner() is not None
|
|
1883
|
+
live = {}
|
|
1884
|
+
for name, tier in TOOL_TIERS.items():
|
|
1885
|
+
need = TOOL_NEEDS.get(name, ANYWHERE)
|
|
1886
|
+
if need == ANYWHERE:
|
|
1887
|
+
live[name] = tier
|
|
1888
|
+
elif need == NEEDS_CONTAINER and has_container:
|
|
1889
|
+
live[name] = tier
|
|
1890
|
+
elif need == NEEDS_WORKSPACE and has_workspace:
|
|
1891
|
+
live[name] = tier
|
|
1892
|
+
elif need == NEEDS_EITHER and (has_workspace or has_container):
|
|
1893
|
+
live[name] = tier
|
|
1894
|
+
elif need == NEEDS_BROWSER and (
|
|
1895
|
+
has_container or (has_workspace and browsing.local_interpreter() is not None)
|
|
1896
|
+
):
|
|
1897
|
+
live[name] = tier
|
|
1898
|
+
return live
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
#: One parsed index per workspace, keyed by path and by what the tree looked
|
|
1902
|
+
#: like when it was built. Rebuilt when a Python file's size or mtime changes,
|
|
1903
|
+
#: which is cheap to check and catches every edit the agent itself makes --
|
|
1904
|
+
#: an index that went stale mid-run would answer confidently about code that
|
|
1905
|
+
#: no longer exists, which is worse than being slow.
|
|
1906
|
+
_CODE_MAPS: dict[str, tuple[int, dict]] = {}
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def _tree_stamp(root) -> int:
|
|
1910
|
+
stamp = 0
|
|
1911
|
+
for path in root.rglob("*.py"):
|
|
1912
|
+
if _codemap.SKIP_DIRS.intersection(path.parts):
|
|
1913
|
+
continue
|
|
1914
|
+
try:
|
|
1915
|
+
info = path.stat()
|
|
1916
|
+
except OSError:
|
|
1917
|
+
continue
|
|
1918
|
+
stamp ^= hash((str(path), int(info.st_mtime_ns), info.st_size))
|
|
1919
|
+
return stamp
|
|
1920
|
+
|
|
1921
|
+
|
|
1922
|
+
def code_map(query: str) -> ToolResult:
|
|
1923
|
+
"""Where a Python name is DEFINED and what references it, read from the
|
|
1924
|
+
syntax tree rather than searched for.
|
|
1925
|
+
|
|
1926
|
+
`query` is one of:
|
|
1927
|
+
|
|
1928
|
+
define <name> every class/function/method with that exact name
|
|
1929
|
+
uses <name> every file and line that references it
|
|
1930
|
+
imports <module> every file importing that module or below it
|
|
1931
|
+
outline <path> every definition in one file, in order
|
|
1932
|
+
|
|
1933
|
+
Exact names, not substrings: `save` and `save_all` are different
|
|
1934
|
+
functions, and conflating them is what makes grep a poor answer here. No
|
|
1935
|
+
model call and nothing leaves the machine -- see agent/pipeline/codemap.py,
|
|
1936
|
+
including why this covers Python and says so rather than half-covering
|
|
1937
|
+
everything.
|
|
1938
|
+
"""
|
|
1939
|
+
try:
|
|
1940
|
+
root = resolve_in_workspace(".")
|
|
1941
|
+
except (OutsideWorkspace, OSError, ValueError) as exc:
|
|
1942
|
+
return ToolResult(stdout="", stderr=f"code_map: {exc}", returncode=1)
|
|
1943
|
+
|
|
1944
|
+
key = str(root)
|
|
1945
|
+
stamp = _tree_stamp(root)
|
|
1946
|
+
cached = _CODE_MAPS.get(key)
|
|
1947
|
+
if cached is None or cached[0] != stamp:
|
|
1948
|
+
files = _codemap.index_tree(root)
|
|
1949
|
+
if not files:
|
|
1950
|
+
return ToolResult(
|
|
1951
|
+
stdout="",
|
|
1952
|
+
stderr=("code_map: no Python files here. It reads Python only "
|
|
1953
|
+
"-- use execute_bash with grep for other languages."),
|
|
1954
|
+
returncode=1,
|
|
1955
|
+
)
|
|
1956
|
+
_CODE_MAPS[key] = (stamp, files)
|
|
1957
|
+
cached = _CODE_MAPS[key]
|
|
1958
|
+
|
|
1959
|
+
answer = _codemap.render(cached[1], query)
|
|
1960
|
+
broken = [i.path for i in cached[1].values() if i.error]
|
|
1961
|
+
if broken:
|
|
1962
|
+
answer += f"\n({len(broken)} file(s) did not parse: {', '.join(broken[:3])})"
|
|
1963
|
+
return ToolResult(stdout=answer, stderr="", returncode=0)
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
#: What nodes.py's _tool_loop actually calls, keyed by the tool name an
|
|
1967
|
+
#: ACTION: line names. Every entry takes one positional string (the CODE:
|
|
1968
|
+
#: body, whatever that means for the tool -- code, a shell command, a
|
|
1969
|
+
#: query) and returns a ToolResult, so _tool_loop's formatting/feedback
|
|
1970
|
+
#: logic never needs to know which tool it just called.
|
|
1971
|
+
TOOL_DISPATCH: dict[str, Callable[[str], ToolResult]] = {
|
|
1972
|
+
"execute_python": execute_python,
|
|
1973
|
+
"execute_bash": execute_bash,
|
|
1974
|
+
"read_file": read_file,
|
|
1975
|
+
"view_image": view_image,
|
|
1976
|
+
"list_files": list_files,
|
|
1977
|
+
"write_file": write_file,
|
|
1978
|
+
"edit_file": edit_file,
|
|
1979
|
+
"browse": browse,
|
|
1980
|
+
"exercise": exercise,
|
|
1981
|
+
"look": look,
|
|
1982
|
+
"look_act": look_act,
|
|
1983
|
+
"browse_act": browse_act,
|
|
1984
|
+
"web_search": web_search,
|
|
1985
|
+
"rag": rag,
|
|
1986
|
+
"code_map": code_map,
|
|
1987
|
+
"complete_code": complete_code,
|
|
1988
|
+
"predict_edit": predict_edit,
|
|
1989
|
+
"recall_memory": recall_memory,
|
|
1990
|
+
}
|