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,276 @@
1
+ """
2
+ File tools — `read`, `write`, `edit`.
3
+
4
+ One design, not three: **an agent may only change what it has looked at.** `read`
5
+ records what it saw; `write` and `edit` refuse a file this session has not read, and
6
+ warn when it changed underneath them. The cost is asymmetric — a refused edit costs one
7
+ turn, an overwritten file the agent never read costs work that no longer exists.
8
+
9
+ `edit` matches an exact, unique string, not a line range or a diff: line numbers go
10
+ stale the moment anything above them moves, and a fuzzy match silently edits the wrong
11
+ place, while a unique literal either matches once or fails loudly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Annotated
18
+
19
+ from pydantic import Field
20
+
21
+ from stcode.core.harness.approvals import ToolPermission
22
+ from stcode.core.harness.context import HarnessContext
23
+ from stcode.core.harness.tools.base import Runtime, ToolError, tool
24
+
25
+ MAX_LINE_LENGTH = 2000
26
+ """Chars per line before it is cut. A minified bundle is one line of hundreds of
27
+ thousands of chars, and without this a single `read` blows the whole context window."""
28
+
29
+ DEFAULT_READ_LIMIT = 2000
30
+ """Lines returned when the caller does not say. Enough for almost any source file, small
31
+ enough that reading a log by accident is survivable."""
32
+
33
+ READ_MAX_OUTPUT = 32768
34
+ """`read` shows more than other tools before eliding — file contents are the ground
35
+ truth an agent reasons from, and a half-seen function is worse than a slow turn."""
36
+
37
+ _BINARY_SNIFF_BYTES = 8192
38
+
39
+
40
+ def _is_binary(path: Path) -> bool:
41
+ """A NUL byte in the first pages — what `grep` and `git` use, and right about source
42
+ trees far more often than any extension list."""
43
+ try:
44
+ with path.open("rb") as handle:
45
+ return b"\x00" in handle.read(_BINARY_SNIFF_BYTES)
46
+ except OSError:
47
+ return False
48
+
49
+
50
+ def number_lines(lines: list[str], start: int = 1) -> str:
51
+ """Render with line numbers, `cat -n` style.
52
+
53
+ Not decoration: they are how the agent cites `file.py:42`, how `edit` failures are
54
+ explained, and how a second read lines up with the first.
55
+ """
56
+ return "\n".join(f"{start + offset:6d}\t{line}" for offset, line in enumerate(lines))
57
+
58
+
59
+ @tool(permission=ToolPermission.READ, max_output=READ_MAX_OUTPUT)
60
+ async def read(
61
+ path: str,
62
+ offset: Annotated[int, Field(ge=0)] = 0,
63
+ limit: Annotated[int | None, Field(ge=1)] = None,
64
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
65
+ ) -> str:
66
+ """Read a file from the filesystem and return it with line numbers.
67
+
68
+ Prefer reading a whole file over guessing which part matters: the numbers you get
69
+ back are what you cite in your answer and what `edit` needs to be unambiguous. For
70
+ a file too large to take in one go, page through it with `offset`.
71
+
72
+ Reading a file is also what earns you the right to change it — `write` and `edit`
73
+ both refuse to touch a file this session has not read.
74
+
75
+ Args:
76
+ path: File to read. Absolute, or relative to the session's working directory.
77
+ offset: 0-based line to start at. Use when paging through a large file.
78
+ limit: How many lines to return. Defaults to 2000.
79
+ """
80
+ context = runtime.context
81
+ target = context.resolve(path)
82
+
83
+ if not target.exists():
84
+ near = _suggest_neighbours(target)
85
+ raise ToolError(f"{target} does not exist.{near}")
86
+ if target.is_dir():
87
+ raise ToolError(f"{target} is a directory. Use `ls` to list it, or `glob` to find files in it.")
88
+ if _is_binary(target):
89
+ size = target.stat().st_size
90
+ raise ToolError(
91
+ f"{target} is a binary file ({size:,} bytes) and cannot be read as text. "
92
+ "Use `bash` with a tool that understands its format."
93
+ )
94
+
95
+ text = target.read_text(encoding="utf-8", errors="replace")
96
+ context.note_read(target)
97
+
98
+ if not text:
99
+ return f"{target} exists but is empty (0 bytes)."
100
+
101
+ lines = text.splitlines()
102
+ window = lines[offset : offset + (limit or DEFAULT_READ_LIMIT)]
103
+ if not window:
104
+ raise ToolError(
105
+ f"{target} has {len(lines):,} lines; offset {offset} is past the end."
106
+ )
107
+
108
+ clipped = [
109
+ line if len(line) <= MAX_LINE_LENGTH else f"{line[:MAX_LINE_LENGTH]}… [line truncated]"
110
+ for line in window
111
+ ]
112
+ body = number_lines(clipped, start=offset + 1)
113
+
114
+ shown_to = offset + len(window)
115
+ if shown_to < len(lines):
116
+ body += (
117
+ f"\n\n[showing lines {offset + 1}-{shown_to} of {len(lines):,}; "
118
+ f"continue with offset={shown_to}]"
119
+ )
120
+ return body
121
+
122
+
123
+ @tool(permission=ToolPermission.WRITE)
124
+ async def write(
125
+ path: str,
126
+ content: str,
127
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
128
+ ) -> str:
129
+ """Write `content` to `path`, replacing whatever is there. Creates parent directories.
130
+
131
+ This replaces the entire file. To change part of one, use `edit` — a whole-file
132
+ rewrite of a file you only partly understand silently drops the parts you did not
133
+ reproduce.
134
+
135
+ Never write documentation, READMEs, or summary files unless they were asked for.
136
+
137
+ Args:
138
+ path: File to write. Absolute, or relative to the working directory.
139
+ content: The complete new contents of the file.
140
+ """
141
+ context = runtime.context
142
+ target = context.ensure_writable(path)
143
+
144
+ existed = target.exists()
145
+ if existed:
146
+ if target.is_dir():
147
+ raise ToolError(f"{target} is a directory.")
148
+ if not context.has_read(target):
149
+ raise ToolError(
150
+ f"{target} already exists and this session has not read it. Read it "
151
+ "first — overwriting a file you have not seen destroys work you cannot "
152
+ "get back."
153
+ )
154
+ if context.changed_since_read(target):
155
+ raise ToolError(
156
+ f"{target} changed on disk since you read it. Read it again, then decide "
157
+ "whether your change still applies."
158
+ )
159
+
160
+ target.parent.mkdir(parents=True, exist_ok=True)
161
+ target.write_text(content, encoding="utf-8")
162
+ context.note_read(target)
163
+
164
+ verb = "Updated" if existed else "Created"
165
+ return f"{verb} {target} ({len(content.splitlines()):,} lines, {len(content):,} bytes)."
166
+
167
+
168
+ @tool(permission=ToolPermission.WRITE)
169
+ async def edit(
170
+ path: str,
171
+ old_string: str,
172
+ new_string: str,
173
+ replace_all: bool = False,
174
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
175
+ ) -> str:
176
+ """Replace an exact string in a file. The match must be unique unless `replace_all`.
177
+
178
+ Include enough surrounding context in `old_string` to make it unique — usually two
179
+ or three lines is plenty. If the edit fails because the string appears more than
180
+ once, widen it rather than switching to `replace_all`: "there are three of these and
181
+ I meant the second" is a different intent from "change all three".
182
+
183
+ Preserve the file's existing indentation exactly. `read` prefixes every line with a
184
+ line number and a tab; that prefix is not part of the file, and including it in
185
+ `old_string` is the most common reason an edit finds no match.
186
+
187
+ Args:
188
+ path: File to edit.
189
+ old_string: Exact text to find, including its indentation.
190
+ new_string: Text to replace it with. Must differ from `old_string`.
191
+ replace_all: Replace every occurrence instead of requiring exactly one.
192
+ """
193
+ context = runtime.context
194
+ target = context.ensure_writable(path)
195
+
196
+ if old_string == new_string:
197
+ raise ToolError("`old_string` and `new_string` are identical — nothing to do.")
198
+ if not target.exists():
199
+ raise ToolError(f"{target} does not exist. Use `write` to create it.")
200
+ if not context.has_read(target):
201
+ raise ToolError(f"Read {target} before editing it.")
202
+ if context.changed_since_read(target):
203
+ raise ToolError(
204
+ f"{target} changed on disk since you read it. Read it again before editing."
205
+ )
206
+
207
+ original = target.read_text(encoding="utf-8", errors="replace")
208
+ occurrences = original.count(old_string)
209
+
210
+ if occurrences == 0:
211
+ raise ToolError(
212
+ f"`old_string` was not found in {target}.\n"
213
+ f"{_mismatch_hint(original, old_string)}"
214
+ )
215
+ if occurrences > 1 and not replace_all:
216
+ raise ToolError(
217
+ f"`old_string` appears {occurrences} times in {target}. Add surrounding "
218
+ "lines until it is unique, or pass replace_all=True if you mean all of them."
219
+ )
220
+
221
+ updated = original.replace(old_string, new_string)
222
+ target.write_text(updated, encoding="utf-8")
223
+ context.note_read(target)
224
+
225
+ count = f"{occurrences} occurrences" if replace_all and occurrences > 1 else "1 occurrence"
226
+ return f"Edited {target} ({count} replaced).\n\n{_edit_preview(updated, new_string)}"
227
+
228
+
229
+ def _edit_preview(text: str, needle: str, context_lines: int = 4) -> str:
230
+ """Show the edited region with line numbers, so the agent sees what it produced.
231
+
232
+ A bare confirmation invites the agent to assume the edit landed as imagined. This is
233
+ how a wrong-but-successful edit gets noticed now instead of three turns later.
234
+ """
235
+ index = text.find(needle.split("\n", 1)[0]) if needle else -1
236
+ if index < 0:
237
+ return ""
238
+ line_number = text.count("\n", 0, index)
239
+ lines = text.splitlines()
240
+ start = max(line_number - context_lines, 0)
241
+ end = min(line_number + needle.count("\n") + context_lines + 1, len(lines))
242
+ return number_lines(lines[start:end], start=start + 1)
243
+
244
+
245
+ def _mismatch_hint(text: str, needle: str) -> str:
246
+ """Say *why* a match failed when the reason is recoverable in one step."""
247
+ stripped = "\n".join(line.strip() for line in needle.splitlines())
248
+ haystack = "\n".join(line.strip() for line in text.splitlines())
249
+ if stripped and stripped in haystack:
250
+ return (
251
+ "The text is present but its indentation differs. Read the file again and "
252
+ "copy the leading whitespace exactly."
253
+ )
254
+ first = needle.split("\n", 1)[0].strip()
255
+ if first and first in text:
256
+ return (
257
+ f"Its first line ({first[:60]!r}) is present, so the mismatch is further "
258
+ "down — a trailing space, a tab, or a line ending. Re-read that region."
259
+ )
260
+ return "Read the file and copy the target text from what you see."
261
+
262
+
263
+ def _suggest_neighbours(target: Path, limit: int = 5) -> str:
264
+ """A missing file is usually a typo or the wrong directory. Say which."""
265
+ parent = target.parent
266
+ if not parent.is_dir():
267
+ return f" Its parent directory {parent} does not exist either."
268
+ siblings = sorted(p.name for p in parent.iterdir() if not p.name.startswith("."))
269
+ if not siblings:
270
+ return ""
271
+ return f" {parent} contains: {', '.join(siblings[:limit])}" + (
272
+ f", … ({len(siblings)} entries)" if len(siblings) > limit else ""
273
+ )
274
+
275
+
276
+ __all__ = ["edit", "number_lines", "read", "write"]
@@ -0,0 +1,72 @@
1
+ """
2
+ Human in the loop — `ask_user_question`.
3
+
4
+ Meant to be used rarely. An agent that asks at every fork is slower than doing the work
5
+ and worse company than a colleague who makes reasonable calls; one that never asks
6
+ builds the wrong thing confidently. The test is whether the answer would change what
7
+ gets built.
8
+
9
+ It is the one tool that hands control back: `runtime.ask` blocks the turn until the UI
10
+ answers. With no user attached there is no `on_ask`, and it says so instead of hanging.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated
16
+
17
+ from pydantic import Field
18
+
19
+ from stcode.core.harness.approvals import ToolPermission
20
+ from stcode.core.harness.context import HarnessContext
21
+ from stcode.core.harness.tools.base import Question, Runtime, ToolError, tool
22
+
23
+
24
+ @tool(permission=ToolPermission.INTERACTIVE, spill=False)
25
+ async def ask_user_question(
26
+ question: str,
27
+ options: Annotated[list[str] | None, Field(max_length=4)] = None,
28
+ header: str = "",
29
+ multi_select: bool = False,
30
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
31
+ ) -> str:
32
+ """Ask the user a question and wait for their answer.
33
+
34
+ Only for decisions that are genuinely theirs: a product choice with no right
35
+ answer, a trade-off you cannot resolve from the code, an ambiguity where two
36
+ readings lead to materially different work. Anything you can settle by reading the
37
+ repository, following an existing convention, or picking the obvious default, settle
38
+ yourself and say what you assumed.
39
+
40
+ Do not use it to ask whether to continue, whether your plan looks right, or to
41
+ report progress. Those stop the work without advancing the decision.
42
+
43
+ Ask everything you need in one call rather than trickling questions across turns.
44
+
45
+ Args:
46
+ question: The full question, ending in a question mark. Be specific about what
47
+ differs between the choices.
48
+ options: Two to four concrete choices. Put your recommendation first and mark it
49
+ "(recommended)". The user can always answer something else.
50
+ header: A 1-3 word label for the decision, e.g. "Auth method", "Storage".
51
+ multi_select: Allow more than one option to be chosen. For choices that are not
52
+ mutually exclusive.
53
+ """
54
+ if not question.strip():
55
+ raise ToolError("`question` is empty.")
56
+ if options is not None and len(options) < 2:
57
+ raise ToolError(
58
+ "Give at least two options, or none at all — a single option is not a choice."
59
+ )
60
+
61
+ answer = await runtime.ask(
62
+ Question(
63
+ question=question.strip(),
64
+ header=header.strip(),
65
+ options=list(options or []),
66
+ multi_select=multi_select,
67
+ )
68
+ )
69
+ return f"The user answered: {answer}"
70
+
71
+
72
+ __all__ = ["ask_user_question"]
@@ -0,0 +1,113 @@
1
+ """
2
+ Tool registry — which tools exist, and which an agent may see.
3
+
4
+ Holding tools by name is trivial; *selecting* them is where the architecture shows up. A
5
+ sub-agent spawned with `tools=["read", "grep"]` must not reach `write`, and plan mode
6
+ must not advertise tools it forbids — being offered a capability and then refused it
7
+ wastes a turn and reads, to the model, like a bug to work around.
8
+
9
+ Selection is by name *and* permission, and unknown names raise: a typo that quietly
10
+ produced a smaller tool set would get diagnosed as "the model is being lazy".
11
+
12
+ Beside `base.py` and `schema.py` because it belongs to the tool subsystem, not to the
13
+ facade: it knows what a `Tool` is, and `Harness` knows it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Iterable, Iterator, Sequence
19
+
20
+ from stcode.core.common.tools import ToolDefinition
21
+ from stcode.core.harness.approvals import ApprovalMode, is_forbidden
22
+ from stcode.core.harness.tools.base import Tool, ToolError
23
+
24
+
25
+ class ToolRegistry:
26
+ """A named collection of tools."""
27
+
28
+ def __init__(self, tools: Iterable[Tool[Any]] = ()) -> None:
29
+ self._tools: dict[str, Tool[Any]] = {}
30
+ for entry in tools:
31
+ self.register(entry)
32
+
33
+ def __len__(self) -> int:
34
+ return len(self._tools)
35
+
36
+ def __contains__(self, name: str) -> bool:
37
+ return name in self._tools
38
+
39
+ def __iter__(self) -> Iterator[Tool[Any]]:
40
+ return iter(self._tools.values())
41
+
42
+ def __getitem__(self, name: str) -> Tool[Any]:
43
+ try:
44
+ return self._tools[name]
45
+ except KeyError:
46
+ raise ToolError(f"No tool named {name!r}. Available: {', '.join(self.names())}.") from None
47
+
48
+ def get(self, name: str) -> Tool[Any] | None:
49
+ return self._tools.get(name)
50
+
51
+ def names(self) -> list[str]:
52
+ return sorted(self._tools)
53
+
54
+ def register(self, entry: Tool[Any], *, replace: bool = False) -> None:
55
+ """Add a tool. Refuses to shadow an existing name unless asked to.
56
+
57
+ Silent replacement is the failure this guards: an MCP server taking over the
58
+ name `read` would redirect every file read in the session, with nothing in the
59
+ log to say so.
60
+ """
61
+ if entry.name in self._tools and not replace:
62
+ raise ToolError(
63
+ f"A tool named {entry.name!r} is already registered. Pass replace=True "
64
+ "if shadowing it is intended."
65
+ )
66
+ self._tools[entry.name] = entry
67
+
68
+ def extend(self, tools: Iterable[Tool[Any]], *, replace: bool = False) -> None:
69
+ for entry in tools:
70
+ self.register(entry, replace=replace)
71
+
72
+ def select(
73
+ self,
74
+ names: Sequence[str] | None = None,
75
+ *,
76
+ approval_mode: ApprovalMode | None = None,
77
+ ) -> list[Tool[Any]]:
78
+ """The tools an agent may use, by name and by what the mode permits.
79
+
80
+ `names=None` means everything registered. Tools the mode forbids are dropped
81
+ rather than advertised-and-refused; ones that merely need approval are kept,
82
+ since asking is a normal part of using them.
83
+ """
84
+ if names is None:
85
+ chosen = list(self._tools.values())
86
+ else:
87
+ unknown = [name for name in names if name not in self._tools]
88
+ if unknown:
89
+ raise ToolError(
90
+ f"Unknown tool(s): {', '.join(unknown)}. Available: {', '.join(self.names())}."
91
+ )
92
+ chosen = [self._tools[name] for name in names]
93
+
94
+ if approval_mode is None:
95
+ return chosen
96
+ return [entry for entry in chosen if not is_forbidden(approval_mode, entry.permission)]
97
+
98
+ def definitions(
99
+ self,
100
+ names: Sequence[str] | None = None,
101
+ *,
102
+ approval_mode: ApprovalMode | None = None,
103
+ ) -> list[ToolDefinition]:
104
+ """What goes on the wire to a provider.
105
+
106
+ Sorted by name, so the list is byte-identical between turns with the same tool
107
+ set — prompt caching depends on tool definitions not reordering.
108
+ """
109
+ chosen = self.select(names, approval_mode=approval_mode)
110
+ return [entry.to_tool_definition() for entry in sorted(chosen, key=lambda t: t.name)]
111
+
112
+
113
+ __all__ = ["ToolRegistry"]
@@ -0,0 +1,97 @@
1
+ """
2
+ `repl` — a persistent Python interpreter for the session.
3
+
4
+ The namespace lives in a subprocess (`core/repl/`) and survives between calls. Two jobs,
5
+ both about keeping bulk out of the context window:
6
+
7
+ * **MCP-as-code.** Tool definitions in the prompt prefix cost 10-30k tokens every turn;
8
+ the same servers under `.stcode/mcp_servers/` cost a grep and an import.
9
+ * **`tool_out`.** What `elide` cut is injected here under the result's `output_id`, so
10
+ eliding loses nothing — the whole value is one slice away.
11
+
12
+ Not for sub-agents, and not a replacement for `read`/`edit`/`bash`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Annotated
18
+
19
+ from pydantic import Field
20
+
21
+ from stcode.core.common.truncate import DEFAULT_VIEW_LIMIT
22
+ from stcode.core.harness.approvals import ToolPermission
23
+ from stcode.core.harness.context import HarnessContext
24
+ from stcode.core.harness.tools.base import Runtime, ToolError, tool
25
+
26
+ DEFAULT_TIMEOUT = 120.0
27
+
28
+ REPL_MAX_OUTPUT = DEFAULT_VIEW_LIMIT
29
+ """The same cap every other tool gets. The REPL has no claim to a bigger one — the whole
30
+ point of a persistent namespace is that the bulk stays in a variable."""
31
+
32
+
33
+ @tool(permission=ToolPermission.EXECUTE, max_output=REPL_MAX_OUTPUT, spill=False)
34
+ async def repl(
35
+ code: str,
36
+ timeout: Annotated[float, Field(gt=0, le=900)] = DEFAULT_TIMEOUT,
37
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
38
+ ) -> str:
39
+ """Run Python in this session's persistent interpreter and return what it printed.
40
+
41
+ The namespace persists for the whole session: a variable you set now is still there
42
+ twenty turns from now. Use that. Assign results to names, and print only the part
43
+ you need to reason about — output is capped at 8192 characters, and the variable
44
+ holding the rest is still there to slice.
45
+
46
+ Top-level `await` works — use it. Do not call `asyncio.run(...)`: it opens its own
47
+ event loop and closes it when the cell ends, which drops anything the namespace was
48
+ holding open, such as an MCP server connection.
49
+
50
+ `tool_out` is a dict already in the namespace. When a tool result was elided, the
51
+ whole value is in there under the id the elision named — slice it here rather than
52
+ calling the tool again.
53
+
54
+ Reach for this when a result is too big to want in full, or when you need to compute
55
+ over something rather than read it: parse a large JSON payload and print three
56
+ fields, filter ten thousand rows down to the four that matter, call an MCP tool from
57
+ `.stcode/mcp_servers/`. For editing files and running commands, use the dedicated
58
+ tools — they are shorter and their output is shaped for you.
59
+
60
+ Args:
61
+ code: Python to execute. Multi-line is normal; this is a cell, not a line.
62
+ timeout: Seconds before the cell is interrupted. The namespace survives a
63
+ timeout, so whatever the cell managed to build is still inspectable.
64
+ """
65
+ backend = runtime.context.repl
66
+ if backend is None:
67
+ raise ToolError(
68
+ "This session has no REPL attached, so `repl` cannot run. Use the direct "
69
+ "tools (read/write/edit/bash/glob/grep) instead."
70
+ )
71
+
72
+ result = await backend.execute(code, timeout=timeout, on_stream=_stream_to(runtime))
73
+ view = result.view(REPL_MAX_OUTPUT)
74
+
75
+ if result.ok:
76
+ return view or "(no output)"
77
+ # Outcome names the failure mode; the view carries the traceback. Both matter: a
78
+ # timeout and an exception need different next moves, and `view()` alone does not
79
+ # distinguish them.
80
+ return f"[{result.outcome}]\n{view}" if view else f"[{result.outcome}] (no output)"
81
+
82
+
83
+ def _stream_to(runtime: Runtime[HarnessContext]): # type: ignore[no-untyped-def]
84
+ """Forward the cell's output to the UI as it arrives.
85
+
86
+ A session that looks hung is a real UX problem, and a REPL cell is the longest
87
+ thing a turn can contain. Progress here is the difference between "working" and
88
+ "frozen".
89
+ """
90
+
91
+ async def on_stream(name: str, text: str) -> None:
92
+ await runtime.progress(text)
93
+
94
+ return on_stream
95
+
96
+
97
+ __all__ = ["DEFAULT_TIMEOUT", "REPL_MAX_OUTPUT", "repl"]