zettcode-app 0.4.2__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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The gauge the header plays when the reasoning effort changes: a hairline that fills, and a glow that travels.
|
|
2
|
+
|
|
3
|
+
A frame is a value without a colour: the cells, the word, and how bright the glow is on each. The painter turns
|
|
4
|
+
it into spans with the theme's colours, and the switch to the top of the scale has an animation of its own in
|
|
5
|
+
:mod:`.shimmer`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
#: How long the effort gauge plays after a switch. It is a function of time rather
|
|
15
|
+
#: than of a frame count, so it is as smooth as the loop that paints it and its
|
|
16
|
+
#: length is one number to tune: a switch at the top of the scale is worth a moment
|
|
17
|
+
#: of watching, and the gauge holds the corner for this one.
|
|
18
|
+
EFFORT_SECONDS = 3
|
|
19
|
+
|
|
20
|
+
#: Fraction of the second the fill takes to arrive; the rest is the glow leaving.
|
|
21
|
+
EFFORT_FILL_SHARE = 0.8
|
|
22
|
+
|
|
23
|
+
#: Half the width, in cells, of the glow that travels over the gauge and over the
|
|
24
|
+
#: word. Its brightness falls off as a cosine, so it has no edge.
|
|
25
|
+
EFFORT_GLOW_HALF_WIDTH = 3.5
|
|
26
|
+
|
|
27
|
+
#: Cells past either end the glow travels, so it enters and leaves rather than
|
|
28
|
+
#: appearing on the first cell.
|
|
29
|
+
EFFORT_GLOW_RUNWAY = 4
|
|
30
|
+
|
|
31
|
+
#: Fractions of the second the gauge takes to arrive and to leave. Both are short
|
|
32
|
+
#: and the leaving one is longer: a thing that appears should be there at once, and
|
|
33
|
+
#: one that goes should be allowed to go, or the header jumps back to plain text.
|
|
34
|
+
EFFORT_FADE_IN = 0.12
|
|
35
|
+
EFFORT_FADE_OUT = 0.25
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class GaugeCell:
|
|
40
|
+
"""One cell of the effort gauge, described without a colour.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
char: The glyph: a full rule, a half rule for the leading edge, or the
|
|
44
|
+
faint rail that has not been reached.
|
|
45
|
+
heat: Where on the scale this cell sits, ``0`` at the cheapest level and
|
|
46
|
+
``1`` at the dearest; the colour ramp reads it.
|
|
47
|
+
glow: How much of the travelling highlight is on this cell, ``0`` to ``1``.
|
|
48
|
+
lit: Whether the fill has reached it; an unlit cell is rail.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
char: str
|
|
52
|
+
heat: float
|
|
53
|
+
glow: float
|
|
54
|
+
lit: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class EffortFrame:
|
|
59
|
+
"""One instant of the effort gauge: its cells and the word beside them.
|
|
60
|
+
|
|
61
|
+
``presence`` is how much of the gauge is there: it rises from nothing, holds,
|
|
62
|
+
and falls back to nothing, and the painter blends every colour toward the
|
|
63
|
+
resting header by what is missing, so the gauge fades in and out instead of
|
|
64
|
+
appearing and vanishing between one frame and the next.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
cells: tuple[GaugeCell, ...]
|
|
68
|
+
word: str
|
|
69
|
+
word_heat: float
|
|
70
|
+
word_glow: tuple[float, ...]
|
|
71
|
+
presence: float
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _glow(position: float, centre: float) -> float:
|
|
75
|
+
"""Return the highlight's brightness at a position: a cosine, zero past its reach."""
|
|
76
|
+
distance = abs(position - centre)
|
|
77
|
+
if distance >= EFFORT_GLOW_HALF_WIDTH:
|
|
78
|
+
return 0.0
|
|
79
|
+
return 0.5 * (1 + math.cos(math.pi * distance / EFFORT_GLOW_HALF_WIDTH))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def effort_frame(previous: str, current: str, levels: Sequence[str], *, progress: float) -> EffortFrame | None:
|
|
83
|
+
"""Return the effort gauge ``progress`` of the way through a switch.
|
|
84
|
+
|
|
85
|
+
The gauge is a hairline with one cell per level. The fill eases out from the
|
|
86
|
+
level being left to the level chosen, so a jump of four rungs travels four
|
|
87
|
+
times as far as a nudge and a step down wipes back. Its colour is a property
|
|
88
|
+
of *where* a cell sits on the scale rather than of what is being switched to:
|
|
89
|
+
the cheap end is the accent, the dear end is warm, and the word beside it takes
|
|
90
|
+
the heat of the level it names — so the position of the fill and the colour of
|
|
91
|
+
the word say the same thing twice, and `ultra` is the one that is red.
|
|
92
|
+
|
|
93
|
+
A soft highlight travels over the gauge and then over the word, a cosine of its
|
|
94
|
+
distance rather than a window with edges, which is what keeps it from looking
|
|
95
|
+
like a progress bar.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
previous: Level the switch came from; an unknown name starts from empty.
|
|
99
|
+
current: Level being reached; an unknown name answers ``None``.
|
|
100
|
+
levels: The configured levels, cheapest first.
|
|
101
|
+
progress: ``0`` at the start through ``1`` at the end; outside that range
|
|
102
|
+
there is nothing to draw and the answer is ``None``, which is the
|
|
103
|
+
caller's cue to release the frame loop.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The cells and the word, or ``None`` when there is nothing to animate.
|
|
107
|
+
"""
|
|
108
|
+
if not 0.0 <= progress < 1.0 or not levels or current not in levels:
|
|
109
|
+
return None
|
|
110
|
+
count = len(levels)
|
|
111
|
+
target = levels.index(current)
|
|
112
|
+
source = levels.index(previous) if previous in levels else -1
|
|
113
|
+
arrived = 1 - (1 - min(1.0, progress / EFFORT_FILL_SHARE)) ** 3
|
|
114
|
+
reach = (source + 1) + (target - source) * arrived
|
|
115
|
+
span = count + 2 * EFFORT_GLOW_RUNWAY + len(current) + 1
|
|
116
|
+
sweep = -EFFORT_GLOW_RUNWAY + progress * span
|
|
117
|
+
last = max(1, count - 1)
|
|
118
|
+
cells = []
|
|
119
|
+
for index in range(count):
|
|
120
|
+
fill = max(0.0, min(1.0, reach - index))
|
|
121
|
+
lit = fill > 0.45
|
|
122
|
+
char = "\u2501" if fill >= 1.0 else "\u2578" if lit else "\u2500"
|
|
123
|
+
cells.append(GaugeCell(char, index / last, _glow(index, sweep) if lit else 0.0, lit))
|
|
124
|
+
word_glow = tuple(_glow(count + 1 + offset, sweep) for offset in range(len(current)))
|
|
125
|
+
presence = min(1.0, progress / EFFORT_FADE_IN, (1.0 - progress) / EFFORT_FADE_OUT)
|
|
126
|
+
return EffortFrame(tuple(cells), current, target / last, word_glow, max(0.0, presence))
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Project agent events into transcript entries.
|
|
2
|
+
|
|
3
|
+
The agent's events are translated once, by :func:`zettcode.sdk.translation.translate`, into the SDK's typed events,
|
|
4
|
+
and this module draws those. A program that drives the agent through the SDK reads the same events, so what the
|
|
5
|
+
terminal shows and what the SDK reports cannot drift apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from zett_agent.dispatcher import AgentEventDispatcher
|
|
14
|
+
from zett_agent.events import AgentEvent
|
|
15
|
+
|
|
16
|
+
from zettcode.engine.dialogue.ask import declined_answer
|
|
17
|
+
from zettcode.messaging.relayed import Relayed
|
|
18
|
+
from zettcode.plugin_api.entries import EntryStatus
|
|
19
|
+
from zettcode.sdk.events import (
|
|
20
|
+
AnyEvent,
|
|
21
|
+
ApprovalRequested,
|
|
22
|
+
CompactionDelta,
|
|
23
|
+
CompactionFinished,
|
|
24
|
+
CompactionStarted,
|
|
25
|
+
Finished,
|
|
26
|
+
ModelStopped,
|
|
27
|
+
Notice,
|
|
28
|
+
QuestionAsked,
|
|
29
|
+
ReasoningDelta,
|
|
30
|
+
ReasoningFinished,
|
|
31
|
+
ReasoningStarted,
|
|
32
|
+
ServerToolFinished,
|
|
33
|
+
ServerToolStarted,
|
|
34
|
+
Steered,
|
|
35
|
+
SteeringFinished,
|
|
36
|
+
SteeringInterrupted,
|
|
37
|
+
TextDelta,
|
|
38
|
+
ToolFinished,
|
|
39
|
+
ToolStarted,
|
|
40
|
+
UsageUpdated,
|
|
41
|
+
)
|
|
42
|
+
from zettcode.sdk.translation import Bridge, translate
|
|
43
|
+
from zettcode.sdk.types import Usage
|
|
44
|
+
|
|
45
|
+
from .stops import stop_text
|
|
46
|
+
from .transcript import Transcript
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _NobodyAnswers(Bridge):
|
|
50
|
+
"""The bridge of a projector that was given nobody to answer for: an answer sent through it goes nowhere."""
|
|
51
|
+
|
|
52
|
+
def approve(self, session_id: str, call_id: str, approve: bool, remember: bool) -> None:
|
|
53
|
+
raise RuntimeError("This projector has no agent to carry an approval to")
|
|
54
|
+
|
|
55
|
+
def answer(self, question: object, text: str | None) -> None:
|
|
56
|
+
raise RuntimeError("This projector has no agent to carry an answer to")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger("zettcode.terminal")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TranscriptProjector(AgentEventDispatcher):
|
|
63
|
+
"""Turn AgentClient callbacks into transcript blocks.
|
|
64
|
+
|
|
65
|
+
Every callback returns immediately. The approval callback in particular must
|
|
66
|
+
not await the user: the agent is already suspended waiting for the response
|
|
67
|
+
that the UI will emit later.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
transcript: Transcript,
|
|
73
|
+
*,
|
|
74
|
+
bridge: Bridge | None = None,
|
|
75
|
+
on_approval: Callable[[ApprovalRequested], None] | None = None,
|
|
76
|
+
on_ask: Callable[[QuestionAsked], None] | None = None,
|
|
77
|
+
on_usage: Callable[[Usage, str], None] | None = None,
|
|
78
|
+
on_steering_started: Callable[[str], Relayed | str | None] | None = None,
|
|
79
|
+
on_steering_interrupted: Callable[[str], None] | None = None,
|
|
80
|
+
side_running: Callable[[], bool] | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Send projected events to ``transcript``; approvals go to ``on_approval``.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
transcript: Model that receives the projected entries.
|
|
86
|
+
bridge: How an answer to an approval or a question reaches the suspended agent
|
|
87
|
+
(:class:`zettcode.sdk.translation.AgentBridge` over the shell's agent).
|
|
88
|
+
on_approval: Called synchronously for a shell approval request; the
|
|
89
|
+
callback must not await, because the agent is suspended until the
|
|
90
|
+
UI answers it later.
|
|
91
|
+
on_ask: Called synchronously with each question the model asks; the
|
|
92
|
+
callback must not await, for the same reason.
|
|
93
|
+
on_usage: Called synchronously with each cumulative usage update and the session it is for, so
|
|
94
|
+
the shell can refresh the status line without polling the store.
|
|
95
|
+
on_steering_started: Called with the text of a queued steering
|
|
96
|
+
message the agent just adopted, so the shell can drop it from
|
|
97
|
+
the pending queue before the row is echoed. What it returns, when
|
|
98
|
+
it is a :class:`~zettcode.messaging.relayed.Relayed`, is drawn as that session's
|
|
99
|
+
message — what its sender wrote, not the line the model reads; a
|
|
100
|
+
string is drawn as the row's text.
|
|
101
|
+
on_steering_interrupted: Called with the text of a steering message
|
|
102
|
+
a newer one superseded, so the shell can drop it too.
|
|
103
|
+
side_running: Asked whether the run in flight is a side question — a question the model answers without
|
|
104
|
+
the exchange joining the conversation. Such a run is drawn only when the shell began it, so a
|
|
105
|
+
plugin that asks one for itself (a recap) is not also drawn as an answer of its own. ``None``
|
|
106
|
+
treats every run as one the shell began. Two plain flags are enough here because the SDK runs one
|
|
107
|
+
request at a time: a second run is refused while one is in flight, so a plugin's ask cannot
|
|
108
|
+
overlap a turn the shell began and a stale answer cannot be drawn as a turn's.
|
|
109
|
+
"""
|
|
110
|
+
self.transcript = transcript
|
|
111
|
+
self.bridge: Bridge = bridge if bridge is not None else _NobodyAnswers()
|
|
112
|
+
self.on_approval = on_approval
|
|
113
|
+
self.on_ask = on_ask
|
|
114
|
+
self.on_usage = on_usage
|
|
115
|
+
self.on_steering_started = on_steering_started
|
|
116
|
+
self.on_steering_interrupted = on_steering_interrupted
|
|
117
|
+
#: What a hosted tool's input has streamed so far, by call. The translation fills it as fragments arrive
|
|
118
|
+
#: and reads the query out of it when the call reports back.
|
|
119
|
+
self._hosted: dict[str, str] = {}
|
|
120
|
+
#: Whether the shell began the run in flight, and so draws it.
|
|
121
|
+
self._begun = False
|
|
122
|
+
#: Whether a run nobody began has been reported as left out, so dropping one is a line and not one per
|
|
123
|
+
#: token; it is cleared when a turn is begun, so it reports the next run that way too.
|
|
124
|
+
self._reported_side = False
|
|
125
|
+
self._side_running = side_running
|
|
126
|
+
|
|
127
|
+
def begin_turn(self, prompt: str, *, side: bool = False, relayed: Relayed | None = None) -> None:
|
|
128
|
+
"""Begin drawing one run into the transcript, echoing the prompt that asked for it.
|
|
129
|
+
|
|
130
|
+
The shell begins a turn before every run it shows on screen, which is also how a side question it asked
|
|
131
|
+
is told apart from one a plugin asked for itself: see ``side_running``.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
prompt: What the reader asked, echoed as the turn's first row.
|
|
135
|
+
side: The question is a side one, which the transcript shows as such.
|
|
136
|
+
relayed: The turn answers a message another session sent, which the transcript shows as that.
|
|
137
|
+
"""
|
|
138
|
+
# The row first, the flag second: an echo that raises leaves no run to draw, and a flag set before it would
|
|
139
|
+
# stay set, drawing every later side question as an answer of its own.
|
|
140
|
+
if relayed is None:
|
|
141
|
+
self.transcript.begin_turn(prompt, side=side)
|
|
142
|
+
else:
|
|
143
|
+
self.transcript.begin_turn(prompt, side=side, relayed=relayed)
|
|
144
|
+
self._begun = True
|
|
145
|
+
self._reported_side = False
|
|
146
|
+
|
|
147
|
+
def end_turn(self) -> None:
|
|
148
|
+
"""Stop drawing, once the run the shell began has ended.
|
|
149
|
+
|
|
150
|
+
Nothing of whatever runs next is drawn until a turn is begun for it, which is what keeps a plugin's own
|
|
151
|
+
side question out of the transcript between turns.
|
|
152
|
+
"""
|
|
153
|
+
self._begun = False
|
|
154
|
+
|
|
155
|
+
async def dispatch(self, event: AgentEvent) -> None:
|
|
156
|
+
"""Note that the agent spoke, then draw what the event stands for.
|
|
157
|
+
|
|
158
|
+
Every event ends a silence, whichever it is: the waiting row counts how long it has been since the model
|
|
159
|
+
last said anything, and has to be told each time it does.
|
|
160
|
+
|
|
161
|
+
A side question the shell did not begin is left out: its answer would land under the last turn's line as a
|
|
162
|
+
reply nobody asked for, and whoever asked for it — a plugin's recap — draws the part it wants itself. The
|
|
163
|
+
drop is logged, a line rather than one per token, so an answer that is missing from the transcript can be
|
|
164
|
+
explained from the log.
|
|
165
|
+
"""
|
|
166
|
+
self.transcript.heard()
|
|
167
|
+
if not self._begun and self._side_running is not None and self._side_running():
|
|
168
|
+
if not self._reported_side:
|
|
169
|
+
self._reported_side = True
|
|
170
|
+
logger.debug("projector.side_not_drawn", extra={"session": event.session_id})
|
|
171
|
+
return
|
|
172
|
+
for translated in translate(event, self.bridge, self._hosted):
|
|
173
|
+
self.project(translated, event)
|
|
174
|
+
|
|
175
|
+
def project(self, event: AnyEvent, source: AgentEvent | None = None) -> None:
|
|
176
|
+
"""Draw one SDK event.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
event: The event, as the SDK reports it.
|
|
180
|
+
source: The agent event it came from, for the few details the SDK's event leaves out.
|
|
181
|
+
"""
|
|
182
|
+
transcript = self.transcript
|
|
183
|
+
match event:
|
|
184
|
+
case TextDelta(text=text):
|
|
185
|
+
# Closes reasoning before the first answer token lands in the transcript.
|
|
186
|
+
transcript.complete_thinking()
|
|
187
|
+
transcript.append_answer(text)
|
|
188
|
+
case ReasoningStarted():
|
|
189
|
+
transcript.start_thinking()
|
|
190
|
+
case ReasoningDelta(text=text):
|
|
191
|
+
transcript.append_thinking(text)
|
|
192
|
+
case ReasoningFinished():
|
|
193
|
+
transcript.complete_thinking()
|
|
194
|
+
case ToolStarted(call_id=call_id, name=name, arguments=arguments):
|
|
195
|
+
logger.debug("tool.start", extra={"tool": name, "call": call_id})
|
|
196
|
+
transcript.start_tool(call_id, name, arguments)
|
|
197
|
+
case ToolFinished(call_id=call_id, name=name, output=output, ok=ok, skipped=skipped):
|
|
198
|
+
logger.debug("tool.finish", extra={"tool": name, "call": call_id, "ok": ok, "skipped": skipped})
|
|
199
|
+
self._tool_finished(call_id, name, output, ok, skipped)
|
|
200
|
+
case ServerToolStarted(call_id=call_id, name=name, arguments=arguments):
|
|
201
|
+
transcript.start_tool(call_id, name, arguments)
|
|
202
|
+
case ServerToolFinished(call_id=call_id, arguments=arguments, output=output, ok=ok):
|
|
203
|
+
if arguments:
|
|
204
|
+
# The row opened before its input had streamed in; now it knows what was searched for.
|
|
205
|
+
transcript.restate_tool(call_id, arguments)
|
|
206
|
+
status = EntryStatus.COMPLETED if ok else EntryStatus.FAILED
|
|
207
|
+
transcript.complete_tool(call_id, output or "Completed", status=status)
|
|
208
|
+
case CompactionStarted():
|
|
209
|
+
transcript.start_compaction()
|
|
210
|
+
case CompactionDelta(text=text):
|
|
211
|
+
transcript.append_compaction(text)
|
|
212
|
+
case CompactionFinished(applied=applied):
|
|
213
|
+
transcript.complete_compaction()
|
|
214
|
+
transcript.notice(f"Context compaction {'applied' if applied else 'skipped'}")
|
|
215
|
+
case ModelStopped():
|
|
216
|
+
# The event follows the step's completion and is sent only for an abnormal ending, so the reply
|
|
217
|
+
# above it is whatever the model managed to write, and this line is what stands between it and
|
|
218
|
+
# being taken for an answer.
|
|
219
|
+
transcript.complete_thinking()
|
|
220
|
+
stop = source.stop if source is not None else None
|
|
221
|
+
if stop is not None and not stop.ok:
|
|
222
|
+
transcript.error(stop_text(stop))
|
|
223
|
+
case Steered(text=text):
|
|
224
|
+
adopted = self.on_steering_started(text) if self.on_steering_started is not None else None
|
|
225
|
+
if isinstance(adopted, Relayed):
|
|
226
|
+
transcript.begin_turn(adopted.text, relayed=adopted)
|
|
227
|
+
else:
|
|
228
|
+
transcript.begin_turn(adopted or text)
|
|
229
|
+
case SteeringFinished():
|
|
230
|
+
transcript.complete_thinking()
|
|
231
|
+
case SteeringInterrupted(text=text):
|
|
232
|
+
if self.on_steering_interrupted is not None:
|
|
233
|
+
self.on_steering_interrupted(text)
|
|
234
|
+
transcript.complete_thinking()
|
|
235
|
+
transcript.notice("steering superseded by a newer message")
|
|
236
|
+
case Finished():
|
|
237
|
+
transcript.complete_thinking()
|
|
238
|
+
case ApprovalRequested() if self.on_approval is not None:
|
|
239
|
+
self.on_approval(event)
|
|
240
|
+
case QuestionAsked() if self.on_ask is not None:
|
|
241
|
+
self.on_ask(event)
|
|
242
|
+
case UsageUpdated(session_id=session_id, usage=usage) if self.on_usage is not None:
|
|
243
|
+
self.on_usage(usage, session_id)
|
|
244
|
+
case Notice(text=text):
|
|
245
|
+
# News about an MCP server or its configuration. It is a remark in the conversation,
|
|
246
|
+
# not an error: the request goes on without whatever could not be reached.
|
|
247
|
+
transcript.notice(text)
|
|
248
|
+
case _:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
def _tool_finished(self, call_id: str, name: str, output: str, ok: bool, skipped: bool) -> None:
|
|
252
|
+
"""Attach a tool's ending to its row: skipped, failed, or done.
|
|
253
|
+
|
|
254
|
+
A question the reader cancelled comes back as a result, not a failure: the model reads that it was declined
|
|
255
|
+
and carries on. The row is marked skipped so it does not look like a question that was answered.
|
|
256
|
+
"""
|
|
257
|
+
if skipped:
|
|
258
|
+
self.transcript.complete_tool(call_id, output or "Skipped", status=EntryStatus.SKIPPED)
|
|
259
|
+
elif not ok:
|
|
260
|
+
self.transcript.complete_tool(call_id, output or "Tool failed", status=EntryStatus.FAILED)
|
|
261
|
+
else:
|
|
262
|
+
declined = name == "ask_user" and declined_answer(output)
|
|
263
|
+
self.transcript.complete_tool(
|
|
264
|
+
call_id, output or "Completed", status=EntryStatus.SKIPPED if declined else EntryStatus.COMPLETED
|
|
265
|
+
)
|