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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. 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"]