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,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)
|