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,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"]