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,837 @@
|
|
|
1
|
+
"""Agent output, on its way into the transcript: a chain of render handlers.
|
|
2
|
+
|
|
3
|
+
Every kind of output the agent produces — a tool call, the reasoning channel,
|
|
4
|
+
the answer — is shaped by the first handler that claims it, so presentation
|
|
5
|
+
rules live in one place and a new one is a new link rather than an ``if`` inside
|
|
6
|
+
the transcript.
|
|
7
|
+
|
|
8
|
+
For tools the chain also decides the words: output should read like a log, not
|
|
9
|
+
like a payload, so a call shows as ``Read src/app.py`` rather than
|
|
10
|
+
``read_file {"path": "src/app.py"}``. Each handler claims a family of tools —
|
|
11
|
+
reading, editing, deleting, running a command, searching, the plan — and the
|
|
12
|
+
last one claims whatever is left, which keeps an unknown tool at
|
|
13
|
+
``name key=value`` instead of a JSON blob. A handler may shape the result too:
|
|
14
|
+
:class:`ReadTool` drops the vendor's continuation hint and names the scanner for
|
|
15
|
+
the file, so the row's body is highlighted as the language it is.
|
|
16
|
+
|
|
17
|
+
Text handlers work on the streamed deltas, not on whole messages: a reasoning
|
|
18
|
+
channel arrives in fragments, and so does an answer. That is why
|
|
19
|
+
:meth:`RenderHandler.text` is told whether the fragment opens a message — the
|
|
20
|
+
only place a chat preamble such as ``Assistant:`` can appear.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import difflib
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from collections.abc import Mapping, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from zettcode.plugin_api.entries import (
|
|
34
|
+
DeleteEntry,
|
|
35
|
+
EditEntry,
|
|
36
|
+
EntryStatus,
|
|
37
|
+
MessageEntry,
|
|
38
|
+
ReadEntry,
|
|
39
|
+
ToolEntry,
|
|
40
|
+
WriteEntry,
|
|
41
|
+
)
|
|
42
|
+
from zettcode.presenters.text import DECLINED_NOTE, limit_output
|
|
43
|
+
from zettcode.tui import ELLIPSIS, SEPARATOR
|
|
44
|
+
from zettcode.tui.render import GENERIC, language_for
|
|
45
|
+
|
|
46
|
+
#: Output kinds the chain shapes; a link claims one or more of them.
|
|
47
|
+
TOOL = "tool"
|
|
48
|
+
THINKING = "thinking"
|
|
49
|
+
ANSWER = "answer"
|
|
50
|
+
|
|
51
|
+
#: Suffixes that look like a picture, copied from the read tool's accepted set: a row is opened from the arguments,
|
|
52
|
+
#: before the result exists, so the suffix is all it has. The durable answer is a boolean on the finished event, taken
|
|
53
|
+
#: from the result's own content — see :func:`is_picture_path`.
|
|
54
|
+
PICTURE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_picture_path(path: str) -> bool:
|
|
58
|
+
"""Return whether ``path`` looks like a picture by its suffix.
|
|
59
|
+
|
|
60
|
+
A guess, and named like one: the read tool decides by the file's signature, so a PNG copied to ``.bin`` is counted
|
|
61
|
+
as text and a text file named ``notes.png`` is not counted. Both are a line about a row nobody is reading closely;
|
|
62
|
+
the exact answer needs the boolean the result will carry (see :data:`PICTURE_SUFFIXES`).
|
|
63
|
+
"""
|
|
64
|
+
return Path(path).suffix.lower() in PICTURE_SUFFIXES
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
#: The hint a bounded read leaves behind; it is advice for the model, not the
|
|
68
|
+
#: reader, and the row already shows the text it applies to.
|
|
69
|
+
CONTINUATION = re.compile(r"\n?\.\.\. \[(?:line truncated|output truncated|more lines)[^\]]*\]\s*\Z")
|
|
70
|
+
|
|
71
|
+
#: Wrapper tags a model may leave around its reasoning channel.
|
|
72
|
+
REASONING_TAGS = re.compile(r"</?(?:think(?:ing)?|reasoning)>|<\|(?:thinking|reasoning)\|>", re.IGNORECASE)
|
|
73
|
+
|
|
74
|
+
#: A chat preamble a locally hosted model sometimes repeats in front of an answer.
|
|
75
|
+
CHAT_PREAMBLE = re.compile(r"^\s*(?:assistant|ai)\s*[:\uff1a]\s*", re.IGNORECASE)
|
|
76
|
+
|
|
77
|
+
#: Escape sequences a model may echo from output it has read.
|
|
78
|
+
ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
#: One row of an edit's diff: ``("+", text)`` an addition, ``("-", text)`` a deletion, ``(" ", text)`` a line
|
|
82
|
+
#: kept for context, ``("@", label)`` the break between one edit and the next.
|
|
83
|
+
DiffRow = tuple[str, str]
|
|
84
|
+
|
|
85
|
+
#: Rows kept of one edit call's diff. The arguments are what the model sent and can be a whole file; the
|
|
86
|
+
#: row shows a handful and expands to a screenful, so what is stored only has to be more than that.
|
|
87
|
+
MAX_DIFF_ROWS = 400
|
|
88
|
+
|
|
89
|
+
#: Unchanged lines kept on each side of a change, so an edit reads in place without repeating the file.
|
|
90
|
+
DIFF_CONTEXT = 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class ToolRow:
|
|
95
|
+
"""How one tool call appears as a transcript row.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
title: The row's words, e.g. ``Read src/app.py``.
|
|
99
|
+
language: Scanner for the row's output when it is source code, so a
|
|
100
|
+
``read_file`` body is highlighted; ``None`` draws it plain.
|
|
101
|
+
|
|
102
|
+
This is only the words. What else a tool's row carries — the lines an edit changes, the change
|
|
103
|
+
itself — belongs to that tool's entry, which its handler builds in :meth:`RenderHandler.open_tool`.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
title: str
|
|
107
|
+
language: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RenderHandler(ABC):
|
|
111
|
+
"""One link of the chain: how one kind of output is shaped before it shows.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
kinds: Output kinds this link claims: ``"tool"``, ``"thinking"``, or
|
|
115
|
+
``"answer"``.
|
|
116
|
+
names: Tool names, for a tool link that claims only one family; an empty
|
|
117
|
+
set means the link claims every tool.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
kinds: frozenset[str] = frozenset()
|
|
121
|
+
names: frozenset[str] = frozenset()
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def handles(self, kind: str, name: str = "") -> bool:
|
|
125
|
+
"""Return whether this link claims one piece of output."""
|
|
126
|
+
|
|
127
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
128
|
+
"""Return the words of a tool row; only a tool link changes this."""
|
|
129
|
+
return ToolRow(name)
|
|
130
|
+
|
|
131
|
+
def open_tool(
|
|
132
|
+
self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
|
|
133
|
+
) -> ToolEntry:
|
|
134
|
+
"""Return the entry for one call as it starts: the class that carries what this tool shows.
|
|
135
|
+
|
|
136
|
+
The default is a plain :class:`ToolEntry` with the words :meth:`describe` gives it. A tool
|
|
137
|
+
whose row shows more has its own entry class and builds it here, so the transcript, which
|
|
138
|
+
opens every row the same way, never has to know which tool it is.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
name: The tool's name.
|
|
142
|
+
arguments: What the model called it with.
|
|
143
|
+
id: The row's identity in the transcript.
|
|
144
|
+
call_id: Identifier the matching result will carry.
|
|
145
|
+
started_at: Clock reading the row opens on.
|
|
146
|
+
"""
|
|
147
|
+
row = self.describe(name, arguments)
|
|
148
|
+
return ToolEntry(id=id, call_id=call_id, tool=name, title=row.title, started_at=started_at)
|
|
149
|
+
|
|
150
|
+
def body(self, name: str, output: str) -> str:
|
|
151
|
+
"""Return the text under a tool row; only a tool link changes this."""
|
|
152
|
+
return output
|
|
153
|
+
|
|
154
|
+
def finished(self, entry: ToolEntry, text: str, status: EntryStatus) -> None:
|
|
155
|
+
"""Adjust a row now that its result is in — a count, a size — and change nothing by default.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
entry: The row, whose body is already set and bounded.
|
|
159
|
+
text: The body as it came back, after :meth:`body` shaped it and before it was bounded.
|
|
160
|
+
status: Whether the call completed, failed or was skipped: a row may only count what worked.
|
|
161
|
+
"""
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def text(self, text: str, *, opening: bool) -> str:
|
|
165
|
+
"""Return one streamed fragment, unchanged unless a link shapes it.
|
|
166
|
+
|
|
167
|
+
A handler sees deltas, never a whole message, so its rule has to hold
|
|
168
|
+
for a fragment. ``opening`` marks the fragment that starts a message,
|
|
169
|
+
which is the only place something like a chat preamble can appear.
|
|
170
|
+
"""
|
|
171
|
+
return text
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ToolHandler(RenderHandler):
|
|
175
|
+
"""Base for the tool links: each claims one family of tool names."""
|
|
176
|
+
|
|
177
|
+
kinds = frozenset({TOOL})
|
|
178
|
+
|
|
179
|
+
def handles(self, kind: str, name: str = "") -> bool:
|
|
180
|
+
"""Claim one tool, or every tool when the link names none."""
|
|
181
|
+
return kind == TOOL and (not self.names or name in self.names)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Renderers:
|
|
185
|
+
"""The chain: the first link that claims a piece of output shapes it.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
handlers: Links in order, most specific first. The last link is expected
|
|
189
|
+
to claim every tool; text no link claims is shown exactly as it
|
|
190
|
+
arrived.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, handlers: Sequence[RenderHandler]) -> None:
|
|
194
|
+
self.handlers = tuple(handlers)
|
|
195
|
+
|
|
196
|
+
def _claim(self, kind: str, name: str = "") -> RenderHandler | None:
|
|
197
|
+
"""Return the first link that claims one piece of output."""
|
|
198
|
+
return next((handler for handler in self.handlers if handler.handles(kind, name)), None)
|
|
199
|
+
|
|
200
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
201
|
+
"""Return the row for one call, asking each link until one claims it."""
|
|
202
|
+
handler = self._claim(TOOL, name)
|
|
203
|
+
return ToolRow(name) if handler is None else handler.describe(name, arguments)
|
|
204
|
+
|
|
205
|
+
def open_tool(
|
|
206
|
+
self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
|
|
207
|
+
) -> ToolEntry:
|
|
208
|
+
"""Return the entry for one call, of the class its claiming link builds."""
|
|
209
|
+
handler = self._claim(TOOL, name)
|
|
210
|
+
if handler is None:
|
|
211
|
+
return ToolEntry(id=id, call_id=call_id, tool=name, title=name, started_at=started_at)
|
|
212
|
+
return handler.open_tool(name, arguments, id=id, call_id=call_id, started_at=started_at)
|
|
213
|
+
|
|
214
|
+
def body(self, name: str, output: str) -> str:
|
|
215
|
+
"""Return the body for one call's result, through the claiming link."""
|
|
216
|
+
handler = self._claim(TOOL, name)
|
|
217
|
+
return output if handler is None else handler.body(name, output)
|
|
218
|
+
|
|
219
|
+
def finished(self, entry: ToolEntry, text: str, status: EntryStatus) -> None:
|
|
220
|
+
"""Let the claiming link adjust a row once its result is in."""
|
|
221
|
+
handler = self._claim(TOOL, entry.tool)
|
|
222
|
+
if handler is not None:
|
|
223
|
+
handler.finished(entry, text, status)
|
|
224
|
+
|
|
225
|
+
def text(self, kind: str, text: str, *, opening: bool = False) -> str:
|
|
226
|
+
"""Return one streamed fragment of ``kind``, shaped by the claiming link."""
|
|
227
|
+
handler = self._claim(kind)
|
|
228
|
+
return text if handler is None else handler.text(text, opening=opening)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def argument(arguments: Mapping[str, object], key: str, default: str = "") -> str:
|
|
232
|
+
"""Return one argument as text, or ``default`` when it is missing or empty."""
|
|
233
|
+
value = arguments.get(key)
|
|
234
|
+
if isinstance(value, str) and value.strip():
|
|
235
|
+
return value.strip()
|
|
236
|
+
return default
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def edit_changes(arguments: Mapping[str, object]) -> tuple[tuple[str, str], ...]:
|
|
240
|
+
"""Return the text pairs an ``edit`` call replaces: one pair now, a list of them from an older transcript.
|
|
241
|
+
|
|
242
|
+
A call carries ``old_string`` and ``new_string``. A session stored before that shape gave the arguments an
|
|
243
|
+
``edits`` list, and it is read the same way so the row of an old transcript still shows its diff. Anything
|
|
244
|
+
that is not two strings is dropped rather than guessed at.
|
|
245
|
+
"""
|
|
246
|
+
legacy = arguments.get("edits")
|
|
247
|
+
if isinstance(legacy, Sequence) and not isinstance(legacy, str):
|
|
248
|
+
return tuple(
|
|
249
|
+
(edit["old_text"], edit["new_text"])
|
|
250
|
+
for edit in legacy
|
|
251
|
+
if isinstance(edit, Mapping)
|
|
252
|
+
and isinstance(edit.get("old_text"), str)
|
|
253
|
+
and isinstance(edit.get("new_text"), str)
|
|
254
|
+
)
|
|
255
|
+
old_string, new_string = arguments.get("old_string"), arguments.get("new_string")
|
|
256
|
+
if isinstance(old_string, str) and isinstance(new_string, str):
|
|
257
|
+
return ((old_string, new_string),)
|
|
258
|
+
return ()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def edited_lines(changes: Sequence[tuple[str, str]]) -> tuple[int, int]:
|
|
262
|
+
"""Return the lines an ``edit`` call adds and removes.
|
|
263
|
+
|
|
264
|
+
The tool answers ``Replaced 1 occurrence in app.py``, which says that a change landed but not how large it was:
|
|
265
|
+
a two-line fix and a two-hundred-line rewrite read the same. The call carries both texts, so the difference is
|
|
266
|
+
counted here — with :class:`difflib.SequenceMatcher` rather than by subtracting lengths, because a replacement
|
|
267
|
+
that keeps the lines around it should not read as a rewrite, and that is what a reader comparing two rows is
|
|
268
|
+
looking at.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
changes: The old and new text of each change, as :func:`edit_changes` reads them.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
``(added, removed)`` across every change; ``(0, 0)`` when the call carried nothing countable.
|
|
275
|
+
|
|
276
|
+
A change that replaces every occurrence counts once, not once per occurrence: how many the file holds is
|
|
277
|
+
something only the file knows, and the tool's own answer is where that number appears.
|
|
278
|
+
"""
|
|
279
|
+
added = removed = 0
|
|
280
|
+
for old_text, new_text in changes:
|
|
281
|
+
matcher = difflib.SequenceMatcher(None, old_text.splitlines(), new_text.splitlines())
|
|
282
|
+
for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes():
|
|
283
|
+
if tag in ("replace", "delete"):
|
|
284
|
+
removed += old_end - old_start
|
|
285
|
+
if tag in ("replace", "insert"):
|
|
286
|
+
added += new_end - new_start
|
|
287
|
+
return (added, removed)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def edit_diff(changes: Sequence[tuple[str, str]], *, limit: int = MAX_DIFF_ROWS) -> tuple[DiffRow, ...]:
|
|
291
|
+
"""Return the rows of the change an ``edit`` call makes.
|
|
292
|
+
|
|
293
|
+
Each change carries the text it replaces and the text it puts there, so the change can be shown
|
|
294
|
+
without reading the file: the lines that differ, and one unchanged line around each so it reads in
|
|
295
|
+
place. Changes are told apart by a labelled break when there is more than one — which only an older
|
|
296
|
+
transcript has — and what is kept is cut at ``limit`` rows with a last row saying so, because the
|
|
297
|
+
arguments can be a whole file.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
changes: The old and new text of each change, as :func:`edit_changes` reads them.
|
|
301
|
+
limit: Most rows to keep.
|
|
302
|
+
"""
|
|
303
|
+
rows: list[DiffRow] = []
|
|
304
|
+
for number, (old_text, new_text) in enumerate(changes, start=1):
|
|
305
|
+
if len(changes) > 1:
|
|
306
|
+
rows.append(("@", f"edit {number} of {len(changes)}"))
|
|
307
|
+
old_lines, new_lines = old_text.splitlines(), new_text.splitlines()
|
|
308
|
+
opcodes = difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes()
|
|
309
|
+
for index, (tag, old_start, old_end, new_start, new_end) in enumerate(opcodes):
|
|
310
|
+
if tag == "equal":
|
|
311
|
+
kept = old_lines[old_start:old_end]
|
|
312
|
+
first, last = index == 0, index == len(opcodes) - 1
|
|
313
|
+
if len(kept) > DIFF_CONTEXT * (2 - first - last):
|
|
314
|
+
head = [] if first else kept[:DIFF_CONTEXT]
|
|
315
|
+
tail = [] if last else kept[-DIFF_CONTEXT:]
|
|
316
|
+
rows.extend((" ", line) for line in head)
|
|
317
|
+
if not first and not last:
|
|
318
|
+
rows.append(("@", ELLIPSIS))
|
|
319
|
+
rows.extend((" ", line) for line in tail)
|
|
320
|
+
else:
|
|
321
|
+
rows.extend((" ", line) for line in kept)
|
|
322
|
+
continue
|
|
323
|
+
rows.extend(("-", line) for line in old_lines[old_start:old_end])
|
|
324
|
+
rows.extend(("+", line) for line in new_lines[new_start:new_end])
|
|
325
|
+
return _capped(rows, limit)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _capped(rows: list[DiffRow], limit: int) -> tuple[DiffRow, ...]:
|
|
329
|
+
"""Keep ``limit`` rows, and say how many were not kept."""
|
|
330
|
+
if len(rows) > limit:
|
|
331
|
+
rows = [*rows[:limit], ("@", f"{ELLIPSIS} {len(rows) - limit} more rows not kept")]
|
|
332
|
+
return tuple(rows)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def write_diff(content: object, *, limit: int = MAX_DIFF_ROWS) -> tuple[DiffRow, ...]:
|
|
336
|
+
"""Return the rows of the change a ``write_file`` call makes: every line of the file, added.
|
|
337
|
+
|
|
338
|
+
A write replaces the file, and what it replaced is not in the call, so nothing can be shown as
|
|
339
|
+
removed and nothing as unchanged; the new content is the change. A file past ``limit`` lines is
|
|
340
|
+
cut there with a last row saying so, and an empty or unreadable ``content`` is no change to show.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
content: The call's ``content`` argument as the model sent it.
|
|
344
|
+
limit: Most rows to keep.
|
|
345
|
+
"""
|
|
346
|
+
if not isinstance(content, str):
|
|
347
|
+
return ()
|
|
348
|
+
return _write_rows(content.splitlines(), limit)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _write_rows(lines: list[str], limit: int) -> tuple[DiffRow, ...]:
|
|
352
|
+
"""The kept lines as additions, without building a row for every line of a file that is too long."""
|
|
353
|
+
rows: list[DiffRow] = [("+", line) for line in lines[:limit]]
|
|
354
|
+
if len(lines) > limit:
|
|
355
|
+
rows.append(("@", f"{ELLIPSIS} {len(lines) - limit} more rows not kept"))
|
|
356
|
+
return tuple(rows)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def written_lines(content: object) -> int:
|
|
360
|
+
"""Return how many lines a ``write_file`` call writes: what the file will hold, not what it held.
|
|
361
|
+
|
|
362
|
+
The tool overwrites, and what it overwrote is not in the call, so this is the size of the new
|
|
363
|
+
file — which is the number a reader asks of a row that says it created one.
|
|
364
|
+
"""
|
|
365
|
+
return len(content.splitlines()) if isinstance(content, str) else 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def summarise(arguments: Mapping[str, object], *, limit: int = 48) -> str:
|
|
369
|
+
"""Return ``key=value`` pairs for a tool no handler claims.
|
|
370
|
+
|
|
371
|
+
The row is for a reader: no braces, no quotes around plain text, and a long
|
|
372
|
+
value is cut rather than wrapped. A structured value becomes its size, which
|
|
373
|
+
is the only part of it a row can say in words.
|
|
374
|
+
"""
|
|
375
|
+
parts: list[str] = []
|
|
376
|
+
for key, value in arguments.items():
|
|
377
|
+
collapsed = " ".join(_value_text(value).split())
|
|
378
|
+
parts.append(f"{key}={collapsed if len(collapsed) <= limit else collapsed[: limit - 1] + ELLIPSIS}")
|
|
379
|
+
return " ".join(parts)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _value_text(value: object) -> str:
|
|
383
|
+
"""Return one argument as words, never as JSON syntax."""
|
|
384
|
+
if isinstance(value, str):
|
|
385
|
+
return value
|
|
386
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
387
|
+
return str(value)
|
|
388
|
+
if isinstance(value, Mapping):
|
|
389
|
+
return f"{len(value)} fields"
|
|
390
|
+
if isinstance(value, Sequence):
|
|
391
|
+
return f"{len(value)} items"
|
|
392
|
+
return str(value)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def file_path(arguments: Mapping[str, object], default: str = "") -> str:
|
|
396
|
+
"""Return the path a file tool was called for.
|
|
397
|
+
|
|
398
|
+
``file_path`` is the argument now; a session stored before the rename carries ``path``, and is still shown the
|
|
399
|
+
file it changed rather than as an unknown one.
|
|
400
|
+
"""
|
|
401
|
+
return argument(arguments, "file_path") or argument(arguments, "path") or default
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def language_for_path(path: str) -> str | None:
|
|
405
|
+
"""Return the scanner for a file's contents, or ``None`` when it is not code."""
|
|
406
|
+
language = language_for(Path(path).suffix.lstrip("."))
|
|
407
|
+
return None if language == GENERIC else language
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class ReadTool(ToolHandler):
|
|
411
|
+
"""``read_file`` and ``read``: which file, and the scanner for its contents."""
|
|
412
|
+
|
|
413
|
+
names = frozenset({"read_file", "read"})
|
|
414
|
+
|
|
415
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
416
|
+
"""Show the file, and highlight the body as the language it is."""
|
|
417
|
+
path = file_path(arguments, "(unknown file)")
|
|
418
|
+
return ToolRow(f"Read {path}", language_for_path(path))
|
|
419
|
+
|
|
420
|
+
def open_tool(
|
|
421
|
+
self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
|
|
422
|
+
) -> ToolEntry:
|
|
423
|
+
"""A read row, which knows the language its body is in, and whether the file is a picture."""
|
|
424
|
+
row = self.describe(name, arguments)
|
|
425
|
+
path = file_path(arguments)
|
|
426
|
+
return ReadEntry(
|
|
427
|
+
id=id,
|
|
428
|
+
call_id=call_id,
|
|
429
|
+
tool=name,
|
|
430
|
+
title=row.title,
|
|
431
|
+
language=row.language,
|
|
432
|
+
picture=is_picture_path(path),
|
|
433
|
+
started_at=started_at,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def body(self, name: str, output: str) -> str:
|
|
437
|
+
"""Drop the continuation hint; the row bounds the text it applies to."""
|
|
438
|
+
return CONTINUATION.sub("", output).rstrip()
|
|
439
|
+
|
|
440
|
+
def finished(self, entry: ToolEntry, text: str, status: EntryStatus) -> None:
|
|
441
|
+
"""Count what the read returned, on the row, if it returned text and the call worked.
|
|
442
|
+
|
|
443
|
+
The count is of what came back — a range asked for is not the same number — and of the whole of it, not of
|
|
444
|
+
the preview the row keeps, so a read past the output limit is counted in full. A picture carries no lines,
|
|
445
|
+
and a failed call's body is the error message rather than the file, so neither is counted.
|
|
446
|
+
"""
|
|
447
|
+
if status is not EntryStatus.COMPLETED or not isinstance(entry, ReadEntry) or entry.picture:
|
|
448
|
+
return
|
|
449
|
+
entry.lines = len(text.splitlines())
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class WriteTool(ToolHandler):
|
|
453
|
+
"""``write_file``: the file being written."""
|
|
454
|
+
|
|
455
|
+
names = frozenset({"write_file"})
|
|
456
|
+
|
|
457
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
458
|
+
"""Show the file being written: the row is the call, so it says what the call does, not what it did."""
|
|
459
|
+
return ToolRow(f"Write {file_path(arguments, '(unknown file)')}")
|
|
460
|
+
|
|
461
|
+
def open_tool(
|
|
462
|
+
self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
|
|
463
|
+
) -> ToolEntry:
|
|
464
|
+
"""A write row, which says how many lines the file will hold and shows them as additions."""
|
|
465
|
+
content = arguments.get("content")
|
|
466
|
+
return WriteEntry(
|
|
467
|
+
id=id,
|
|
468
|
+
call_id=call_id,
|
|
469
|
+
tool=name,
|
|
470
|
+
title=self.describe(name, arguments).title,
|
|
471
|
+
path=file_path(arguments),
|
|
472
|
+
added=written_lines(content),
|
|
473
|
+
diff=write_diff(content),
|
|
474
|
+
started_at=started_at,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class EditTool(ToolHandler):
|
|
479
|
+
"""``edit``: the file, and the size of the one change the call makes.
|
|
480
|
+
|
|
481
|
+
A session stored before the tool was renamed calls it ``replace_in_file``, and is still shown the same way; one
|
|
482
|
+
stored before a call carried a single change gave it a list under ``edits``, and that list is read too.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
names = frozenset({"edit", "replace_in_file"})
|
|
486
|
+
|
|
487
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
488
|
+
"""Show the file; the size of the change is drawn beside the title, before it lands.
|
|
489
|
+
|
|
490
|
+
`+12 -3` beside the file says whether this is a tweak or a rewrite, which is the question a reader asks
|
|
491
|
+
the row. One change a call is why the title counts nothing: a count of one is noise on every row.
|
|
492
|
+
"""
|
|
493
|
+
return ToolRow(f"Edit {file_path(arguments, '(unknown file)')}")
|
|
494
|
+
|
|
495
|
+
def open_tool(
|
|
496
|
+
self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
|
|
497
|
+
) -> ToolEntry:
|
|
498
|
+
"""An edit row, which carries the lines it changes and the change itself.
|
|
499
|
+
|
|
500
|
+
The counts ride outside the title: they are drawn in the colours an addition and a
|
|
501
|
+
deletion get, which a title made of one string cannot be.
|
|
502
|
+
"""
|
|
503
|
+
changes = edit_changes(arguments)
|
|
504
|
+
added, removed = edited_lines(changes)
|
|
505
|
+
return EditEntry(
|
|
506
|
+
id=id,
|
|
507
|
+
call_id=call_id,
|
|
508
|
+
tool=name,
|
|
509
|
+
title=self.describe(name, arguments).title,
|
|
510
|
+
path=file_path(arguments),
|
|
511
|
+
added=added,
|
|
512
|
+
removed=removed,
|
|
513
|
+
diff=edit_diff(changes),
|
|
514
|
+
started_at=started_at,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class DeleteTool(ToolHandler):
|
|
519
|
+
"""``delete_file``: the file being removed."""
|
|
520
|
+
|
|
521
|
+
names = frozenset({"delete_file"})
|
|
522
|
+
|
|
523
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
524
|
+
"""Show the file being removed."""
|
|
525
|
+
return ToolRow(f"Deleted {file_path(arguments, '(unknown file)')}")
|
|
526
|
+
|
|
527
|
+
def open_tool(
|
|
528
|
+
self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
|
|
529
|
+
) -> ToolEntry:
|
|
530
|
+
"""A delete row, which knows which file went."""
|
|
531
|
+
return DeleteEntry(
|
|
532
|
+
id=id,
|
|
533
|
+
call_id=call_id,
|
|
534
|
+
tool=name,
|
|
535
|
+
title=self.describe(name, arguments).title,
|
|
536
|
+
path=file_path(arguments),
|
|
537
|
+
started_at=started_at,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class ShellTool(ToolHandler):
|
|
542
|
+
"""``bash``: the command itself, and the directory it runs in when the call names one."""
|
|
543
|
+
|
|
544
|
+
names = frozenset({"bash"})
|
|
545
|
+
|
|
546
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
547
|
+
"""Show the command, not a JSON copy of it, and where it runs when that is not the workspace."""
|
|
548
|
+
command = argument(arguments, "command", "(unknown command)")
|
|
549
|
+
where = argument(arguments, "work_dir")
|
|
550
|
+
return ToolRow(f"Bash {command}" + (f" {SEPARATOR} {where}" if where else ""))
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class SearchTool(ToolHandler):
|
|
554
|
+
"""``glob`` and ``grep``: what was looked for, and where."""
|
|
555
|
+
|
|
556
|
+
names = frozenset({"glob", "grep"})
|
|
557
|
+
|
|
558
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
559
|
+
"""Phrase a listing and a content search differently."""
|
|
560
|
+
pattern = argument(arguments, "pattern")
|
|
561
|
+
if name == "glob":
|
|
562
|
+
return ToolRow(f"Listed {pattern}" if pattern else "Listed files")
|
|
563
|
+
where = argument(arguments, "file_pattern") or argument(arguments, "path")
|
|
564
|
+
return ToolRow(f"Searched {pattern or 'the workspace'}" + (f" in {where}" if where else ""))
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
class WebSearchTool(ToolHandler):
|
|
568
|
+
"""``web_search``: a search the endpoint ran, so the row says what was searched for."""
|
|
569
|
+
|
|
570
|
+
names = frozenset({"web_search"})
|
|
571
|
+
|
|
572
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
573
|
+
"""Name the query; the call is the endpoint's, so nothing else of it is ours to show."""
|
|
574
|
+
query = argument(arguments, "query")
|
|
575
|
+
return ToolRow(f'Searched the web for "{query}"' if query else "Searched the web")
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class AskUserTool(ToolHandler):
|
|
579
|
+
"""``ask_user``: the one row that waited for a person, so it is named as a thing, not as a verb.
|
|
580
|
+
|
|
581
|
+
The body is what the person did, not the protocol: the tool answers with the payload the shell sent, and that
|
|
582
|
+
JSON is neither readable nor useful in a transcript.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
names = frozenset({"ask_user"})
|
|
586
|
+
|
|
587
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
588
|
+
"""Show the question that was put, under the name the shell gives that tool."""
|
|
589
|
+
question = argument(arguments, "question")
|
|
590
|
+
return ToolRow(f"Ask-User: {question}" if question else "Ask-User")
|
|
591
|
+
|
|
592
|
+
def body(self, name: str, output: str) -> str:
|
|
593
|
+
"""Say what the person did: declined, answered, or answered with a picture.
|
|
594
|
+
|
|
595
|
+
Anything that is not the answer payload is left exactly as it came, so a failure or another tool's result
|
|
596
|
+
is never rewritten by this row.
|
|
597
|
+
"""
|
|
598
|
+
payload = _ask_payload(output)
|
|
599
|
+
if payload is None:
|
|
600
|
+
return output
|
|
601
|
+
if payload.get("declined") is True:
|
|
602
|
+
return DECLINED_NOTE
|
|
603
|
+
answer = payload.get("answer")
|
|
604
|
+
if isinstance(answer, str) and answer.strip():
|
|
605
|
+
return f"You answered: {answer.strip()}"
|
|
606
|
+
if isinstance(payload.get("parts"), list) and payload["parts"]:
|
|
607
|
+
return "You answered with a picture."
|
|
608
|
+
return output
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _ask_payload(output: str) -> Mapping[str, object] | None:
|
|
612
|
+
"""Return the payload of an ``ask_user`` result, or ``None`` when the text is not one."""
|
|
613
|
+
try:
|
|
614
|
+
result = json.loads(output)
|
|
615
|
+
except ValueError:
|
|
616
|
+
return None
|
|
617
|
+
payload = result.get("payload") if isinstance(result, Mapping) else None
|
|
618
|
+
return payload if isinstance(payload, Mapping) else None
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class TodoTool(ToolHandler):
|
|
622
|
+
"""``todo_write``: a plan, so the number of steps is the story."""
|
|
623
|
+
|
|
624
|
+
names = frozenset({"todo_write"})
|
|
625
|
+
|
|
626
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
627
|
+
"""Say how much of the plan the call carries."""
|
|
628
|
+
todos = arguments.get("todos")
|
|
629
|
+
count = len(todos) if isinstance(todos, Sequence) and not isinstance(todos, str) else 0
|
|
630
|
+
if not count:
|
|
631
|
+
return ToolRow("Updated the plan")
|
|
632
|
+
return ToolRow(f"Updated {count} todo{'' if count == 1 else 's'}")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class SkillTool(ToolHandler):
|
|
636
|
+
"""``read_skill``: the skill whose instructions the call loads."""
|
|
637
|
+
|
|
638
|
+
names = frozenset({"read_skill"})
|
|
639
|
+
|
|
640
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
641
|
+
"""Show which skill is being read, as the catalog named it."""
|
|
642
|
+
return ToolRow(f"Read skill {argument(arguments, 'name', '(unknown skill)')}")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class TaskTool(ToolHandler):
|
|
646
|
+
"""``task``: work handed to a subagent, named by the profile that runs it."""
|
|
647
|
+
|
|
648
|
+
names = frozenset({"task"})
|
|
649
|
+
|
|
650
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
651
|
+
"""Say which subagent was chosen, and what it was asked to do."""
|
|
652
|
+
profile = argument(arguments, "subagent_type", "(unknown)")
|
|
653
|
+
summary = argument(arguments, "description")
|
|
654
|
+
title = f"Subagent {profile}"
|
|
655
|
+
return ToolRow(f"{title} {SEPARATOR} {summary}" if summary else title)
|
|
656
|
+
|
|
657
|
+
def body(self, name: str, output: str) -> str:
|
|
658
|
+
"""Show the child's report, not the envelope that carried it back."""
|
|
659
|
+
try:
|
|
660
|
+
payload = json.loads(output)
|
|
661
|
+
except (TypeError, ValueError):
|
|
662
|
+
return output
|
|
663
|
+
if isinstance(payload, Mapping) and isinstance(payload.get("content"), str):
|
|
664
|
+
return payload["content"]
|
|
665
|
+
return output
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
class MessageTool(ToolHandler):
|
|
672
|
+
"""``send_message``: a message to another session, kept whole so the row can show it."""
|
|
673
|
+
|
|
674
|
+
names = frozenset({"send_message"})
|
|
675
|
+
|
|
676
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
677
|
+
"""Say who it is for: a group as ``#name`` (everyone in it reads it), a session by name or the end of its id."""
|
|
678
|
+
group = argument(arguments, "group")
|
|
679
|
+
if group:
|
|
680
|
+
return ToolRow(f"Message to group #{group.lstrip('#')}")
|
|
681
|
+
return ToolRow(f"Message to {self._recipient(argument(arguments, 'to', '(unknown)'))}")
|
|
682
|
+
|
|
683
|
+
@staticmethod
|
|
684
|
+
def _recipient(to: str) -> str:
|
|
685
|
+
"""Return the recipient as a reader can use it: a name as it is, an id by its last characters."""
|
|
686
|
+
return f"session {to[-6:]}" if _UUID.fullmatch(to) else to
|
|
687
|
+
|
|
688
|
+
def open_tool(
|
|
689
|
+
self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
|
|
690
|
+
) -> ToolEntry:
|
|
691
|
+
"""A message row, which holds the text that was sent."""
|
|
692
|
+
message = arguments.get("message")
|
|
693
|
+
return MessageEntry(
|
|
694
|
+
id=id,
|
|
695
|
+
call_id=call_id,
|
|
696
|
+
tool=name,
|
|
697
|
+
title=self.describe(name, arguments).title,
|
|
698
|
+
to=argument(arguments, "to"),
|
|
699
|
+
group=argument(arguments, "group").lstrip("#"),
|
|
700
|
+
message=limit_output(message) if isinstance(message, str) else "",
|
|
701
|
+
started_at=started_at,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class HistoryTool(ToolHandler):
|
|
706
|
+
"""``message_history``: what was said before, with one session or in a group."""
|
|
707
|
+
|
|
708
|
+
names = frozenset({"message_history"})
|
|
709
|
+
|
|
710
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
711
|
+
"""Say whose history it reads: a group's as ``#name``, one session's by name or the end of its id."""
|
|
712
|
+
group = argument(arguments, "group")
|
|
713
|
+
if group:
|
|
714
|
+
return ToolRow(f"Read history of group #{group.lstrip('#')}")
|
|
715
|
+
session = argument(arguments, "session")
|
|
716
|
+
if session:
|
|
717
|
+
return ToolRow(f"Read history with {MessageTool._recipient(session)}")
|
|
718
|
+
return ToolRow("Read message history")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class GroupTool(ToolHandler):
|
|
722
|
+
"""``manage_group``: make or change a group chat."""
|
|
723
|
+
|
|
724
|
+
names = frozenset({"manage_group"})
|
|
725
|
+
|
|
726
|
+
_VERBS = {"create": "Create", "add": "Add to", "remove": "Remove from", "leave": "Leave", "delete": "Delete"}
|
|
727
|
+
|
|
728
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
729
|
+
"""Say what is done to which group, and with whom."""
|
|
730
|
+
verb = self._VERBS.get(argument(arguments, "action"), "Change")
|
|
731
|
+
members = argument(arguments, "members")
|
|
732
|
+
with_ = f" with {members}" if members and verb in ("Create", "Add to", "Remove from") else ""
|
|
733
|
+
return ToolRow(f"{verb} group #{argument(arguments, 'group').lstrip('#') or '(unknown)'}{with_}")
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class NamespacedTool(ToolHandler):
|
|
737
|
+
"""A tool whose name carries where it came from: ``server__tool``, so the row names the server.
|
|
738
|
+
|
|
739
|
+
The server is part of what the call was: the same ``search`` in two servers is two different tools, with
|
|
740
|
+
different reach and different answers, and a row that drops the server makes them look like one.
|
|
741
|
+
|
|
742
|
+
The name is all a renderer knows, so the row says what the name says — ``github/create_issue`` — and not that
|
|
743
|
+
the tool speaks MCP: a plugin registers a namespaced name of its own with the same shape, and the runtime does
|
|
744
|
+
not mark its remote tools here. A name without both parts is shown as it arrived.
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
def handles(self, kind: str, name: str = "") -> bool:
|
|
748
|
+
"""Claim a namespaced name that no earlier link claimed."""
|
|
749
|
+
return kind == TOOL and "__" in name.strip("_") and not name.startswith("__")
|
|
750
|
+
|
|
751
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
752
|
+
"""Show ``server/tool``, so the row names where the capability came from.
|
|
753
|
+
|
|
754
|
+
The runtime names a remote tool ``<server>__<tool>``; a name that prefixed the family with ``mcp`` —
|
|
755
|
+
``mcp__github__create_issue`` — is read the same way, and one without both parts keeps its own name.
|
|
756
|
+
"""
|
|
757
|
+
remote = name[5:] if name.startswith("mcp__") else name
|
|
758
|
+
server, _, tool = remote.partition("__")
|
|
759
|
+
label = f"{server}/{tool}" if server and tool else name
|
|
760
|
+
return ToolRow(f"{label} {summarise(arguments)}".rstrip())
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
class FallbackTool(ToolHandler):
|
|
764
|
+
"""Anything no other link claims: its name and flat arguments, never JSON."""
|
|
765
|
+
|
|
766
|
+
def handles(self, kind: str, name: str = "") -> bool:
|
|
767
|
+
"""Claim every call the earlier links passed over."""
|
|
768
|
+
return kind == TOOL
|
|
769
|
+
|
|
770
|
+
def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
|
|
771
|
+
"""Show ``name key=value`` so an unknown tool still reads as a sentence."""
|
|
772
|
+
return ToolRow(f"{name} {summarise(arguments)}".rstrip())
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class ReasoningHandler(RenderHandler):
|
|
776
|
+
"""``thinking``: the reasoning channel, without the wrappers providers add.
|
|
777
|
+
|
|
778
|
+
A model may open and close its reasoning with tags of its own, or echo an
|
|
779
|
+
escape sequence it read in a tool result; neither belongs in the transcript,
|
|
780
|
+
and both are removed per fragment so a half-streamed tag never shows.
|
|
781
|
+
"""
|
|
782
|
+
|
|
783
|
+
kinds = frozenset({THINKING})
|
|
784
|
+
|
|
785
|
+
def handles(self, kind: str, name: str = "") -> bool:
|
|
786
|
+
"""Claim the reasoning channel."""
|
|
787
|
+
return kind == THINKING
|
|
788
|
+
|
|
789
|
+
def text(self, text: str, *, opening: bool) -> str:
|
|
790
|
+
"""Strip provider tags and escapes from one reasoning fragment."""
|
|
791
|
+
return ANSI.sub("", REASONING_TAGS.sub("", text))
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
class AnswerHandler(RenderHandler):
|
|
795
|
+
"""``answer``: the assistant's own text, without a chat preamble.
|
|
796
|
+
|
|
797
|
+
A locally hosted model sometimes repeats ``Assistant:`` before answering;
|
|
798
|
+
the transcript is already the assistant, so it is dropped — but only in the
|
|
799
|
+
fragment that opens the message, because that is the only place a preamble
|
|
800
|
+
can be, and a later fragment may legitimately start with the same word.
|
|
801
|
+
"""
|
|
802
|
+
|
|
803
|
+
kinds = frozenset({ANSWER})
|
|
804
|
+
|
|
805
|
+
def handles(self, kind: str, name: str = "") -> bool:
|
|
806
|
+
"""Claim the answer channel."""
|
|
807
|
+
return kind == ANSWER
|
|
808
|
+
|
|
809
|
+
def text(self, text: str, *, opening: bool) -> str:
|
|
810
|
+
"""Strip escapes always, and a chat preamble when the message opens."""
|
|
811
|
+
cleaned = ANSI.sub("", text)
|
|
812
|
+
return CHAT_PREAMBLE.sub("", cleaned, count=1) if opening else cleaned
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
#: The chain the application renders with, most specific handler first.
|
|
816
|
+
DEFAULT_RENDERERS = Renderers(
|
|
817
|
+
(
|
|
818
|
+
ReadTool(),
|
|
819
|
+
WriteTool(),
|
|
820
|
+
EditTool(),
|
|
821
|
+
DeleteTool(),
|
|
822
|
+
ShellTool(),
|
|
823
|
+
SearchTool(),
|
|
824
|
+
WebSearchTool(),
|
|
825
|
+
AskUserTool(),
|
|
826
|
+
TodoTool(),
|
|
827
|
+
SkillTool(),
|
|
828
|
+
TaskTool(),
|
|
829
|
+
MessageTool(),
|
|
830
|
+
HistoryTool(),
|
|
831
|
+
GroupTool(),
|
|
832
|
+
NamespacedTool(),
|
|
833
|
+
FallbackTool(),
|
|
834
|
+
ReasoningHandler(),
|
|
835
|
+
AnswerHandler(),
|
|
836
|
+
)
|
|
837
|
+
)
|