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.
- letscode/__main__.py +13 -0
- letscode/agent/agent.py +181 -0
- letscode/agent/compaction.py +273 -0
- letscode/agent/events.py +163 -0
- letscode/agent/execution_env.py +360 -0
- letscode/agent/hooks.py +39 -0
- letscode/agent/hookspecs.py +226 -0
- letscode/agent/loop.py +684 -0
- letscode/agent/messages.py +100 -0
- letscode/agent/state.py +49 -0
- letscode/agent/tools.py +245 -0
- letscode/cli/__init__.py +0 -0
- letscode/cli/app.py +653 -0
- letscode/cli/rpc.py +312 -0
- letscode/commands/__init__.py +0 -0
- letscode/commands/builtin.py +330 -0
- letscode/commands/dispatch.py +121 -0
- letscode/config/__init__.py +7 -0
- letscode/config/loader.py +187 -0
- letscode/context/__init__.py +11 -0
- letscode/context/files.py +113 -0
- letscode/frontends/__init__.py +0 -0
- letscode/frontends/basic/__init__.py +0 -0
- letscode/frontends/basic/footer.py +105 -0
- letscode/frontends/basic/tui.py +1121 -0
- letscode/frontends/protocol.py +46 -0
- letscode/llm/__init__.py +0 -0
- letscode/llm/client.py +272 -0
- letscode/llm/errors.py +22 -0
- letscode/llm/models.py +57 -0
- letscode/llm/stream.py +134 -0
- letscode/llm/types.py +54 -0
- letscode/plugins/__init__.py +0 -0
- letscode/plugins/builtin.py +122 -0
- letscode/plugins/manager.py +76 -0
- letscode/plugins/registries.py +242 -0
- letscode/session/__init__.py +0 -0
- letscode/session/format.py +80 -0
- letscode/session/store.py +194 -0
- letscode/skills/__init__.py +0 -0
- letscode/skills/discovery.py +106 -0
- letscode/skills/loader.py +100 -0
- letscode/skills/synth.py +121 -0
- letscode/tools/__init__.py +0 -0
- letscode/tools/bash.py +67 -0
- letscode/tools/builtin.py +26 -0
- letscode/tools/edit.py +130 -0
- letscode/tools/read.py +98 -0
- letscode/tools/write.py +79 -0
- letscode-0.5.0.dist-info/METADATA +283 -0
- letscode-0.5.0.dist-info/RECORD +54 -0
- {letscode-0.1.0.dist-info → letscode-0.5.0.dist-info}/WHEEL +1 -1
- letscode-0.5.0.dist-info/entry_points.txt +2 -0
- letscode/__init__.py +0 -2
- letscode-0.1.0.dist-info/METADATA +0 -9
- letscode-0.1.0.dist-info/RECORD +0 -6
- letscode-0.1.0.dist-info/entry_points.txt +0 -3
- /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()
|
letscode/agent/agent.py
ADDED
|
@@ -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)
|
letscode/agent/events.py
ADDED
|
@@ -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``."""
|