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,97 @@
1
+ """
2
+ Agent events — what a caller sees while a turn runs.
3
+
4
+ Deliberately *not* a re-wrapping of the provider's stream: `TextDelta` and
5
+ `ReasoningDelta` are re-exported from `core/providers/types.py` unchanged, since a
6
+ parallel `AgentTextDelta` would be a translation layer whose only job is staying in sync.
7
+
8
+ What the agent adds is the four events a provider has no concept of — a tool starting,
9
+ a tool finishing, a turn ending, the agent giving up. `daemon/protocol.py` maps them
10
+ one-to-one onto the wire, which is why they carry primitives: a `ToolResult` with a 4 MB
11
+ payload does not belong on a socket, so `ToolFinished` carries a preview.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Union
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+ from stcode.core.providers.types import ReasoningDelta, TextDelta, Usage
21
+
22
+ PREVIEW_CHARS = 240
23
+ """How much of a tool result rides on `ToolFinished`. The full result is in the
24
+ session; `todo_write` also sends its complete result so clients can display the plan."""
25
+
26
+
27
+ class ToolStarted(BaseModel):
28
+ """A tool call was accepted and is running."""
29
+
30
+ type: str = "tool_started"
31
+ id: str
32
+ name: str
33
+ arguments: dict[str, Any] = Field(default_factory=dict)
34
+
35
+
36
+ class ToolFinished(BaseModel):
37
+ """A tool call returned. `ok` is False for a tool-level failure — a normal outcome
38
+ the model reacts to, not an error in the loop."""
39
+
40
+ type: str = "tool_finished"
41
+ id: str
42
+ name: str
43
+ ok: bool = True
44
+ preview: str = ""
45
+
46
+
47
+ class SupervisorNudge(BaseModel):
48
+ """The supervisor spotted a loop and said something.
49
+
50
+ An event so a client can show it: the agent is about to change direction and the
51
+ reason should not be invisible. The nudge reaches the *model* as a `supervisor`
52
+ record in the session, not through this.
53
+ """
54
+
55
+ type: str = "supervisor"
56
+ text: str
57
+ symptom: str = ""
58
+
59
+
60
+ class TurnFinished(BaseModel):
61
+ """The model replied without calling a tool — the only stop condition.
62
+
63
+ Not an `answer` dict, not a `ready` flag: models already end a turn by talking.
64
+ `text` is the assistant's final message.
65
+ """
66
+
67
+ type: str = "turn_finished"
68
+ text: str = ""
69
+ usage: Usage = Field(default_factory=Usage)
70
+ tool_calls: int = 0
71
+
72
+
73
+ class AgentFailed(BaseModel):
74
+ """The turn ended without an answer: the tool-call ceiling, a provider error, or an
75
+ interrupt. Terminal for this turn only; the agent stays usable."""
76
+
77
+ type: str = "agent_failed"
78
+ message: str
79
+ recoverable: bool = True
80
+
81
+
82
+ AgentEvent = Union[
83
+ TextDelta, ReasoningDelta, ToolStarted, ToolFinished, SupervisorNudge, TurnFinished, AgentFailed
84
+ ]
85
+
86
+ __all__ = [
87
+ "PREVIEW_CHARS",
88
+ "AgentEvent",
89
+ "AgentFailed",
90
+ "ReasoningDelta",
91
+ "SupervisorNudge",
92
+ "TextDelta",
93
+ "ToolFinished",
94
+ "ToolStarted",
95
+ "TurnFinished",
96
+ "Usage",
97
+ ]
@@ -0,0 +1,226 @@
1
+ """
2
+ Supervisor — a second pair of eyes on the trajectory.
3
+
4
+ The session file is already the trajectory, so this is just a reader.
5
+
6
+ **Counting first, model second.** Four plain-Python heuristics run at zero token cost;
7
+ only a hit costs one `difficulty="low"` call, and that model may answer NONE. Always on,
8
+ usually free.
9
+
10
+ Three rules:
11
+
12
+ * Nudges go into `messages`, never the system prompt — editing the cached prefix would
13
+ cost a full cache miss per piece of advice.
14
+ * The supervisor has no tools. One that could write is a second agent.
15
+ * It checks *inside* a turn, because that is where a loop happens.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections import Counter
22
+ from contextlib import aclosing
23
+ from typing import Any, Sequence
24
+
25
+ from stcode.core.providers.gateway import Difficulty, LLMGateway
26
+ from stcode.core.providers.types import Message, TextDelta
27
+
28
+ DEFAULT_EVERY = 8
29
+ """Iterations between checks. Low enough to catch a loop before it burns the turn's
30
+ budget, high enough not to interrupt a productive stretch."""
31
+
32
+ DEFAULT_WINDOW = 80
33
+ """Records the heuristics look at. At ~4 records per iteration (assistant, tool_call,
34
+ tool_result, usage), 80 records represents ~20 iterations — sufficient to observe patterns
35
+ across multiple checks."""
36
+
37
+ REPEAT_THRESHOLD = 3
38
+ ERROR_RATE_THRESHOLD = 0.5
39
+ SAME_FILE_THRESHOLD = 4
40
+ IDLE_CALLS_THRESHOLD = 8
41
+ """Calls with no file written before "lots of looking, no doing" counts. High on
42
+ purpose: research legitimately writes nothing, and a supervisor that fires on reading is
43
+ one you switch off."""
44
+
45
+ WRITING_TOOLS = frozenset({"write", "edit"})
46
+
47
+ NO_NUDGE = "NONE"
48
+
49
+ SYSTEM = """\
50
+ You are watching another agent work, and you can see the last few minutes of what it \
51
+ did. Your only question: is it making progress, or is it stuck in a loop?
52
+
53
+ Reply with exactly the word NONE if it is working normally. Trying something twice, \
54
+ reading before editing, and a failed command followed by a fixed one are all normal.
55
+
56
+ Otherwise reply with one or two sentences, addressed to the agent, that name what it \
57
+ keeps doing and what to do instead. Be concrete — name the file, the command, or the \
58
+ assumption you think is wrong. No preamble, no praise, no questions."""
59
+
60
+
61
+ class Supervisor:
62
+ """Watches a session and, rarely, says something."""
63
+
64
+ def __init__(
65
+ self,
66
+ gateway: LLMGateway,
67
+ *,
68
+ every: int = DEFAULT_EVERY,
69
+ window: int = DEFAULT_WINDOW,
70
+ difficulty: Difficulty = "low",
71
+ ) -> None:
72
+ self.gateway = gateway
73
+ self.every = every
74
+ self.window = window
75
+ self.difficulty = difficulty
76
+ self._last_nudge = ""
77
+ """The previous nudge. Repeating it is itself a loop, and an agent that ignored
78
+ the advice once will ignore the repeat."""
79
+
80
+ async def check(self, records: Sequence[dict[str, Any]]) -> str | None:
81
+ """A nudge for the agent, or None. Costs nothing unless a heuristic fires."""
82
+ recent = list(records)[-self.window :]
83
+ symptom = self.smell(recent)
84
+ if symptom is None:
85
+ return None
86
+ nudge = await self.diagnose(recent, symptom)
87
+ if nudge is None or nudge == self._last_nudge:
88
+ return None
89
+ self._last_nudge = nudge
90
+ return nudge
91
+
92
+ def due(self, iteration: int) -> bool:
93
+ """Whether this iteration is a checkpoint. Never the first — no trajectory yet."""
94
+ return iteration > 0 and iteration % self.every == 0
95
+
96
+ # ---- the free half ----
97
+
98
+ @staticmethod
99
+ def smell(records: Sequence[dict[str, Any]]) -> str | None:
100
+ """Four countable signs of being stuck. No model, no tokens.
101
+
102
+ Returns the symptom in plain words: that sentence is what the diagnosis call is
103
+ given to explain, and a boolean would tell the cheap model nothing.
104
+ """
105
+ calls = [r for r in records if r.get("type") == "tool_call"]
106
+ results = [r for r in records if r.get("type") == "tool_result"]
107
+
108
+ repeated = _most_repeated(calls)
109
+ if repeated and repeated[1] >= REPEAT_THRESHOLD:
110
+ return f"it called `{repeated[0]}` with identical arguments {repeated[1]} times"
111
+
112
+ if len(results) >= 4:
113
+ failures = sum(1 for r in results if r.get("is_error"))
114
+ if failures / len(results) > ERROR_RATE_THRESHOLD:
115
+ return f"{failures} of its last {len(results)} tool calls failed"
116
+
117
+ edited = _most_edited(calls)
118
+ if edited and edited[1] >= SAME_FILE_THRESHOLD:
119
+ return f"it has written to {edited[0]} {edited[1]} times"
120
+
121
+ if len(calls) >= IDLE_CALLS_THRESHOLD and not any(
122
+ call.get("name") in WRITING_TOOLS for call in calls
123
+ ):
124
+ return f"{len(calls)} tool calls in a row without writing a single file"
125
+
126
+ return None
127
+
128
+ # ---- the cheap half ----
129
+
130
+ async def diagnose(self, records: Sequence[dict[str, Any]], symptom: str) -> str | None:
131
+ """Ask a small model whether the symptom is really stagnation.
132
+
133
+ A veto, not a rubber stamp: NONE means a false alarm, which it usually is. A
134
+ failure returns None — a broken supervisor must never be able to end a turn.
135
+ """
136
+ prompt = (
137
+ f"What the counters noticed: {symptom}.\n\n"
138
+ f"What the agent has been doing:\n{render_trajectory(records)}\n\n"
139
+ "Is it stuck? Reply NONE, or one or two sentences of redirection."
140
+ )
141
+ try:
142
+ reply = ""
143
+ # `aclosing` as everywhere else: an abandoned provider stream stays
144
+ # suspended holding a connection, possibly past the event loop's close.
145
+ async with aclosing(
146
+ self.gateway.stream(
147
+ [Message(role="user", content=prompt)],
148
+ system=SYSTEM,
149
+ difficulty=self.difficulty,
150
+ )
151
+ ) as stream:
152
+ async for event in stream:
153
+ if isinstance(event, TextDelta):
154
+ reply += event.text
155
+ except Exception: # noqa: BLE001 — advice is optional; the turn is not
156
+ return None
157
+
158
+ answer = reply.strip()
159
+ if not answer or answer.upper().startswith(NO_NUDGE):
160
+ return None
161
+ return answer
162
+
163
+
164
+ def render_trajectory(records: Sequence[dict[str, Any]]) -> str:
165
+ """The recent trajectory as a few short lines.
166
+
167
+ Deliberately lossy: the supervisor looks for a *shape*, and sending whole file
168
+ contents would cost more than the loop it is trying to prevent.
169
+ """
170
+ lines: list[str] = []
171
+ for record in records:
172
+ kind = record.get("type")
173
+ if kind == "tool_call":
174
+ lines.append(f"call {record.get('name')}({_short(record.get('arguments'))})")
175
+ elif kind == "tool_result":
176
+ mark = "ERROR" if record.get("is_error") else "ok"
177
+ lines.append(f" -> {mark}: {_short(record.get('content'), 120)}")
178
+ elif kind == "assistant" and record.get("content"):
179
+ lines.append(f"said: {_short(record.get('content'), 160)}")
180
+ elif kind == "user":
181
+ lines.append(f"user: {_short(record.get('content'), 160)}")
182
+ return "\n".join(lines)
183
+
184
+
185
+ def _short(value: Any, limit: int = 100) -> str:
186
+ text = json.dumps(value, default=str) if isinstance(value, (dict, list)) else str(value or "")
187
+ text = " ".join(text.split())
188
+ return text if len(text) <= limit else text[: limit - 1] + "…"
189
+
190
+
191
+ def _signature(call: dict[str, Any]) -> str:
192
+ return json.dumps(
193
+ [call.get("name"), call.get("arguments")], sort_keys=True, default=str
194
+ )
195
+
196
+
197
+ def _most_repeated(calls: Sequence[dict[str, Any]]) -> tuple[str, int] | None:
198
+ """The most-repeated (tool, arguments) pair, by tool name."""
199
+ if not calls:
200
+ return None
201
+ counts = Counter(_signature(call) for call in calls)
202
+ signature, count = counts.most_common(1)[0]
203
+ name = json.loads(signature)[0]
204
+ return (str(name), count)
205
+
206
+
207
+ def _most_edited(calls: Sequence[dict[str, Any]]) -> tuple[str, int] | None:
208
+ """The file written to most often in the window."""
209
+ paths = Counter(
210
+ str((call.get("arguments") or {}).get("path", ""))
211
+ for call in calls
212
+ if call.get("name") in WRITING_TOOLS
213
+ )
214
+ paths.pop("", None)
215
+ if not paths:
216
+ return None
217
+ path, count = paths.most_common(1)[0]
218
+ return (path, count)
219
+
220
+
221
+ __all__ = [
222
+ "DEFAULT_EVERY",
223
+ "DEFAULT_WINDOW",
224
+ "Supervisor",
225
+ "render_trajectory",
226
+ ]
@@ -0,0 +1,120 @@
1
+ """
2
+ `task` — a sub-agent as a tool. Solo mode only.
3
+
4
+ Here rather than in `core/harness/tools/` because of the dependency arrow: this spawns
5
+ an `Agent`, and a tool inside the harness that did so would point it at its own caller.
6
+
7
+ **The docstring does two jobs.** No model is trained on this scaffold, so expect `task`
8
+ to be under-used; and a vaguely-described sub-agent is the top cause of duplicated,
9
+ off-target work. So it argues for delegation *and* forces an output format and a scope.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Annotated, Any
15
+
16
+ from pydantic import Field
17
+
18
+ from stcode.core.agent.events import AgentFailed, TurnFinished
19
+ from stcode.core.harness.approvals import ToolPermission
20
+ from stcode.core.harness.context import HarnessContext
21
+ from stcode.core.harness.tools.base import Runtime, Tool, ToolError, tool
22
+ from stcode.core.providers.gateway import Difficulty
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ from stcode.core.agent.agent import Agent
26
+
27
+ MAX_DEPTH = 1
28
+ """Rule 3. A sub-agent gets neither `task` nor `repl`, so this is belt and
29
+ braces — but recursion plus no budget is a fork bomb, and the cheap check is worth it."""
30
+
31
+
32
+ def make_task_tool(parent: "Agent") -> Tool[Any]:
33
+ """Build the `task` tool bound to `parent`, the agent that spawns from it."""
34
+
35
+ @tool(permission=ToolPermission.EXECUTE, timeout=None)
36
+ async def task(
37
+ prompt: str,
38
+ name: str,
39
+ tools: list[str] | None = None,
40
+ scope: str | None = None,
41
+ difficulty: Annotated[Difficulty, Field()] = "medium",
42
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
43
+ ) -> str:
44
+ """Hand a bounded piece of work to a sub-agent with its own context.
45
+
46
+ Use this whenever answering something would cost you many file reads. The
47
+ sub-agent reads the twenty files and you get back the paragraph — its tool
48
+ output never enters your context, only its final message does. If you are about
49
+ to read more than two or three files to answer one question, delegate instead.
50
+
51
+ Not for small things. Spawning costs a full agent's startup, so a question a
52
+ single `grep` answers is cheaper to answer yourself.
53
+
54
+ Write the prompt as a briefing, not a title. It gets no follow-up question and
55
+ cannot see your conversation, so it must contain:
56
+
57
+ - what to do, concretely;
58
+ - **the output format you want back** — "a table of file:line and the handler
59
+ name", "three sentences on whether X is safe" — because a sub-agent told only
60
+ the topic returns a transcript;
61
+ - what not to touch.
62
+
63
+ Run several at once by calling this multiple times in the same turn; they
64
+ execute in parallel. Give any two that write files **disjoint** `scope`s — two
65
+ agents that can write the same file is a design error, not a race to manage.
66
+
67
+ Args:
68
+ prompt: The full briefing, including the output format you expect back.
69
+ name: Short identifier for this sub-agent, e.g. "api-scout". Appears in the
70
+ trajectory and in its own session file.
71
+ tools: Tool names it may use. Omit for the standard set; narrow it to
72
+ ["read", "grep", "glob"] for anything that should only investigate.
73
+ scope: Directory it may write inside, e.g. "src/api". Omit for read-only
74
+ work. Two writers must never be given overlapping scopes.
75
+ difficulty: Model tier. Use "low" for mechanical search and summarising,
76
+ "high" only for work that needs real reasoning.
77
+ """
78
+ from stcode.core.agent.agent import Agent
79
+
80
+ if runtime.depth >= MAX_DEPTH:
81
+ raise ToolError(
82
+ "Sub-agents cannot spawn sub-agents. Do this one yourself, or report "
83
+ "back to your parent that it needs splitting."
84
+ )
85
+
86
+ child_harness = parent.harness.for_subagent(name, tools=tools, scope=scope)
87
+ child_session = parent.session.child(name)
88
+ child_harness.session_id = child_session.id
89
+
90
+ child = Agent(
91
+ gateway=parent.gateway, # shared: closing it would kill the parent's stream
92
+ harness=child_harness,
93
+ session=child_session,
94
+ difficulty=difficulty,
95
+ max_turns=parent.max_turns,
96
+ )
97
+ async with child:
98
+ await runtime.progress(f"{name}: {prompt.splitlines()[0][:80]}")
99
+ # Every event the child produces goes out to whoever is watching, tagged
100
+ # with this sub-agent's name. Out the side rather than into the parent's
101
+ # stream: the parent's events are its turn, each with a record behind it,
102
+ # and the child's belong to the child's transcript.
103
+ answer = ""
104
+ async for event in child.run(prompt):
105
+ runtime.emit(name, event)
106
+ if isinstance(event, TurnFinished):
107
+ answer = event.text
108
+ elif isinstance(event, AgentFailed):
109
+ # As an error, not as text. A sub-agent that never reached the model
110
+ # returns a sentence beginning "the sub-agent did not finish", and a
111
+ # parent handed that as a *successful* result reads it as findings
112
+ # and writes them up — which is how five failed scouts become a
113
+ # confident report about five repositories nobody looked at.
114
+ raise ToolError(f"{name} did not finish: {event.message}")
115
+ return answer
116
+
117
+ return task
118
+
119
+
120
+ __all__ = ["MAX_DEPTH", "make_task_tool"]
@@ -0,0 +1,21 @@
1
+ """
2
+ Common — vocabulary shared across `core/` subpackages, importing from none of them.
3
+
4
+ Must stay a leaf. Two tests decide whether something belongs here: it is needed by more
5
+ than one subpackage, *and* its logic is not really about any one of them. A type with a
6
+ single consumer belongs in that consumer — `OutputStore` lived here until it turned out
7
+ only `core/harness/` ever held one.
8
+ """
9
+
10
+ from stcode.core.common.ids import new_id
11
+ from stcode.core.common.tools import ToolDefinition, ToolResult
12
+ from stcode.core.common.truncate import DEFAULT_VIEW_LIMIT, NARROW_REQUEST_HINT, elide
13
+
14
+ __all__ = [
15
+ "DEFAULT_VIEW_LIMIT",
16
+ "NARROW_REQUEST_HINT",
17
+ "ToolDefinition",
18
+ "ToolResult",
19
+ "elide",
20
+ "new_id",
21
+ ]
@@ -0,0 +1,27 @@
1
+ """
2
+ Compat — the two standard-library names stcode uses that Python 3.10 lacks.
3
+
4
+ Both arrived in 3.11. Everything else in the codebase already runs on 3.10, so these
5
+ two are the entire cost of supporting it; import them from here, never from the
6
+ standard library directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from enum import Enum
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ from enum import StrEnum
17
+ else: # pragma: no cover - exercised by the 3.10 CI job
18
+ import tomli as tomllib
19
+
20
+ class StrEnum(str, Enum):
21
+ """`enum.StrEnum` for 3.10: members are strings and print as their value."""
22
+
23
+ def __str__(self) -> str:
24
+ return str(self.value)
25
+
26
+
27
+ __all__ = ["StrEnum", "tomllib"]
@@ -0,0 +1,43 @@
1
+ """
2
+ Ids — one generator, for everything that needs a name that sorts by when it was made.
3
+
4
+ A session file, a team message, anything later. It lived in `core/session/` while
5
+ sessions were the only caller, and `core/team/mailbox.py` reached across for it with a
6
+ function-level import to avoid the dependency. Two callers with nothing else in common
7
+ is the definition of `core/common/`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ import time
14
+
15
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
16
+
17
+ _last_id: tuple[int, int] = (0, 0)
18
+
19
+
20
+ def new_id() -> str:
21
+ """A monotonic ULID: 48 bits of ms timestamp, 80 bits of randomness, base32.
22
+
23
+ Sortable by creation time as a plain string — the whole reason not to use `uuid4`.
24
+ `Session.list()` is then a directory listing needing no index and no metadata read,
25
+ and an inbox drains oldest-first for free.
26
+
27
+ Monotonic because "same millisecond" is not hypothetical: spawning three sub-agents
28
+ in one turn does it, and random suffixes would order them arbitrarily. Within a
29
+ millisecond the randomness increments, which also covers NTP stepping the clock back.
30
+ """
31
+ global _last_id
32
+ stamp = int(time.time() * 1000)
33
+ last_stamp, last_random = _last_id
34
+ if stamp > last_stamp:
35
+ _last_id = (stamp, secrets.randbits(80))
36
+ else:
37
+ _last_id = (last_stamp, last_random + 1)
38
+ stamp, randomness = _last_id
39
+ value = (stamp << 80) | (randomness & ((1 << 80) - 1))
40
+ return "".join(_CROCKFORD[(value >> shift) & 0x1F] for shift in range(125, -1, -5))
41
+
42
+
43
+ __all__ = ["new_id"]
@@ -0,0 +1,50 @@
1
+ """
2
+ Tool vocabulary shared by the layers that build tools and the layers that ship them.
3
+
4
+ `core/common/` imports nothing else in `core/`, which is where a type with three
5
+ consumers belongs: `core/harness/` builds `ToolDefinition`s from signatures,
6
+ `core/agent/` selects which a turn advertises, `core/providers/` puts them on the wire.
7
+
8
+ `ToolResult` travels the other way, and is deliberately *not* `ToolResultBlock`: a block
9
+ is the wire shape, a result is what the tool produced — a rendered view for the model,
10
+ the untruncated payload for `tool_out`, and metadata for the trajectory. The agent loop
11
+ narrows one into the other.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+
21
+ class ToolDefinition(BaseModel):
22
+ """A tool as the model sees it: a name, the prompt that teaches it, and a JSON
23
+ Schema for the arguments.
24
+
25
+ Keep `input_schema` byte-stable across turns — tool definitions sit in the cached
26
+ prefix alongside the system prompt, which is the biggest cost lever there is.
27
+ """
28
+
29
+ name: str
30
+ description: str
31
+ input_schema: dict[str, Any]
32
+
33
+
34
+ class ToolResult(BaseModel):
35
+ """What one tool invocation produced.
36
+
37
+ `content` is the only field reaching the model, already rendered and already capped.
38
+ `payload` is the same information untruncated, bound for `tool_out[output_id]` so
39
+ the agent can slice it later — dropping it turns an elision into a deletion.
40
+ """
41
+
42
+ content: str
43
+ is_error: bool = False
44
+ payload: Any = None
45
+ output_id: str | None = None
46
+ metadata: dict[str, Any] = Field(default_factory=dict)
47
+
48
+ @classmethod
49
+ def error(cls, message: str, **metadata: Any) -> "ToolResult":
50
+ return cls(content=message, is_error=True, metadata=metadata)