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.
Files changed (129) hide show
  1. agent/README.md +22 -0
  2. agent/__init__.py +0 -0
  3. agent/cli/README.md +77 -0
  4. agent/cli/__init__.py +0 -0
  5. agent/cli/art.py +371 -0
  6. agent/cli/chat.py +309 -0
  7. agent/cli/clipboard.py +106 -0
  8. agent/cli/context.py +56 -0
  9. agent/cli/doctor.py +60 -0
  10. agent/cli/errors.py +58 -0
  11. agent/cli/eval.py +130 -0
  12. agent/cli/eval_claw.py +414 -0
  13. agent/cli/eval_compaction.py +106 -0
  14. agent/cli/eval_hle.py +92 -0
  15. agent/cli/eval_memory.py +253 -0
  16. agent/cli/eval_swe.py +172 -0
  17. agent/cli/lessons.py +97 -0
  18. agent/cli/main.py +67 -0
  19. agent/cli/modals.py +570 -0
  20. agent/cli/models.py +64 -0
  21. agent/cli/output.py +54 -0
  22. agent/cli/route.py +78 -0
  23. agent/cli/sessions.py +125 -0
  24. agent/cli/setup_screen.py +562 -0
  25. agent/cli/shell.py +548 -0
  26. agent/cli/tui.py +1807 -0
  27. agent/cli/ui.py +14 -0
  28. agent/cli/usage_panel.py +159 -0
  29. agent/config/README.md +7 -0
  30. agent/config/__init__.py +0 -0
  31. agent/config/envfile.py +76 -0
  32. agent/eval/README.md +76 -0
  33. agent/eval/__init__.py +0 -0
  34. agent/eval/claw_bench.py +1031 -0
  35. agent/eval/compaction_bench.py +229 -0
  36. agent/eval/data/README.md +10 -0
  37. agent/eval/data/claw/README.md +108 -0
  38. agent/eval/data/claw/llm_judge-gemini.patch +57 -0
  39. agent/eval/data/claw/otto.yaml +35 -0
  40. agent/eval/failures.py +276 -0
  41. agent/eval/golden/README.md +33 -0
  42. agent/eval/golden/code_01.json +6 -0
  43. agent/eval/golden/code_02.json +6 -0
  44. agent/eval/golden/code_03.json +6 -0
  45. agent/eval/golden/code_04.json +6 -0
  46. agent/eval/golden/code_05.json +6 -0
  47. agent/eval/golden/code_06.json +6 -0
  48. agent/eval/golden/math_01.json +6 -0
  49. agent/eval/golden/math_02.json +6 -0
  50. agent/eval/golden/math_03.json +6 -0
  51. agent/eval/golden/math_04.json +6 -0
  52. agent/eval/golden/math_05.json +6 -0
  53. agent/eval/golden/math_06.json +6 -0
  54. agent/eval/golden/nphard_gcp_01.json +6 -0
  55. agent/eval/golden/nphard_ksp_01.json +6 -0
  56. agent/eval/golden/nphard_math_binpacking_01.json +6 -0
  57. agent/eval/golden/nphard_math_clique_01.json +6 -0
  58. agent/eval/golden/nphard_math_setcover_01.json +6 -0
  59. agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
  60. agent/eval/golden/nphard_tsp_01.json +6 -0
  61. agent/eval/golden/nphard_tsp_02.json +6 -0
  62. agent/eval/hle_bench.py +273 -0
  63. agent/eval/langfuse_sync.py +172 -0
  64. agent/eval/memory_bench.py +538 -0
  65. agent/eval/runner.py +174 -0
  66. agent/eval/single_agent.py +120 -0
  67. agent/eval/swe_bench.py +604 -0
  68. agent/eval/terminal_bench.py +345 -0
  69. agent/memory/README.md +102 -0
  70. agent/memory/__init__.py +42 -0
  71. agent/memory/embeddings.py +302 -0
  72. agent/memory/hashing.py +15 -0
  73. agent/memory/lessons.py +483 -0
  74. agent/memory/queue.py +531 -0
  75. agent/memory/retrieval.py +493 -0
  76. agent/memory/session.py +60 -0
  77. agent/memory/sessions.py +436 -0
  78. agent/memory/store.py +429 -0
  79. agent/memory/tokens.py +60 -0
  80. agent/memory/wiring.py +146 -0
  81. agent/pipeline/README.md +135 -0
  82. agent/pipeline/__init__.py +0 -0
  83. agent/pipeline/browsing.py +609 -0
  84. agent/pipeline/budget.py +403 -0
  85. agent/pipeline/codemap.py +254 -0
  86. agent/pipeline/evidence.py +325 -0
  87. agent/pipeline/execution.py +67 -0
  88. agent/pipeline/modes.py +137 -0
  89. agent/pipeline/native.py +1137 -0
  90. agent/pipeline/nodes.py +3644 -0
  91. agent/pipeline/pricing.py +209 -0
  92. agent/pipeline/progress.py +139 -0
  93. agent/pipeline/rag.py +139 -0
  94. agent/pipeline/research.py +1325 -0
  95. agent/pipeline/run.py +528 -0
  96. agent/pipeline/screen.py +77 -0
  97. agent/pipeline/state.py +220 -0
  98. agent/pipeline/toolkit.py +328 -0
  99. agent/pipeline/tools.py +1990 -0
  100. agent/pipeline/tracing.py +147 -0
  101. agent/pipeline/usage.py +251 -0
  102. agent/pipeline/vision.py +84 -0
  103. agent/pipeline/walkthrough.py +735 -0
  104. agent/pipeline/workspace.py +229 -0
  105. agent/router/README.md +60 -0
  106. agent/router/__init__.py +0 -0
  107. agent/router/automap.py +114 -0
  108. agent/router/health.py +229 -0
  109. agent/router/llm_provider/README.md +38 -0
  110. agent/router/llm_provider/__init__.py +202 -0
  111. agent/router/llm_provider/anthropic_provider.py +128 -0
  112. agent/router/llm_provider/base.py +507 -0
  113. agent/router/llm_provider/custom.py +152 -0
  114. agent/router/llm_provider/gemini_provider.py +122 -0
  115. agent/router/llm_provider/inception_provider.py +687 -0
  116. agent/router/llm_provider/openai_provider.py +151 -0
  117. agent/router/llm_provider/retired.py +145 -0
  118. agent/router/llm_provider/temperature.py +371 -0
  119. agent/router/mapping.py +579 -0
  120. agent/router/outcomes.py +363 -0
  121. agent/router/overrides.py +389 -0
  122. agent/router/reload.py +28 -0
  123. agent/router/router.py +413 -0
  124. agent/router/setup.py +123 -0
  125. otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
  126. otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
  127. otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
  128. otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  129. otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ }