letscode 0.1.0__py3-none-any.whl → 0.5.0__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 (58) hide show
  1. letscode/__main__.py +13 -0
  2. letscode/agent/agent.py +181 -0
  3. letscode/agent/compaction.py +273 -0
  4. letscode/agent/events.py +163 -0
  5. letscode/agent/execution_env.py +360 -0
  6. letscode/agent/hooks.py +39 -0
  7. letscode/agent/hookspecs.py +226 -0
  8. letscode/agent/loop.py +684 -0
  9. letscode/agent/messages.py +100 -0
  10. letscode/agent/state.py +49 -0
  11. letscode/agent/tools.py +245 -0
  12. letscode/cli/__init__.py +0 -0
  13. letscode/cli/app.py +653 -0
  14. letscode/cli/rpc.py +312 -0
  15. letscode/commands/__init__.py +0 -0
  16. letscode/commands/builtin.py +330 -0
  17. letscode/commands/dispatch.py +121 -0
  18. letscode/config/__init__.py +7 -0
  19. letscode/config/loader.py +187 -0
  20. letscode/context/__init__.py +11 -0
  21. letscode/context/files.py +113 -0
  22. letscode/frontends/__init__.py +0 -0
  23. letscode/frontends/basic/__init__.py +0 -0
  24. letscode/frontends/basic/footer.py +105 -0
  25. letscode/frontends/basic/tui.py +1121 -0
  26. letscode/frontends/protocol.py +46 -0
  27. letscode/llm/__init__.py +0 -0
  28. letscode/llm/client.py +272 -0
  29. letscode/llm/errors.py +22 -0
  30. letscode/llm/models.py +57 -0
  31. letscode/llm/stream.py +134 -0
  32. letscode/llm/types.py +54 -0
  33. letscode/plugins/__init__.py +0 -0
  34. letscode/plugins/builtin.py +122 -0
  35. letscode/plugins/manager.py +76 -0
  36. letscode/plugins/registries.py +242 -0
  37. letscode/session/__init__.py +0 -0
  38. letscode/session/format.py +80 -0
  39. letscode/session/store.py +194 -0
  40. letscode/skills/__init__.py +0 -0
  41. letscode/skills/discovery.py +106 -0
  42. letscode/skills/loader.py +100 -0
  43. letscode/skills/synth.py +121 -0
  44. letscode/tools/__init__.py +0 -0
  45. letscode/tools/bash.py +67 -0
  46. letscode/tools/builtin.py +26 -0
  47. letscode/tools/edit.py +130 -0
  48. letscode/tools/read.py +98 -0
  49. letscode/tools/write.py +79 -0
  50. letscode-0.5.0.dist-info/METADATA +283 -0
  51. letscode-0.5.0.dist-info/RECORD +54 -0
  52. {letscode-0.1.0.dist-info → letscode-0.5.0.dist-info}/WHEEL +1 -1
  53. letscode-0.5.0.dist-info/entry_points.txt +2 -0
  54. letscode/__init__.py +0 -2
  55. letscode-0.1.0.dist-info/METADATA +0 -9
  56. letscode-0.1.0.dist-info/RECORD +0 -6
  57. letscode-0.1.0.dist-info/entry_points.txt +0 -3
  58. /letscode/{COMMING_SOON.py → agent/__init__.py} +0 -0
letscode/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Enable ``python -m letscode`` (same entry point as the console script).
2
+
3
+ ``letscode`` is a PEP 420 namespace package (no ``__init__.py`` by
4
+ design), so this dunder file trips INP001 — silenced with rationale.
5
+ """
6
+ # ruff: noqa: INP001 # intentional namespace package; see module docstring
7
+
8
+ from __future__ import annotations
9
+
10
+ from letscode.cli.app import main
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,181 @@
1
+ """Agent class — imperative wrapper around ``agent_loop``.
2
+
3
+ Constructed with an ``AgentState``, an ``LLMStream``, and optionally a
4
+ ``PluginManager`` (T21). Driven via ``prompt(text)``; listeners attached
5
+ via ``subscribe()`` fire on every emitted event; the optional
6
+ ``PluginManager``'s ``letscode_on_event`` hook fires for the same events.
7
+ ``wait_for_idle()`` returns when no run is in progress; ``abort()``
8
+ signals cancellation via a shared ``asyncio.Event``.
9
+
10
+ T05 scope: no steering or follow-up queues. M6 adds them.
11
+
12
+ See design section 3.5.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import inspect
20
+ import time
21
+ import uuid
22
+ from typing import TYPE_CHECKING
23
+
24
+ from letscode.agent.execution_env import LocalEnv
25
+ from letscode.agent.hooks import call_async_hook
26
+ from letscode.agent.loop import agent_loop
27
+ from letscode.agent.messages import UserMessage
28
+ from letscode.llm.types import TextPart
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Awaitable, Callable, Sequence
32
+
33
+ from letscode.agent.events import Event
34
+ from letscode.agent.execution_env import ExecutionEnv
35
+ from letscode.agent.loop import LLMStream
36
+ from letscode.agent.messages import AgentMessage
37
+ from letscode.agent.state import AgentState
38
+ from letscode.plugins.manager import PluginManager
39
+
40
+
41
+ class Agent:
42
+ """Application-facing handle to drive one agent session."""
43
+
44
+ def __init__(
45
+ self,
46
+ state: AgentState,
47
+ llm: LLMStream,
48
+ pm: PluginManager | None = None,
49
+ *,
50
+ env: ExecutionEnv | None = None,
51
+ ) -> None:
52
+ self.state = state
53
+ self._llm = llm
54
+ self._pm = pm
55
+ self._env: ExecutionEnv = env if env is not None else LocalEnv()
56
+ self._subscribers: list[Callable[[Event], Awaitable[None] | None]] = []
57
+ self._cancel_event = asyncio.Event()
58
+ self._idle_event = asyncio.Event()
59
+ self._idle_event.set()
60
+
61
+ def subscribe(
62
+ self, callback: Callable[[Event], Awaitable[None] | None]
63
+ ) -> Callable[[], None]:
64
+ """Register ``callback`` to fire for every emitted event.
65
+
66
+ Returns an unsubscribe handle. The callback may be sync or async.
67
+ Callbacks fire in registration order; the agent awaits each one
68
+ (including async ones) before continuing the loop.
69
+ """
70
+ self._subscribers.append(callback)
71
+
72
+ def _unsubscribe() -> None:
73
+ # Idempotent: a second call is a no-op rather than an error.
74
+ with contextlib.suppress(ValueError):
75
+ self._subscribers.remove(callback)
76
+
77
+ return _unsubscribe
78
+
79
+ async def prompt(self, text: str) -> None:
80
+ """Send one user message and run a single turn.
81
+
82
+ Returns only after ``agent_end`` listeners have all completed.
83
+ Raises ``RuntimeError`` if a run is already in progress.
84
+ """
85
+ if not self._idle_event.is_set():
86
+ msg = "Agent.prompt called while a run is in progress"
87
+ raise RuntimeError(msg)
88
+ user = UserMessage(
89
+ id=str(uuid.uuid4()),
90
+ content=[TextPart(text=text)],
91
+ timestamp=time.time(),
92
+ )
93
+ await self._run([user])
94
+
95
+ async def continue_(self) -> None:
96
+ """Resume the loop without adding a new user message (T31).
97
+
98
+ Frontends call this to retry after a recoverable
99
+ :class:`~letscode.llm.errors.ProviderError`. The agent re-enters
100
+ ``agent_loop`` with the existing ``state.messages`` and no new
101
+ inputs, which restarts the assistant turn that just failed.
102
+ """
103
+ if not self._idle_event.is_set():
104
+ msg = "Agent.continue_ called while a run is in progress"
105
+ raise RuntimeError(msg)
106
+ await self._run([])
107
+
108
+ def steer(self, text: str) -> None:
109
+ """Queue a user message to deliver at the next turn boundary (T40).
110
+
111
+ Safe to call mid-run, including from a different task. The
112
+ agent loop drains the queue after each ``TurnEnd`` event,
113
+ appending the queued text as a :class:`UserMessage` to
114
+ ``state.messages``. Delivery cadence is governed by
115
+ ``state.steering_mode`` — ``"one-at-a-time"`` (the default)
116
+ pops one per turn; ``"all"`` drains the whole queue.
117
+ """
118
+ self.state.steering_queue.append(text)
119
+
120
+ @property
121
+ def queued_steering(self) -> tuple[str, ...]:
122
+ """Read-only snapshot of pending steering messages."""
123
+ return tuple(self.state.steering_queue)
124
+
125
+ def follow_up(self, text: str) -> None:
126
+ """Queue a user message to deliver after the agent would otherwise end (T41).
127
+
128
+ Where ``steer`` injects between tool batches, ``follow_up``
129
+ injects at the point where the loop would emit ``AgentEnd``.
130
+ With a non-empty follow-up queue, the loop drains it (per
131
+ ``state.follow_up_mode``), appends each as a UserMessage, and
132
+ runs another turn instead of ending. Useful for "after this
133
+ finishes, also do X" interactions.
134
+ """
135
+ self.state.follow_up_queue.append(text)
136
+
137
+ @property
138
+ def queued_follow_up(self) -> tuple[str, ...]:
139
+ """Read-only snapshot of pending follow-up messages."""
140
+ return tuple(self.state.follow_up_queue)
141
+
142
+ async def wait_for_idle(self) -> None:
143
+ """Block until the agent has no active run."""
144
+ await self._idle_event.wait()
145
+
146
+ def abort(self) -> None:
147
+ """Signal the active run to cancel.
148
+
149
+ Sets the shared cancel event; the LLM client and the loop honor it
150
+ at the next checkpoint. No-op if no run is in progress.
151
+ """
152
+ self._cancel_event.set()
153
+
154
+ async def _run(self, inputs: Sequence[AgentMessage]) -> None:
155
+ self._cancel_event.clear()
156
+ self._idle_event.clear()
157
+ try:
158
+ async for event in agent_loop(
159
+ inputs,
160
+ self.state,
161
+ llm=self._llm,
162
+ cancel_event=self._cancel_event,
163
+ pm=self._pm,
164
+ env=self._env,
165
+ ):
166
+ await self._fire(event)
167
+ finally:
168
+ self._idle_event.set()
169
+
170
+ async def _fire(self, event: Event) -> None:
171
+ # Snapshot the list so subscribe/unsubscribe during firing is safe.
172
+ for callback in list(self._subscribers):
173
+ result = callback(event)
174
+ if inspect.isawaitable(result):
175
+ await result
176
+ # T21: also fire the plugin on_event hook.
177
+ if self._pm is not None:
178
+ await call_async_hook(
179
+ self._pm._pm.hook.letscode_on_event, # noqa: SLF001
180
+ event=event,
181
+ )
@@ -0,0 +1,273 @@
1
+ """Compaction primitives (T43).
2
+
3
+ When a session approaches the model's context-window limit, *compaction*
4
+ collapses the older portion into a single :class:`CustomMessage` summary
5
+ so the conversation can keep going. The summary is produced by the LLM
6
+ itself in a separate one-shot call.
7
+
8
+ This module is the policy + the per-call machinery; T44 wires it into
9
+ the agent loop's per-turn overflow check and exposes a ``/compact``
10
+ slash command.
11
+
12
+ Two-tier message model recap (T37): we drop the collapsed messages
13
+ from ``state.messages`` and replace them with a single
14
+ :class:`~letscode.agent.messages.CustomMessage` of kind ``"compaction"``.
15
+ The built-in ``letscode_convert_to_llm`` hook expands that marker into a
16
+ synthetic :class:`AssistantMessage` so the LLM sees its own internal
17
+ summary on subsequent calls — the *transcript* (the JSONL on disk) is
18
+ unchanged, the *LLM view* is compacted.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ import uuid
25
+ from dataclasses import dataclass
26
+ from typing import TYPE_CHECKING
27
+
28
+ from letscode.agent.events import TextDelta
29
+ from letscode.agent.messages import (
30
+ AssistantMessage,
31
+ CustomMessage,
32
+ ToolResultMessage,
33
+ UserMessage,
34
+ )
35
+ from letscode.llm.types import TextPart
36
+
37
+ _TOOL_RESULT_TRUNCATE_CHARS = 1000
38
+ """Per-tool-result cap when rendering the conversation for summarisation —
39
+ prevents an oversize tool log from blowing past the context window during
40
+ the summary call itself."""
41
+
42
+ if TYPE_CHECKING:
43
+ from collections.abc import Sequence
44
+
45
+ from letscode.agent.loop import LLMStream
46
+ from letscode.agent.messages import AgentMessage
47
+ from letscode.agent.state import AgentState
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Policy
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class CompactionPolicy:
57
+ """When + how aggressively to compact.
58
+
59
+ - ``threshold_ratio``: compact when the estimated input-token count
60
+ exceeds this fraction of the model's context window.
61
+ - ``keep_recent_messages``: always preserve the last N messages
62
+ uncollapsed so the assistant has clear local context.
63
+ - ``summary_system_prompt``: passed as the system prompt of the
64
+ summarisation call; overridable per-invocation in ``compact()``.
65
+ """
66
+
67
+ threshold_ratio: float = 0.8
68
+ keep_recent_messages: int = 4
69
+ summary_system_prompt: str = (
70
+ "You summarise prior conversation history concisely. Keep the "
71
+ "summary under 200 words, preserving file names, function names, "
72
+ "user goals, and any outstanding follow-ups. Do not invent details."
73
+ )
74
+
75
+
76
+ DEFAULT_POLICY = CompactionPolicy()
77
+ """The default compaction policy used by the agent loop."""
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Token estimation
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ _CHARS_PER_TOKEN_APPROX = 4
86
+ """Rough rule of thumb (English text + JSON tool calls); imprecise but
87
+ adequate for "are we approaching the limit" decisions."""
88
+
89
+
90
+ def estimate_tokens(messages: Sequence[AgentMessage]) -> int:
91
+ """Approximate the token count of the LLM-visible portion of ``messages``.
92
+
93
+ Counts characters in :class:`TextPart` content plus a rough surcharge
94
+ for tool-call JSON. ``CustomMessage`` entries are skipped (they don't
95
+ reach the LLM unless a plugin expands them, and the expanded form is
96
+ counted on the next pass).
97
+ """
98
+ total_chars = 0
99
+ for m in messages:
100
+ if isinstance(m, CustomMessage):
101
+ continue
102
+ if isinstance(m, AssistantMessage):
103
+ for p in m.content:
104
+ if isinstance(p, TextPart):
105
+ total_chars += len(p.text)
106
+ for tc in m.tool_calls:
107
+ # Tool calls serialise as JSON in the wire format.
108
+ total_chars += len(tc.name) + sum(
109
+ len(str(v)) for v in tc.arguments.values()
110
+ )
111
+ elif isinstance(m, (UserMessage, ToolResultMessage)):
112
+ for p in m.content:
113
+ if isinstance(p, TextPart):
114
+ total_chars += len(p.text)
115
+ return total_chars // _CHARS_PER_TOKEN_APPROX
116
+
117
+
118
+ def should_compact(
119
+ state: AgentState,
120
+ *,
121
+ context_window: int,
122
+ policy: CompactionPolicy = DEFAULT_POLICY,
123
+ ) -> bool:
124
+ """Return ``True`` when token usage crosses ``policy.threshold_ratio``.
125
+
126
+ ``context_window`` is the model's input limit (e.g. from the footer's
127
+ :data:`MODEL_PRICES` table). A non-positive value short-circuits to
128
+ ``False`` — compaction needs a known window to be meaningful.
129
+ """
130
+ if context_window <= 0:
131
+ return False
132
+ if len(state.messages) <= policy.keep_recent_messages:
133
+ return False
134
+ used = estimate_tokens(state.messages)
135
+ return used > policy.threshold_ratio * context_window
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # The compact() primitive
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ async def compact(
144
+ state: AgentState,
145
+ llm: LLMStream,
146
+ *,
147
+ policy: CompactionPolicy = DEFAULT_POLICY,
148
+ custom_summary_prompt: str | None = None,
149
+ ) -> CustomMessage | None:
150
+ """Collapse the older portion of ``state.messages`` into a summary.
151
+
152
+ Splits ``state.messages`` at ``-policy.keep_recent_messages``,
153
+ summarises the older slice via a one-shot LLM call, replaces it
154
+ with a single :class:`~letscode.agent.messages.CustomMessage` of
155
+ kind ``"compaction"``, and returns that marker.
156
+
157
+ Returns ``None`` if there's nothing to compact (the message list is
158
+ shorter than the keep-recent floor).
159
+
160
+ The summary call uses ``state.model`` — same provider, same context
161
+ window. Pass ``custom_summary_prompt`` to override the default
162
+ summarisation instructions for a specific call (e.g.
163
+ "focus on the auth flow"). Tool messages in the collapsed slice are
164
+ rendered as ``[tool result …]`` lines so the model still sees the
165
+ factual events it acted on.
166
+ """
167
+ if len(state.messages) <= policy.keep_recent_messages:
168
+ return None
169
+
170
+ to_summarise = state.messages[: -policy.keep_recent_messages]
171
+ kept = state.messages[-policy.keep_recent_messages :]
172
+
173
+ system_prompt = policy.summary_system_prompt
174
+ if custom_summary_prompt:
175
+ system_prompt = (
176
+ f"{system_prompt}\n\nAdditional instructions:\n{custom_summary_prompt}"
177
+ )
178
+
179
+ request_msg = _build_summary_request(to_summarise)
180
+ summary_text = await _stream_summary(
181
+ llm,
182
+ model=state.model,
183
+ system=system_prompt,
184
+ request=request_msg,
185
+ )
186
+
187
+ marker = CustomMessage(
188
+ id=str(uuid.uuid4()),
189
+ kind="compaction",
190
+ payload={
191
+ "summary": summary_text.strip(),
192
+ "collapsed_count": len(to_summarise),
193
+ },
194
+ timestamp=time.time(),
195
+ )
196
+ # Replace the collapsed slice with the marker; keep the rest as-is.
197
+ state.messages[:] = [marker, *kept]
198
+ return marker
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Internal helpers
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def _build_summary_request(
207
+ messages: Sequence[AgentMessage],
208
+ ) -> list[UserMessage]:
209
+ """Render ``messages`` as a single user message asking for a summary."""
210
+ rendered = _render_conversation(messages)
211
+ body = (
212
+ "Summarise the following conversation between a user and an "
213
+ "AI coding assistant. Preserve file names, intent, and "
214
+ "outstanding work.\n\n--- BEGIN ---\n"
215
+ f"{rendered}\n--- END ---"
216
+ )
217
+ return [
218
+ UserMessage(
219
+ id="compaction-request",
220
+ content=[TextPart(text=body)],
221
+ timestamp=time.time(),
222
+ ),
223
+ ]
224
+
225
+
226
+ def _render_conversation(messages: Sequence[AgentMessage]) -> str:
227
+ """Render a slice of the transcript as plain text for the summariser."""
228
+ lines: list[str] = []
229
+ for m in messages:
230
+ if isinstance(m, CustomMessage):
231
+ if m.kind == "compaction":
232
+ summary = str(m.payload.get("summary", "(empty)"))
233
+ lines.append(f"[earlier-summary]\n{summary}")
234
+ continue
235
+ if isinstance(m, UserMessage):
236
+ text = "".join(p.text for p in m.content if isinstance(p, TextPart))
237
+ lines.append(f"[user]\n{text}")
238
+ elif isinstance(m, AssistantMessage):
239
+ text = "".join(p.text for p in m.content if isinstance(p, TextPart))
240
+ tools = (
241
+ f" (tool calls: {', '.join(tc.name for tc in m.tool_calls)})"
242
+ if m.tool_calls
243
+ else ""
244
+ )
245
+ lines.append(f"[assistant{tools}]\n{text}")
246
+ elif isinstance(m, ToolResultMessage):
247
+ text = "".join(p.text for p in m.content if isinstance(p, TextPart))
248
+ # Cap individual tool outputs so the summary request itself
249
+ # doesn't blow past the context window.
250
+ if len(text) >= _TOOL_RESULT_TRUNCATE_CHARS:
251
+ text = text[:_TOOL_RESULT_TRUNCATE_CHARS] + " …(truncated)"
252
+ err = " (error)" if m.is_error else ""
253
+ lines.append(f"[tool-result{err}]\n{text}")
254
+ return "\n\n".join(lines)
255
+
256
+
257
+ async def _stream_summary(
258
+ llm: LLMStream,
259
+ *,
260
+ model: str,
261
+ system: str,
262
+ request: list[UserMessage],
263
+ ) -> str:
264
+ """Drive the LLM through one summarisation turn, returning collected text."""
265
+ buffer: list[str] = []
266
+ async for stream_event in llm.stream(
267
+ model=model,
268
+ messages=request,
269
+ system=system,
270
+ ):
271
+ if isinstance(stream_event, TextDelta):
272
+ buffer.append(stream_event.delta)
273
+ return "".join(buffer)
@@ -0,0 +1,163 @@
1
+ """Event and StreamEvent unions emitted by the agent loop.
2
+
3
+ See design sections 1.4 and 1.5. Each event carries a ``type`` literal that
4
+ discriminates the union; both unions round-trip through Pydantic JSON.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Annotated, Any, Literal
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from letscode.agent.messages import ( # noqa: TC001 # Pydantic resolves at runtime
14
+ AgentMessage,
15
+ AssistantMessage,
16
+ ToolResultMessage,
17
+ )
18
+ from letscode.agent.tools import (
19
+ ToolPartialResult, # noqa: TC001 # Pydantic resolves at runtime
20
+ )
21
+ from letscode.llm.types import Usage # noqa: TC001 # Pydantic resolves at runtime
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Stream events: deltas emitted while an assistant message is streaming.
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ class TextDelta(BaseModel):
29
+ type: Literal["text_delta"] = "text_delta"
30
+ delta: str
31
+
32
+
33
+ class ThinkingDelta(BaseModel):
34
+ type: Literal["thinking_delta"] = "thinking_delta"
35
+ delta: str
36
+
37
+
38
+ class ToolCallStart(BaseModel):
39
+ type: Literal["tool_call_start"] = "tool_call_start"
40
+ tool_call_id: str
41
+ name: str
42
+
43
+
44
+ class ToolCallArgsDelta(BaseModel):
45
+ type: Literal["tool_call_args_delta"] = "tool_call_args_delta"
46
+ tool_call_id: str
47
+ delta: str
48
+
49
+
50
+ class ToolCallEnd(BaseModel):
51
+ type: Literal["tool_call_end"] = "tool_call_end"
52
+ tool_call_id: str
53
+
54
+
55
+ class UsageEvent(BaseModel):
56
+ type: Literal["usage"] = "usage"
57
+ usage: Usage
58
+
59
+
60
+ class StopEvent(BaseModel):
61
+ type: Literal["stop"] = "stop"
62
+ reason: str
63
+
64
+
65
+ StreamEvent = Annotated[
66
+ TextDelta
67
+ | ThinkingDelta
68
+ | ToolCallStart
69
+ | ToolCallArgsDelta
70
+ | ToolCallEnd
71
+ | UsageEvent
72
+ | StopEvent,
73
+ Field(discriminator="type"),
74
+ ]
75
+ """Discriminated union of streaming deltas, keyed by ``type``."""
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Agent loop events.
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class AgentStart(BaseModel):
84
+ type: Literal["agent_start"] = "agent_start"
85
+ messages: list[AgentMessage]
86
+
87
+
88
+ class AgentEnd(BaseModel):
89
+ type: Literal["agent_end"] = "agent_end"
90
+ messages: list[AgentMessage]
91
+ error: str | None = None
92
+
93
+
94
+ class TurnStart(BaseModel):
95
+ type: Literal["turn_start"] = "turn_start"
96
+ turn: int
97
+
98
+
99
+ class TurnEnd(BaseModel):
100
+ type: Literal["turn_end"] = "turn_end"
101
+ turn: int
102
+ message: AssistantMessage
103
+ tool_results: list[ToolResultMessage]
104
+
105
+
106
+ class MessageStart(BaseModel):
107
+ type: Literal["message_start"] = "message_start"
108
+ message: AgentMessage
109
+
110
+
111
+ class MessageUpdate(BaseModel):
112
+ """Emitted only for streaming assistant messages."""
113
+
114
+ type: Literal["message_update"] = "message_update"
115
+ message: AssistantMessage
116
+ stream_event: StreamEvent
117
+
118
+
119
+ class MessageEnd(BaseModel):
120
+ type: Literal["message_end"] = "message_end"
121
+ message: AgentMessage
122
+
123
+
124
+ class ToolExecutionStart(BaseModel):
125
+ type: Literal["tool_execution_start"] = "tool_execution_start"
126
+ tool_call_id: str
127
+ tool_name: str
128
+ arguments: dict[str, Any]
129
+
130
+
131
+ class ToolExecutionUpdate(BaseModel):
132
+ type: Literal["tool_execution_update"] = "tool_execution_update"
133
+ tool_call_id: str
134
+ partial: ToolPartialResult
135
+
136
+
137
+ class ToolExecutionEnd(BaseModel):
138
+ type: Literal["tool_execution_end"] = "tool_execution_end"
139
+ tool_call_id: str
140
+ result: ToolResultMessage
141
+
142
+
143
+ class ErrorEvent(BaseModel):
144
+ type: Literal["error"] = "error"
145
+ error: str
146
+ recoverable: bool
147
+
148
+
149
+ Event = Annotated[
150
+ AgentStart
151
+ | AgentEnd
152
+ | TurnStart
153
+ | TurnEnd
154
+ | MessageStart
155
+ | MessageUpdate
156
+ | MessageEnd
157
+ | ToolExecutionStart
158
+ | ToolExecutionUpdate
159
+ | ToolExecutionEnd
160
+ | ErrorEvent,
161
+ Field(discriminator="type"),
162
+ ]
163
+ """Discriminated union of agent-loop events, keyed by ``type``."""