sid-harness 0.3.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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/_native.py
ADDED
|
@@ -0,0 +1,1639 @@
|
|
|
1
|
+
"""First-party SID coding loop over routed model tool calls (LQ-576).
|
|
2
|
+
|
|
3
|
+
The agent works on a bounded in-memory workspace. It can inspect and edit only
|
|
4
|
+
that workspace; approved tests execute through the existing sandbox seam. The
|
|
5
|
+
resulting tree is returned to the caller for the existing independent review
|
|
6
|
+
and BellerBuild scan. No external coding-agent UI or SDK is required.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
import fnmatch
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
MAX_FILES = 200
|
|
25
|
+
MAX_WORKSPACE_BYTES = 8 * 1024 * 1024
|
|
26
|
+
MAX_FILE_BYTES = 1024 * 1024
|
|
27
|
+
MAX_TURNS = 12
|
|
28
|
+
MAX_TOOL_CALLS = 32
|
|
29
|
+
MAX_TOOL_OUTPUT_CHARS = 6000
|
|
30
|
+
|
|
31
|
+
# SUB-94: project policy. The workspace's root instruction files and its
|
|
32
|
+
# optional ``.sid/config`` are loaded by the ONE loop, so the CLI, the local
|
|
33
|
+
# bridge, Studio chat and the workbench all obey the same precedence. Rule text
|
|
34
|
+
# is concatenated in this order (nothing is silently overridden); the config
|
|
35
|
+
# supplies run options, and explicit request flags are applied on top of it.
|
|
36
|
+
PROJECT_RULE_FILES: tuple[str, ...] = ("AGENTS.md", "SID.md")
|
|
37
|
+
PROJECT_CONFIG_PATH = ".sid/config"
|
|
38
|
+
PROJECT_RULES_PRECEDENCE = (
|
|
39
|
+
".sid/config options < AGENTS.md < SID.md < explicit request flags "
|
|
40
|
+
"(--skills, --subagent, --plan); a .sid/config that forces plan mode is a "
|
|
41
|
+
"read-only floor a run cannot loosen"
|
|
42
|
+
)
|
|
43
|
+
MAX_PROJECT_RULE_BYTES = 256 * 1024
|
|
44
|
+
|
|
45
|
+
# The loop's base contract stays list/read/write/run_approved_tests. The shell
|
|
46
|
+
# tool is a FIFTH, opt-in tool: it is advertised and honored only when the
|
|
47
|
+
# caller supplies an explicitly approved ``ShellPolicy`` with a sandbox root
|
|
48
|
+
# (``run_native_agent(..., shell_policy=...)``). With no policy — the server
|
|
49
|
+
# path — the loop cannot run a shell at all. Everything below is stdlib-only so
|
|
50
|
+
# the module stays vendorable verbatim into the local harness (``_native.py``).
|
|
51
|
+
SHELL_TOOL_NAME = "run_shell"
|
|
52
|
+
SHELL_MODE_ARGV = "argv"
|
|
53
|
+
SHELL_MODE_SHELL = "shell"
|
|
54
|
+
SHELL_MODES = (SHELL_MODE_ARGV, SHELL_MODE_SHELL)
|
|
55
|
+
DEFAULT_SHELL_TIMEOUT_SECONDS = 120.0
|
|
56
|
+
MAX_SHELL_TIMEOUT_SECONDS = 900.0
|
|
57
|
+
MAX_SHELL_OUTPUT_CHARS = 8000
|
|
58
|
+
|
|
59
|
+
# Programs that are never run, even when a caller allow-lists them: they are
|
|
60
|
+
# destructive, privilege-escalating, network/tunnel, or a second shell (which
|
|
61
|
+
# would defeat the argv/shell policy itself).
|
|
62
|
+
ALWAYS_DENIED_PROGRAMS = frozenset({
|
|
63
|
+
"at", "bcdedit", "bash", "chattr", "chgrp", "chmod", "chown", "cmd",
|
|
64
|
+
"command.com", "crontab", "csh", "curl", "dash", "dd", "del", "diskpart",
|
|
65
|
+
"doas", "erase", "eval", "exec", "fdisk", "firewall-cmd", "fish", "format",
|
|
66
|
+
"ftp", "halt", "icacls", "init", "ip6tables", "iptables", "kill",
|
|
67
|
+
"killall", "ksh", "mkfs", "mount", "nc", "ncat", "net", "netcat", "netsh",
|
|
68
|
+
"nft", "parted", "pkexec", "pkill", "poweroff", "powershell", "pwsh",
|
|
69
|
+
"reboot", "reg", "regedit", "rm", "rmdir", "rsync", "runas", "sc", "scp",
|
|
70
|
+
"schtasks", "service", "setfacl", "sftp", "sh", "shred", "shutdown",
|
|
71
|
+
"socat", "source", "ssh", "su", "sudo", "systemctl", "takeown", "taskkill",
|
|
72
|
+
"telnet", "ufw", "umount", "wget", "wmic", "wsl", "xargs", "zsh",
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
# Default allow-list: the build / install / migrate / format / inspect tools
|
|
76
|
+
# this tool exists for. Callers may replace it (``ShellPolicy(allow=...)``);
|
|
77
|
+
# the ALWAYS_DENIED_PROGRAMS set still applies.
|
|
78
|
+
DEFAULT_ALLOWED_PROGRAMS = frozenset({
|
|
79
|
+
"7z", "ar", "awk", "basename", "black", "cargo", "cat", "cc", "clang",
|
|
80
|
+
"clang++", "clippy-driver", "cmake", "cmp", "coverage", "coverage3",
|
|
81
|
+
"c++", "date", "diff", "dir", "dirname", "dotnet", "echo", "env",
|
|
82
|
+
"eslint", "flake8", "g++", "gcc", "git", "git-lfs", "go", "gofmt",
|
|
83
|
+
"gradle", "gradlew", "grep", "gunzip", "gzip", "head", "isort", "jar",
|
|
84
|
+
"java", "javac", "jest", "ld", "ls", "make", "md", "meson", "mkdir",
|
|
85
|
+
"msbuild", "mvn", "mvnw", "mypy", "ninja", "node", "npm", "npx", "nuget",
|
|
86
|
+
"objcopy", "pip", "pip3", "pipenv", "pnpm", "poetry", "prettier",
|
|
87
|
+
"printenv", "printf", "pwd", "py.test", "pytest", "python", "python3",
|
|
88
|
+
"realpath", "readlink", "rg", "robocopy", "ruff", "rustc", "rustfmt",
|
|
89
|
+
"sed", "sort", "strip", "tar", "tee", "tox", "tr", "tsc", "type", "uniq",
|
|
90
|
+
"unzip", "uv", "uvx", "vite", "wc", "webpack", "where", "which",
|
|
91
|
+
"xcopy", "yarn", "zip",
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
# Programs whose ``-c``/``-e`` flags run inline code. Refused unless the caller
|
|
95
|
+
# explicitly opts in (``allow_inline_code=True``); ``-m``/script paths still work.
|
|
96
|
+
INLINE_CODE_PROGRAMS = frozenset({
|
|
97
|
+
"bun", "deno", "node", "nodejs", "perl", "php", "python", "python3",
|
|
98
|
+
"ruby",
|
|
99
|
+
})
|
|
100
|
+
INLINE_CODE_FLAGS = frozenset({"-c", "-e", "-r", "-p", "--eval", "--print"})
|
|
101
|
+
|
|
102
|
+
# Token sequences refused even when the executable is allow-listed: publishing
|
|
103
|
+
# and irreversible history operations. The harness never commits or pushes.
|
|
104
|
+
DENIED_ARG_SEQUENCES: tuple[tuple[str, ...], ...] = (
|
|
105
|
+
("cargo", "publish"),
|
|
106
|
+
("dotnet", "nuget", "push"),
|
|
107
|
+
("gem", "push"),
|
|
108
|
+
("git", "checkout", "--"),
|
|
109
|
+
("git", "clean"),
|
|
110
|
+
("git", "commit"),
|
|
111
|
+
("git", "config", "--global"),
|
|
112
|
+
("git", "config", "--system"),
|
|
113
|
+
("git", "filter-branch"),
|
|
114
|
+
("git", "push"),
|
|
115
|
+
("git", "remote", "add"),
|
|
116
|
+
("git", "remote", "set-url"),
|
|
117
|
+
("git", "reset", "--hard"),
|
|
118
|
+
("npm", "publish"),
|
|
119
|
+
("npm", "unpublish"),
|
|
120
|
+
("pip", "uninstall"),
|
|
121
|
+
("pnpm", "publish"),
|
|
122
|
+
("twine", "upload"),
|
|
123
|
+
("yarn", "publish"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Shell-injection shapes. In argv mode no shell is involved, so these are only
|
|
127
|
+
# refused as tokens (a harmless literal ``>`` is allowed): splitting, pipes,
|
|
128
|
+
# command substitution, and control bytes. In shell mode redirection and
|
|
129
|
+
# backgrounding are refused too.
|
|
130
|
+
INJECTION_ARGV_MARKERS = (";", "|", "`", "$(", "${", "\n", "\r", "\x00")
|
|
131
|
+
INJECTION_SHELL_MARKERS = (*INJECTION_ARGV_MARKERS, "&", ">", "<")
|
|
132
|
+
|
|
133
|
+
# Process environment kept in sandbox mode. Anything else (tokens, cloud keys,
|
|
134
|
+
# database URLs) never reaches the child.
|
|
135
|
+
SANDBOX_ENV_ALLOW = frozenset({
|
|
136
|
+
"CONDA_PREFIX", "HOME", "LANG", "LC_ALL", "PATH", "PATHEXT", "PYTHONPATH",
|
|
137
|
+
"SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "USERPROFILE", "VIRTUAL_ENV",
|
|
138
|
+
"WINDIR",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# SID Studio guardrail: the observed failure mode was a routed model answering
|
|
145
|
+
# a workspace request with generic assistant boilerplate (declining the file as
|
|
146
|
+
# though it had no editor). The loop's system prompt forbids that family
|
|
147
|
+
# outright and requires that any genuine limitation be named precisely. Kept
|
|
148
|
+
# here (not imported from app.policy) so this module stays stdlib-only and
|
|
149
|
+
# vendorable verbatim.
|
|
150
|
+
WORKSPACE_GUARDRAIL = (
|
|
151
|
+
"You have the project workspace and its tools — the workspace is right "
|
|
152
|
+
"here. NEVER decline the files or ask the user to paste or upload them: "
|
|
153
|
+
"read a file with read_file, or find it with list_files. Do not describe "
|
|
154
|
+
"yourself as a model that has no editor. If a capability you need is "
|
|
155
|
+
"genuinely unavailable (for example no sandbox is configured to run the "
|
|
156
|
+
"tests), name exactly what is missing and the safe next step — never a "
|
|
157
|
+
"generic refusal."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# SUB-81: plan mode. The same loop runs read-only: write_file is not offered,
|
|
161
|
+
# every intended edit is submitted through propose_file_change (recorded, never
|
|
162
|
+
# applied), and the model must close with a step plan. The proposal is returned
|
|
163
|
+
# as a diff + readback so an explicit client-side Approve can apply it through
|
|
164
|
+
# the existing write-back path.
|
|
165
|
+
PLAN_MODE_SYSTEM = (
|
|
166
|
+
"PLAN MODE — READ-ONLY. You may inspect the workspace, but you cannot write "
|
|
167
|
+
"it: write_file, apply_patch, delete_file and move_file are disabled. Submit "
|
|
168
|
+
"every intended edit through propose_file_change(path, content), which "
|
|
169
|
+
"records a PROPOSAL and writes nothing. Never say a file was written, saved, "
|
|
170
|
+
"or changed, and never claim the task is done. Finish with a short numbered "
|
|
171
|
+
"step plan: the steps, the files each step touches, and how to verify the "
|
|
172
|
+
"result. The user must approve the plan before anything is applied."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
PROPOSE_TOOL = {
|
|
176
|
+
"type": "function",
|
|
177
|
+
"function": {
|
|
178
|
+
"name": "propose_file_change",
|
|
179
|
+
"description": (
|
|
180
|
+
"Plan mode only: propose new content for a file. Records the proposal "
|
|
181
|
+
"for the user to approve; writes NOTHING to the workspace."
|
|
182
|
+
),
|
|
183
|
+
"parameters": {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {
|
|
186
|
+
"path": {"type": "string"},
|
|
187
|
+
"content": {"type": "string"},
|
|
188
|
+
},
|
|
189
|
+
"required": ["path", "content"],
|
|
190
|
+
"additionalProperties": False,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
Complete = Callable[[list[dict[str, Any]], list[dict[str, Any]]], Awaitable[dict[str, Any]]]
|
|
196
|
+
RunTests = Callable[[dict[str, str]], Awaitable[dict[str, Any]]]
|
|
197
|
+
# SUB-58: a caller (the local harness) may stop the loop between turns for a
|
|
198
|
+
# clean interrupt. Called with (step, tool_calls_seen); returning True ends the
|
|
199
|
+
# loop with a partial result. Default None keeps the server path unaffected.
|
|
200
|
+
ShouldStop = Callable[[int, int], bool]
|
|
201
|
+
# A caller may supply extra tool schemas plus ONE dispatch seam (the harness's
|
|
202
|
+
# ToolRegistry). Dispatch receives (name, arguments, files, run_tests) and is
|
|
203
|
+
# responsible for routing base tool names back to _tool_result; it must not fork
|
|
204
|
+
# the execution engine. Default None keeps the server path on the base contract.
|
|
205
|
+
Dispatch = Callable[[str, dict[str, Any], dict[str, str], "RunTests"], Any]
|
|
206
|
+
|
|
207
|
+
TOOLS = [
|
|
208
|
+
{
|
|
209
|
+
"type": "function",
|
|
210
|
+
"function": {
|
|
211
|
+
"name": "list_files",
|
|
212
|
+
"description": "List files available in the approved project workspace.",
|
|
213
|
+
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"type": "function",
|
|
218
|
+
"function": {
|
|
219
|
+
"name": "read_file",
|
|
220
|
+
"description": "Read one file from the approved project workspace.",
|
|
221
|
+
"parameters": {
|
|
222
|
+
"type": "object",
|
|
223
|
+
"properties": {"path": {"type": "string"}},
|
|
224
|
+
"required": ["path"],
|
|
225
|
+
"additionalProperties": False,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"type": "function",
|
|
231
|
+
"function": {
|
|
232
|
+
"name": "write_file",
|
|
233
|
+
"description": "Create or replace a text file in the approved project workspace.",
|
|
234
|
+
"parameters": {
|
|
235
|
+
"type": "object",
|
|
236
|
+
"properties": {
|
|
237
|
+
"path": {"type": "string"},
|
|
238
|
+
"content": {"type": "string"},
|
|
239
|
+
},
|
|
240
|
+
"required": ["path", "content"],
|
|
241
|
+
"additionalProperties": False,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"type": "function",
|
|
247
|
+
"function": {
|
|
248
|
+
"name": "apply_patch",
|
|
249
|
+
"description": (
|
|
250
|
+
"Edit one file by anchored replacements. Each edit supplies the exact "
|
|
251
|
+
"existing text (old) that must occur exactly once and its replacement "
|
|
252
|
+
"(new); use a few surrounding lines as the anchor. Returns a unified "
|
|
253
|
+
"diff of the applied change. Prefer this over write_file: it sends only "
|
|
254
|
+
"the changed region, not the whole file."
|
|
255
|
+
),
|
|
256
|
+
"parameters": {
|
|
257
|
+
"type": "object",
|
|
258
|
+
"properties": {
|
|
259
|
+
"path": {"type": "string"},
|
|
260
|
+
"edits": {
|
|
261
|
+
"type": "array",
|
|
262
|
+
"minItems": 1,
|
|
263
|
+
"items": {
|
|
264
|
+
"type": "object",
|
|
265
|
+
"properties": {
|
|
266
|
+
"old": {"type": "string"},
|
|
267
|
+
"new": {"type": "string"},
|
|
268
|
+
},
|
|
269
|
+
"required": ["old", "new"],
|
|
270
|
+
"additionalProperties": False,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
"required": ["path", "edits"],
|
|
275
|
+
"additionalProperties": False,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"type": "function",
|
|
281
|
+
"function": {
|
|
282
|
+
"name": "delete_file",
|
|
283
|
+
"description": (
|
|
284
|
+
"Delete one file from the workspace. Requires confirm=true: a deletion "
|
|
285
|
+
"is never silent, and it is applied to disk only when the caller "
|
|
286
|
+
"approves the run's diff."
|
|
287
|
+
),
|
|
288
|
+
"parameters": {
|
|
289
|
+
"type": "object",
|
|
290
|
+
"properties": {
|
|
291
|
+
"path": {"type": "string"},
|
|
292
|
+
"confirm": {"type": "boolean"},
|
|
293
|
+
},
|
|
294
|
+
"required": ["path", "confirm"],
|
|
295
|
+
"additionalProperties": False,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"type": "function",
|
|
301
|
+
"function": {
|
|
302
|
+
"name": "move_file",
|
|
303
|
+
"description": (
|
|
304
|
+
"Move or rename one file inside the workspace. Requires confirm=true "
|
|
305
|
+
"and never overwrites an existing destination."
|
|
306
|
+
),
|
|
307
|
+
"parameters": {
|
|
308
|
+
"type": "object",
|
|
309
|
+
"properties": {
|
|
310
|
+
"from": {"type": "string"},
|
|
311
|
+
"to": {"type": "string"},
|
|
312
|
+
"confirm": {"type": "boolean"},
|
|
313
|
+
},
|
|
314
|
+
"required": ["from", "to", "confirm"],
|
|
315
|
+
"additionalProperties": False,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
"type": "function",
|
|
321
|
+
"function": {
|
|
322
|
+
"name": "grep",
|
|
323
|
+
"description": (
|
|
324
|
+
"Search file contents across the workspace. Returns path, line number, "
|
|
325
|
+
"and line text for each match. Honours the shared path denylist and an "
|
|
326
|
+
"optional exclude list of glob patterns."
|
|
327
|
+
),
|
|
328
|
+
"parameters": {
|
|
329
|
+
"type": "object",
|
|
330
|
+
"properties": {
|
|
331
|
+
"pattern": {"type": "string"},
|
|
332
|
+
"path": {
|
|
333
|
+
"type": "string",
|
|
334
|
+
"description": "Optional file or directory prefix to search.",
|
|
335
|
+
},
|
|
336
|
+
"regex": {"type": "boolean"},
|
|
337
|
+
"case_sensitive": {"type": "boolean"},
|
|
338
|
+
"exclude": {"type": "array", "items": {"type": "string"}},
|
|
339
|
+
"max_results": {"type": "integer"},
|
|
340
|
+
},
|
|
341
|
+
"required": ["pattern"],
|
|
342
|
+
"additionalProperties": False,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
"type": "function",
|
|
348
|
+
"function": {
|
|
349
|
+
"name": "glob",
|
|
350
|
+
"description": (
|
|
351
|
+
"Find workspace files by glob pattern (for example **/*.py). Honours "
|
|
352
|
+
"the shared path denylist and an optional exclude list."
|
|
353
|
+
),
|
|
354
|
+
"parameters": {
|
|
355
|
+
"type": "object",
|
|
356
|
+
"properties": {
|
|
357
|
+
"pattern": {"type": "string"},
|
|
358
|
+
"exclude": {"type": "array", "items": {"type": "string"}},
|
|
359
|
+
"max_results": {"type": "integer"},
|
|
360
|
+
},
|
|
361
|
+
"required": ["pattern"],
|
|
362
|
+
"additionalProperties": False,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
"type": "function",
|
|
368
|
+
"function": {
|
|
369
|
+
"name": "run_approved_tests",
|
|
370
|
+
"description": "Run the test command the user approved before this task.",
|
|
371
|
+
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
# Opt-in fifth tool. NOT part of TOOLS: the base contract is unchanged and the
|
|
377
|
+
# server path never advertises it. A caller that supplies an approved
|
|
378
|
+
# ``ShellPolicy`` gets this schema appended for the run.
|
|
379
|
+
SHELL_TOOL = {
|
|
380
|
+
"type": "function",
|
|
381
|
+
"function": {
|
|
382
|
+
"name": SHELL_TOOL_NAME,
|
|
383
|
+
"description": (
|
|
384
|
+
"Run ONE command the user explicitly approved for this task inside "
|
|
385
|
+
"the sandbox workspace (build, install, migrate, format, or read-only "
|
|
386
|
+
"git). Pass an argv list when the policy is 'argv' mode; a single "
|
|
387
|
+
"command line only when the policy is 'shell' mode. The sandbox root "
|
|
388
|
+
"is the working directory; the command may not escape it."
|
|
389
|
+
),
|
|
390
|
+
"parameters": {
|
|
391
|
+
"type": "object",
|
|
392
|
+
"properties": {
|
|
393
|
+
"argv": {
|
|
394
|
+
"type": "array",
|
|
395
|
+
"items": {"type": "string"},
|
|
396
|
+
"description": "Command as an argv list (preferred; no shell).",
|
|
397
|
+
},
|
|
398
|
+
"command": {
|
|
399
|
+
"type": "string",
|
|
400
|
+
"description": "Command line (only when the policy is 'shell' mode).",
|
|
401
|
+
},
|
|
402
|
+
"cwd": {
|
|
403
|
+
"type": "string",
|
|
404
|
+
"description": "Relative working directory inside the sandbox root.",
|
|
405
|
+
},
|
|
406
|
+
"timeout_seconds": {
|
|
407
|
+
"type": "number",
|
|
408
|
+
"description": "Requested wall-clock timeout; clamped by policy.",
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
"additionalProperties": False,
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# Tools that mutate the workspace. Plan mode disables all of them (SUB-81) and
|
|
417
|
+
# the harness registry gates them behind the approved-plan check (SUB-56).
|
|
418
|
+
MUTATING_TOOL_NAMES = frozenset(
|
|
419
|
+
{"write_file", "apply_patch", "delete_file", "move_file"}
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
DEFAULT_MAX_SEARCH_RESULTS = 100
|
|
423
|
+
MAX_SEARCH_RESULTS = 500
|
|
424
|
+
MAX_GREP_LINE_CHARS = 400
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class NativeAgentError(ValueError):
|
|
428
|
+
"""The first-party agent could not safely complete the requested loop."""
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class NativeAgentInputError(NativeAgentError):
|
|
432
|
+
"""The submitted workspace is outside the native agent's accepted bounds."""
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _safe_path(raw: Any) -> str:
|
|
436
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
437
|
+
raise NativeAgentError("tool path must be a non-empty relative path")
|
|
438
|
+
path = raw.strip().replace("\\", "/")
|
|
439
|
+
parsed = PurePosixPath(path)
|
|
440
|
+
if parsed.is_absolute() or any(part in ("", ".", "..") for part in parsed.parts):
|
|
441
|
+
raise NativeAgentError("tool path must stay inside the project workspace")
|
|
442
|
+
lowered = [part.lower() for part in parsed.parts]
|
|
443
|
+
leaf = lowered[-1]
|
|
444
|
+
if any(part in {".git", "node_modules", ".venv", "venv", "__pycache__"} for part in lowered):
|
|
445
|
+
raise NativeAgentError("agent tools cannot access dependency or VCS directories")
|
|
446
|
+
if any(
|
|
447
|
+
part == ".env" or (part.startswith(".env.") and part != ".env.example")
|
|
448
|
+
for part in lowered
|
|
449
|
+
):
|
|
450
|
+
raise NativeAgentError("agent tools cannot read or write environment secret files")
|
|
451
|
+
if leaf.endswith((".pem", ".key", ".p12", ".pfx")):
|
|
452
|
+
raise NativeAgentError("agent tools cannot read or write private-key files")
|
|
453
|
+
return str(parsed)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _glob_segments(pattern: str) -> list[str]:
|
|
457
|
+
return [part for part in pattern.replace("\\", "/").strip("/").split("/") if part]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _match_glob_segments(pattern_parts: list[str], path_parts: list[str]) -> bool:
|
|
461
|
+
if not pattern_parts:
|
|
462
|
+
return not path_parts
|
|
463
|
+
head = pattern_parts[0]
|
|
464
|
+
if head == "**":
|
|
465
|
+
return any(
|
|
466
|
+
_match_glob_segments(pattern_parts[1:], path_parts[index:])
|
|
467
|
+
for index in range(len(path_parts) + 1)
|
|
468
|
+
)
|
|
469
|
+
if not path_parts:
|
|
470
|
+
return False
|
|
471
|
+
return fnmatch.fnmatchcase(path_parts[0], head) and _match_glob_segments(
|
|
472
|
+
pattern_parts[1:], path_parts[1:]
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _glob_match(pattern: str, rel: str) -> bool:
|
|
477
|
+
"""Path-aware glob: ``*``/``?`` stay inside a segment, ``**`` spans them."""
|
|
478
|
+
if not pattern:
|
|
479
|
+
return False
|
|
480
|
+
if "/" not in pattern.replace("\\", "/"):
|
|
481
|
+
return fnmatch.fnmatchcase(rel.rsplit("/", 1)[-1], pattern)
|
|
482
|
+
return _match_glob_segments(_glob_segments(pattern), _glob_segments(rel))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _matches_any(patterns: object, rel: str) -> bool:
|
|
486
|
+
if not isinstance(patterns, (list, tuple)):
|
|
487
|
+
return False
|
|
488
|
+
return any(isinstance(item, str) and _glob_match(item, rel) for item in patterns)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _search_path_ok(rel: str, scope: str | None) -> bool:
|
|
492
|
+
if not scope:
|
|
493
|
+
return True
|
|
494
|
+
scope = scope.replace("\\", "/").strip("/")
|
|
495
|
+
return rel == scope or rel.startswith(scope + "/")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _search_limit(raw: object) -> int:
|
|
499
|
+
try:
|
|
500
|
+
value = int(raw) # type: ignore[arg-type]
|
|
501
|
+
except (TypeError, ValueError):
|
|
502
|
+
return DEFAULT_MAX_SEARCH_RESULTS
|
|
503
|
+
if value <= 0:
|
|
504
|
+
return DEFAULT_MAX_SEARCH_RESULTS
|
|
505
|
+
return min(value, MAX_SEARCH_RESULTS)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _file_changed_diff(path: str, before: str, after: str | None) -> str:
|
|
509
|
+
return "".join(
|
|
510
|
+
difflib.unified_diff(
|
|
511
|
+
before.splitlines(True),
|
|
512
|
+
(after or "").splitlines(True),
|
|
513
|
+
fromfile=f"a/{path}",
|
|
514
|
+
tofile=f"b/{path}" if after is not None else "/dev/null",
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _grep_result(
|
|
520
|
+
arguments: dict[str, Any],
|
|
521
|
+
files: dict[str, str],
|
|
522
|
+
*,
|
|
523
|
+
max_results: int = DEFAULT_MAX_SEARCH_RESULTS,
|
|
524
|
+
) -> dict[str, Any]:
|
|
525
|
+
pattern = arguments.get("pattern")
|
|
526
|
+
if not isinstance(pattern, str) or not pattern:
|
|
527
|
+
return {"error": "pattern must be a non-empty string"}
|
|
528
|
+
use_regex = bool(arguments.get("regex", True))
|
|
529
|
+
case_sensitive = bool(arguments.get("case_sensitive", True))
|
|
530
|
+
scope = arguments.get("path")
|
|
531
|
+
if scope is not None and not isinstance(scope, str):
|
|
532
|
+
return {"error": "path must be a string"}
|
|
533
|
+
try:
|
|
534
|
+
rx = re.compile(
|
|
535
|
+
pattern if use_regex else re.escape(pattern),
|
|
536
|
+
0 if case_sensitive else re.IGNORECASE,
|
|
537
|
+
)
|
|
538
|
+
except re.error as exc:
|
|
539
|
+
return {"error": f"invalid grep pattern: {exc}"}
|
|
540
|
+
limit = _search_limit(arguments.get("max_results", max_results))
|
|
541
|
+
exclude = arguments.get("exclude")
|
|
542
|
+
|
|
543
|
+
matches: list[dict[str, Any]] = []
|
|
544
|
+
truncated = False
|
|
545
|
+
for path in sorted(files):
|
|
546
|
+
if not _search_path_ok(path, scope) or _matches_any(exclude, path):
|
|
547
|
+
continue
|
|
548
|
+
try:
|
|
549
|
+
_safe_path(path)
|
|
550
|
+
except NativeAgentError:
|
|
551
|
+
continue
|
|
552
|
+
for number, line in enumerate(files[path].splitlines(), 1):
|
|
553
|
+
if not rx.search(line):
|
|
554
|
+
continue
|
|
555
|
+
if len(matches) >= limit:
|
|
556
|
+
truncated = True
|
|
557
|
+
break
|
|
558
|
+
matches.append(
|
|
559
|
+
{
|
|
560
|
+
"path": path,
|
|
561
|
+
"line": number,
|
|
562
|
+
"text": line[:MAX_GREP_LINE_CHARS],
|
|
563
|
+
}
|
|
564
|
+
)
|
|
565
|
+
if truncated:
|
|
566
|
+
break
|
|
567
|
+
return {"matches": matches, "count": len(matches), "truncated": truncated}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _glob_result(
|
|
571
|
+
arguments: dict[str, Any],
|
|
572
|
+
files: dict[str, str],
|
|
573
|
+
*,
|
|
574
|
+
max_results: int = DEFAULT_MAX_SEARCH_RESULTS,
|
|
575
|
+
) -> dict[str, Any]:
|
|
576
|
+
pattern = arguments.get("pattern")
|
|
577
|
+
if not isinstance(pattern, str) or not pattern:
|
|
578
|
+
return {"error": "pattern must be a non-empty string"}
|
|
579
|
+
limit = _search_limit(arguments.get("max_results", max_results))
|
|
580
|
+
exclude = arguments.get("exclude")
|
|
581
|
+
matched: list[str] = []
|
|
582
|
+
truncated = False
|
|
583
|
+
for path in sorted(files):
|
|
584
|
+
if _matches_any(exclude, path) or not _glob_match(pattern, path):
|
|
585
|
+
continue
|
|
586
|
+
try:
|
|
587
|
+
_safe_path(path)
|
|
588
|
+
except NativeAgentError:
|
|
589
|
+
continue
|
|
590
|
+
if len(matched) >= limit:
|
|
591
|
+
truncated = True
|
|
592
|
+
break
|
|
593
|
+
matched.append(path)
|
|
594
|
+
return {"files": matched, "count": len(matched), "truncated": truncated}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _apply_patch_result(
|
|
598
|
+
arguments: dict[str, Any],
|
|
599
|
+
files: dict[str, str],
|
|
600
|
+
*,
|
|
601
|
+
max_file_bytes: int = MAX_FILE_BYTES,
|
|
602
|
+
max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
|
|
603
|
+
) -> dict[str, Any]:
|
|
604
|
+
try:
|
|
605
|
+
path = _safe_path(arguments.get("path"))
|
|
606
|
+
except NativeAgentError as exc:
|
|
607
|
+
return {"error": str(exc)}
|
|
608
|
+
if path not in files:
|
|
609
|
+
return {"error": f"file not found: {path}"}
|
|
610
|
+
edits = arguments.get("edits")
|
|
611
|
+
if not isinstance(edits, list) or not edits:
|
|
612
|
+
return {"error": "edits must be a non-empty array of {old, new} anchors"}
|
|
613
|
+
original = files[path]
|
|
614
|
+
updated = original
|
|
615
|
+
for index, edit in enumerate(edits, 1):
|
|
616
|
+
if not isinstance(edit, dict):
|
|
617
|
+
return {"error": f"edit {index} must be an object with old/new"}
|
|
618
|
+
old = edit.get("old")
|
|
619
|
+
new = edit.get("new")
|
|
620
|
+
if not isinstance(old, str) or not isinstance(new, str):
|
|
621
|
+
return {"error": f"edit {index} requires string old and new"}
|
|
622
|
+
if not old:
|
|
623
|
+
return {"error": f"edit {index} anchor (old) must be non-empty"}
|
|
624
|
+
occurrences = updated.count(old)
|
|
625
|
+
if occurrences == 0:
|
|
626
|
+
return {
|
|
627
|
+
"error": f"edit {index} anchor not found in {path}",
|
|
628
|
+
"anchor": old[:MAX_GREP_LINE_CHARS],
|
|
629
|
+
}
|
|
630
|
+
if occurrences > 1:
|
|
631
|
+
return {
|
|
632
|
+
"error": (
|
|
633
|
+
f"edit {index} anchor is not unique in {path} "
|
|
634
|
+
f"({occurrences} occurrences); include more surrounding context"
|
|
635
|
+
),
|
|
636
|
+
"occurrences": occurrences,
|
|
637
|
+
}
|
|
638
|
+
updated = updated.replace(old, new, 1)
|
|
639
|
+
size = len(updated.encode("utf-8"))
|
|
640
|
+
if size > max_file_bytes:
|
|
641
|
+
return {"error": f"file exceeds the {max_file_bytes}-byte limit"}
|
|
642
|
+
projected_size = sum(
|
|
643
|
+
len(value.encode("utf-8")) for key, value in files.items() if key != path
|
|
644
|
+
) + size
|
|
645
|
+
if projected_size > max_workspace_bytes:
|
|
646
|
+
return {"error": f"workspace exceeds the {max_workspace_bytes}-byte limit"}
|
|
647
|
+
files[path] = updated
|
|
648
|
+
diff = _file_changed_diff(path, original, updated)
|
|
649
|
+
additions = sum(
|
|
650
|
+
1 for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++")
|
|
651
|
+
)
|
|
652
|
+
deletions = sum(
|
|
653
|
+
1 for line in diff.splitlines() if line.startswith("-") and not line.startswith("---")
|
|
654
|
+
)
|
|
655
|
+
return {
|
|
656
|
+
"patched": path,
|
|
657
|
+
"edits": len(edits),
|
|
658
|
+
"bytes": size,
|
|
659
|
+
"additions": additions,
|
|
660
|
+
"deletions": deletions,
|
|
661
|
+
"diff": diff,
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _delete_file_result(
|
|
666
|
+
arguments: dict[str, Any], files: dict[str, str]
|
|
667
|
+
) -> dict[str, Any]:
|
|
668
|
+
try:
|
|
669
|
+
path = _safe_path(arguments.get("path"))
|
|
670
|
+
except NativeAgentError as exc:
|
|
671
|
+
return {"error": str(exc)}
|
|
672
|
+
if arguments.get("confirm") is not True:
|
|
673
|
+
return {
|
|
674
|
+
"error": "delete_file requires explicit approval: pass confirm=true",
|
|
675
|
+
"path": path,
|
|
676
|
+
"pending": True,
|
|
677
|
+
}
|
|
678
|
+
if path not in files:
|
|
679
|
+
return {"error": f"file not found: {path}"}
|
|
680
|
+
removed = files.pop(path)
|
|
681
|
+
return {
|
|
682
|
+
"deleted": path,
|
|
683
|
+
"approved": True,
|
|
684
|
+
"bytes": len(removed.encode("utf-8")),
|
|
685
|
+
"diff": _file_changed_diff(path, removed, None),
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _move_file_result(
|
|
690
|
+
arguments: dict[str, Any], files: dict[str, str]
|
|
691
|
+
) -> dict[str, Any]:
|
|
692
|
+
try:
|
|
693
|
+
source = _safe_path(arguments.get("from"))
|
|
694
|
+
destination = _safe_path(arguments.get("to"))
|
|
695
|
+
except NativeAgentError as exc:
|
|
696
|
+
return {"error": str(exc)}
|
|
697
|
+
if arguments.get("confirm") is not True:
|
|
698
|
+
return {
|
|
699
|
+
"error": "move_file requires explicit approval: pass confirm=true",
|
|
700
|
+
"from": source,
|
|
701
|
+
"to": destination,
|
|
702
|
+
"pending": True,
|
|
703
|
+
}
|
|
704
|
+
if source not in files:
|
|
705
|
+
return {"error": f"file not found: {source}"}
|
|
706
|
+
if source == destination:
|
|
707
|
+
return {"error": "from and to must be different paths"}
|
|
708
|
+
if destination in files:
|
|
709
|
+
return {"error": f"destination already exists: {destination}"}
|
|
710
|
+
content = files.pop(source)
|
|
711
|
+
files[destination] = content
|
|
712
|
+
return {
|
|
713
|
+
"moved": {"from": source, "to": destination},
|
|
714
|
+
"approved": True,
|
|
715
|
+
"bytes": len(content.encode("utf-8")),
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _validate_workspace(
|
|
720
|
+
files: dict[str, str],
|
|
721
|
+
*,
|
|
722
|
+
max_files: int = MAX_FILES,
|
|
723
|
+
max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
|
|
724
|
+
max_file_bytes: int = MAX_FILE_BYTES,
|
|
725
|
+
) -> dict[str, str]:
|
|
726
|
+
if not isinstance(files, dict) or not files:
|
|
727
|
+
raise NativeAgentInputError("select a non-empty text workspace before running")
|
|
728
|
+
if len(files) > max_files:
|
|
729
|
+
raise NativeAgentInputError(f"workspace exceeds the {max_files}-file limit")
|
|
730
|
+
normalized: dict[str, str] = {}
|
|
731
|
+
total_bytes = 0
|
|
732
|
+
for raw_path, content in files.items():
|
|
733
|
+
try:
|
|
734
|
+
path = _safe_path(raw_path)
|
|
735
|
+
except NativeAgentError as exc:
|
|
736
|
+
raise NativeAgentInputError(str(exc)) from exc
|
|
737
|
+
if path in normalized:
|
|
738
|
+
raise NativeAgentInputError(f"workspace contains duplicate normalized path {path!r}")
|
|
739
|
+
if not isinstance(content, str):
|
|
740
|
+
raise NativeAgentInputError(f"workspace file {path!r} must contain text")
|
|
741
|
+
size = len(content.encode("utf-8"))
|
|
742
|
+
if size > max_file_bytes:
|
|
743
|
+
raise NativeAgentInputError(f"workspace file {path!r} exceeds the {max_file_bytes}-byte limit")
|
|
744
|
+
total_bytes += size
|
|
745
|
+
normalized[path] = content
|
|
746
|
+
if total_bytes > max_workspace_bytes:
|
|
747
|
+
raise NativeAgentInputError(f"workspace exceeds the {max_workspace_bytes}-byte limit")
|
|
748
|
+
return normalized
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _project_rule_path(raw: Any) -> str:
|
|
752
|
+
path = str(raw).replace("\\", "/").strip()
|
|
753
|
+
while path.startswith("./"):
|
|
754
|
+
path = path[2:]
|
|
755
|
+
return path
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _sha256_text(text: str) -> str:
|
|
759
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def load_project_rules(workspace_files: dict[str, str] | None) -> dict[str, Any]:
|
|
763
|
+
"""Load the workspace's root project policy in ONE precedence order.
|
|
764
|
+
|
|
765
|
+
``AGENTS.md`` then ``SID.md`` carry the binding instructions; the optional
|
|
766
|
+
``.sid/config`` JSON object supplies run options (``skills``,
|
|
767
|
+
``skills_root``, ``subagents``, ``plan``). The returned block names every
|
|
768
|
+
file with its ``sha256`` and a digest over the whole policy, so a receipt
|
|
769
|
+
can never claim a run that silently ignored project rules. A malformed
|
|
770
|
+
config fails closed — it is project policy, not a preference.
|
|
771
|
+
"""
|
|
772
|
+
normalized: dict[str, str] = {}
|
|
773
|
+
for raw_path, content in (workspace_files or {}).items():
|
|
774
|
+
if isinstance(content, str):
|
|
775
|
+
normalized[_project_rule_path(raw_path)] = content
|
|
776
|
+
|
|
777
|
+
files: list[dict[str, Any]] = []
|
|
778
|
+
instructions: list[str] = []
|
|
779
|
+
config: dict[str, Any] = {}
|
|
780
|
+
for path in (*PROJECT_RULE_FILES, PROJECT_CONFIG_PATH):
|
|
781
|
+
content = normalized.get(path)
|
|
782
|
+
if content is None:
|
|
783
|
+
continue
|
|
784
|
+
size = len(content.encode("utf-8"))
|
|
785
|
+
if size > MAX_PROJECT_RULE_BYTES:
|
|
786
|
+
raise NativeAgentInputError(
|
|
787
|
+
f"project policy file {path!r} exceeds the "
|
|
788
|
+
f"{MAX_PROJECT_RULE_BYTES}-byte limit"
|
|
789
|
+
)
|
|
790
|
+
digest = _sha256_text(content)
|
|
791
|
+
files.append({"path": path, "bytes": size, "sha256": digest})
|
|
792
|
+
if path == PROJECT_CONFIG_PATH:
|
|
793
|
+
try:
|
|
794
|
+
parsed = json.loads(content)
|
|
795
|
+
except json.JSONDecodeError as exc:
|
|
796
|
+
raise NativeAgentInputError(
|
|
797
|
+
f"project policy {path!r} is not valid JSON "
|
|
798
|
+
f"(line {exc.lineno}: {exc.msg}); refusing to run without it"
|
|
799
|
+
) from exc
|
|
800
|
+
if not isinstance(parsed, dict):
|
|
801
|
+
raise NativeAgentInputError(
|
|
802
|
+
f"project policy {path!r} must be a JSON object; "
|
|
803
|
+
"refusing to run without it"
|
|
804
|
+
)
|
|
805
|
+
config = parsed
|
|
806
|
+
continue
|
|
807
|
+
body = content.strip()
|
|
808
|
+
if body:
|
|
809
|
+
instructions.append(f"## {path} (sha256 {digest})\n{body}")
|
|
810
|
+
|
|
811
|
+
policy_digest = hashlib.sha256()
|
|
812
|
+
for entry in files:
|
|
813
|
+
policy_digest.update(entry["path"].encode("utf-8"))
|
|
814
|
+
policy_digest.update(b"\x00")
|
|
815
|
+
policy_digest.update(entry["sha256"].encode("ascii"))
|
|
816
|
+
policy_digest.update(b"\n")
|
|
817
|
+
return {
|
|
818
|
+
"precedence": PROJECT_RULES_PRECEDENCE,
|
|
819
|
+
"files": files,
|
|
820
|
+
"sources": [entry["path"] for entry in files],
|
|
821
|
+
"instructions": "\n\n".join(instructions),
|
|
822
|
+
"config": config,
|
|
823
|
+
"digest": policy_digest.hexdigest(),
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _tool_result(
|
|
828
|
+
name: str,
|
|
829
|
+
arguments: dict[str, Any],
|
|
830
|
+
files: dict[str, str],
|
|
831
|
+
run_tests: RunTests,
|
|
832
|
+
*,
|
|
833
|
+
max_tool_output_chars: int = MAX_TOOL_OUTPUT_CHARS,
|
|
834
|
+
max_file_bytes: int = MAX_FILE_BYTES,
|
|
835
|
+
max_files: int = MAX_FILES,
|
|
836
|
+
max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
|
|
837
|
+
) -> Awaitable[dict[str, Any]] | dict[str, Any]:
|
|
838
|
+
if name == "list_files":
|
|
839
|
+
return {"files": sorted(files)}
|
|
840
|
+
if name == "read_file":
|
|
841
|
+
try:
|
|
842
|
+
path = _safe_path(arguments.get("path"))
|
|
843
|
+
except NativeAgentError as exc:
|
|
844
|
+
return {"error": str(exc)}
|
|
845
|
+
if path not in files:
|
|
846
|
+
return {"error": f"file not found: {path}"}
|
|
847
|
+
return {"path": path, "content": files[path][:max_tool_output_chars], "truncated": len(files[path]) > max_tool_output_chars}
|
|
848
|
+
if name == "write_file":
|
|
849
|
+
try:
|
|
850
|
+
path = _safe_path(arguments.get("path"))
|
|
851
|
+
except NativeAgentError as exc:
|
|
852
|
+
return {"error": str(exc)}
|
|
853
|
+
content = arguments.get("content")
|
|
854
|
+
if not isinstance(content, str):
|
|
855
|
+
return {"error": "content must be a string"}
|
|
856
|
+
if len(content.encode("utf-8")) > max_file_bytes:
|
|
857
|
+
return {"error": f"file exceeds the {max_file_bytes}-byte limit"}
|
|
858
|
+
if path not in files and len(files) >= max_files:
|
|
859
|
+
return {"error": f"workspace exceeds the {max_files}-file limit"}
|
|
860
|
+
projected_size = sum(len(value.encode("utf-8")) for key, value in files.items() if key != path)
|
|
861
|
+
projected_size += len(content.encode("utf-8"))
|
|
862
|
+
if projected_size > max_workspace_bytes:
|
|
863
|
+
return {"error": f"workspace exceeds the {max_workspace_bytes}-byte limit"}
|
|
864
|
+
files[path] = content
|
|
865
|
+
return {"written": path, "bytes": len(content.encode("utf-8"))}
|
|
866
|
+
if name == "apply_patch":
|
|
867
|
+
return _apply_patch_result(
|
|
868
|
+
arguments,
|
|
869
|
+
files,
|
|
870
|
+
max_file_bytes=max_file_bytes,
|
|
871
|
+
max_workspace_bytes=max_workspace_bytes,
|
|
872
|
+
)
|
|
873
|
+
if name == "delete_file":
|
|
874
|
+
return _delete_file_result(arguments, files)
|
|
875
|
+
if name == "move_file":
|
|
876
|
+
return _move_file_result(arguments, files)
|
|
877
|
+
if name == "grep":
|
|
878
|
+
return _grep_result(arguments, files)
|
|
879
|
+
if name == "glob":
|
|
880
|
+
return _glob_result(arguments, files)
|
|
881
|
+
if name == "run_approved_tests":
|
|
882
|
+
return run_tests(files)
|
|
883
|
+
return {"error": f"unknown tool: {name}"}
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
@dataclass(frozen=True)
|
|
887
|
+
class ShellPolicy:
|
|
888
|
+
"""The explicit approval and boundary for the ``run_shell`` tool.
|
|
889
|
+
|
|
890
|
+
Every field defaults to the refusing state: ``approved=False`` means the
|
|
891
|
+
tool is never advertised or executed, and a missing ``root`` means there is
|
|
892
|
+
no sandbox to run in. A caller that wants the tool must opt in with an
|
|
893
|
+
approved policy, a real sandbox root, and (optionally) its own allow/deny
|
|
894
|
+
lists — the built-in dangerous-program set is always added to the deny list.
|
|
895
|
+
|
|
896
|
+
``sandbox=True`` is a requirement, not a claim of isolation: every command
|
|
897
|
+
runs confined to ``root`` with a scrubbed environment and no shell unless
|
|
898
|
+
``mode`` says so. The built-in local runner reports ``isolated=False`` so a
|
|
899
|
+
caller never mistakes it for a microVM boundary.
|
|
900
|
+
"""
|
|
901
|
+
|
|
902
|
+
approved: bool = False
|
|
903
|
+
root: str | None = None
|
|
904
|
+
sandbox: bool = True
|
|
905
|
+
mode: str = SHELL_MODE_ARGV
|
|
906
|
+
allow: tuple[str, ...] | None = None
|
|
907
|
+
deny: tuple[str, ...] = ()
|
|
908
|
+
allow_inline_code: bool = False
|
|
909
|
+
approver: Callable[[str], bool] | None = None
|
|
910
|
+
max_timeout_seconds: float = DEFAULT_SHELL_TIMEOUT_SECONDS
|
|
911
|
+
max_output_chars: int = MAX_SHELL_OUTPUT_CHARS
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _shell_refusal(command: str, argv: list[str] | None, detail: str) -> dict[str, Any]:
|
|
915
|
+
return {
|
|
916
|
+
"tool": SHELL_TOOL_NAME,
|
|
917
|
+
"command": command,
|
|
918
|
+
"argv": argv,
|
|
919
|
+
"cwd": None,
|
|
920
|
+
"executed": False,
|
|
921
|
+
"exit_code": None,
|
|
922
|
+
"passed": None,
|
|
923
|
+
"timed_out": False,
|
|
924
|
+
"duration_ms": 0,
|
|
925
|
+
"output": "",
|
|
926
|
+
"output_truncated": False,
|
|
927
|
+
"sandbox": True,
|
|
928
|
+
"isolated": False,
|
|
929
|
+
"backend": "none",
|
|
930
|
+
"detail": detail,
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _program_name(token: str) -> str:
|
|
935
|
+
"""Bare, lower-cased program name for policy matching.
|
|
936
|
+
|
|
937
|
+
``C:\\Python312\\python.exe``, ``python3.12``, and ``python`` all normalise
|
|
938
|
+
to a name the allow/deny lists can match. Absolute program paths are
|
|
939
|
+
permitted only because the basename must still be allow-listed.
|
|
940
|
+
"""
|
|
941
|
+
name = str(token).replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
942
|
+
if name.endswith(".exe"):
|
|
943
|
+
name = name[: -len(".exe")]
|
|
944
|
+
if re.fullmatch(r"python[0-9.]*", name):
|
|
945
|
+
return "python"
|
|
946
|
+
if re.fullmatch(r"pip[0-9.]*", name):
|
|
947
|
+
return "pip"
|
|
948
|
+
return name
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
_PARENT_ESCAPE = re.compile(r"\.\.(?:[/\\]|$)")
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _escapes_workspace(token: str) -> bool:
|
|
955
|
+
normalized = str(token).replace("\\", "/")
|
|
956
|
+
if normalized.startswith(("/", "~")) or _WINDOWS_ABSOLUTE.match(normalized):
|
|
957
|
+
return True
|
|
958
|
+
return bool(_PARENT_ESCAPE.search(normalized))
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _has_injection(text: str, markers: tuple[str, ...]) -> str | None:
|
|
962
|
+
for marker in markers:
|
|
963
|
+
if marker in text:
|
|
964
|
+
return marker
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _sandbox_environment() -> dict[str, str]:
|
|
969
|
+
return {
|
|
970
|
+
key: value
|
|
971
|
+
for key, value in os.environ.items()
|
|
972
|
+
if key.upper() in SANDBOX_ENV_ALLOW
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _capture_shell(
|
|
977
|
+
invocation: list[str] | str,
|
|
978
|
+
*,
|
|
979
|
+
cwd: Path,
|
|
980
|
+
timeout: float,
|
|
981
|
+
output_limit: int,
|
|
982
|
+
use_shell: bool,
|
|
983
|
+
env: dict[str, str],
|
|
984
|
+
) -> tuple[int | None, bool, bytes, bool]:
|
|
985
|
+
"""Run one bounded command; keep at most ``output_limit`` bytes of output."""
|
|
986
|
+
process = subprocess.Popen(
|
|
987
|
+
invocation,
|
|
988
|
+
cwd=str(cwd),
|
|
989
|
+
shell=use_shell,
|
|
990
|
+
stdin=subprocess.DEVNULL,
|
|
991
|
+
stdout=subprocess.PIPE,
|
|
992
|
+
stderr=subprocess.STDOUT,
|
|
993
|
+
env=env,
|
|
994
|
+
)
|
|
995
|
+
kept: list[bytes] = []
|
|
996
|
+
state = {"kept": 0, "truncated": False}
|
|
997
|
+
|
|
998
|
+
def drain() -> None:
|
|
999
|
+
stream = process.stdout
|
|
1000
|
+
if stream is None:
|
|
1001
|
+
return
|
|
1002
|
+
while True:
|
|
1003
|
+
block = stream.read(65536)
|
|
1004
|
+
if not block:
|
|
1005
|
+
break
|
|
1006
|
+
room = output_limit - state["kept"]
|
|
1007
|
+
if room > 0:
|
|
1008
|
+
chunk = block[:room]
|
|
1009
|
+
kept.append(chunk)
|
|
1010
|
+
state["kept"] += len(chunk)
|
|
1011
|
+
if len(chunk) < len(block):
|
|
1012
|
+
state["truncated"] = True
|
|
1013
|
+
else:
|
|
1014
|
+
state["truncated"] = True
|
|
1015
|
+
|
|
1016
|
+
reader = threading.Thread(target=drain, name="sid-shell-output", daemon=True)
|
|
1017
|
+
reader.start()
|
|
1018
|
+
timed_out = False
|
|
1019
|
+
try:
|
|
1020
|
+
exit_code: int | None = process.wait(timeout=timeout)
|
|
1021
|
+
except subprocess.TimeoutExpired:
|
|
1022
|
+
timed_out = True
|
|
1023
|
+
process.kill()
|
|
1024
|
+
try:
|
|
1025
|
+
exit_code = process.wait(timeout=5)
|
|
1026
|
+
except subprocess.TimeoutExpired: # pragma: no cover - kill is best-effort
|
|
1027
|
+
exit_code = None
|
|
1028
|
+
reader.join(timeout=10)
|
|
1029
|
+
if process.stdout is not None:
|
|
1030
|
+
try:
|
|
1031
|
+
process.stdout.close()
|
|
1032
|
+
except OSError:
|
|
1033
|
+
pass
|
|
1034
|
+
return exit_code, timed_out, b"".join(kept), state["truncated"]
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def run_shell_tool(
|
|
1038
|
+
arguments: dict[str, Any] | None,
|
|
1039
|
+
policy: ShellPolicy | None,
|
|
1040
|
+
) -> dict[str, Any]:
|
|
1041
|
+
"""Validate one ``run_shell`` request against ``policy`` and run it.
|
|
1042
|
+
|
|
1043
|
+
Refusals are returned as a result dict (``executed=False`` plus ``detail``),
|
|
1044
|
+
never raised, so the loop can show the model exactly what the policy
|
|
1045
|
+
refused. The command string and exit code are always in the returned block,
|
|
1046
|
+
which is what the run receipt records.
|
|
1047
|
+
"""
|
|
1048
|
+
arguments = arguments if isinstance(arguments, dict) else {}
|
|
1049
|
+
raw_command = arguments.get("command")
|
|
1050
|
+
raw_argv = arguments.get("argv")
|
|
1051
|
+
argv_input = (
|
|
1052
|
+
[str(token) for token in raw_argv]
|
|
1053
|
+
if isinstance(raw_argv, (list, tuple)) and all(isinstance(t, str) for t in raw_argv)
|
|
1054
|
+
else None
|
|
1055
|
+
)
|
|
1056
|
+
display = (
|
|
1057
|
+
str(raw_command)
|
|
1058
|
+
if isinstance(raw_command, str) and raw_command.strip()
|
|
1059
|
+
else (" ".join(argv_input) if argv_input else "")
|
|
1060
|
+
)
|
|
1061
|
+
if policy is None or not policy.approved:
|
|
1062
|
+
return _shell_refusal(
|
|
1063
|
+
display,
|
|
1064
|
+
argv_input,
|
|
1065
|
+
"shell execution requires an explicit approval before this task",
|
|
1066
|
+
)
|
|
1067
|
+
if not policy.sandbox:
|
|
1068
|
+
return _shell_refusal(display, argv_input, "shell execution requires the sandbox")
|
|
1069
|
+
if policy.mode not in SHELL_MODES:
|
|
1070
|
+
return _shell_refusal(display, argv_input, f"unknown shell mode: {policy.mode!r}")
|
|
1071
|
+
if policy.root is None:
|
|
1072
|
+
return _shell_refusal(
|
|
1073
|
+
display, argv_input, "no sandbox root was supplied; refusing to run on the host"
|
|
1074
|
+
)
|
|
1075
|
+
try:
|
|
1076
|
+
root = Path(policy.root).expanduser().resolve()
|
|
1077
|
+
except OSError as exc:
|
|
1078
|
+
return _shell_refusal(display, argv_input, f"invalid sandbox root: {exc}")
|
|
1079
|
+
if not root.is_dir():
|
|
1080
|
+
return _shell_refusal(display, argv_input, f"sandbox root is not a directory: {root}")
|
|
1081
|
+
|
|
1082
|
+
if policy.mode == SHELL_MODE_ARGV:
|
|
1083
|
+
if isinstance(raw_command, str) and raw_command.strip():
|
|
1084
|
+
return _shell_refusal(
|
|
1085
|
+
display, argv_input, "argv mode: a shell command string is refused; pass argv"
|
|
1086
|
+
)
|
|
1087
|
+
if not argv_input:
|
|
1088
|
+
return _shell_refusal(display, argv_input, "argv mode requires a non-empty argv list")
|
|
1089
|
+
if any(not token for token in argv_input):
|
|
1090
|
+
return _shell_refusal(display, argv_input, "argv must not contain empty tokens")
|
|
1091
|
+
argv = list(argv_input)
|
|
1092
|
+
detail_markers = INJECTION_ARGV_MARKERS
|
|
1093
|
+
use_shell = False
|
|
1094
|
+
invocation: list[str] | str = argv
|
|
1095
|
+
else:
|
|
1096
|
+
if argv_input is not None:
|
|
1097
|
+
return _shell_refusal(
|
|
1098
|
+
display, argv_input, "shell mode takes one command line, not an argv list"
|
|
1099
|
+
)
|
|
1100
|
+
if not isinstance(raw_command, str) or not raw_command.strip():
|
|
1101
|
+
return _shell_refusal(display, argv_input, "shell mode requires a command string")
|
|
1102
|
+
command = raw_command.strip()
|
|
1103
|
+
display = command
|
|
1104
|
+
detail_markers = INJECTION_SHELL_MARKERS
|
|
1105
|
+
argv = command.split()
|
|
1106
|
+
use_shell = True
|
|
1107
|
+
invocation = command
|
|
1108
|
+
|
|
1109
|
+
if any("\x00" in token for token in argv):
|
|
1110
|
+
return _shell_refusal(display, argv, "command must not contain NUL bytes")
|
|
1111
|
+
marker = _has_injection(display, detail_markers)
|
|
1112
|
+
if marker is not None:
|
|
1113
|
+
return _shell_refusal(
|
|
1114
|
+
display, argv, f"command shape is refused (contains {marker!r})"
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
program = _program_name(argv[0])
|
|
1118
|
+
allowed = DEFAULT_ALLOWED_PROGRAMS if policy.allow is None else frozenset(policy.allow)
|
|
1119
|
+
denied = ALWAYS_DENIED_PROGRAMS | frozenset(policy.deny)
|
|
1120
|
+
if program in denied:
|
|
1121
|
+
return _shell_refusal(display, argv, f"program is denied by policy: {program!r}")
|
|
1122
|
+
if program not in allowed:
|
|
1123
|
+
return _shell_refusal(display, argv, f"program is not allow-listed: {program!r}")
|
|
1124
|
+
if (
|
|
1125
|
+
not policy.allow_inline_code
|
|
1126
|
+
and program in INLINE_CODE_PROGRAMS
|
|
1127
|
+
and any(token in INLINE_CODE_FLAGS for token in argv[1:])
|
|
1128
|
+
):
|
|
1129
|
+
return _shell_refusal(
|
|
1130
|
+
display, argv, "inline code execution (-c/-e) is not approved by policy"
|
|
1131
|
+
)
|
|
1132
|
+
lowered = [token.lower() for token in argv]
|
|
1133
|
+
for sequence in DENIED_ARG_SEQUENCES:
|
|
1134
|
+
if any(
|
|
1135
|
+
tuple(lowered[index : index + len(sequence)]) == sequence
|
|
1136
|
+
for index in range(len(lowered) - len(sequence) + 1)
|
|
1137
|
+
):
|
|
1138
|
+
return _shell_refusal(
|
|
1139
|
+
display, argv, "command shape is denied by policy: " + " ".join(sequence)
|
|
1140
|
+
)
|
|
1141
|
+
for token in argv[1:]:
|
|
1142
|
+
if _escapes_workspace(token):
|
|
1143
|
+
return _shell_refusal(
|
|
1144
|
+
display, argv, f"command must stay inside the sandbox: {token!r}"
|
|
1145
|
+
)
|
|
1146
|
+
if policy.approver is not None:
|
|
1147
|
+
try:
|
|
1148
|
+
approved = bool(policy.approver(display))
|
|
1149
|
+
except Exception as exc: # an approver bug must fail closed
|
|
1150
|
+
return _shell_refusal(
|
|
1151
|
+
display, argv, f"approval check failed: {type(exc).__name__}: {exc}"
|
|
1152
|
+
)
|
|
1153
|
+
if not approved:
|
|
1154
|
+
return _shell_refusal(display, argv, "command was not approved")
|
|
1155
|
+
|
|
1156
|
+
raw_cwd = arguments.get("cwd") or "."
|
|
1157
|
+
if not isinstance(raw_cwd, str) or not raw_cwd.strip():
|
|
1158
|
+
return _shell_refusal(display, argv, "cwd must be a non-empty relative path")
|
|
1159
|
+
if _escapes_workspace(raw_cwd):
|
|
1160
|
+
return _shell_refusal(display, argv, "cwd must stay inside the sandbox root")
|
|
1161
|
+
try:
|
|
1162
|
+
cwd = (root / raw_cwd.replace("\\", "/").strip("/")).resolve()
|
|
1163
|
+
except OSError as exc:
|
|
1164
|
+
return _shell_refusal(display, argv, f"invalid cwd: {exc}")
|
|
1165
|
+
if cwd != root and root not in cwd.parents:
|
|
1166
|
+
return _shell_refusal(display, argv, "cwd escapes the sandbox root")
|
|
1167
|
+
if not cwd.is_dir():
|
|
1168
|
+
return _shell_refusal(display, argv, f"cwd is not a directory: {raw_cwd}")
|
|
1169
|
+
|
|
1170
|
+
try:
|
|
1171
|
+
timeout = float(arguments.get("timeout_seconds") or policy.max_timeout_seconds)
|
|
1172
|
+
except (TypeError, ValueError):
|
|
1173
|
+
timeout = policy.max_timeout_seconds
|
|
1174
|
+
if timeout <= 0:
|
|
1175
|
+
timeout = policy.max_timeout_seconds
|
|
1176
|
+
timeout = min(timeout, MAX_SHELL_TIMEOUT_SECONDS)
|
|
1177
|
+
output_limit = max(1, min(int(policy.max_output_chars), MAX_SHELL_OUTPUT_CHARS))
|
|
1178
|
+
|
|
1179
|
+
started = time.monotonic()
|
|
1180
|
+
try:
|
|
1181
|
+
exit_code, timed_out, raw, truncated = _capture_shell(
|
|
1182
|
+
invocation,
|
|
1183
|
+
cwd=cwd,
|
|
1184
|
+
timeout=timeout,
|
|
1185
|
+
output_limit=output_limit,
|
|
1186
|
+
use_shell=use_shell,
|
|
1187
|
+
env=_sandbox_environment(),
|
|
1188
|
+
)
|
|
1189
|
+
except FileNotFoundError:
|
|
1190
|
+
return _shell_refusal(display, argv, f"command not found: {argv[0]!r}")
|
|
1191
|
+
except OSError as exc:
|
|
1192
|
+
return _shell_refusal(
|
|
1193
|
+
display, argv, f"could not start command: {type(exc).__name__}: {exc}"
|
|
1194
|
+
)
|
|
1195
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
1196
|
+
output = raw.decode("utf-8", errors="replace")[:output_limit]
|
|
1197
|
+
if len(raw) > output_limit:
|
|
1198
|
+
truncated = True
|
|
1199
|
+
return {
|
|
1200
|
+
"tool": SHELL_TOOL_NAME,
|
|
1201
|
+
"command": display,
|
|
1202
|
+
"argv": argv,
|
|
1203
|
+
"mode": policy.mode,
|
|
1204
|
+
"cwd": str(cwd),
|
|
1205
|
+
"executed": True,
|
|
1206
|
+
"exit_code": exit_code,
|
|
1207
|
+
"passed": exit_code == 0,
|
|
1208
|
+
"timed_out": timed_out,
|
|
1209
|
+
"duration_ms": duration_ms,
|
|
1210
|
+
"output": output,
|
|
1211
|
+
"output_truncated": truncated,
|
|
1212
|
+
"sandbox": True,
|
|
1213
|
+
"isolated": False,
|
|
1214
|
+
"backend": "local",
|
|
1215
|
+
"sandbox_root": str(root),
|
|
1216
|
+
"detail": (
|
|
1217
|
+
f"command timed out after {timeout:g}s"
|
|
1218
|
+
if timed_out
|
|
1219
|
+
else ("command passed" if exit_code == 0 else f"command exited {exit_code}")
|
|
1220
|
+
),
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _propose_result(
|
|
1225
|
+
arguments: dict[str, Any],
|
|
1226
|
+
files: dict[str, str],
|
|
1227
|
+
proposed: dict[str, str],
|
|
1228
|
+
*,
|
|
1229
|
+
max_file_bytes: int = MAX_FILE_BYTES,
|
|
1230
|
+
max_files: int = MAX_FILES,
|
|
1231
|
+
max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
|
|
1232
|
+
) -> dict[str, Any]:
|
|
1233
|
+
"""Record a plan-mode file proposal. Same bounds as write_file, no mutation
|
|
1234
|
+
of the workspace: the proposal lives in ``proposed`` until the user
|
|
1235
|
+
approves it through the readback path."""
|
|
1236
|
+
try:
|
|
1237
|
+
path = _safe_path(arguments.get("path"))
|
|
1238
|
+
except NativeAgentError as exc:
|
|
1239
|
+
return {"error": str(exc)}
|
|
1240
|
+
content = arguments.get("content")
|
|
1241
|
+
if not isinstance(content, str):
|
|
1242
|
+
return {"error": "content must be a string"}
|
|
1243
|
+
if len(content.encode("utf-8")) > max_file_bytes:
|
|
1244
|
+
return {"error": f"file exceeds the {max_file_bytes}-byte limit"}
|
|
1245
|
+
known = (set(files) | set(proposed)) - {path}
|
|
1246
|
+
if path not in files and len(known) >= max_files:
|
|
1247
|
+
return {"error": f"workspace exceeds the {max_files}-file limit"}
|
|
1248
|
+
projected_size = sum(len(value.encode("utf-8")) for key, value in files.items() if key != path)
|
|
1249
|
+
projected_size += sum(
|
|
1250
|
+
len(value.encode("utf-8")) for key, value in proposed.items() if key != path
|
|
1251
|
+
)
|
|
1252
|
+
projected_size += len(content.encode("utf-8"))
|
|
1253
|
+
if projected_size > max_workspace_bytes:
|
|
1254
|
+
return {"error": f"workspace exceeds the {max_workspace_bytes}-byte limit"}
|
|
1255
|
+
proposed[path] = content
|
|
1256
|
+
return {"proposed": path, "bytes": len(content.encode("utf-8")), "written": False}
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def artifact_hash(files: dict[str, bytes]) -> str:
|
|
1260
|
+
"""Stable digest over the tested tree: sorted unique relative paths +
|
|
1261
|
+
bytes. Same construction as the scanner's payload attestation
|
|
1262
|
+
(api/services/bc_build_upload.py payload_artifact_sha256) so the join is
|
|
1263
|
+
exact. Owned here — the loop is what produces ``tested_artifact_hash``; the
|
|
1264
|
+
server's ``agent_service`` re-exports it so there is one construction, and
|
|
1265
|
+
the module stays stdlib-only so it can be vendored verbatim into the local
|
|
1266
|
+
harness with no monorepo import."""
|
|
1267
|
+
unique: dict[str, bytes] = {}
|
|
1268
|
+
for rel, data in files.items():
|
|
1269
|
+
unique[rel.replace("\\", "/")] = data
|
|
1270
|
+
digest = hashlib.sha256()
|
|
1271
|
+
for rel in sorted(unique):
|
|
1272
|
+
digest.update(rel.encode("utf-8"))
|
|
1273
|
+
digest.update(b"\x00")
|
|
1274
|
+
digest.update(unique[rel])
|
|
1275
|
+
digest.update(b"\x00")
|
|
1276
|
+
return digest.hexdigest()
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _diff(before: dict[str, str], after: dict[str, str]) -> str:
|
|
1280
|
+
chunks: list[str] = []
|
|
1281
|
+
for path in sorted(set(before) | set(after)):
|
|
1282
|
+
old = before.get(path, "")
|
|
1283
|
+
new = after.get(path, "")
|
|
1284
|
+
if old == new:
|
|
1285
|
+
continue
|
|
1286
|
+
chunks.extend(
|
|
1287
|
+
difflib.unified_diff(
|
|
1288
|
+
old.splitlines(True),
|
|
1289
|
+
new.splitlines(True),
|
|
1290
|
+
fromfile=f"a/{path}" if path in before else "/dev/null",
|
|
1291
|
+
tofile=f"b/{path}" if path in after else "/dev/null",
|
|
1292
|
+
)
|
|
1293
|
+
)
|
|
1294
|
+
return "".join(chunks)
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
async def run_native_agent(
|
|
1298
|
+
*,
|
|
1299
|
+
task: str,
|
|
1300
|
+
workspace_files: dict[str, str],
|
|
1301
|
+
acceptance_criteria: list[str],
|
|
1302
|
+
out_of_scope: list[str],
|
|
1303
|
+
test_argv: list[str],
|
|
1304
|
+
complete: Complete,
|
|
1305
|
+
run_tests: RunTests,
|
|
1306
|
+
max_turns: int = MAX_TURNS,
|
|
1307
|
+
max_tool_calls: int = MAX_TOOL_CALLS,
|
|
1308
|
+
max_tool_output_chars: int = MAX_TOOL_OUTPUT_CHARS,
|
|
1309
|
+
max_files: int = MAX_FILES,
|
|
1310
|
+
max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
|
|
1311
|
+
max_file_bytes: int = MAX_FILE_BYTES,
|
|
1312
|
+
should_stop: ShouldStop | None = None,
|
|
1313
|
+
tool_schemas: list[dict[str, Any]] | None = None,
|
|
1314
|
+
tool_dispatch: Dispatch | None = None,
|
|
1315
|
+
plan_only: bool = False,
|
|
1316
|
+
shell_policy: ShellPolicy | None = None,
|
|
1317
|
+
) -> dict[str, Any]:
|
|
1318
|
+
"""Run a bounded first-party tool loop and test only inside the sandbox.
|
|
1319
|
+
|
|
1320
|
+
SUB-58: the bounds are parameters defaulting to the module constants, which
|
|
1321
|
+
are kept and never removed. ``should_stop`` lets a caller end the loop
|
|
1322
|
+
between turns for a clean interrupt; the returned result then carries
|
|
1323
|
+
``interrupted`` plus the partial trace, and the final approved test is not
|
|
1324
|
+
run (an interrupted run is never reported as tested or verified).
|
|
1325
|
+
|
|
1326
|
+
``tool_schemas``/``tool_dispatch`` let a caller (the local harness) extend
|
|
1327
|
+
the tool set through ONE registry seam. When ``tool_schemas`` is given the
|
|
1328
|
+
loop advertises those schemas to the model; ``tool_dispatch`` then handles
|
|
1329
|
+
every call (base names must still route to ``_tool_result``). Both default to
|
|
1330
|
+
None, which keeps the server path on the base four-tool contract.
|
|
1331
|
+
|
|
1332
|
+
``plan_only`` (SUB-81) runs the same loop READ-ONLY: ``write_file`` is never
|
|
1333
|
+
advertised or honored, ``propose_file_change`` records intentions, no tests
|
|
1334
|
+
run (nothing changed), and the result carries the model's step plan plus the
|
|
1335
|
+
diff/readback of the proposal. Nothing is written anywhere; approval happens
|
|
1336
|
+
later through the caller's existing readback path.
|
|
1337
|
+
|
|
1338
|
+
``shell_policy`` is the ONE opt-in seam for the fifth tool, ``run_shell``.
|
|
1339
|
+
With no policy (the server path) the tool is never advertised or honored.
|
|
1340
|
+
An approved policy advertises the schema and runs each request through
|
|
1341
|
+
``run_shell_tool`` (allow/deny lists, argv-or-shell mode, sandbox root,
|
|
1342
|
+
bounded timeout/output). Every request — executed or refused — is recorded
|
|
1343
|
+
in the result's ``shell`` list with its command and exit code, which is what
|
|
1344
|
+
the run receipt reports.
|
|
1345
|
+
"""
|
|
1346
|
+
files = _validate_workspace(
|
|
1347
|
+
dict(workspace_files),
|
|
1348
|
+
max_files=max_files,
|
|
1349
|
+
max_workspace_bytes=max_workspace_bytes,
|
|
1350
|
+
max_file_bytes=max_file_bytes,
|
|
1351
|
+
)
|
|
1352
|
+
# SUB-94: load project policy from the workspace the loop is about to edit.
|
|
1353
|
+
# This is the ONE loader every run path reaches (CLI, bridge, Studio chat,
|
|
1354
|
+
# workbench), so precedence cannot drift between surfaces.
|
|
1355
|
+
project_rules = load_project_rules(files)
|
|
1356
|
+
config_plan = project_rules["config"].get("plan")
|
|
1357
|
+
if config_plan is not None and not isinstance(config_plan, bool):
|
|
1358
|
+
raise NativeAgentInputError(
|
|
1359
|
+
"project policy .sid/config field 'plan' must be true or false"
|
|
1360
|
+
)
|
|
1361
|
+
if config_plan:
|
|
1362
|
+
# Project policy can force the one loop read-only on every surface; an
|
|
1363
|
+
# explicit caller plan flag can never loosen it (precedence is additive).
|
|
1364
|
+
plan_only = True
|
|
1365
|
+
original = dict(files)
|
|
1366
|
+
base_tools = tool_schemas if tool_schemas is not None else TOOLS
|
|
1367
|
+
if plan_only:
|
|
1368
|
+
base_tools = [
|
|
1369
|
+
schema
|
|
1370
|
+
for schema in base_tools
|
|
1371
|
+
if (schema.get("function") or {}).get("name") not in MUTATING_TOOL_NAMES
|
|
1372
|
+
]
|
|
1373
|
+
if not any((s.get("function") or {}).get("name") == "propose_file_change" for s in base_tools):
|
|
1374
|
+
base_tools = [*base_tools, PROPOSE_TOOL]
|
|
1375
|
+
elif shell_policy is not None and shell_policy.approved:
|
|
1376
|
+
# Explicit approval gate: the fifth tool is advertised only for a caller
|
|
1377
|
+
# that approved it. Plan mode never gets it (read-only run).
|
|
1378
|
+
if not any((s.get("function") or {}).get("name") == SHELL_TOOL_NAME for s in base_tools):
|
|
1379
|
+
base_tools = [*base_tools, SHELL_TOOL]
|
|
1380
|
+
active_tools = base_tools
|
|
1381
|
+
criteria = "\n".join(f"- {criterion}" for criterion in acceptance_criteria)
|
|
1382
|
+
exclusions = "\n".join(f"- {item}" for item in out_of_scope) or "- None stated"
|
|
1383
|
+
posture = (
|
|
1384
|
+
"You are SID, working inside a first-party, bounded coding workstation. "
|
|
1385
|
+
"Use only the supplied project tools. The user's approved criteria are "
|
|
1386
|
+
"binding; do not broaden scope. Inspect before editing, make the smallest "
|
|
1387
|
+
"change, run the approved tests, and report honestly. You cannot publish. "
|
|
1388
|
+
"Do not read secrets, private keys, dependency directories, or VCS data. "
|
|
1389
|
+
"Finish only after tests pass or explain the exact blocker."
|
|
1390
|
+
)
|
|
1391
|
+
system_parts = [posture, WORKSPACE_GUARDRAIL]
|
|
1392
|
+
if project_rules["instructions"]:
|
|
1393
|
+
system_parts.append(
|
|
1394
|
+
"PROJECT RULES — binding project policy loaded from the workspace. "
|
|
1395
|
+
"Apply every rule to what you inspect, edit, and run. If a rule "
|
|
1396
|
+
"cannot be satisfied, name that as a blocker; never silently ignore "
|
|
1397
|
+
"project policy.\n\n"
|
|
1398
|
+
f"Precedence: {project_rules['precedence']}\n\n"
|
|
1399
|
+
+ project_rules["instructions"]
|
|
1400
|
+
)
|
|
1401
|
+
if plan_only:
|
|
1402
|
+
system_parts.append(PLAN_MODE_SYSTEM)
|
|
1403
|
+
elif shell_policy is not None and shell_policy.approved:
|
|
1404
|
+
system_parts.append(
|
|
1405
|
+
"SHELL — the user explicitly approved run_shell for this task. Use one "
|
|
1406
|
+
"command at a time, stay inside the sandbox root, and never try to "
|
|
1407
|
+
"escape it or chain commands. Refusals are policy, not failures to route "
|
|
1408
|
+
"around: report the blocker instead."
|
|
1409
|
+
)
|
|
1410
|
+
system_parts.extend(
|
|
1411
|
+
[
|
|
1412
|
+
f"APPROVED ACCEPTANCE CRITERIA:\n{criteria}\n\nOUT OF SCOPE:\n{exclusions}",
|
|
1413
|
+
f"APPROVED TEST ARGV: {json.dumps(test_argv)}",
|
|
1414
|
+
]
|
|
1415
|
+
)
|
|
1416
|
+
system = "\n\n".join(system_parts)
|
|
1417
|
+
messages: list[dict[str, Any]] = [
|
|
1418
|
+
{"role": "system", "content": system},
|
|
1419
|
+
{
|
|
1420
|
+
"role": "user",
|
|
1421
|
+
"content": (
|
|
1422
|
+
f"Task: {task.strip()}\nWorkspace files: {len(files)}\n"
|
|
1423
|
+
+ (
|
|
1424
|
+
"Inspect with list_files/read_file, propose edits with "
|
|
1425
|
+
"propose_file_change (nothing is written), and finish with a "
|
|
1426
|
+
"numbered step plan."
|
|
1427
|
+
if plan_only
|
|
1428
|
+
else "Use list_files/read_file/grep/glob to inspect relevant code, "
|
|
1429
|
+
"apply_patch (preferred) or write_file to edit, and "
|
|
1430
|
+
"run_approved_tests to run the approved command. delete_file and "
|
|
1431
|
+
"move_file require confirm=true."
|
|
1432
|
+
)
|
|
1433
|
+
),
|
|
1434
|
+
},
|
|
1435
|
+
]
|
|
1436
|
+
final_response = ""
|
|
1437
|
+
last_test: dict[str, Any] | None = None
|
|
1438
|
+
route_calls: list[dict[str, Any]] = []
|
|
1439
|
+
proposed: dict[str, str] = {}
|
|
1440
|
+
# Every run_shell request (executed or refused), in call order. The receipt
|
|
1441
|
+
# reports the command and exit code from here.
|
|
1442
|
+
shell_runs: list[dict[str, Any]] = []
|
|
1443
|
+
patches: list[dict[str, Any]] = []
|
|
1444
|
+
# Per-turn trace: what the model actually did (tool names, arg sizes,
|
|
1445
|
+
# finish reason) so a run receipt explains its own loop behavior — the
|
|
1446
|
+
# batch-1 debugging needed this and the receipt had nothing to show.
|
|
1447
|
+
turn_trace: list[dict[str, Any]] = []
|
|
1448
|
+
tool_calls_seen = 0
|
|
1449
|
+
interrupted = False
|
|
1450
|
+
|
|
1451
|
+
for step in range(1, max_turns + 1):
|
|
1452
|
+
if should_stop is not None and should_stop(step, tool_calls_seen):
|
|
1453
|
+
interrupted = True
|
|
1454
|
+
final_response = (
|
|
1455
|
+
f"Agent interrupted by the caller at step {step - 1} "
|
|
1456
|
+
f"({len(turn_trace)} turn(s) completed)."
|
|
1457
|
+
)
|
|
1458
|
+
break
|
|
1459
|
+
response = await complete(messages, active_tools)
|
|
1460
|
+
route_calls.append({
|
|
1461
|
+
"model": response.get("model"),
|
|
1462
|
+
"routing": response.get("routing"),
|
|
1463
|
+
})
|
|
1464
|
+
try:
|
|
1465
|
+
message = response["choices"][0]["message"]
|
|
1466
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
1467
|
+
raise NativeAgentError("SID model returned an invalid tool-loop response") from exc
|
|
1468
|
+
calls = message.get("tool_calls") or []
|
|
1469
|
+
content = message.get("content") or ""
|
|
1470
|
+
if not isinstance(content, str):
|
|
1471
|
+
content = str(content)
|
|
1472
|
+
turn_trace.append({
|
|
1473
|
+
"step": step,
|
|
1474
|
+
"finish_reason": response["choices"][0].get("finish_reason"),
|
|
1475
|
+
"content_chars": len(content),
|
|
1476
|
+
"tool_calls": [str((c.get("function") or {}).get("name") or "?") for c in calls],
|
|
1477
|
+
"usage": response.get("usage") or {},
|
|
1478
|
+
})
|
|
1479
|
+
if not calls:
|
|
1480
|
+
final_response = content
|
|
1481
|
+
break
|
|
1482
|
+
# Thinking-mode upstreams (deepseek) require the assistant turn's
|
|
1483
|
+
# reasoning_content to be passed back on subsequent turns; dropping it
|
|
1484
|
+
# hard-400s every continuation after the first tool call ("The
|
|
1485
|
+
# reasoning_content in the thinking mode must be passed back to the
|
|
1486
|
+
# API"). Carry it verbatim through the loop's history.
|
|
1487
|
+
assistant_msg: dict[str, Any] = {"role": "assistant", "content": content or None, "tool_calls": calls}
|
|
1488
|
+
reasoning = message.get("reasoning_content")
|
|
1489
|
+
if isinstance(reasoning, str) and reasoning:
|
|
1490
|
+
assistant_msg["reasoning_content"] = reasoning
|
|
1491
|
+
messages.append(assistant_msg)
|
|
1492
|
+
for call in calls:
|
|
1493
|
+
tool_calls_seen += 1
|
|
1494
|
+
if tool_calls_seen > max_tool_calls:
|
|
1495
|
+
raise NativeAgentError(f"agent exceeded the {max_tool_calls}-tool-call limit")
|
|
1496
|
+
call_id = str(call.get("id") or f"sid_tool_{tool_calls_seen}")
|
|
1497
|
+
function = call.get("function") or {}
|
|
1498
|
+
name = str(function.get("name") or "")
|
|
1499
|
+
try:
|
|
1500
|
+
arguments = json.loads(function.get("arguments") or "{}")
|
|
1501
|
+
if not isinstance(arguments, dict):
|
|
1502
|
+
raise ValueError("tool arguments must be an object")
|
|
1503
|
+
if plan_only and name in MUTATING_TOOL_NAMES:
|
|
1504
|
+
# Hard zero-write floor: even a model that hallucinates a
|
|
1505
|
+
# disabled mutating tool cannot mutate the workspace in plan
|
|
1506
|
+
# mode.
|
|
1507
|
+
result = {
|
|
1508
|
+
"error": (
|
|
1509
|
+
"plan mode is read-only: "
|
|
1510
|
+
f"{name} is disabled. Submit the intended edit with "
|
|
1511
|
+
"propose_file_change."
|
|
1512
|
+
),
|
|
1513
|
+
"written": False,
|
|
1514
|
+
}
|
|
1515
|
+
elif plan_only and name == "propose_file_change":
|
|
1516
|
+
result = _propose_result(
|
|
1517
|
+
arguments,
|
|
1518
|
+
files,
|
|
1519
|
+
proposed,
|
|
1520
|
+
max_file_bytes=max_file_bytes,
|
|
1521
|
+
max_files=max_files,
|
|
1522
|
+
max_workspace_bytes=max_workspace_bytes,
|
|
1523
|
+
)
|
|
1524
|
+
elif plan_only and name == "run_approved_tests":
|
|
1525
|
+
result = {
|
|
1526
|
+
"skipped": True,
|
|
1527
|
+
"reason": (
|
|
1528
|
+
"plan mode is read-only: nothing is written, so no tests "
|
|
1529
|
+
"run. Name the verification step in the plan."
|
|
1530
|
+
),
|
|
1531
|
+
}
|
|
1532
|
+
elif plan_only and name == SHELL_TOOL_NAME:
|
|
1533
|
+
result = {
|
|
1534
|
+
"tool": SHELL_TOOL_NAME,
|
|
1535
|
+
"executed": False,
|
|
1536
|
+
"exit_code": None,
|
|
1537
|
+
"skipped": True,
|
|
1538
|
+
"reason": "plan mode is read-only: shell is not available",
|
|
1539
|
+
}
|
|
1540
|
+
elif name == SHELL_TOOL_NAME:
|
|
1541
|
+
# The fifth tool is owned by the loop, not the extension
|
|
1542
|
+
# registry: an approved ShellPolicy is the one way in, and
|
|
1543
|
+
# the policy refuses every request when it is absent.
|
|
1544
|
+
result = run_shell_tool(arguments, shell_policy)
|
|
1545
|
+
elif tool_dispatch is not None:
|
|
1546
|
+
# A caller-supplied registry routes EVERY call (base and
|
|
1547
|
+
# extension) through one seam; base names still land in
|
|
1548
|
+
# _tool_result via the registry, so the contract keeps one
|
|
1549
|
+
# owner and there is no second engine.
|
|
1550
|
+
result = tool_dispatch(name, arguments, files, run_tests)
|
|
1551
|
+
else:
|
|
1552
|
+
result = _tool_result(
|
|
1553
|
+
name,
|
|
1554
|
+
arguments,
|
|
1555
|
+
files,
|
|
1556
|
+
run_tests,
|
|
1557
|
+
max_tool_output_chars=max_tool_output_chars,
|
|
1558
|
+
max_file_bytes=max_file_bytes,
|
|
1559
|
+
max_files=max_files,
|
|
1560
|
+
max_workspace_bytes=max_workspace_bytes,
|
|
1561
|
+
)
|
|
1562
|
+
if hasattr(result, "__await__"):
|
|
1563
|
+
result = await result
|
|
1564
|
+
if name == "run_approved_tests":
|
|
1565
|
+
last_test = result
|
|
1566
|
+
elif name == SHELL_TOOL_NAME:
|
|
1567
|
+
shell_runs.append(result)
|
|
1568
|
+
if (
|
|
1569
|
+
name == "apply_patch"
|
|
1570
|
+
and isinstance(result, dict)
|
|
1571
|
+
and result.get("diff")
|
|
1572
|
+
):
|
|
1573
|
+
patches.append(
|
|
1574
|
+
{
|
|
1575
|
+
"path": result.get("patched"),
|
|
1576
|
+
"edits": result.get("edits"),
|
|
1577
|
+
"diff": result["diff"],
|
|
1578
|
+
}
|
|
1579
|
+
)
|
|
1580
|
+
except Exception as exc:
|
|
1581
|
+
result = {"error": f"tool failed: {type(exc).__name__}: {exc}"}
|
|
1582
|
+
payload = json.dumps(result, ensure_ascii=False, default=str)
|
|
1583
|
+
messages.append({"role": "tool", "tool_call_id": call_id, "content": payload[:max_tool_output_chars]})
|
|
1584
|
+
else:
|
|
1585
|
+
final_response = "Agent stopped at the bounded turn limit before returning a final answer."
|
|
1586
|
+
|
|
1587
|
+
# Always run the exact approved test command once on the final file set,
|
|
1588
|
+
# even if the model forgot or ran tests before its last edit. A clean
|
|
1589
|
+
# interrupt (SUB-58) skips this: the caller stopped the run, so it is not
|
|
1590
|
+
# tested and never reported as tested or verified. Plan mode (SUB-81) has
|
|
1591
|
+
# nothing to test: it never wrote the workspace.
|
|
1592
|
+
if not interrupted and not plan_only:
|
|
1593
|
+
final_test = await run_tests(files)
|
|
1594
|
+
last_test = final_test
|
|
1595
|
+
elif plan_only:
|
|
1596
|
+
last_test = {
|
|
1597
|
+
"backend": None,
|
|
1598
|
+
"isolated": False,
|
|
1599
|
+
"exit_code": None,
|
|
1600
|
+
"passed": False,
|
|
1601
|
+
"skipped": True,
|
|
1602
|
+
"reason": "plan mode is read-only: nothing was written, so no tests were run",
|
|
1603
|
+
"stdout": "",
|
|
1604
|
+
"stderr": "",
|
|
1605
|
+
}
|
|
1606
|
+
if plan_only:
|
|
1607
|
+
proposed = {path: content for path, content in proposed.items() if files.get(path) != content}
|
|
1608
|
+
changes = sorted(proposed)
|
|
1609
|
+
readback = {path: proposed[path] for path in changes}
|
|
1610
|
+
diff_text = _diff(original, {**files, **proposed})
|
|
1611
|
+
else:
|
|
1612
|
+
changes = sorted(path for path in set(original) | set(files) if original.get(path) != files.get(path))
|
|
1613
|
+
readback = {path: files[path] for path in changes if path in files}
|
|
1614
|
+
diff_text = _diff(original, files)
|
|
1615
|
+
artifact_files = {path: content.encode("utf-8") for path, content in files.items()}
|
|
1616
|
+
return {
|
|
1617
|
+
"final_response": final_response,
|
|
1618
|
+
"project_rules": project_rules,
|
|
1619
|
+
"plan": final_response if plan_only else None,
|
|
1620
|
+
"plan_mode": plan_only,
|
|
1621
|
+
"writes": 0 if plan_only else len(changes),
|
|
1622
|
+
"test": last_test,
|
|
1623
|
+
"tests": last_test,
|
|
1624
|
+
"readback": readback,
|
|
1625
|
+
"artifact_files": artifact_files,
|
|
1626
|
+
"tested_artifact_hash": artifact_hash(artifact_files),
|
|
1627
|
+
"diff": diff_text or None,
|
|
1628
|
+
"patches": patches,
|
|
1629
|
+
"changed": changes,
|
|
1630
|
+
"isolated": bool(last_test and last_test.get("isolated")),
|
|
1631
|
+
"backend": last_test.get("backend") if last_test else None,
|
|
1632
|
+
"model": route_calls[-1]["model"] if route_calls else "auto",
|
|
1633
|
+
"route_calls": route_calls,
|
|
1634
|
+
"turn_trace": turn_trace,
|
|
1635
|
+
"shell": shell_runs,
|
|
1636
|
+
"steps": step if "step" in locals() else 0,
|
|
1637
|
+
"tool_calls": tool_calls_seen,
|
|
1638
|
+
"interrupted": interrupted,
|
|
1639
|
+
}
|