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,651 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent — the turn loop. Message in, events out.
|
|
3
|
+
|
|
4
|
+
await agent.push("Add rate limiting") # queue work
|
|
5
|
+
async for ev in agent.events(): ... # drive it, forever
|
|
6
|
+
|
|
7
|
+
async for ev in agent.run("Add rate limiting"): ... # one turn, to completion
|
|
8
|
+
|
|
9
|
+
`run()` is `push()` then `events()` until the first terminal event. The split is not
|
|
10
|
+
ergonomics: a `push` arriving mid-turn is delivered at the next **tool-call boundary**,
|
|
11
|
+
after this round of results and before the next request — never spliced into the model
|
|
12
|
+
call in flight. That is what lets you attach to a working agent and steer it without
|
|
13
|
+
destroying what it is doing. To stop it instead, `interrupt()`.
|
|
14
|
+
|
|
15
|
+
**The stop condition is that the model stopped calling tools.** No `answer` dict, no
|
|
16
|
+
`ready` flag — models already end a turn by talking, and a protocol on top of that is
|
|
17
|
+
a protocol the model has to be taught.
|
|
18
|
+
|
|
19
|
+
This imports session, harness and providers; none of them import back. `task` lives
|
|
20
|
+
here for that reason — a tool that spawns an `Agent` would point the harness at its
|
|
21
|
+
own caller.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
from contextlib import aclosing
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from types import TracebackType
|
|
30
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, Callable, Iterable, Sequence
|
|
31
|
+
|
|
32
|
+
from stcode.core.agent.events import (
|
|
33
|
+
PREVIEW_CHARS,
|
|
34
|
+
AgentEvent,
|
|
35
|
+
AgentFailed,
|
|
36
|
+
SupervisorNudge,
|
|
37
|
+
ToolFinished,
|
|
38
|
+
ToolStarted,
|
|
39
|
+
TurnFinished,
|
|
40
|
+
)
|
|
41
|
+
from stcode.core.agent.supervisor import Supervisor
|
|
42
|
+
from stcode.core.common import trace
|
|
43
|
+
from stcode.core.harness import Harness
|
|
44
|
+
from stcode.core.harness.approvals import DEFAULT_APPROVAL_MODE, ApprovalMode
|
|
45
|
+
from stcode.core.harness.tools.base import ApprovalFn, AskFn, EventFn, ProgressFn, Tool
|
|
46
|
+
from stcode.core.configs import DEFAULT_MAX_TURNS, Config, Difficulty, resolve_prompt
|
|
47
|
+
from stcode.core.providers.gateway import LLMGateway
|
|
48
|
+
from stcode.core.providers.types import (
|
|
49
|
+
MessageStop,
|
|
50
|
+
ReasoningDelta,
|
|
51
|
+
TextDelta,
|
|
52
|
+
ToolCallEnd,
|
|
53
|
+
Usage,
|
|
54
|
+
)
|
|
55
|
+
from stcode.core.session import Session
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
58
|
+
from stcode.core.configs import AgentConfig
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Agent:
|
|
62
|
+
"""One agent: a gateway, a harness, a session, and the loop that joins them."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
gateway: LLMGateway,
|
|
68
|
+
harness: Harness,
|
|
69
|
+
session: Session,
|
|
70
|
+
difficulty: Difficulty = "high",
|
|
71
|
+
max_turns: int = DEFAULT_MAX_TURNS,
|
|
72
|
+
max_concurrent: int = 4,
|
|
73
|
+
reasoning_effort: str = "",
|
|
74
|
+
owns_gateway: bool = False,
|
|
75
|
+
supervisor: Supervisor | None = None,
|
|
76
|
+
mailbox: Any = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.gateway = gateway
|
|
79
|
+
self.harness = harness
|
|
80
|
+
self.session = session
|
|
81
|
+
self.difficulty = difficulty
|
|
82
|
+
self.max_turns = max_turns
|
|
83
|
+
self.reasoning_effort = reasoning_effort
|
|
84
|
+
"""What `[agent] reasoning_effort` asked for, if anything. A floor, not a
|
|
85
|
+
fixture: a `/effort` chosen mid-conversation lands in the session's own `meta`
|
|
86
|
+
and wins, because that is a statement about *this* conversation."""
|
|
87
|
+
self.supervisor = supervisor
|
|
88
|
+
"""Checked every `supervisor.every` iterations *inside* a turn. None for
|
|
89
|
+
sub-agents: a nudge arrives about when a one-shot worker is finishing anyway."""
|
|
90
|
+
|
|
91
|
+
self.mailbox = mailbox
|
|
92
|
+
"""This role's Mailbox, in team mode. Drained at the top of every turn. Typed
|
|
93
|
+
loosely so `core/agent` need not import `core/team` just to hold a reference."""
|
|
94
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
95
|
+
self._owns_gateway = owns_gateway
|
|
96
|
+
self._inbox: asyncio.Queue[str] = asyncio.Queue()
|
|
97
|
+
self._interrupted = asyncio.Event()
|
|
98
|
+
self._busy = False
|
|
99
|
+
|
|
100
|
+
# ---- construction ----
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
async def create(
|
|
104
|
+
cls,
|
|
105
|
+
config: Config,
|
|
106
|
+
*,
|
|
107
|
+
cwd: str | Path | None = None,
|
|
108
|
+
role: str = "",
|
|
109
|
+
approval_mode: ApprovalMode | None = None,
|
|
110
|
+
session: Session | None = None,
|
|
111
|
+
gateway: LLMGateway | None = None,
|
|
112
|
+
**harness_kwargs: Any,
|
|
113
|
+
) -> "Agent":
|
|
114
|
+
"""Build an agent from a loaded `Config`."""
|
|
115
|
+
# Idempotent, and off unless [trace] says otherwise: the first agent built in
|
|
116
|
+
# this process starts the exporter, every later one finds it running.
|
|
117
|
+
trace.configure(config.trace)
|
|
118
|
+
|
|
119
|
+
owns_gateway = gateway is None
|
|
120
|
+
gateway = gateway or LLMGateway(config.model)
|
|
121
|
+
# `role` names the prompt section and the session `meta` record. It does not by
|
|
122
|
+
# itself turn on team mode — `[team] enabled` (or `STCODE_TEAM`, or `--team`) is
|
|
123
|
+
# a deployment declaring that a shared volume is mounted, and a solo session
|
|
124
|
+
# naming a role should not go looking for an inbox that is not there.
|
|
125
|
+
team = config.team
|
|
126
|
+
role = role or team.role
|
|
127
|
+
joins_team = team.enabled
|
|
128
|
+
if joins_team and not role:
|
|
129
|
+
# Loud: team mode with nobody to be is a mailbox with no owner, and every
|
|
130
|
+
# message to it would be addressed to "".
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"[team] enabled is on but no role is set. Name one in [team] role, "
|
|
133
|
+
"with --role, or with STCODE_ROLE — a team member has to be somebody."
|
|
134
|
+
)
|
|
135
|
+
if joins_team and team.url:
|
|
136
|
+
# Reserved for a team service. Falling back to `shared_dir` instead would
|
|
137
|
+
# leave the operator on a volume they believe they have replaced.
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"[team] url is set ({team.url}), but there is no team service client "
|
|
140
|
+
"yet. Remove it and share a volume through [team] shared_dir."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# The config's own prompt, when it is an agent profile. Resolved here so a
|
|
144
|
+
# mounted profile never has to be found by name.
|
|
145
|
+
harness_kwargs.setdefault("agent_prompt", resolve_prompt(config))
|
|
146
|
+
|
|
147
|
+
# `setdefault`, not a keyword: a caller passing `load_mcp=False` meant it, and
|
|
148
|
+
# passing both would be a duplicate-argument TypeError.
|
|
149
|
+
harness_kwargs.setdefault("load_mcp", config.mcp.enabled)
|
|
150
|
+
harness_kwargs.setdefault("mcp_expose", config.mcp.expose)
|
|
151
|
+
harness_kwargs.setdefault("role", role)
|
|
152
|
+
harness = await Harness.create(
|
|
153
|
+
cwd=cwd,
|
|
154
|
+
approval_mode=approval_mode or config.agent.approval_mode,
|
|
155
|
+
**harness_kwargs,
|
|
156
|
+
)
|
|
157
|
+
harness.context.tool_config = config.tools
|
|
158
|
+
session = session or Session.create(
|
|
159
|
+
cwd=harness.context.cwd,
|
|
160
|
+
role=role,
|
|
161
|
+
model=config.model.route(config.agent.difficulty)[1] if config.model.providers else "",
|
|
162
|
+
directory=config.session.dir,
|
|
163
|
+
# No file until the first message. A `stcode` that was opened in the wrong
|
|
164
|
+
# directory and closed again should leave nothing to list.
|
|
165
|
+
defer=True,
|
|
166
|
+
)
|
|
167
|
+
harness.session_id = session.id
|
|
168
|
+
|
|
169
|
+
agent = cls(
|
|
170
|
+
gateway=gateway,
|
|
171
|
+
harness=harness,
|
|
172
|
+
session=session,
|
|
173
|
+
difficulty=config.agent.difficulty,
|
|
174
|
+
max_turns=config.agent.max_turns,
|
|
175
|
+
max_concurrent=config.agent.max_concurrent,
|
|
176
|
+
reasoning_effort=config.agent.reasoning_effort,
|
|
177
|
+
owns_gateway=owns_gateway,
|
|
178
|
+
supervisor=(
|
|
179
|
+
Supervisor(
|
|
180
|
+
gateway,
|
|
181
|
+
every=config.supervisor.every,
|
|
182
|
+
window=config.supervisor.window,
|
|
183
|
+
difficulty=config.supervisor.difficulty,
|
|
184
|
+
)
|
|
185
|
+
if config.supervisor.enabled and harness.depth == 0
|
|
186
|
+
else None
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
if config.agent.enable_task and harness.depth < config.agent.max_depth:
|
|
190
|
+
agent.enable_task()
|
|
191
|
+
if joins_team:
|
|
192
|
+
agent.enable_team(
|
|
193
|
+
team.shared_dir, role, description=team.description, team=team.name
|
|
194
|
+
)
|
|
195
|
+
# Last, so it can also withhold `task` and the team tools: the config is the
|
|
196
|
+
# operator's word, and a tool added by a feature switch does not outrank it.
|
|
197
|
+
apply_tool_policy(harness, config.agent)
|
|
198
|
+
return agent
|
|
199
|
+
|
|
200
|
+
def attach(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
on_ask: AskFn | None = None,
|
|
204
|
+
on_approval: ApprovalFn | None = None,
|
|
205
|
+
on_progress: ProgressFn | None = None,
|
|
206
|
+
on_event: EventFn | None = None,
|
|
207
|
+
tools: Iterable["Tool[Any] | Callable[[Agent], Tool[Any]]"] = (),
|
|
208
|
+
) -> "Agent":
|
|
209
|
+
"""Wire this agent to a caller: its callbacks, and any tools it brings.
|
|
210
|
+
|
|
211
|
+
The one method a host needs. A daemon supplies three callbacks, a notebook
|
|
212
|
+
supplies none, an application supplies its own tools — all of it is the same
|
|
213
|
+
three assignments and the same `register` + `allow` that `enable_task` does, so
|
|
214
|
+
it is written once here instead of at every call site.
|
|
215
|
+
|
|
216
|
+
A tool may be a `Tool`, or a factory taking this agent — the shape a tool needs
|
|
217
|
+
when it closes over the agent it was built for, as `task` does.
|
|
218
|
+
|
|
219
|
+
`on_event` is the one that is not about this agent: it is where a **sub-agent's**
|
|
220
|
+
events go, so a host can render one working without those events entering this
|
|
221
|
+
agent's turn.
|
|
222
|
+
|
|
223
|
+
Returns `self`, so it chains onto `Agent.create(...)`.
|
|
224
|
+
"""
|
|
225
|
+
if on_ask is not None:
|
|
226
|
+
self.harness.on_ask = on_ask
|
|
227
|
+
if on_approval is not None:
|
|
228
|
+
self.harness.on_approval = on_approval
|
|
229
|
+
if on_progress is not None:
|
|
230
|
+
self.harness.on_progress = on_progress
|
|
231
|
+
if on_event is not None:
|
|
232
|
+
self.harness.on_event = on_event
|
|
233
|
+
for entry in tools:
|
|
234
|
+
built = entry if isinstance(entry, Tool) else entry(self)
|
|
235
|
+
self.harness.registry.register(built, replace=True)
|
|
236
|
+
self.harness.allow(built.name)
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def enable_task(self) -> None:
|
|
240
|
+
"""Register the sub-agent tool on this agent's harness.
|
|
241
|
+
|
|
242
|
+
Registered rather than built in: `task` needs *this* agent to spawn from, and
|
|
243
|
+
`core/harness/` may not know `core/agent/` exists.
|
|
244
|
+
"""
|
|
245
|
+
from stcode.core.agent.task import make_task_tool
|
|
246
|
+
|
|
247
|
+
self.harness.registry.register(make_task_tool(self), replace=True)
|
|
248
|
+
self.harness.allow("task")
|
|
249
|
+
|
|
250
|
+
def enable_team(
|
|
251
|
+
self, shared_dir: str | Path, role: str, *, description: str = "", team: str = ""
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Join a team: an inbox and a member card on the shared volume, then
|
|
254
|
+
`find_teammate` and `send_team_message`.
|
|
255
|
+
|
|
256
|
+
Registered from here for the same reason as `task` — the tools close over this
|
|
257
|
+
agent's mailbox, and `core/harness` must not import `core/team`.
|
|
258
|
+
|
|
259
|
+
`task` survives. The two split different things: a team splits a *product* into
|
|
260
|
+
roles with their own checkouts and one merge boundary each, a sub-agent splits
|
|
261
|
+
one role's *task* inside its own checkout, creating no boundary at all. A
|
|
262
|
+
sub-agent still gets no team tools (they are not in `WORKER_TOOLS`), so the
|
|
263
|
+
messaging discipline between roles is untouched. `[agent] enable_task = false`
|
|
264
|
+
is the off switch if you want one.
|
|
265
|
+
"""
|
|
266
|
+
from stcode.core.team import (
|
|
267
|
+
Mailbox,
|
|
268
|
+
make_find_teammate_tool,
|
|
269
|
+
make_send_team_message_tool,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
mailbox = Mailbox(shared_dir, role)
|
|
273
|
+
try:
|
|
274
|
+
mailbox.join(description=description, team=team)
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
raise RuntimeError(
|
|
277
|
+
f"Team mode needs a writable shared volume at {mailbox.root} ({exc}). "
|
|
278
|
+
"Mount one, point [team] shared_dir somewhere else, or clear [team] role "
|
|
279
|
+
"to run solo."
|
|
280
|
+
) from exc
|
|
281
|
+
self.mailbox = mailbox
|
|
282
|
+
for built in (make_find_teammate_tool(mailbox), make_send_team_message_tool(mailbox)):
|
|
283
|
+
self.harness.registry.register(built, replace=True)
|
|
284
|
+
self.harness.allow(built.name)
|
|
285
|
+
|
|
286
|
+
# ---- driving ----
|
|
287
|
+
|
|
288
|
+
async def push(self, text: str) -> None:
|
|
289
|
+
"""Queue a message.
|
|
290
|
+
|
|
291
|
+
Delivered at the next tool-call boundary if a turn is running, and started as a
|
|
292
|
+
turn of its own if none is. Never spliced into the model call in flight.
|
|
293
|
+
"""
|
|
294
|
+
await self._inbox.put(text)
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def busy(self) -> bool:
|
|
298
|
+
return self._busy
|
|
299
|
+
|
|
300
|
+
async def events(self) -> AsyncGenerator[AgentEvent, None]:
|
|
301
|
+
"""Run queued messages forever, yielding what happens.
|
|
302
|
+
|
|
303
|
+
**One consumer.** Whoever iterates this drives the loop, so there is no
|
|
304
|
+
background task to leak and no second loop to keep in sync. Two iterators would
|
|
305
|
+
race for the same queue — fan out from one instead.
|
|
306
|
+
"""
|
|
307
|
+
while True:
|
|
308
|
+
text = await self._inbox.get()
|
|
309
|
+
async with aclosing(self._run_turn(text)) as turn:
|
|
310
|
+
async for event in turn:
|
|
311
|
+
yield event
|
|
312
|
+
|
|
313
|
+
async def run(self, text: str) -> AsyncIterator[AgentEvent]:
|
|
314
|
+
"""One turn, start to finish. The shortcut, not a second loop.
|
|
315
|
+
|
|
316
|
+
`aclosing` because returning early from an `async for` leaves the generator
|
|
317
|
+
below suspended until the GC gets to it — possibly after the event loop has
|
|
318
|
+
closed, stranding any network connection down there.
|
|
319
|
+
|
|
320
|
+
One level is not enough: `GeneratorExit` into `events()` unwinds its own frame
|
|
321
|
+
but never awaits `_run_turn().aclose()`. So every level wraps the one under it
|
|
322
|
+
— `run` → `events` → `_run_turn` → `_stream` → the provider's.
|
|
323
|
+
"""
|
|
324
|
+
await self.push(text)
|
|
325
|
+
async with aclosing(self.events()) as stream:
|
|
326
|
+
async for event in stream:
|
|
327
|
+
yield event
|
|
328
|
+
if isinstance(event, (TurnFinished, AgentFailed)):
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
async def result(self, text: str, *, on_event: Callable[[AgentEvent], None] | None = None) -> str:
|
|
332
|
+
"""Run one turn and return just the final text. What `task` hands its parent.
|
|
333
|
+
|
|
334
|
+
`on_event` sees everything that happened on the way, which is how a watching
|
|
335
|
+
client gets to show the sub-agent working. The return value is unaffected: the
|
|
336
|
+
parent still gets the final message and nothing else.
|
|
337
|
+
"""
|
|
338
|
+
async for event in self.run(text):
|
|
339
|
+
if on_event is not None:
|
|
340
|
+
on_event(event)
|
|
341
|
+
if isinstance(event, TurnFinished):
|
|
342
|
+
return event.text
|
|
343
|
+
if isinstance(event, AgentFailed):
|
|
344
|
+
return f"[the sub-agent did not finish] {event.message}"
|
|
345
|
+
return ""
|
|
346
|
+
|
|
347
|
+
async def interrupt(self) -> None:
|
|
348
|
+
"""Stop the turn in flight at its next checkpoint.
|
|
349
|
+
|
|
350
|
+
Sets both the flag the loop checks between steps and the cancellation event
|
|
351
|
+
every `Runtime` watches. Tools in flight see it at their next
|
|
352
|
+
`raise_if_cancelled()`; the stream stops at its next event.
|
|
353
|
+
"""
|
|
354
|
+
self._interrupted.set()
|
|
355
|
+
self.harness.cancel.set()
|
|
356
|
+
|
|
357
|
+
# ---- the loop ----
|
|
358
|
+
|
|
359
|
+
async def _run_turn(self, text: str) -> AsyncGenerator[AgentEvent, None]:
|
|
360
|
+
self._interrupted.clear()
|
|
361
|
+
self.harness.cancel.clear()
|
|
362
|
+
self._busy = True
|
|
363
|
+
# The root span of a turn. Everything under it — each model call, each tool —
|
|
364
|
+
# nests inside, so one trace is one thing the agent was asked to do.
|
|
365
|
+
with trace.span(
|
|
366
|
+
f"agent.turn {self.harness.agent_name}",
|
|
367
|
+
attributes={
|
|
368
|
+
"gen_ai.agent.name": self.harness.agent_name,
|
|
369
|
+
"gen_ai.conversation.id": self.session.id,
|
|
370
|
+
"stcode.role": self.session.meta().get("role", ""),
|
|
371
|
+
"stcode.approval_mode": self.harness.approval_mode,
|
|
372
|
+
},
|
|
373
|
+
) as recorder:
|
|
374
|
+
if trace.records_content():
|
|
375
|
+
recorder.set(stcode__input=text)
|
|
376
|
+
# `aclosing`, like every other level: returning early from `run()` has to
|
|
377
|
+
# reach the `finally` below, or the agent stays `busy` forever.
|
|
378
|
+
async with aclosing(self._turn_body(text)) as body:
|
|
379
|
+
async for event in body:
|
|
380
|
+
if isinstance(event, TurnFinished) and trace.records_content():
|
|
381
|
+
recorder.set(stcode__output=event.text)
|
|
382
|
+
elif isinstance(event, AgentFailed):
|
|
383
|
+
recorder.fail(RuntimeError(event.message))
|
|
384
|
+
yield event
|
|
385
|
+
|
|
386
|
+
async def _turn_body(self, text: str) -> AsyncGenerator[AgentEvent, None]:
|
|
387
|
+
"""The loop itself. Split from `_run_turn` only to keep the span around it."""
|
|
388
|
+
try:
|
|
389
|
+
self._drain_mailbox()
|
|
390
|
+
self.session.append(type="user", content=text)
|
|
391
|
+
|
|
392
|
+
iteration = 0
|
|
393
|
+
while self.max_turns == 0 or iteration < self.max_turns:
|
|
394
|
+
iteration += 1
|
|
395
|
+
reply, calls, usage, failure = "", [], Usage(), None
|
|
396
|
+
try:
|
|
397
|
+
async with aclosing(self._stream()) as stream:
|
|
398
|
+
async for event in stream:
|
|
399
|
+
if isinstance(event, TextDelta):
|
|
400
|
+
reply += event.text
|
|
401
|
+
yield event
|
|
402
|
+
elif isinstance(event, ReasoningDelta):
|
|
403
|
+
yield event
|
|
404
|
+
elif isinstance(event, ToolCallEnd):
|
|
405
|
+
calls.append(event)
|
|
406
|
+
elif isinstance(event, MessageStop):
|
|
407
|
+
usage = event.usage
|
|
408
|
+
if self._interrupted.is_set():
|
|
409
|
+
break
|
|
410
|
+
except Exception as exc: # noqa: BLE001 — any provider failure ends the turn
|
|
411
|
+
failure = f"{type(exc).__name__}: {exc}"
|
|
412
|
+
|
|
413
|
+
# Every call needs a result before the next request, so nothing between
|
|
414
|
+
# here and the tool loop may return early: a `tool_use` block with no
|
|
415
|
+
# matching `tool_result` is a 400 on the following turn.
|
|
416
|
+
if failure is not None:
|
|
417
|
+
self.session.append(type="error", message=failure)
|
|
418
|
+
yield AgentFailed(message=failure)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if reply or calls:
|
|
422
|
+
self.session.append(type="assistant", content=reply)
|
|
423
|
+
for call in calls:
|
|
424
|
+
self.session.append(
|
|
425
|
+
type="tool_call", id=call.id, name=call.name, arguments=call.input
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if self._interrupted.is_set() and not calls:
|
|
429
|
+
yield AgentFailed(message="Interrupted.")
|
|
430
|
+
return
|
|
431
|
+
if not calls:
|
|
432
|
+
yield TurnFinished(text=reply, usage=usage)
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
async for event in self._run_tools(calls):
|
|
436
|
+
yield event
|
|
437
|
+
|
|
438
|
+
if self._interrupted.is_set():
|
|
439
|
+
yield AgentFailed(message="Interrupted.")
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
# The steering point. After this round of results, before the next
|
|
443
|
+
# request: the model has not been asked anything yet, so a message
|
|
444
|
+
# landing here changes what it is about to do rather than orphaning a
|
|
445
|
+
# `tool_use` block that has no `tool_result`.
|
|
446
|
+
self._deliver()
|
|
447
|
+
|
|
448
|
+
async for event in self._supervise(iteration):
|
|
449
|
+
yield event
|
|
450
|
+
|
|
451
|
+
yield AgentFailed(
|
|
452
|
+
message=(
|
|
453
|
+
f"Reached the ceiling of {self.max_turns} model/tool rounds in one turn "
|
|
454
|
+
"without finishing. The trajectory is in the session log."
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
finally:
|
|
458
|
+
self._busy = False
|
|
459
|
+
|
|
460
|
+
def _deliver(self) -> int:
|
|
461
|
+
"""Hand over everything waiting: pushed messages, then team mail.
|
|
462
|
+
|
|
463
|
+
Called at a tool-call boundary, where the transcript is whole — every
|
|
464
|
+
`tool_use` has its `tool_result` — so a `user` message can be appended without
|
|
465
|
+
producing a request the provider will reject.
|
|
466
|
+
|
|
467
|
+
Returns how many messages landed, for a caller that wants to say so.
|
|
468
|
+
"""
|
|
469
|
+
delivered = 0
|
|
470
|
+
while True:
|
|
471
|
+
try:
|
|
472
|
+
text = self._inbox.get_nowait()
|
|
473
|
+
except asyncio.QueueEmpty:
|
|
474
|
+
break
|
|
475
|
+
self.session.append(type="user", content=text)
|
|
476
|
+
delivered += 1
|
|
477
|
+
return delivered + self._drain_mailbox()
|
|
478
|
+
|
|
479
|
+
def _drain_mailbox(self) -> int:
|
|
480
|
+
"""Take delivery of the team's messages.
|
|
481
|
+
|
|
482
|
+
Written as `inbox` records, which `Session.messages()` renders as prefixed user
|
|
483
|
+
messages — the route supervisor nudges take too, for the same reason: the
|
|
484
|
+
cached system prefix must not move.
|
|
485
|
+
"""
|
|
486
|
+
if self.mailbox is None:
|
|
487
|
+
return 0
|
|
488
|
+
count = 0
|
|
489
|
+
for message in self.mailbox.drain():
|
|
490
|
+
self.session.append(
|
|
491
|
+
type="inbox",
|
|
492
|
+
**{"from": message.sender},
|
|
493
|
+
subject=message.subject,
|
|
494
|
+
body=message.body,
|
|
495
|
+
refs=message.refs,
|
|
496
|
+
)
|
|
497
|
+
count += 1
|
|
498
|
+
return count
|
|
499
|
+
|
|
500
|
+
async def _supervise(self, iteration: int) -> AsyncGenerator[AgentEvent, None]:
|
|
501
|
+
"""Let the supervisor look, on its own schedule.
|
|
502
|
+
|
|
503
|
+
The nudge goes in as a `supervisor` record, rendered as a prefixed `user`
|
|
504
|
+
message. Never into the system prompt — that is the cached prefix, and moving
|
|
505
|
+
it would make every piece of advice cost a full cache miss.
|
|
506
|
+
"""
|
|
507
|
+
if self.supervisor is None or not self.supervisor.due(iteration):
|
|
508
|
+
return
|
|
509
|
+
nudge = await self.supervisor.check(self.session.records())
|
|
510
|
+
if not nudge:
|
|
511
|
+
return
|
|
512
|
+
self.session.append(type="supervisor", content=nudge)
|
|
513
|
+
yield SupervisorNudge(text=nudge)
|
|
514
|
+
|
|
515
|
+
def _overrides(self) -> dict[str, Any]:
|
|
516
|
+
"""`provider`, `model` and `reasoning_effort` from this session's own `meta`.
|
|
517
|
+
|
|
518
|
+
Read before every call rather than cached on the agent, for the same reason the
|
|
519
|
+
tool definitions are: a value fixed at construction is a value the person
|
|
520
|
+
watching the turn can no longer change.
|
|
521
|
+
|
|
522
|
+
Absent keys are **omitted**, not passed as None, so difficulty routing is
|
|
523
|
+
untouched when nobody has overridden anything. `difficulty` itself is never
|
|
524
|
+
overridden here — a tier is a statement about one piece of work, an override is
|
|
525
|
+
a statement about the conversation, and sub-agents inherit neither.
|
|
526
|
+
|
|
527
|
+
`[agent] reasoning_effort` is folded in underneath, so the config's answer
|
|
528
|
+
applies from the first call and a `/effort` during the conversation replaces it.
|
|
529
|
+
"""
|
|
530
|
+
overrides = self.session.overrides()
|
|
531
|
+
resolved = {
|
|
532
|
+
key: overrides[key]
|
|
533
|
+
for key in ("provider", "model", "reasoning_effort")
|
|
534
|
+
if overrides.get(key)
|
|
535
|
+
}
|
|
536
|
+
if self.reasoning_effort and "reasoning_effort" not in resolved:
|
|
537
|
+
resolved["reasoning_effort"] = self.reasoning_effort
|
|
538
|
+
return resolved
|
|
539
|
+
|
|
540
|
+
async def _stream(self) -> AsyncIterator[Any]:
|
|
541
|
+
async for event in self.gateway.stream(
|
|
542
|
+
self.session.messages(),
|
|
543
|
+
system=self.harness.system_prompt(),
|
|
544
|
+
tools=self.harness.tool_definitions(),
|
|
545
|
+
difficulty=self.difficulty,
|
|
546
|
+
**self._overrides(),
|
|
547
|
+
):
|
|
548
|
+
if isinstance(event, MessageStop):
|
|
549
|
+
# The gateway emits usage, the agent records it. A gateway importing
|
|
550
|
+
# Session would depend on its own caller.
|
|
551
|
+
trace_id, _ = trace.current_ids()
|
|
552
|
+
self.session.append(
|
|
553
|
+
type="usage",
|
|
554
|
+
difficulty=self.difficulty,
|
|
555
|
+
stop_reason=event.stop_reason,
|
|
556
|
+
# The join between the trajectory and the trace, when one is being
|
|
557
|
+
# exported. Empty otherwise, and the session stands alone as before.
|
|
558
|
+
**({"trace_id": trace_id} if trace_id else {}),
|
|
559
|
+
**event.usage.model_dump(),
|
|
560
|
+
)
|
|
561
|
+
yield event
|
|
562
|
+
|
|
563
|
+
async def _run_tools(self, calls: Sequence[ToolCallEnd]) -> AsyncGenerator[AgentEvent, None]:
|
|
564
|
+
"""Run this turn's calls concurrently and record every result.
|
|
565
|
+
|
|
566
|
+
No `return_exceptions`: `Harness.invoke()` never raises for a tool-level
|
|
567
|
+
failure, so an exception here is a harness bug, not news for the model.
|
|
568
|
+
"""
|
|
569
|
+
for call in calls:
|
|
570
|
+
yield ToolStarted(id=call.id, name=call.name, arguments=call.input)
|
|
571
|
+
|
|
572
|
+
async def invoke(call: ToolCallEnd) -> Any:
|
|
573
|
+
async with self._semaphore:
|
|
574
|
+
return await self.harness.invoke(call.name, call.input, tool_call_id=call.id)
|
|
575
|
+
|
|
576
|
+
results = await asyncio.gather(*(invoke(call) for call in calls))
|
|
577
|
+
|
|
578
|
+
for call, result in zip(calls, results):
|
|
579
|
+
self.session.append(
|
|
580
|
+
type="tool_result",
|
|
581
|
+
id=call.id,
|
|
582
|
+
name=call.name,
|
|
583
|
+
content=result.content,
|
|
584
|
+
is_error=result.is_error,
|
|
585
|
+
)
|
|
586
|
+
yield ToolFinished(
|
|
587
|
+
id=call.id,
|
|
588
|
+
name=call.name,
|
|
589
|
+
ok=not result.is_error,
|
|
590
|
+
preview=(
|
|
591
|
+
result.content if call.name == "todo_write"
|
|
592
|
+
else result.content[:PREVIEW_CHARS]
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# ---- lifecycle ----
|
|
597
|
+
|
|
598
|
+
async def aclose(self) -> None:
|
|
599
|
+
"""Release the harness (MCP, background shells) and close the transcript.
|
|
600
|
+
|
|
601
|
+
The gateway closes only if this agent built it — a sub-agent shares its
|
|
602
|
+
parent's, and closing that mid-turn would kill the parent's stream.
|
|
603
|
+
"""
|
|
604
|
+
await self.harness.aclose()
|
|
605
|
+
self.session.close()
|
|
606
|
+
if self._owns_gateway:
|
|
607
|
+
await self.gateway.aclose()
|
|
608
|
+
|
|
609
|
+
async def __aenter__(self) -> "Agent":
|
|
610
|
+
return self
|
|
611
|
+
|
|
612
|
+
async def __aexit__(
|
|
613
|
+
self,
|
|
614
|
+
exc_type: type[BaseException] | None,
|
|
615
|
+
exc: BaseException | None,
|
|
616
|
+
tb: TracebackType | None,
|
|
617
|
+
) -> None:
|
|
618
|
+
await self.aclose()
|
|
619
|
+
|
|
620
|
+
def __repr__(self) -> str:
|
|
621
|
+
return f"Agent({self.harness.agent_name}, session={self.session.id})"
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def apply_tool_policy(harness: Harness, settings: "AgentConfig") -> None:
|
|
625
|
+
"""Narrow this agent's tool set to what `[agent]` asked for.
|
|
626
|
+
|
|
627
|
+
`tools` is an allow-list replacing the default set; `exclude_tools` subtracts from
|
|
628
|
+
whatever is left. Both are checked against the registry, which by now holds the
|
|
629
|
+
built-ins *and* anything registered since — `task`, the team tools, MCP tools in
|
|
630
|
+
`expose = "tools"` mode — so a name that exists can be named here.
|
|
631
|
+
|
|
632
|
+
An unknown name raises. A config that quietly produced a smaller tool set would be
|
|
633
|
+
diagnosed as "the model is ignoring its tools", weeks later.
|
|
634
|
+
"""
|
|
635
|
+
include, exclude = list(settings.tools), list(settings.exclude_tools)
|
|
636
|
+
if not include and not exclude:
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
known = set(harness.registry.names())
|
|
640
|
+
unknown = sorted({name for name in (*include, *exclude) if name not in known})
|
|
641
|
+
if unknown:
|
|
642
|
+
raise ValueError(
|
|
643
|
+
f"[agent] names tool(s) that do not exist: {', '.join(unknown)}. "
|
|
644
|
+
f"Available: {', '.join(sorted(known))}."
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
allowed = include or (harness.allowed if harness.allowed is not None else harness.registry.names())
|
|
648
|
+
harness.allowed = [name for name in allowed if name not in exclude]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
__all__ = ["DEFAULT_APPROVAL_MODE", "DEFAULT_MAX_TURNS", "Agent", "apply_tool_policy"]
|