stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shell tools — `bash`, and `bash_output` for the background case.
|
|
3
|
+
|
|
4
|
+
Commands run with the user's privileges; containerize before any autonomous run. From
|
|
5
|
+
inside the process this module does four things:
|
|
6
|
+
|
|
7
|
+
* **Refuses the unrecoverable** — a short denylist of damage no later turn could undo.
|
|
8
|
+
Not a security boundary (a script or base64 goes round it); it guards against a model
|
|
9
|
+
writing something destructive by accident.
|
|
10
|
+
* **Asks proportionally.** `git status` and `rm -rf build/` are the same tool call, so
|
|
11
|
+
read-only commands drop to READ per call and the rest still ask.
|
|
12
|
+
* **Never hangs.** stdin closed, pagers off, prompts disabled. A command that stops to
|
|
13
|
+
ask a question stops the turn and produces nothing to react to.
|
|
14
|
+
* **Each call is a fresh process** — `cd`, `export` and `source` do not carry over. Pass
|
|
15
|
+
`cwd` instead. The model will assume otherwise, so the docstring says so outright.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shlex
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Annotated
|
|
28
|
+
|
|
29
|
+
from pydantic import Field
|
|
30
|
+
|
|
31
|
+
from stcode.core.harness.approvals import ToolPermission
|
|
32
|
+
from stcode.core.harness.context import HarnessContext
|
|
33
|
+
from stcode.core.harness.tools.base import Runtime, ToolError, tool
|
|
34
|
+
|
|
35
|
+
DEFAULT_TIMEOUT = 120.0
|
|
36
|
+
MAX_TIMEOUT = 900.0
|
|
37
|
+
BASH_MAX_OUTPUT = 16384
|
|
38
|
+
|
|
39
|
+
NON_INTERACTIVE_ENV = {
|
|
40
|
+
"GIT_PAGER": "cat",
|
|
41
|
+
"PAGER": "cat",
|
|
42
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
43
|
+
"GIT_ASKPASS": "echo",
|
|
44
|
+
"DEBIAN_FRONTEND": "noninteractive",
|
|
45
|
+
"CI": "1",
|
|
46
|
+
"NO_COLOR": "1",
|
|
47
|
+
"TERM": "dumb",
|
|
48
|
+
}
|
|
49
|
+
"""Set on every command. `TERM=dumb` and `NO_COLOR` matter as much as the prompt
|
|
50
|
+
suppressors: ANSI escapes are invisible to the reader and expensive in the context."""
|
|
51
|
+
|
|
52
|
+
_DESTRUCTIVE = (
|
|
53
|
+
(re.compile(r"\bmkfs(\.\w+)?\b"), "filesystem format"),
|
|
54
|
+
(re.compile(r"\bdd\b[^|;]*\bof=/dev/"), "raw write to a block device"),
|
|
55
|
+
(re.compile(r">\s*/dev/(sd|nvme|hd|disk)"), "redirect onto a block device"),
|
|
56
|
+
(re.compile(r":\s*\(\s*\)\s*\{.*\|.*&\s*\}\s*;?\s*:"), "fork bomb"),
|
|
57
|
+
(re.compile(r"\b(shutdown|reboot|halt|poweroff)\b"), "host power control"),
|
|
58
|
+
(re.compile(r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|k|fi)?sh\b"), "piping a download into a shell"),
|
|
59
|
+
(re.compile(r"\bchmod\s+-[a-zA-Z]*R[a-zA-Z]*\s+777\s+/\s*$"), "world-writable root"),
|
|
60
|
+
(re.compile(r"\bgit\s+(push\s+[^|;]*--force(?!-with-lease)|reset\s+--hard\s+\S*origin)"), "irreversible git history rewrite"),
|
|
61
|
+
)
|
|
62
|
+
"""Refused outright. The test for belonging here is "could the next turn undo it?", not
|
|
63
|
+
"is it dangerous?". Deletion is judged by target instead, since `rm -rf build/` is
|
|
64
|
+
fine."""
|
|
65
|
+
|
|
66
|
+
_IRREPLACEABLE = frozenset(
|
|
67
|
+
{
|
|
68
|
+
"/", "/*", "~", "~/", "~/*", "$HOME", "$HOME/*", "${HOME}", "..", "../", "../*",
|
|
69
|
+
".", "./", "./*", "*",
|
|
70
|
+
"/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/opt", "/proc",
|
|
71
|
+
"/root", "/run", "/sbin", "/srv", "/sys", "/usr", "/var",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
"""Delete targets with nothing behind them. `.` and `*` are here because from the
|
|
75
|
+
session's cwd they mean the user's whole project."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _deletes_something_irreplaceable(command: str) -> str | None:
|
|
79
|
+
"""Whether any `rm` here points at a path that cannot be recreated.
|
|
80
|
+
|
|
81
|
+
Judged on the target, not the flags: `rm -rf node_modules/` and `rm -rf /` differ
|
|
82
|
+
only in the argument, and refusing on `-rf` would block routine cleanup until
|
|
83
|
+
whoever reads the refusals learns to ignore them.
|
|
84
|
+
"""
|
|
85
|
+
for segment in _split_command(command):
|
|
86
|
+
if os.path.basename(segment[0]) != "rm":
|
|
87
|
+
continue
|
|
88
|
+
for argument in segment[1:]:
|
|
89
|
+
if argument.startswith("-"):
|
|
90
|
+
continue
|
|
91
|
+
normalized = argument.rstrip("/") + "/" if argument.endswith("/") else argument
|
|
92
|
+
if argument in _IRREPLACEABLE or normalized in _IRREPLACEABLE:
|
|
93
|
+
return f"deleting {argument!r}, which is not something a later turn could rebuild"
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_INTERACTIVE = (
|
|
98
|
+
(re.compile(r"\bgit\s+(rebase|add|commit)\b[^|;]*\s-i\b"), "git's interactive mode"),
|
|
99
|
+
(re.compile(r"^\s*(vim?|nano|emacs|less|more|top|htop)\b"), "a full-screen program"),
|
|
100
|
+
(re.compile(r"\bssh\b(?![^|;]*\s-o\s*BatchMode)"), "ssh, which may prompt for a passphrase"),
|
|
101
|
+
)
|
|
102
|
+
"""Commands that wait for a human at a TTY there is no way to reach. Refused with an
|
|
103
|
+
alternative rather than left to consume the whole timeout in silence."""
|
|
104
|
+
|
|
105
|
+
_READ_ONLY_COMMANDS = frozenset(
|
|
106
|
+
{
|
|
107
|
+
"ls", "pwd", "cat", "head", "tail", "wc", "file", "stat", "du", "df", "tree",
|
|
108
|
+
"echo", "date", "whoami", "hostname", "uname", "which", "type", "env", "printenv",
|
|
109
|
+
"grep", "rg", "fd", "find", "sort", "uniq", "cut", "awk", "sed", "diff", "cmp",
|
|
110
|
+
"python", "python3", "node", "jq", "basename", "dirname", "realpath", "readlink",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
_READ_ONLY_SUBCOMMANDS = {
|
|
114
|
+
"git": frozenset({"status", "log", "diff", "show", "branch", "remote", "config",
|
|
115
|
+
"blame", "describe", "rev-parse", "ls-files", "shortlog", "tag"}),
|
|
116
|
+
"uv": frozenset({"tree", "version", "python"}),
|
|
117
|
+
"docker": frozenset({"ps", "images", "logs", "version", "inspect"}),
|
|
118
|
+
"npm": frozenset({"ls", "view", "outdated"}),
|
|
119
|
+
"cargo": frozenset({"tree", "metadata"}),
|
|
120
|
+
}
|
|
121
|
+
"""`sed` and `awk` are here for the overwhelmingly common case, a pipe filter. `sed -i`
|
|
122
|
+
edits in place, so `is_read_only` checks for that rather than trusting the name."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class BackgroundShell:
|
|
127
|
+
"""A command still running after `bash` returned.
|
|
128
|
+
|
|
129
|
+
Output drains continuously into `buffer` rather than on demand: a process whose pipe
|
|
130
|
+
fills up blocks forever, and a build stalled at 64 KB with no error is close to
|
|
131
|
+
impossible to diagnose from outside.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
id: str
|
|
135
|
+
command: str
|
|
136
|
+
process: asyncio.subprocess.Process
|
|
137
|
+
started_at: float = field(default_factory=time.monotonic)
|
|
138
|
+
buffer: list[str] = field(default_factory=list)
|
|
139
|
+
consumed: int = 0
|
|
140
|
+
pump: asyncio.Task[None] | None = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def running(self) -> bool:
|
|
144
|
+
return self.process.returncode is None
|
|
145
|
+
|
|
146
|
+
def drain(self) -> str:
|
|
147
|
+
"""Everything printed since the last drain."""
|
|
148
|
+
chunk = "".join(self.buffer[self.consumed :])
|
|
149
|
+
self.consumed = len(self.buffer)
|
|
150
|
+
return chunk
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _split_command(command: str) -> list[list[str]]:
|
|
154
|
+
"""Best-effort split into the commands a line runs.
|
|
155
|
+
|
|
156
|
+
`shlex` is not a shell parser and this is not trying to be one — it is enough to
|
|
157
|
+
decide whether every segment of `git status | head -20` is read-only, and it fails
|
|
158
|
+
closed: an unparseable line is simply not read-only.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
tokens = shlex.split(command, comments=True)
|
|
162
|
+
except ValueError:
|
|
163
|
+
return []
|
|
164
|
+
segments: list[list[str]] = [[]]
|
|
165
|
+
for token in tokens:
|
|
166
|
+
if token in ("|", "||", "&&", ";", "&"):
|
|
167
|
+
segments.append([])
|
|
168
|
+
else:
|
|
169
|
+
segments[-1].append(token)
|
|
170
|
+
return [segment for segment in segments if segment]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_read_only(command: str) -> bool:
|
|
174
|
+
"""Whether every segment of `command` only observes the workspace."""
|
|
175
|
+
segments = _split_command(command)
|
|
176
|
+
if not segments:
|
|
177
|
+
return False
|
|
178
|
+
for segment in segments:
|
|
179
|
+
name = os.path.basename(segment[0])
|
|
180
|
+
if name == "sed" and any(arg.startswith("-i") for arg in segment[1:]):
|
|
181
|
+
return False # in-place edit wearing a filter's name
|
|
182
|
+
if name in _READ_ONLY_SUBCOMMANDS:
|
|
183
|
+
arguments = [arg for arg in segment[1:] if not arg.startswith("-")]
|
|
184
|
+
if not arguments or arguments[0] not in _READ_ONLY_SUBCOMMANDS[name]:
|
|
185
|
+
return False
|
|
186
|
+
continue
|
|
187
|
+
if name not in _READ_ONLY_COMMANDS:
|
|
188
|
+
return False
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _refuse(command: str) -> str | None:
|
|
193
|
+
"""The reason this command will not be run, or None."""
|
|
194
|
+
for pattern, why in _DESTRUCTIVE:
|
|
195
|
+
if pattern.search(command):
|
|
196
|
+
return (
|
|
197
|
+
f"refused: this looks like {why}, which no later turn could undo. "
|
|
198
|
+
"If you genuinely need it, ask the user to run it themselves."
|
|
199
|
+
)
|
|
200
|
+
deletion = _deletes_something_irreplaceable(command)
|
|
201
|
+
if deletion:
|
|
202
|
+
return (
|
|
203
|
+
f"refused: this is {deletion}. Name the specific subdirectory you mean, or "
|
|
204
|
+
"ask the user to do it themselves."
|
|
205
|
+
)
|
|
206
|
+
for pattern, why in _INTERACTIVE:
|
|
207
|
+
if pattern.search(command):
|
|
208
|
+
return (
|
|
209
|
+
f"refused: this invokes {why} and there is no terminal to answer it. "
|
|
210
|
+
"Use the non-interactive equivalent."
|
|
211
|
+
)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _permission_for(arguments: dict[str, object]) -> ToolPermission:
|
|
216
|
+
command = str(arguments.get("command", ""))
|
|
217
|
+
return ToolPermission.READ if is_read_only(command) else ToolPermission.EXECUTE
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@tool(
|
|
221
|
+
permission=ToolPermission.EXECUTE,
|
|
222
|
+
permission_for=_permission_for,
|
|
223
|
+
max_output=BASH_MAX_OUTPUT,
|
|
224
|
+
)
|
|
225
|
+
async def bash(
|
|
226
|
+
command: str,
|
|
227
|
+
timeout: Annotated[float, Field(gt=0, le=MAX_TIMEOUT)] = DEFAULT_TIMEOUT,
|
|
228
|
+
background: bool = False,
|
|
229
|
+
cwd: str = "",
|
|
230
|
+
description: str = "",
|
|
231
|
+
runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Run a shell command in the session's working directory.
|
|
234
|
+
|
|
235
|
+
Runs with your privileges. Prefer the dedicated tools where one fits — `read`,
|
|
236
|
+
`glob`, and `grep` are faster than `cat`, `find`, and `grep`, and their output is
|
|
237
|
+
shaped for you rather than for a terminal. Reach for this when you need a real
|
|
238
|
+
program: a test runner, a build, `git`, a package manager.
|
|
239
|
+
|
|
240
|
+
**Every call is a new process.** Nothing carries over between calls — not the
|
|
241
|
+
working directory, not `export`ed variables, not an activated virtualenv. `cd src`
|
|
242
|
+
in one call has no effect on the next one. To work in a subdirectory, pass `cwd`;
|
|
243
|
+
to use a virtualenv, call its binaries by path (`.venv/bin/pytest`) or prefix the
|
|
244
|
+
command (`uv run pytest`).
|
|
245
|
+
|
|
246
|
+
The command runs with no stdin and no pager, so anything that would stop to ask a
|
|
247
|
+
question fails instead of hanging. Quote paths containing spaces.
|
|
248
|
+
|
|
249
|
+
Use `background=True` for anything long-running you want to keep working alongside —
|
|
250
|
+
a dev server, a watch task, a full test suite. It returns a shell id immediately;
|
|
251
|
+
read from it with `bash_output`.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
command: The command line to run.
|
|
255
|
+
timeout: Seconds to wait before giving up and killing it.
|
|
256
|
+
background: Return immediately and keep the command running.
|
|
257
|
+
cwd: Directory to run in, relative to the session root. Defaults to the
|
|
258
|
+
session root itself.
|
|
259
|
+
description: A 5-10 word description of what this does, shown to the user
|
|
260
|
+
while it runs. e.g. "Run the unit tests".
|
|
261
|
+
"""
|
|
262
|
+
context = runtime.context
|
|
263
|
+
refusal = _refuse(command)
|
|
264
|
+
if refusal:
|
|
265
|
+
raise ToolError(f"`{command}` was not run — {refusal}")
|
|
266
|
+
|
|
267
|
+
working_dir = context.resolve(cwd) if cwd else context.cwd
|
|
268
|
+
if not working_dir.is_dir():
|
|
269
|
+
raise ToolError(f"`{command}` was not run — {working_dir} is not a directory.")
|
|
270
|
+
|
|
271
|
+
env = {**context.process_env(), **NON_INTERACTIVE_ENV}
|
|
272
|
+
if description:
|
|
273
|
+
await runtime.progress(description)
|
|
274
|
+
|
|
275
|
+
process = await asyncio.create_subprocess_shell(
|
|
276
|
+
command,
|
|
277
|
+
cwd=str(working_dir),
|
|
278
|
+
env=env,
|
|
279
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
280
|
+
stdout=asyncio.subprocess.PIPE,
|
|
281
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
282
|
+
# Its own process group, so a timeout kills the whole pipeline rather than just
|
|
283
|
+
# the shell that spawned it, orphaning the actual work.
|
|
284
|
+
start_new_session=True,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if background:
|
|
288
|
+
return await _start_background(process, command, context)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
stdout, _ = await asyncio.wait_for(process.communicate(), timeout)
|
|
292
|
+
except asyncio.TimeoutError:
|
|
293
|
+
_kill_group(process)
|
|
294
|
+
await process.wait()
|
|
295
|
+
raise ToolError(
|
|
296
|
+
f"`{command}` was killed after {timeout:.0f}s. Raise `timeout` if it is "
|
|
297
|
+
"genuinely slow, or run it with background=True and poll `bash_output`."
|
|
298
|
+
) from None
|
|
299
|
+
|
|
300
|
+
output = stdout.decode("utf-8", errors="replace").rstrip()
|
|
301
|
+
code = process.returncode or 0
|
|
302
|
+
if code == 0:
|
|
303
|
+
return output or "(no output)"
|
|
304
|
+
# Exit code last: the part the model most needs, and `elide` keeps the tail.
|
|
305
|
+
return f"{output}\n\n[exit {code}]" if output else f"[exit {code}, no output]"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _start_background(
|
|
309
|
+
process: asyncio.subprocess.Process, command: str, context: HarnessContext
|
|
310
|
+
) -> str:
|
|
311
|
+
shell = BackgroundShell(id=f"sh_{uuid.uuid4().hex[:8]}", command=command, process=process)
|
|
312
|
+
|
|
313
|
+
async def pump() -> None:
|
|
314
|
+
assert process.stdout is not None
|
|
315
|
+
while True:
|
|
316
|
+
line = await process.stdout.readline()
|
|
317
|
+
if not line:
|
|
318
|
+
break
|
|
319
|
+
shell.buffer.append(line.decode("utf-8", errors="replace"))
|
|
320
|
+
await process.wait()
|
|
321
|
+
|
|
322
|
+
shell.pump = asyncio.create_task(pump())
|
|
323
|
+
context.shells[shell.id] = shell
|
|
324
|
+
return (
|
|
325
|
+
f"Started `{command}` in the background as {shell.id} (pid {process.pid}).\n"
|
|
326
|
+
f"Read its output with bash_output({shell.id!r})."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _kill_group(process: asyncio.subprocess.Process) -> None:
|
|
331
|
+
try:
|
|
332
|
+
os.killpg(os.getpgid(process.pid), 9)
|
|
333
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
334
|
+
process.kill()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@tool(permission=ToolPermission.READ, max_output=BASH_MAX_OUTPUT)
|
|
338
|
+
async def bash_output(
|
|
339
|
+
shell_id: str,
|
|
340
|
+
kill: bool = False,
|
|
341
|
+
runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
|
|
342
|
+
) -> str:
|
|
343
|
+
"""Read new output from a background shell started by `bash(background=True)`.
|
|
344
|
+
|
|
345
|
+
Returns only what has been printed since the last time you read it, so polling a
|
|
346
|
+
long build does not re-read the whole log each turn.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
shell_id: The id `bash` returned, e.g. `sh_1a2b3c4d`.
|
|
350
|
+
kill: Stop the process after reading whatever it has produced.
|
|
351
|
+
"""
|
|
352
|
+
context = runtime.context
|
|
353
|
+
shell = context.shells.get(shell_id)
|
|
354
|
+
if shell is None:
|
|
355
|
+
known = ", ".join(context.shells) or "none"
|
|
356
|
+
raise ToolError(f"No background shell {shell_id!r}. Running: {known}.")
|
|
357
|
+
|
|
358
|
+
chunk = shell.drain()
|
|
359
|
+
if kill and shell.running:
|
|
360
|
+
_kill_group(shell.process)
|
|
361
|
+
await shell.process.wait()
|
|
362
|
+
|
|
363
|
+
elapsed = time.monotonic() - shell.started_at
|
|
364
|
+
if shell.running:
|
|
365
|
+
status = f"[{shell_id} still running, {elapsed:.0f}s elapsed]"
|
|
366
|
+
else:
|
|
367
|
+
status = f"[{shell_id} exited {shell.process.returncode} after {elapsed:.0f}s]"
|
|
368
|
+
context.shells.pop(shell_id, None)
|
|
369
|
+
return f"{chunk.rstrip()}\n\n{status}" if chunk.strip() else status
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
__all__ = ["BackgroundShell", "bash", "bash_output", "is_read_only"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`skill` — pull a set of instructions into context on demand.
|
|
3
|
+
|
|
4
|
+
The catalogue is in the system prompt, one line per skill. That is all a session pays
|
|
5
|
+
for a skill it never uses; this tool is the second half.
|
|
6
|
+
|
|
7
|
+
Nine installed skills are roughly 90,000 tokens of instructions and 400 of catalogue.
|
|
8
|
+
Loading everything upfront would spend most of a context window on advice about tasks
|
|
9
|
+
the session is not doing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from stcode.core.harness.approvals import ToolPermission
|
|
15
|
+
from stcode.core.harness.context import HarnessContext
|
|
16
|
+
from stcode.core.harness.tools.base import Runtime, ToolError, tool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@tool(permission=ToolPermission.READ, max_output=65536, spill=False)
|
|
20
|
+
async def skill(
|
|
21
|
+
name: str,
|
|
22
|
+
runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Load a skill's full instructions by name.
|
|
25
|
+
|
|
26
|
+
Skills are pre-written procedures for specific kinds of work. The catalogue in your
|
|
27
|
+
system prompt lists what is available and when each applies — when a task matches
|
|
28
|
+
one of those descriptions, load it *before* starting, not after getting stuck.
|
|
29
|
+
|
|
30
|
+
What comes back is instructions for you to follow, not a report to relay. A skill
|
|
31
|
+
may point at further files in its own directory; read those with `read` as it tells
|
|
32
|
+
you to, rather than loading them speculatively.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
name: The skill's name, exactly as listed in the catalogue.
|
|
36
|
+
"""
|
|
37
|
+
registry = runtime.context.skills
|
|
38
|
+
if registry is None or not len(registry):
|
|
39
|
+
raise ToolError(
|
|
40
|
+
"No skills are available in this session. Proceed without one — skills are "
|
|
41
|
+
"an accelerator, not a prerequisite."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
wanted = name.strip().lstrip("/")
|
|
45
|
+
found = registry.get(wanted)
|
|
46
|
+
if found is None:
|
|
47
|
+
near = [candidate for candidate in registry.names() if wanted.lower() in candidate.lower()]
|
|
48
|
+
suggestion = f" Did you mean: {', '.join(near)}?" if near else f" Available: {', '.join(registry.names())}."
|
|
49
|
+
raise ToolError(f"No skill named {wanted!r}.{suggestion}")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
body = found.load()
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
raise ToolError(f"Could not read {found.path}: {exc.strerror}") from exc
|
|
55
|
+
|
|
56
|
+
# The root path is stated explicitly because the instructions reference sibling
|
|
57
|
+
# files relatively ("see reference.md"), and the agent's cwd is the project, not
|
|
58
|
+
# the skill. Without this line every such reference resolves to the wrong place.
|
|
59
|
+
return (
|
|
60
|
+
f"# Skill: {found.name}\n"
|
|
61
|
+
f"Files referenced below are relative to {found.root}\n\n"
|
|
62
|
+
f"{body}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["skill"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Todo — `todo_write`, the tool that is not really a tool.
|
|
3
|
+
|
|
4
|
+
A device to keep the plan in context. Nothing outside the agent reads these items, and
|
|
5
|
+
writing one changes no file. What it does is force the plan through the model's own
|
|
6
|
+
output, where it stays visible — a multi-step task tracked only in a model's head loses
|
|
7
|
+
its last two items around the fourth tool call.
|
|
8
|
+
|
|
9
|
+
The one rule that matters: *exactly one* item in progress. A list with four
|
|
10
|
+
`in_progress` has stopped describing what is happening.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from stcode.core.harness.approvals import ToolPermission
|
|
16
|
+
from stcode.core.harness.context import HarnessContext, TodoItem
|
|
17
|
+
from stcode.core.harness.tools.base import Runtime, ToolError, tool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool(permission=ToolPermission.READ, spill=False)
|
|
21
|
+
async def todo_write(
|
|
22
|
+
todos: list[TodoItem],
|
|
23
|
+
runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Record or update the task list for the current piece of work.
|
|
26
|
+
|
|
27
|
+
Use it for anything that takes more than a couple of steps, and update it *as you
|
|
28
|
+
go* — mark an item completed the moment it is done, not in a batch at the end. The
|
|
29
|
+
list is how you and the user both keep track of where the work has got to.
|
|
30
|
+
|
|
31
|
+
Skip it entirely for single-step tasks. A one-item todo list is ceremony.
|
|
32
|
+
|
|
33
|
+
Each item needs both phrasings: `content` in the imperative ("Add the retry test")
|
|
34
|
+
and `active_form` in the present participle ("Adding the retry test"), because the
|
|
35
|
+
UI shows the second one while the item is running.
|
|
36
|
+
|
|
37
|
+
Exactly one item may be `in_progress`. Mark an item completed only when it really
|
|
38
|
+
is — if tests fail or the implementation is partial, leave it in progress and add
|
|
39
|
+
an item for what is blocking it.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
todos: The complete list, replacing whatever was there before. Send every item
|
|
43
|
+
each time, not just the ones that changed.
|
|
44
|
+
"""
|
|
45
|
+
context = runtime.context
|
|
46
|
+
in_progress = [item for item in todos if item.status == "in_progress"]
|
|
47
|
+
if len(in_progress) > 1:
|
|
48
|
+
names = ", ".join(repr(item.content) for item in in_progress)
|
|
49
|
+
raise ToolError(
|
|
50
|
+
f"{len(in_progress)} items are in_progress ({names}). Exactly one at a time — "
|
|
51
|
+
"pick the one you are actually working on and leave the rest pending."
|
|
52
|
+
)
|
|
53
|
+
for item in todos:
|
|
54
|
+
if not item.active_form:
|
|
55
|
+
item.active_form = item.content
|
|
56
|
+
|
|
57
|
+
context.todos = list(todos)
|
|
58
|
+
done = sum(1 for item in todos if item.status == "completed")
|
|
59
|
+
return f"{context.render_todos()}\n\n[{done}/{len(todos)} complete]"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["todo_write"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web access — one tool, three Tavily endpoints, chosen by which arguments arrive.
|
|
3
|
+
|
|
4
|
+
`web_search(query=...)` searches, `web_search(url=...)` extracts that page, both
|
|
5
|
+
together crawls from that URL looking for the query. One tool rather than three: those
|
|
6
|
+
are the same intent at three scopes, and the arguments already say which — not a
|
|
7
|
+
decision the model should have to make before it knows what it will find.
|
|
8
|
+
|
|
9
|
+
Web output is always huge; one search returns more tokens than most files in a repo. The
|
|
10
|
+
low default result counts are for that, not to save credits.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Annotated, Any, Literal
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
|
|
20
|
+
from stcode.core.harness.approvals import ToolPermission
|
|
21
|
+
from stcode.core.harness.context import HarnessContext
|
|
22
|
+
from stcode.core.harness.tools.base import Runtime, ToolError, tool
|
|
23
|
+
|
|
24
|
+
TAVILY_BASE_URL = "https://api.tavily.com"
|
|
25
|
+
WEB_MAX_OUTPUT = 24576
|
|
26
|
+
"""Higher than other tools, and still the tool most likely to elide. The remainder is
|
|
27
|
+
in `tool_out` — filter it in the REPL rather than asking for less next time."""
|
|
28
|
+
|
|
29
|
+
_REQUEST_TIMEOUT = 90.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _post(key: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
33
|
+
"""One request to Tavily, with its failures translated into advice.
|
|
34
|
+
|
|
35
|
+
Status codes are mapped rather than surfaced raw: a model that reads "401" tries the
|
|
36
|
+
call again, while a model that reads "the key is rejected" stops and says so.
|
|
37
|
+
"""
|
|
38
|
+
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
|
|
39
|
+
try:
|
|
40
|
+
response = await client.post(
|
|
41
|
+
f"{TAVILY_BASE_URL}/{endpoint}",
|
|
42
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
43
|
+
json={key: value for key, value in payload.items() if value is not None},
|
|
44
|
+
)
|
|
45
|
+
except httpx.TimeoutException:
|
|
46
|
+
raise ToolError(f"Tavily /{endpoint} timed out after {_REQUEST_TIMEOUT:.0f}s.") from None
|
|
47
|
+
except httpx.HTTPError as exc:
|
|
48
|
+
raise ToolError(f"Could not reach Tavily: {exc}") from exc
|
|
49
|
+
|
|
50
|
+
if response.status_code == 401:
|
|
51
|
+
raise ToolError("Tavily rejected the configured key. Do not retry.")
|
|
52
|
+
if response.status_code == 429:
|
|
53
|
+
raise ToolError("Tavily rate limit or credit limit reached. Do not retry this turn.")
|
|
54
|
+
if response.status_code >= 400:
|
|
55
|
+
raise ToolError(f"Tavily /{endpoint} returned {response.status_code}: {response.text[:400]}")
|
|
56
|
+
return response.json() # type: ignore[no-any-return]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@tool(permission=ToolPermission.NETWORK, max_output=WEB_MAX_OUTPUT, timeout=120.0)
|
|
60
|
+
async def web_search(
|
|
61
|
+
query: str | None = None,
|
|
62
|
+
url: str | None = None,
|
|
63
|
+
max_results: Annotated[int, Field(ge=1, le=20)] = 5,
|
|
64
|
+
depth: Literal["basic", "advanced"] = "basic",
|
|
65
|
+
include_domains: list[str] | None = None,
|
|
66
|
+
runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Search the web, read a specific page, or crawl a site — depending on what you pass.
|
|
69
|
+
|
|
70
|
+
* `query` alone — search the web and return the best matches with excerpts.
|
|
71
|
+
* `url` alone — fetch that one page and return its content as markdown.
|
|
72
|
+
* both — crawl outward from `url`, keeping pages relevant to `query`. Use this for
|
|
73
|
+
documentation sites, where the answer is on a page you cannot name in advance.
|
|
74
|
+
|
|
75
|
+
Your training data has a cutoff and this does not. Check here whenever the answer
|
|
76
|
+
depends on a current version number, a recent release, or an API that may have
|
|
77
|
+
changed — and prefer a project's own documentation URL over a general search.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
query: What to search for, or what to look for while crawling. Write it as a
|
|
81
|
+
full question, not keywords.
|
|
82
|
+
url: A page to read, or the site to crawl from.
|
|
83
|
+
max_results: How many results to return. Keep it small; each one is long.
|
|
84
|
+
depth: `advanced` searches harder and costs more. Try `basic` first.
|
|
85
|
+
include_domains: Restrict a search to these domains, e.g. `["docs.python.org"]`.
|
|
86
|
+
"""
|
|
87
|
+
if not query and not url:
|
|
88
|
+
raise ToolError("Pass `query` to search, `url` to read a page, or both to crawl a site.")
|
|
89
|
+
key = runtime.context.tool_config.tavily_key()
|
|
90
|
+
if not key:
|
|
91
|
+
raise ToolError(
|
|
92
|
+
"Web access needs a Tavily API key ([tools] tavily_api_key, or $TAVILY_API_KEY). "
|
|
93
|
+
"Ask the user to set it, or answer from the repository and your own knowledge "
|
|
94
|
+
"and say that you could not check the web."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if url and not url.startswith(("http://", "https://")):
|
|
98
|
+
url = f"https://{url}"
|
|
99
|
+
|
|
100
|
+
if query and url:
|
|
101
|
+
await runtime.progress(f"Crawling {url} for {query!r}")
|
|
102
|
+
payload = await _post(
|
|
103
|
+
key,
|
|
104
|
+
"crawl",
|
|
105
|
+
{"url": url, "instructions": query, "limit": max_results,
|
|
106
|
+
"extract_depth": depth, "format": "markdown"},
|
|
107
|
+
)
|
|
108
|
+
return _render_pages(payload.get("results", []), f"Crawl of {url} for {query!r}")
|
|
109
|
+
|
|
110
|
+
if url:
|
|
111
|
+
await runtime.progress(f"Reading {url}")
|
|
112
|
+
payload = await _post(
|
|
113
|
+
key, "extract", {"urls": [url], "extract_depth": depth, "format": "markdown"}
|
|
114
|
+
)
|
|
115
|
+
results = payload.get("results", [])
|
|
116
|
+
if not results:
|
|
117
|
+
failed = payload.get("failed_results", [])
|
|
118
|
+
reason = failed[0].get("error", "no content") if failed else "no content returned"
|
|
119
|
+
raise ToolError(f"Could not extract {url}: {reason}")
|
|
120
|
+
return _render_pages(results, f"Content of {url}")
|
|
121
|
+
|
|
122
|
+
await runtime.progress(f"Searching for {query!r}")
|
|
123
|
+
payload = await _post(
|
|
124
|
+
key,
|
|
125
|
+
"search",
|
|
126
|
+
{"query": query, "max_results": max_results, "search_depth": depth,
|
|
127
|
+
"include_answer": True, "include_domains": include_domains},
|
|
128
|
+
)
|
|
129
|
+
return _render_search(payload, query or "")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _render_search(payload: dict[str, Any], query: str) -> str:
|
|
133
|
+
results = payload.get("results", [])
|
|
134
|
+
if not results:
|
|
135
|
+
return f"No results for {query!r}. Try different wording or a narrower question."
|
|
136
|
+
|
|
137
|
+
parts: list[str] = [f"# Web search: {query}"]
|
|
138
|
+
answer = payload.get("answer")
|
|
139
|
+
if answer:
|
|
140
|
+
# Tavily's synthesized answer is a summary of the same sources listed below,
|
|
141
|
+
# so it is labelled as such — it is a starting point, not a citation.
|
|
142
|
+
parts.append(f"\n**Summary (Tavily's, not a source):** {answer}")
|
|
143
|
+
|
|
144
|
+
for index, result in enumerate(results, start=1):
|
|
145
|
+
title = result.get("title") or "(untitled)"
|
|
146
|
+
parts.append(
|
|
147
|
+
f"\n## {index}. {title}\n{result.get('url', '')}\n\n"
|
|
148
|
+
f"{(result.get('content') or '').strip()}"
|
|
149
|
+
)
|
|
150
|
+
return "\n".join(parts)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _render_pages(results: list[dict[str, Any]], heading: str) -> str:
|
|
154
|
+
if not results:
|
|
155
|
+
return f"{heading}: nothing was returned."
|
|
156
|
+
parts = [f"# {heading}"]
|
|
157
|
+
for result in results:
|
|
158
|
+
body = result.get("raw_content") or result.get("content") or ""
|
|
159
|
+
parts.append(f"\n## {result.get('url', '(unknown url)')}\n\n{body.strip()}")
|
|
160
|
+
return "\n".join(parts)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = ["web_search"]
|