zettcode-sdk 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/daemon/__init__.py +17 -0
- zettcode/daemon/__main__.py +10 -0
- zettcode/daemon/cli.py +693 -0
- zettcode/daemon/companions.py +63 -0
- zettcode/daemon/process.py +559 -0
- zettcode/daemon/run.py +678 -0
- zettcode/daemon/state.py +356 -0
- zettcode/doctor.py +167 -0
- zettcode/mentions.py +320 -0
- zettcode/sdk/__init__.py +195 -0
- zettcode/sdk/builtins.py +608 -0
- zettcode/sdk/client.py +1810 -0
- zettcode/sdk/commands.py +9 -0
- zettcode/sdk/daemons.py +244 -0
- zettcode/sdk/errors.py +132 -0
- zettcode/sdk/events.py +434 -0
- zettcode/sdk/extensions/__init__.py +6 -0
- zettcode/sdk/extensions/mcp.py +276 -0
- zettcode/sdk/extensions/messaging.py +281 -0
- zettcode/sdk/handlers.py +126 -0
- zettcode/sdk/messenger.py +299 -0
- zettcode/sdk/models.py +156 -0
- zettcode/sdk/py.typed +0 -0
- zettcode/sdk/session.py +1212 -0
- zettcode/sdk/translation.py +384 -0
- zettcode/sdk/types.py +907 -0
- zettcode/table.py +95 -0
- zettcode_sdk-0.3.4.dist-info/METADATA +21 -0
- zettcode_sdk-0.3.4.dist-info/RECORD +30 -0
- zettcode_sdk-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""The MCP extension ZettCode hands to the runtime: one broken server never stops the task.
|
|
2
|
+
|
|
3
|
+
zett-agent's :class:`McpExtension` connects the servers a configuration file names and registers
|
|
4
|
+
their tools. Two things used to make it brittle, and this module is about not letting either one
|
|
5
|
+
end a conversation:
|
|
6
|
+
|
|
7
|
+
* A server that would not start failed the whole request (zett-agent 0.1.14), and the failure
|
|
8
|
+
named nothing. Since 0.1.15 the framework skips a server that cannot be reached or listed,
|
|
9
|
+
carries on with the others, and tells *the model*. This adds the other half: it tells *the
|
|
10
|
+
reader*, once, through the transcript, because a tool that quietly is not there looks like a
|
|
11
|
+
bug in the program.
|
|
12
|
+
* One malformed entry in ``mcp.json`` invalidated the file, and a file that is not valid JSON
|
|
13
|
+
failed the construction of the extension — which happens while the runtime is being built, so
|
|
14
|
+
the first turn never ran. Entries that cannot be used are skipped and named, and a file that
|
|
15
|
+
cannot be read at all means "no MCP servers" plus a line saying why.
|
|
16
|
+
|
|
17
|
+
* Starting a server is slow and silent. A stdio server is a process — ``npx`` may download a package
|
|
18
|
+
first — and the framework connects and lists its tools while the first request waits, so the
|
|
19
|
+
reader saw "Processing" for as long as that took with nothing to say why. A line is said when each
|
|
20
|
+
server starts and when it is ready, with how long it took and how many tools it brought. A server
|
|
21
|
+
whose tool list is already cached starts nothing, so it says nothing.
|
|
22
|
+
|
|
23
|
+
That is said **once per session, at its first turn**: it explains why that turn was slow, and only
|
|
24
|
+
that turn. The framework forgets a tool list after five minutes and lists it again at the top of the
|
|
25
|
+
next request, which in a long conversation is most turns; a reader does not need to be told again
|
|
26
|
+
each time that servers they already know about were looked at. A subagent, which has a session of its
|
|
27
|
+
own beside the one the reader is in, says nothing: the reader never asked it anything.
|
|
28
|
+
|
|
29
|
+
A tool that fails while it runs is the framework's own business and already harmless: the failure
|
|
30
|
+
comes back to the model as an ordinary failed tool result, and the model carries on.
|
|
31
|
+
|
|
32
|
+
What this does *not* do is catch everything. A cancellation is how the reader stops a request, and
|
|
33
|
+
turning it into some other error would make Escape fail instead of stop; only ``Exception`` is
|
|
34
|
+
absorbed here.
|
|
35
|
+
|
|
36
|
+
The module is imported when a runtime starts, not when the application is imported: the MCP SDK it
|
|
37
|
+
builds on costs more than the first frame.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import time
|
|
43
|
+
from collections.abc import Sequence
|
|
44
|
+
from contextvars import ContextVar
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any, cast
|
|
47
|
+
|
|
48
|
+
from zett_agent.agent import AgentRunContext
|
|
49
|
+
from zett_agent.events import AgentEvent, AgentEventType
|
|
50
|
+
from zett_agent.extensions.mcp import DEFAULT_MCP_SERVER_KEYS, McpConfiguration, McpExtension, McpStdioServer
|
|
51
|
+
|
|
52
|
+
from zettcode.engine.extensions.mcp_notice import MCP_NOTICE_EVENT_NAME, notice_payload
|
|
53
|
+
from zettcode.error_text import describe_error
|
|
54
|
+
|
|
55
|
+
#: The request whose tools are being discovered, so a server starting on its own task can say so in
|
|
56
|
+
#: that request's transcript. Set around the framework's discovery and read by the tasks it spawns, which
|
|
57
|
+
#: inherit it; ``None`` outside one.
|
|
58
|
+
_DISCOVERING: ContextVar[AgentRunContext | None] = ContextVar("zettcode_mcp_discovering", default=None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ReportingMcpExtension(McpExtension):
|
|
62
|
+
"""An MCP extension that keeps going when a server or a config entry is broken, and says so.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
**options: Passed to :class:`McpExtension`. ``strict_config`` defaults to ``False`` here:
|
|
66
|
+
the framework's default makes one bad entry invalidate every other server.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, **options: Any) -> None:
|
|
70
|
+
options.setdefault("strict_config", False)
|
|
71
|
+
super().__init__(**options)
|
|
72
|
+
#: What each unavailable server was last reported as, so a server that stays down is
|
|
73
|
+
#: reported once rather than at the top of every turn.
|
|
74
|
+
self._reported: dict[str, str] = {}
|
|
75
|
+
#: The stamp of the configuration file that was last checked for entries to report.
|
|
76
|
+
self._checked: tuple[int, int] | None | object = _UNCHECKED
|
|
77
|
+
#: Lines to say at the first request: what went wrong before there was a request to say it in.
|
|
78
|
+
self.startup_issues: list[str] = []
|
|
79
|
+
#: The sessions whose first turn has been and gone, which is when the start of the servers is said.
|
|
80
|
+
self._narrated: set[str] = set()
|
|
81
|
+
|
|
82
|
+
async def on_tool(self, context: AgentRunContext) -> None:
|
|
83
|
+
"""Register the tools of every server that works, and tell the reader about the ones that do not.
|
|
84
|
+
|
|
85
|
+
An error the framework does not handle itself — it handles a server that cannot connect or
|
|
86
|
+
list — costs this request its MCP tools and nothing else.
|
|
87
|
+
"""
|
|
88
|
+
token = _DISCOVERING.set(context)
|
|
89
|
+
try:
|
|
90
|
+
await super().on_tool(context)
|
|
91
|
+
except Exception as error:
|
|
92
|
+
# Not BaseException: a cancellation has to stay a cancellation. The framework has
|
|
93
|
+
# already closed what it opened for this request before the error got here.
|
|
94
|
+
await self._say(context, f"MCP is unavailable for this request: {describe_error(error)}")
|
|
95
|
+
return
|
|
96
|
+
finally:
|
|
97
|
+
_DISCOVERING.reset(token)
|
|
98
|
+
# After the discovery, not before: every server of this turn is narrated or none is.
|
|
99
|
+
if self._is_a_reader_session(context):
|
|
100
|
+
self._narrated.add(cast(str, context.config.session_id))
|
|
101
|
+
for line in (*self.startup_issues, *self._config_issues(), *self._server_issues(context)):
|
|
102
|
+
await self._say(context, line)
|
|
103
|
+
self.startup_issues.clear()
|
|
104
|
+
|
|
105
|
+
async def _discover(self, request: Any, server: Any) -> Any:
|
|
106
|
+
"""Connect to one server and list its tools, saying when it starts and when it is ready.
|
|
107
|
+
|
|
108
|
+
The framework's cache decides whether anything is started at all; when it holds this
|
|
109
|
+
server's tools nothing is, and nothing is said. The server's command and arguments are
|
|
110
|
+
never repeated here — they can carry a key — only its name and what kind of server it is.
|
|
111
|
+
"""
|
|
112
|
+
context = _DISCOVERING.get()
|
|
113
|
+
cached = self._tool_cache.get(server.name)
|
|
114
|
+
starts = (
|
|
115
|
+
context is not None
|
|
116
|
+
and self._first_turn_of_a_reader_session(context)
|
|
117
|
+
and not (cached is not None and cached.server == server and cached.expires_at > time.monotonic())
|
|
118
|
+
)
|
|
119
|
+
began = time.monotonic()
|
|
120
|
+
if starts:
|
|
121
|
+
kind = "process" if isinstance(server, McpStdioServer) else "connection"
|
|
122
|
+
await self._say_quietly(context, f"Starting MCP server {server.name!r} ({kind})\u2026")
|
|
123
|
+
tools = await super()._discover(request, server)
|
|
124
|
+
if starts and tools is not None:
|
|
125
|
+
count = len(tools)
|
|
126
|
+
took = time.monotonic() - began
|
|
127
|
+
noun = "tool" if count == 1 else "tools"
|
|
128
|
+
await self._say_quietly(context, f"MCP server {server.name!r} is ready: {count} {noun}, {took:.1f}s")
|
|
129
|
+
return tools
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _is_a_reader_session(context: AgentRunContext) -> bool:
|
|
133
|
+
"""Return whether this request belongs to a session the reader is in, not to a subagent's own."""
|
|
134
|
+
return not context.config.parent_session_id
|
|
135
|
+
|
|
136
|
+
def _first_turn_of_a_reader_session(self, context: AgentRunContext) -> bool:
|
|
137
|
+
"""Return whether this is the first request its session has made, in a session the reader is in."""
|
|
138
|
+
return self._is_a_reader_session(context) and cast(str, context.config.session_id) not in self._narrated
|
|
139
|
+
|
|
140
|
+
async def _say_quietly(self, context: AgentRunContext | None, text: str, level: str = "info") -> None:
|
|
141
|
+
"""Say a line without letting the saying fail the discovery it reports on.
|
|
142
|
+
|
|
143
|
+
These are progress lines (a server is starting, or ready), so they are ``info`` unless said otherwise.
|
|
144
|
+
"""
|
|
145
|
+
if context is None:
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
await self._say(context, text, level)
|
|
149
|
+
except Exception: # noqa: BLE001 - news about a server must never be what stops it
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def _server_issues(self, context: AgentRunContext) -> list[str]:
|
|
153
|
+
"""Return a line for each server that became unavailable, or changed why, since last said.
|
|
154
|
+
|
|
155
|
+
The framework keeps the reason on the request it made for this context and tells the model
|
|
156
|
+
through the system prompt; neither is a way to ask it, so this reads the request. A server
|
|
157
|
+
that works again is forgotten, so that its next failure is news.
|
|
158
|
+
"""
|
|
159
|
+
request = self._requests.get(context)
|
|
160
|
+
unavailable: dict[str, str] = dict(getattr(request, "unavailable", {}) or {})
|
|
161
|
+
for name in [name for name in self._reported if name not in unavailable]:
|
|
162
|
+
del self._reported[name]
|
|
163
|
+
lines = []
|
|
164
|
+
for name, reason in unavailable.items():
|
|
165
|
+
if self._reported.get(name) != reason:
|
|
166
|
+
self._reported[name] = reason
|
|
167
|
+
lines.append(f"MCP server {name!r} is not available ({reason}); its tools are skipped until it works")
|
|
168
|
+
return lines
|
|
169
|
+
|
|
170
|
+
def _config_issues(self) -> list[str]:
|
|
171
|
+
"""Return a line for each entry of the configuration file that cannot be used.
|
|
172
|
+
|
|
173
|
+
Checked when the file is first seen and again whenever it changes, not on every turn.
|
|
174
|
+
"""
|
|
175
|
+
path = self.config_path
|
|
176
|
+
if path is None:
|
|
177
|
+
return []
|
|
178
|
+
try:
|
|
179
|
+
status = path.stat()
|
|
180
|
+
except OSError:
|
|
181
|
+
self._checked = None
|
|
182
|
+
return []
|
|
183
|
+
stamp = (status.st_mtime_ns, status.st_size)
|
|
184
|
+
if stamp == self._checked:
|
|
185
|
+
return []
|
|
186
|
+
self._checked = stamp
|
|
187
|
+
try:
|
|
188
|
+
skipped = McpConfiguration.from_file(path, server_keys=self.server_keys, strict=False).skipped
|
|
189
|
+
except (OSError, ValueError) as error:
|
|
190
|
+
return [f"{path.name} cannot be used, so the last working MCP servers stay: {describe_error(error)}"]
|
|
191
|
+
return [f"MCP server {name!r} in {path.name} is ignored: {reason}" for name, reason in skipped.items()]
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
async def _say(context: AgentRunContext, text: str, level: str = "warn") -> None:
|
|
195
|
+
"""Publish one line for the transcript.
|
|
196
|
+
|
|
197
|
+
A line is a warning unless it says otherwise: what reaches here from the checks (a server that is down, an
|
|
198
|
+
entry that is ignored, a file that cannot be read) is something the reader may need to act on, and only the
|
|
199
|
+
progress lines of a start say ``info``.
|
|
200
|
+
"""
|
|
201
|
+
await context.emit(
|
|
202
|
+
AgentEvent(
|
|
203
|
+
type=AgentEventType.CUSTOM,
|
|
204
|
+
session_id=cast(str, context.config.session_id),
|
|
205
|
+
name=MCP_NOTICE_EVENT_NAME,
|
|
206
|
+
payload=notice_payload(text, level),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
#: Marks a stamp that has not been read yet, as distinct from a file that does not exist.
|
|
212
|
+
_UNCHECKED = object()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_mcp_extension(path: str | Path) -> ReportingMcpExtension:
|
|
216
|
+
"""Return the MCP extension for a server file, whatever state the file is in.
|
|
217
|
+
|
|
218
|
+
The framework reads the file while the extension is constructed and raises when it is not
|
|
219
|
+
valid JSON or names no servers. This runs while the runtime is being built, so letting that
|
|
220
|
+
through would mean the first turn never starts because of a typo in a file the reader may not
|
|
221
|
+
even remember. The extension is built with no servers instead, and the reason is said at the
|
|
222
|
+
first request. Fixing the file takes effect on the next start: an extension that never loaded
|
|
223
|
+
a good file has nothing to reload.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
path: The server file the reader configured.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
return ReportingMcpExtension(config_path=path)
|
|
230
|
+
except (OSError, ValueError) as error:
|
|
231
|
+
extension = ReportingMcpExtension(servers=())
|
|
232
|
+
name = Path(path).name
|
|
233
|
+
extension.startup_issues.append(
|
|
234
|
+
f"{name} cannot be read, so MCP is off: {describe_error(error)}. Fix it and restart."
|
|
235
|
+
)
|
|
236
|
+
return extension
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def build_scoped_mcp_extension(paths: Sequence[Path]) -> ReportingMcpExtension | None:
|
|
240
|
+
"""Return the MCP extension for the server files that exist, the first file winning a server name.
|
|
241
|
+
|
|
242
|
+
One file is :func:`build_mcp_extension`, which also notices the file changing. Several are read
|
|
243
|
+
once, here, and merged: the framework watches one path, and a server list assembled from two
|
|
244
|
+
files has no single file to watch. A file that cannot be read costs only its own servers, and
|
|
245
|
+
says why at the first request; an entry in it that cannot be used is named the same way.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
paths: The files to read, the one that wins a name first; a path that is not a file is skipped.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
The extension, or ``None`` when no path is a file, so a run with nothing configured carries no
|
|
252
|
+
MCP instructions at all.
|
|
253
|
+
"""
|
|
254
|
+
present = [path for path in paths if path.is_file()]
|
|
255
|
+
if not present:
|
|
256
|
+
return None
|
|
257
|
+
if len(present) == 1:
|
|
258
|
+
return build_mcp_extension(present[0])
|
|
259
|
+
servers: dict[str, Any] = {}
|
|
260
|
+
issues: list[str] = []
|
|
261
|
+
for path in present:
|
|
262
|
+
label = f"{path.parent.name}/{path.name}"
|
|
263
|
+
try:
|
|
264
|
+
loaded = McpConfiguration.from_file(path, server_keys=DEFAULT_MCP_SERVER_KEYS, strict=False)
|
|
265
|
+
except (OSError, ValueError) as error:
|
|
266
|
+
issues.append(
|
|
267
|
+
f"{label} cannot be read, so its servers are off: {describe_error(error)}. Fix it and restart."
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
issues.extend(f"MCP server {name!r} in {label} is ignored: {reason}" for name, reason in loaded.skipped.items())
|
|
271
|
+
for server in loaded.servers:
|
|
272
|
+
# The earlier file wins, so a project's server of the same name replaces the user's.
|
|
273
|
+
servers.setdefault(server.name, server)
|
|
274
|
+
extension = ReportingMcpExtension(servers=tuple(servers.values()))
|
|
275
|
+
extension.startup_issues.extend(issues)
|
|
276
|
+
return extension
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""The tools that let the model talk to the other ZettCode sessions running on this machine.
|
|
2
|
+
|
|
3
|
+
``list_sessions`` says who is there, and which group chats this session may write to; ``send_message`` hands one of
|
|
4
|
+
them a message, or every member of a group; ``message_history`` reads what this session and another one (or a group)
|
|
5
|
+
said to each other before, from the broker's persistent store; ``manage_group`` makes and changes group chats. The other session
|
|
6
|
+
receives it as a turn of its own — drawn there as a message from this session, with this session's
|
|
7
|
+
id so that it can answer — and this call returns as soon as it has taken the message, not when it has
|
|
8
|
+
finished with it. A message asks for one of two things while that session is busy: it can be handed to
|
|
9
|
+
the request it is running (``mode="steer"``, the default) or wait until that request ends
|
|
10
|
+
(``mode="followup"``); an idle session starts a turn either way. What it does about the message is its
|
|
11
|
+
own agent's business, and its answer, if any, arrives here as a message of its own.
|
|
12
|
+
|
|
13
|
+
All are request-scoped like every tool: they are registered for each request, and a request that is a side
|
|
14
|
+
question keeps only ``list_sessions`` and ``message_history`` (see :mod:`zettcode.engine.dialogue.side`), because
|
|
15
|
+
sending a message is not reading.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from zett_agent.agent import AgentRunContext
|
|
23
|
+
from zett_agent.extensions.base import AgentExtension
|
|
24
|
+
from zett_agent.tools.base import AgentTool, tool
|
|
25
|
+
|
|
26
|
+
from zettcode.broker.client import SendError
|
|
27
|
+
from zettcode.broker.protocol import VIA_AGENT
|
|
28
|
+
from zettcode.modes import MODE_STEER, MODES
|
|
29
|
+
|
|
30
|
+
from ..messenger import Messenger
|
|
31
|
+
|
|
32
|
+
LIST_SESSIONS_TOOL_NAME = "list_sessions"
|
|
33
|
+
SEND_MESSAGE_TOOL_NAME = "send_message"
|
|
34
|
+
MESSAGE_HISTORY_TOOL_NAME = "message_history"
|
|
35
|
+
MANAGE_GROUP_TOOL_NAME = "manage_group"
|
|
36
|
+
|
|
37
|
+
#: The most characters of one stored message ``message_history`` shows; a longer one is cut, with its length.
|
|
38
|
+
HISTORY_TEXT = 600
|
|
39
|
+
|
|
40
|
+
#: What a refused send says, by the reason the broker gave, for the ones a model can do something about.
|
|
41
|
+
_ADVICE = {
|
|
42
|
+
"unknown": "No such session is active. Call list_sessions to see who is.",
|
|
43
|
+
"ambiguous": "More than one session matches. Use the session id from list_sessions.",
|
|
44
|
+
"self": "That is this session; a message to yourself is not sent.",
|
|
45
|
+
"loop": "This conversation between sessions has gone on long enough; stop replying and tell your user.",
|
|
46
|
+
"rate": "Too many messages were sent in a short time. Wait before sending another.",
|
|
47
|
+
"offline": "The session broker is not running, so no other session can be reached right now.",
|
|
48
|
+
"denied": "You are not a member of that group. list_sessions shows the groups you may write to.",
|
|
49
|
+
"gone": "Nobody there is connected right now.",
|
|
50
|
+
"internal": "The broker failed while delivering that. Try again; if it keeps failing, tell your user.",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sentence(error: SendError) -> str:
|
|
55
|
+
"""Return what the model is told when a message did not arrive."""
|
|
56
|
+
advice = _ADVICE.get(error.reason)
|
|
57
|
+
if advice is None:
|
|
58
|
+
return str(error)
|
|
59
|
+
# Offline has nothing to add to what the advice says; the others carry the broker's own detail.
|
|
60
|
+
return advice if error.reason == "offline" else f"{str(error).rstrip('.')}. {advice}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SessionMessagingExtension(AgentExtension):
|
|
64
|
+
"""Register ``list_sessions`` and ``send_message`` for a messenger."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, messenger: Messenger) -> None:
|
|
67
|
+
"""Keep the messenger the tools talk through."""
|
|
68
|
+
self.messenger = messenger
|
|
69
|
+
|
|
70
|
+
async def on_tool(self, context: AgentRunContext) -> None:
|
|
71
|
+
"""Register both tools for this request."""
|
|
72
|
+
context.register_tool(self._list_tool())
|
|
73
|
+
context.register_tool(self._send_tool())
|
|
74
|
+
context.register_tool(self._history_tool())
|
|
75
|
+
context.register_tool(self._group_tool())
|
|
76
|
+
|
|
77
|
+
def _list_tool(self) -> AgentTool:
|
|
78
|
+
"""Create the tool that lists the other active sessions."""
|
|
79
|
+
messenger = self.messenger
|
|
80
|
+
|
|
81
|
+
@tool(name=LIST_SESSIONS_TOOL_NAME)
|
|
82
|
+
async def list_sessions() -> str:
|
|
83
|
+
"""List the other ZettCode sessions that are running on this machine right now, and the group chats
|
|
84
|
+
this session is a member of (the only ones it may write to).
|
|
85
|
+
Each session line gives the name to address it by, its id, what it is about, whether it is idle or working,
|
|
86
|
+
and the directory it works in; each group line gives #name and its members. Sessions come and go, so the
|
|
87
|
+
list is only true now.
|
|
88
|
+
|
|
89
|
+
Snippet:
|
|
90
|
+
list_sessions()
|
|
91
|
+
|
|
92
|
+
Guidelines:
|
|
93
|
+
- Call this before send_message when you do not already know the id of the session to message.
|
|
94
|
+
- A session that is working still receives a message; it handles it when its current request ends.
|
|
95
|
+
- Other sessions belong to the same user, but each one has its own conversation and its own files in mind.
|
|
96
|
+
"""
|
|
97
|
+
others = messenger.peers()
|
|
98
|
+
groups = messenger.groups()
|
|
99
|
+
if not others and not groups:
|
|
100
|
+
if messenger.connected:
|
|
101
|
+
return "No other ZettCode session is running."
|
|
102
|
+
return "No other session can be seen: the session broker is not running."
|
|
103
|
+
lines = []
|
|
104
|
+
if others:
|
|
105
|
+
lines.append(f"{len(others)} other session{'s' if len(others) != 1 else ''}:")
|
|
106
|
+
else:
|
|
107
|
+
lines.append("No other ZettCode session is running.")
|
|
108
|
+
for entry in others:
|
|
109
|
+
about = f'"{entry.title}"' if entry.title else "untitled"
|
|
110
|
+
lines.append(f"- {entry.handle} (id {entry.session_id}) {about}, {entry.status}, in {entry.workspace}")
|
|
111
|
+
if groups:
|
|
112
|
+
names = {entry.session_id: entry.handle for entry in messenger.client.roster}
|
|
113
|
+
lines.append(f"{len(groups)} group chat{'s' if len(groups) != 1 else ''} you may write to:")
|
|
114
|
+
for group in groups:
|
|
115
|
+
members = ", ".join(names.get(m, f"{m[:8]} (offline)") for m in group.members)
|
|
116
|
+
lines.append(f"- #{group.name}: {members}")
|
|
117
|
+
return "\n".join(lines)
|
|
118
|
+
|
|
119
|
+
return list_sessions
|
|
120
|
+
|
|
121
|
+
def _send_tool(self) -> AgentTool:
|
|
122
|
+
"""Create the tool that sends a message to another active session."""
|
|
123
|
+
messenger = self.messenger
|
|
124
|
+
|
|
125
|
+
@tool(name=SEND_MESSAGE_TOOL_NAME)
|
|
126
|
+
async def send_message(to: str = "", message: str = "", mode: str = MODE_STEER, group: str = "") -> str:
|
|
127
|
+
"""Send a message to another running ZettCode session, or to every other member of a group chat you are
|
|
128
|
+
in. The receiver gets it as a request of its own. Returns once they have taken the message, not once
|
|
129
|
+
they have acted on it: a reply, if one is sent, arrives later as a message to you. Every message costs its
|
|
130
|
+
receiver a turn, so send one only when it changes what they will do.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
message: What to tell them. They cannot see this conversation, so say everything they need: what you
|
|
134
|
+
want, which files or facts matter, and what a useful answer looks like.
|
|
135
|
+
to: The session to message: its id from list_sessions, or the name listed beside it. Give this or group.
|
|
136
|
+
group: A group chat to write to instead, such as "review" (as listed by list_sessions, without the
|
|
137
|
+
#). Every other member that is connected receives the message, and everyone can read it.
|
|
138
|
+
mode: What a busy receiver should do. "steer" (the default) hands the message to the request it is
|
|
139
|
+
running, so it changes course now; "followup" waits until that request ends. A session that is
|
|
140
|
+
idle starts on the message either way.
|
|
141
|
+
|
|
142
|
+
Snippet:
|
|
143
|
+
send_message(to="fix-parser", message="The tokenizer change is merged; please rerun your tests.")
|
|
144
|
+
send_message(to="fix-parser", message="Also check the tab case.", mode="followup")
|
|
145
|
+
send_message(group="review", message="Round one is done; findings are in docs/review.md.")
|
|
146
|
+
|
|
147
|
+
Guidelines:
|
|
148
|
+
- Send a message only when your user asked you to, or when the work needs something only another session can do.
|
|
149
|
+
- The receiver cannot see this conversation, so write a message that stands on its own.
|
|
150
|
+
- Do not send the same message twice; a message that was taken is being handled.
|
|
151
|
+
- Reply only when the message needs an answer: it asks a question, asks you to do something, or you have a result, a blocker or a decision the sender is waiting for. Otherwise stay silent.
|
|
152
|
+
- Never send thanks, "got it", "ok", "done", "bye" or any other closing courtesy, and do not answer one you receive: a conversation ends when nobody needs anything more, and replying to a goodbye starts a loop that costs tokens for both sides.
|
|
153
|
+
- When the work is finished, send one message with the result if someone is waiting for it, then stop. Do not confirm that you sent it.
|
|
154
|
+
- Write to a group only when all of its members need to know; otherwise message one session. In a group, do not reply just because you were addressed: answer only when you are the one who has what was asked for, and never echo agreement that others have already given.
|
|
155
|
+
- Your answer to your own user is not sent to the other session; only this tool sends anything.
|
|
156
|
+
"""
|
|
157
|
+
if mode not in MODES:
|
|
158
|
+
raise ValueError("mode is 'steer' or 'followup'")
|
|
159
|
+
if not message.strip():
|
|
160
|
+
raise ValueError("a message needs some text")
|
|
161
|
+
if bool(to) == bool(group):
|
|
162
|
+
raise ValueError("give exactly one of 'to' (a session) or 'group' (a group chat)")
|
|
163
|
+
try:
|
|
164
|
+
if group:
|
|
165
|
+
sent = await messenger.send_group(group, message, via=VIA_AGENT, mode=mode)
|
|
166
|
+
names = ", ".join(peer.handle for peer, _ in sent.delivered)
|
|
167
|
+
note = f"; not delivered to {', '.join(h for h, _ in sent.failed)}" if sent.failed else ""
|
|
168
|
+
return f"Delivered to #{sent.group}: {names}{note}."
|
|
169
|
+
delivered = await messenger.send(to, message, via=VIA_AGENT, mode=mode)
|
|
170
|
+
except SendError as error:
|
|
171
|
+
raise ValueError(_sentence(error)) from error
|
|
172
|
+
peer = delivered.to
|
|
173
|
+
said = {
|
|
174
|
+
"started": "started on it now",
|
|
175
|
+
"steered": "steered it into the request it was running",
|
|
176
|
+
"queued": "queued it until its current request ends",
|
|
177
|
+
"delivered": "delivered it to that session's chat window",
|
|
178
|
+
"held": "kept it for that session, whose inbox is full, and the broker delivers it when there is room",
|
|
179
|
+
}.get(delivered.disposition, "took it")
|
|
180
|
+
return f"Delivered to {peer.handle} (id {peer.session_id}); it {said}."
|
|
181
|
+
|
|
182
|
+
return send_message
|
|
183
|
+
|
|
184
|
+
def _history_tool(self) -> AgentTool:
|
|
185
|
+
"""Create the tool that reads what was said before."""
|
|
186
|
+
messenger = self.messenger
|
|
187
|
+
|
|
188
|
+
@tool(name=MESSAGE_HISTORY_TOOL_NAME)
|
|
189
|
+
async def message_history(session: str = "", group: str = "", limit: int = 20) -> str:
|
|
190
|
+
"""Read the messages this session exchanged earlier with one other session, or what was said in a group
|
|
191
|
+
chat you are in. The broker keeps them across restarts, so this also shows what was said before either
|
|
192
|
+
session was restarted. You see only your own conversations.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
session: The other session: its name or id, from list_sessions or from an earlier message. Give this
|
|
196
|
+
or group, or neither to see your most recent messages with everyone.
|
|
197
|
+
group: A group chat you are in, such as "review".
|
|
198
|
+
limit: How many of the newest messages to show, 1 to 100 (default 20).
|
|
199
|
+
|
|
200
|
+
Snippet:
|
|
201
|
+
message_history(session="fix-parser")
|
|
202
|
+
message_history(group="review", limit=50)
|
|
203
|
+
|
|
204
|
+
Guidelines:
|
|
205
|
+
- Use it to recall what you and another session already agreed before you write to them again.
|
|
206
|
+
- Messages are shown oldest first; a long one is cut.
|
|
207
|
+
"""
|
|
208
|
+
if session and group:
|
|
209
|
+
raise ValueError("give 'session' or 'group', not both")
|
|
210
|
+
try:
|
|
211
|
+
messages, _ = await messenger.history(with_=session, group=group, limit=max(1, min(limit, 100)))
|
|
212
|
+
except SendError as error:
|
|
213
|
+
raise ValueError(_sentence(error)) from error
|
|
214
|
+
if not messages:
|
|
215
|
+
return "No messages yet."
|
|
216
|
+
me = messenger.client.session.session_id if messenger.client.session is not None else ""
|
|
217
|
+
lines = [f"{len(messages)} message{'s' if len(messages) != 1 else ''}, oldest first:"]
|
|
218
|
+
for item in messages:
|
|
219
|
+
sender = item.get("from", {})
|
|
220
|
+
to = item.get("to", {})
|
|
221
|
+
who = "you" if sender.get("session_id") == me else f"@{sender.get('handle', '?')}"
|
|
222
|
+
if to.get("kind") == "group":
|
|
223
|
+
where = f"#{to.get('name', '?')}"
|
|
224
|
+
else:
|
|
225
|
+
where = "you" if to.get("session_id") == me else f"@{to.get('handle', '?')}"
|
|
226
|
+
text = str(item.get("text", ""))
|
|
227
|
+
if len(text) > HISTORY_TEXT:
|
|
228
|
+
text = f"{text[:HISTORY_TEXT]}... [{len(text)} characters]"
|
|
229
|
+
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(float(item.get("ts", 0))))
|
|
230
|
+
lines.append(f"[{when}] {who} -> {where}: {text}")
|
|
231
|
+
return "\n".join(lines)
|
|
232
|
+
|
|
233
|
+
return message_history
|
|
234
|
+
|
|
235
|
+
def _group_tool(self) -> AgentTool:
|
|
236
|
+
"""Create the tool that makes and changes group chats."""
|
|
237
|
+
messenger = self.messenger
|
|
238
|
+
|
|
239
|
+
@tool(name=MANAGE_GROUP_TOOL_NAME)
|
|
240
|
+
async def manage_group(action: str, group: str, members: str = "") -> str:
|
|
241
|
+
"""Create or change a group chat of sessions. A message sent to a group (send_message with group=...)
|
|
242
|
+
reaches every other member that is connected. You become a member of a group you create, and only a
|
|
243
|
+
member may write to it or change it.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
action: "create" (new group with you and the members), "add" (add members), "remove" (remove members),
|
|
247
|
+
"leave" (take yourself out), or "delete" (remove the group; what was said stays in the history).
|
|
248
|
+
group: The group's name: letters, digits, '-' or '_', such as "review".
|
|
249
|
+
members: For create, add and remove: session names or ids from list_sessions, separated by commas.
|
|
250
|
+
|
|
251
|
+
Snippet:
|
|
252
|
+
manage_group(action="create", group="review", members="fix-parser, docs")
|
|
253
|
+
manage_group(action="leave", group="review")
|
|
254
|
+
|
|
255
|
+
Guidelines:
|
|
256
|
+
- Create a group only when your user asked for one, or when several sessions work on one task together.
|
|
257
|
+
- A group with no member left is deleted.
|
|
258
|
+
"""
|
|
259
|
+
names = [part.strip() for part in members.split(",") if part.strip()]
|
|
260
|
+
try:
|
|
261
|
+
found = await messenger.manage_group(action, group, names)
|
|
262
|
+
except SendError as error:
|
|
263
|
+
raise ValueError(_sentence(error)) from error
|
|
264
|
+
if found is None:
|
|
265
|
+
return f"Group #{group.lstrip('#').lower()} no longer exists."
|
|
266
|
+
roster = {entry.session_id: entry.handle for entry in messenger.client.roster}
|
|
267
|
+
who = ", ".join(roster.get(m, f"{m[:8]} (offline)") for m in found.members)
|
|
268
|
+
return (
|
|
269
|
+
f"Group #{found.name} has {len(found.members)} member{'s' if len(found.members) != 1 else ''}: {who}."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return manage_group
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
__all__ = [
|
|
276
|
+
"LIST_SESSIONS_TOOL_NAME",
|
|
277
|
+
"MANAGE_GROUP_TOOL_NAME",
|
|
278
|
+
"MESSAGE_HISTORY_TOOL_NAME",
|
|
279
|
+
"SEND_MESSAGE_TOOL_NAME",
|
|
280
|
+
"SessionMessagingExtension",
|
|
281
|
+
]
|