zettcode-engine 0.3.4__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/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""What is said in the conversation besides the turns themselves.
|
|
2
|
+
|
|
3
|
+
``mentions`` are the resources a draft can pull in with ``@``, ``ask`` is the model
|
|
4
|
+
asking the reader a question, ``side`` is a question that never joins the
|
|
5
|
+
conversation, and ``title`` is the small call that names a session.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The ``ask_user`` protocol: what the model asked, and what answers it.
|
|
2
|
+
|
|
3
|
+
``zett-agent`` owns the tool, the suspension and the routing; this module owns
|
|
4
|
+
the little the shell and the agent have to agree on — the question parsed out of
|
|
5
|
+
the event payload, and the payloads that answer it. Keeping both here means the
|
|
6
|
+
panel never reads a raw payload and the agent never invents one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from zett_agent.events import AgentEvent
|
|
16
|
+
from zett_agent.extensions.ask_user import ASK_USER_EVENT_NAME, ASK_USER_RESPONSE_EVENT_NAME
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ASK_USER_RESPONSE_EVENT_NAME",
|
|
20
|
+
"AskUserQuestion",
|
|
21
|
+
"answer_payload",
|
|
22
|
+
"decline_payload",
|
|
23
|
+
"declined_answer",
|
|
24
|
+
"question_from",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class AskUserQuestion:
|
|
30
|
+
"""One question the model is waiting on.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
session_id: Session the question belongs to, echoed back with the answer.
|
|
34
|
+
call_id: Tool call the answer resolves; the agent routes on it.
|
|
35
|
+
question: The question, trimmed.
|
|
36
|
+
options: Choices the model offered, in display order; empty when it
|
|
37
|
+
offered none.
|
|
38
|
+
allow_multiple: Whether more than one option may be chosen.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
session_id: str
|
|
42
|
+
call_id: str
|
|
43
|
+
question: str
|
|
44
|
+
options: tuple[str, ...] = ()
|
|
45
|
+
allow_multiple: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _options(value: object) -> tuple[str, ...]:
|
|
49
|
+
"""Return the usable choices in a payload field, dropping anything else."""
|
|
50
|
+
if not isinstance(value, list):
|
|
51
|
+
return ()
|
|
52
|
+
return tuple(item.strip() for item in value if isinstance(item, str) and item.strip())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def question_from(event: AgentEvent) -> AskUserQuestion | None:
|
|
56
|
+
"""Return the question an event carries, or ``None`` when it is not one.
|
|
57
|
+
|
|
58
|
+
The payload comes from the runtime, but a malformed one must not take the
|
|
59
|
+
shell down: it is read defensively and reported as "not a question".
|
|
60
|
+
"""
|
|
61
|
+
if event.name != ASK_USER_EVENT_NAME:
|
|
62
|
+
return None
|
|
63
|
+
payload = event.payload if isinstance(event.payload, dict) else {}
|
|
64
|
+
question = payload.get("question")
|
|
65
|
+
call_id = payload.get("tool_call_id")
|
|
66
|
+
if not isinstance(question, str) or not question.strip():
|
|
67
|
+
return None
|
|
68
|
+
if not isinstance(call_id, str) or not call_id:
|
|
69
|
+
return None
|
|
70
|
+
session_id = payload.get("session_id") or event.session_id
|
|
71
|
+
return AskUserQuestion(
|
|
72
|
+
session_id=str(session_id or ""),
|
|
73
|
+
call_id=call_id,
|
|
74
|
+
question=question.strip(),
|
|
75
|
+
options=_options(payload.get("options")),
|
|
76
|
+
allow_multiple=bool(payload.get("allow_multiple")),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def answer_payload(question: AskUserQuestion, answer: str) -> dict[str, Any]:
|
|
81
|
+
"""Return the response payload for a question the reader answered."""
|
|
82
|
+
return {"tool_call_id": question.call_id, "answer": answer}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decline_payload(question: AskUserQuestion) -> dict[str, Any]:
|
|
86
|
+
"""Return the response payload for a question the reader cancelled.
|
|
87
|
+
|
|
88
|
+
``ask_user`` has no rejection channel: the tool returns whatever payload the
|
|
89
|
+
UI sends. A cancel therefore answers with a result that says the question was
|
|
90
|
+
declined — the model reads that and carries on, instead of a suspended call
|
|
91
|
+
waiting for a reply that is never coming.
|
|
92
|
+
"""
|
|
93
|
+
return {
|
|
94
|
+
"tool_call_id": question.call_id,
|
|
95
|
+
"answer": None,
|
|
96
|
+
"declined": True,
|
|
97
|
+
"reason": "the user cancelled the question",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def declined_answer(text: str) -> bool:
|
|
102
|
+
"""Return whether a tool result is the one a cancelled question leaves behind.
|
|
103
|
+
|
|
104
|
+
The tool answers with the payload the shell sent, serialized as JSON. A cancelled question carries
|
|
105
|
+
``"declined": true`` and no answer; a row that has one is not a row that failed, it is a question nobody
|
|
106
|
+
needed to answer, and the transcript says so.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
result = json.loads(text)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
payload = result.get("payload") if isinstance(result, dict) else None
|
|
113
|
+
return isinstance(payload, dict) and payload.get("declined") is True
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Side questions: answers that never join the conversation.
|
|
2
|
+
|
|
3
|
+
A side question — "btw, what does this function do?" — runs in the same session,
|
|
4
|
+
so the model sees the conversation it is about, but nothing it produces joins it.
|
|
5
|
+
The store writes every message of that request with ``include_in_messages=False``,
|
|
6
|
+
the flag the runtime already defines as "record it, never replay it". Nothing has
|
|
7
|
+
to be undone in memory afterwards: the next request rebuilds its context from the
|
|
8
|
+
stored branch.
|
|
9
|
+
|
|
10
|
+
The tools are reading ones for the same reason. An edit made while asking a side
|
|
11
|
+
question would be invisible to the conversation that continues after it, so the
|
|
12
|
+
model would carry on with a false picture of the workspace.
|
|
13
|
+
|
|
14
|
+
Searching is one of them. The main agent searches with its shell and has no ``glob`` or ``grep``, but a side
|
|
15
|
+
question has no shell, so it is given the two search tools here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from zett_agent.agent import AgentRunContext
|
|
24
|
+
from zett_agent.extensions.base import AgentExtension
|
|
25
|
+
from zett_agent.messages import UserMessage
|
|
26
|
+
|
|
27
|
+
#: Attribute marking the message a side question is asked as.
|
|
28
|
+
SIDE = "side"
|
|
29
|
+
|
|
30
|
+
#: Attribute (and stored tag) saying who asked a side question: ``/btw``, or a plugin's own ask.
|
|
31
|
+
ORIGIN = "origin"
|
|
32
|
+
|
|
33
|
+
#: The origin of a side question the reader asked, which is the one a resumed transcript restores by default.
|
|
34
|
+
READER = "reader"
|
|
35
|
+
|
|
36
|
+
#: Attribute (and stored tag) saying the reader watched the exchange on screen.
|
|
37
|
+
WATCHED = "watched"
|
|
38
|
+
|
|
39
|
+
#: Tools a side question may use: the ones that only read.
|
|
40
|
+
READ_ONLY_TOOLS = frozenset({"read", "read_file", "glob", "grep", "read_skill", "list_sessions", "message_history"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_reader(origin: object) -> bool:
|
|
44
|
+
"""Return whether an origin names the reader, whose side questions are the ones a front end draws."""
|
|
45
|
+
return origin == READER
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def question(text: str, *, origin: str = READER, watched: bool | None = None) -> UserMessage:
|
|
49
|
+
"""Return the message a side question is asked as.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text: The question.
|
|
53
|
+
origin: Who asked it — :data:`READER` for the reader's own ``/btw``, or the name of the plugin asking for
|
|
54
|
+
itself (``"recap"``). It is written down with the exchange, and a resumed transcript restores only what the
|
|
55
|
+
reader asked.
|
|
56
|
+
watched: Whether a reopened transcript restores this exchange. Left out, it follows ``origin``: a question the
|
|
57
|
+
reader asked comes back, a plugin's own does not. It says nothing about a front end drawing the exchange
|
|
58
|
+
now — a shell still drops a side question it did not begin — so setting it for something never drawn
|
|
59
|
+
restores a row nobody saw.
|
|
60
|
+
"""
|
|
61
|
+
return UserMessage(
|
|
62
|
+
content=text,
|
|
63
|
+
attributes={SIDE: True, ORIGIN: origin, WATCHED: watched_by(origin, watched)},
|
|
64
|
+
include_in_messages=False,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_side(message: object) -> bool:
|
|
69
|
+
"""Return whether a message is a side question."""
|
|
70
|
+
return isinstance(message, UserMessage) and bool(getattr(message, "attributes", {}).get(SIDE))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _watched(values: Mapping[str, Any]) -> bool:
|
|
74
|
+
"""Return whether a side question's attributes — or a stored line's tags — say the reader watched it.
|
|
75
|
+
|
|
76
|
+
``watched`` is the answer, and it is written beside the origin on every new run, so it is what a stored exchange is
|
|
77
|
+
read from. ``origin`` is not consulted here: it says who asked, for whoever reads the file later, and the two are
|
|
78
|
+
written together, so a file that carries one without the other was not written by this program.
|
|
79
|
+
|
|
80
|
+
Without ``watched`` the answer is no. That is the conservative reading, because the two errors are not equal:
|
|
81
|
+
dropping an aside the reader saw costs a line of context, while restoring one they did not ask puts the model's own
|
|
82
|
+
instructions in their mouth. Files from before both tags hold the recap's asks and the reader's ``/btw`` looking
|
|
83
|
+
alike, and the only thing that asked on its own back then was the recap, so those are the lines this keeps off the
|
|
84
|
+
screen; a ``/btw`` from such a file goes with them, which is the price of not keeping the recap's words.
|
|
85
|
+
"""
|
|
86
|
+
return bool(values.get(WATCHED, False))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def watched_by(origin: str, watched: bool | None) -> bool:
|
|
90
|
+
"""Return whether a side question asked as ``origin`` was watched, when the caller left it out.
|
|
91
|
+
|
|
92
|
+
A question the reader asked is restored; a plugin's own is not. This is the one place the rule is written: the
|
|
93
|
+
message and the request that carries it both come through here.
|
|
94
|
+
"""
|
|
95
|
+
return is_reader(origin) if watched is None else watched
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_watched_side(message: object) -> bool:
|
|
99
|
+
"""Return whether a side question's message says the reader watched it, False for anything that is not one."""
|
|
100
|
+
if not is_side(message):
|
|
101
|
+
return False
|
|
102
|
+
# ``is_side`` read ``attributes.get`` a moment ago, so a side message always holds a mapping here; the default is
|
|
103
|
+
# for the type checker, which sees only ``object``.
|
|
104
|
+
return _watched(getattr(message, "attributes", {}))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_side_run(context: AgentRunContext) -> bool:
|
|
108
|
+
"""Return whether the request being prepared is a side question."""
|
|
109
|
+
# Read defensively: a request that never carried an input — a compaction pass
|
|
110
|
+
# over a restored session, or a test's stand-in context — is not a side one.
|
|
111
|
+
return is_side(getattr(context, "input_message", None))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_side_line(line: object) -> bool:
|
|
115
|
+
"""Return whether a stored line belongs to a side question.
|
|
116
|
+
|
|
117
|
+
The store tags every line of that request, so a reader that walks the branch
|
|
118
|
+
— the replay a resumed session builds, the HTML trace an export writes — can
|
|
119
|
+
tell a side exchange from an ordinary one. The message's own
|
|
120
|
+
``include_in_messages`` flag would not do: instructions carry it too, and
|
|
121
|
+
those are exactly what a trace wants to show.
|
|
122
|
+
"""
|
|
123
|
+
tags = getattr(line, "tags", None)
|
|
124
|
+
return isinstance(tags, Mapping) and bool(tags.get(SIDE))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_watched_side_line(line: object) -> bool:
|
|
128
|
+
"""Return whether a stored line belongs to a side question the reader watched on screen.
|
|
129
|
+
|
|
130
|
+
The run is tagged with what the caller said when it asked, and ``watched`` alone is read back: a question a plugin
|
|
131
|
+
asked for itself stays out of a reopened transcript, so the model's instructions to itself do not come back as the
|
|
132
|
+
reader's words. ``origin`` sits beside it for whoever reads the file, and a line carrying neither tag is not
|
|
133
|
+
restored either — see :func:`_watched` for why that is the cheaper error. What a front end draws *live* is a
|
|
134
|
+
separate decision, taken where the run is begun (``app.ui.app``), not here.
|
|
135
|
+
"""
|
|
136
|
+
if not is_side_line(line):
|
|
137
|
+
return False
|
|
138
|
+
# ``is_side_line`` returned True, so the line carries tags.
|
|
139
|
+
return _watched(line.tags) # type: ignore[attr-defined]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SideQuestions(AgentExtension):
|
|
143
|
+
"""Keep one side question's tools to reading.
|
|
144
|
+
|
|
145
|
+
The shell marks the run it is about to start, because the hook that registers
|
|
146
|
+
tools runs before the question itself is known. Pruning there rather than
|
|
147
|
+
later matters twice over: the tool list is rebuilt for every request, and the
|
|
148
|
+
guidance a prompt carries is written from that same list, so a write tool
|
|
149
|
+
dropped now is never described to the model either.
|
|
150
|
+
|
|
151
|
+
The question itself is a second signal, checked before every turn: it prunes
|
|
152
|
+
the same tools if the mark was not set, which keeps a side question read-only
|
|
153
|
+
even when something else starts it.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
#: After the coding bundle, which is what registers the tools being filtered.
|
|
157
|
+
priority = 120
|
|
158
|
+
|
|
159
|
+
def __init__(self) -> None:
|
|
160
|
+
"""Start with no side question pending."""
|
|
161
|
+
self._pending = False
|
|
162
|
+
|
|
163
|
+
def begin(self) -> None:
|
|
164
|
+
"""Mark the run that is about to start as a side question."""
|
|
165
|
+
self._pending = True
|
|
166
|
+
|
|
167
|
+
def end(self) -> None:
|
|
168
|
+
"""Forget the mark, so the next run is an ordinary one."""
|
|
169
|
+
self._pending = False
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def pending(self) -> bool:
|
|
173
|
+
"""Return whether a side question is the run in flight."""
|
|
174
|
+
return self._pending
|
|
175
|
+
|
|
176
|
+
async def on_tool(self, context: AgentRunContext) -> None:
|
|
177
|
+
"""Offer a side question the read tools only."""
|
|
178
|
+
if self._pending:
|
|
179
|
+
self._read_only(context)
|
|
180
|
+
|
|
181
|
+
async def before_turn(self, context: AgentRunContext) -> None:
|
|
182
|
+
"""Prune again for a question that carried its own mark."""
|
|
183
|
+
if is_side_run(context):
|
|
184
|
+
self._read_only(context)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _read_only(context: AgentRunContext) -> None:
|
|
188
|
+
"""Drop every registered tool that is not one of the reading ones, and make sure the two search tools are there."""
|
|
189
|
+
# Imported here: the tools bring the schema library, which a request that is not a side question never needs.
|
|
190
|
+
from ..tools.files import glob, grep
|
|
191
|
+
|
|
192
|
+
for name in tuple(context.tools):
|
|
193
|
+
if name not in READ_ONLY_TOOLS:
|
|
194
|
+
context.tools.pop(name, None)
|
|
195
|
+
for search in (glob, grep):
|
|
196
|
+
if search.name not in context.tools:
|
|
197
|
+
context.register_tool(search)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Session titles: one small model call that names the first exchange.
|
|
2
|
+
|
|
3
|
+
A title is a nicety, not conversation data: it never enters the message history
|
|
4
|
+
and it is produced by a single provider request instead of another agent turn,
|
|
5
|
+
so no tools, hooks, or extra session records are involved.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from zett_agent.messages import SystemMessage, UserMessage
|
|
13
|
+
from zett_agent.model import AgentModel, ModelEventType, ModelRequest, ReasoningEffort
|
|
14
|
+
|
|
15
|
+
#: Most characters a stored title may use, matching the store's own guard.
|
|
16
|
+
MAX_TITLE_CHARS = 60
|
|
17
|
+
#: Most characters of each side of the exchange sent to the model.
|
|
18
|
+
MAX_EXCERPT_CHARS = 1200
|
|
19
|
+
|
|
20
|
+
INSTRUCTIONS = (
|
|
21
|
+
"You name conversations. Reply with a single short title of at most six words for the "
|
|
22
|
+
"exchange below, in the language the user wrote in. Reply with the title only: no quotes, "
|
|
23
|
+
"no markdown, no trailing period."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#: Decoration a model wraps a title in: bullets, headings, or a list number.
|
|
27
|
+
_LEADING_MARKER = re.compile(r"^(?:[-*\u2022\u25aa>#]+\s*|\d+[.)]\s+)+")
|
|
28
|
+
#: Trailing punctuation that only marks the end of a sentence.
|
|
29
|
+
_TRAILING = ".:;,\u3002"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def summarize_title(
|
|
33
|
+
model: AgentModel,
|
|
34
|
+
*,
|
|
35
|
+
question: str,
|
|
36
|
+
answer: str,
|
|
37
|
+
effort: ReasoningEffort = ReasoningEffort.LOW,
|
|
38
|
+
) -> str | None:
|
|
39
|
+
"""Return a short title for the first exchange, or ``None`` when there is none.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
model: Model to ask; the request carries no tools, so the provider's own
|
|
43
|
+
retry policy applies and no agent turn or session record is created.
|
|
44
|
+
question: The user's first message.
|
|
45
|
+
answer: The assistant's reply to it.
|
|
46
|
+
effort: How hard the model thinks about it. Cheap by default; the caller moves it to a
|
|
47
|
+
level the model takes, because an endpoint that rejects one fails the request.
|
|
48
|
+
"""
|
|
49
|
+
request = ModelRequest(
|
|
50
|
+
messages=[
|
|
51
|
+
SystemMessage(content=INSTRUCTIONS),
|
|
52
|
+
UserMessage(content=f"User: {excerpt(question)}\n\nAssistant: {excerpt(answer)}"),
|
|
53
|
+
],
|
|
54
|
+
reasoning_effort=effort,
|
|
55
|
+
)
|
|
56
|
+
reply = ""
|
|
57
|
+
async for event in model.stream(request):
|
|
58
|
+
if event.type is ModelEventType.TEXT_DELTA:
|
|
59
|
+
reply += event.delta
|
|
60
|
+
return clean_title(reply)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def excerpt(text: str) -> str:
|
|
64
|
+
"""Collapse one side of the exchange to a single bounded paragraph."""
|
|
65
|
+
return " ".join(text.split())[:MAX_EXCERPT_CHARS]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def clean_title(reply: str) -> str | None:
|
|
69
|
+
"""Reduce a model reply to one title line, or ``None`` when nothing is left.
|
|
70
|
+
|
|
71
|
+
Models answer with a bullet, a heading, backticks, or a quoted sentence even
|
|
72
|
+
when told not to, so the first non-empty line is unwrapped before the length
|
|
73
|
+
cap is applied. A reply that is only punctuation leaves no title at all.
|
|
74
|
+
"""
|
|
75
|
+
for line in reply.splitlines():
|
|
76
|
+
cleaned = _LEADING_MARKER.sub("", line.strip())
|
|
77
|
+
cleaned = cleaned.strip("`*_# \t").strip("\"'").strip().rstrip(_TRAILING).strip()
|
|
78
|
+
if cleaned:
|
|
79
|
+
return cleaned[:MAX_TITLE_CHARS].strip() or None
|
|
80
|
+
return None
|