zettcode-messaging 0.4.1__tar.gz
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_messaging-0.4.1/.gitignore +37 -0
- zettcode_messaging-0.4.1/PKG-INFO +20 -0
- zettcode_messaging-0.4.1/pyproject.toml +51 -0
- zettcode_messaging-0.4.1/src/zettcode/messaging/__init__.py +13 -0
- zettcode_messaging-0.4.1/src/zettcode/messaging/extension.py +281 -0
- zettcode_messaging-0.4.1/src/zettcode/messaging/inbox.py +206 -0
- zettcode_messaging-0.4.1/src/zettcode/messaging/messenger.py +299 -0
- zettcode_messaging-0.4.1/src/zettcode/messaging/plugin.py +46 -0
- zettcode_messaging-0.4.1/src/zettcode/messaging/py.typed +0 -0
- zettcode_messaging-0.4.1/src/zettcode/messaging/relay.py +160 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
**/.venv/
|
|
2
|
+
**/__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.smoke/
|
|
8
|
+
.coverage
|
|
9
|
+
**/.pytest_cache/
|
|
10
|
+
**/.ruff_cache/
|
|
11
|
+
.zett-tool-output/
|
|
12
|
+
.env
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
# viztracer writes its trace here by default.
|
|
16
|
+
/result.json
|
|
17
|
+
|
|
18
|
+
# The docs site: dependencies, build output, and VitePress' cache.
|
|
19
|
+
node_modules/
|
|
20
|
+
docs/.vitepress/dist/
|
|
21
|
+
docs/.vitepress/cache/
|
|
22
|
+
docs/.vitepress/.temp/
|
|
23
|
+
|
|
24
|
+
# The TypeScript client is built from its sources; only dist/ is published.
|
|
25
|
+
clients/typescript/dist/
|
|
26
|
+
|
|
27
|
+
# The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
|
|
28
|
+
# the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
|
|
29
|
+
packages/zettcode-gateway/src/zettcode/gateway/static/
|
|
30
|
+
|
|
31
|
+
# The palette gallery is generated by `make palettes`: every widget painted in
|
|
32
|
+
# every palette, written by docs/scripts/palette_gallery.py. Regenerate it
|
|
33
|
+
# rather than committing a 2 MB copy that goes stale the moment a colour moves.
|
|
34
|
+
docs/internal/palettes.html
|
|
35
|
+
|
|
36
|
+
# The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
|
|
37
|
+
packages/order.txt
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: zettcode-messaging
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Talking to the other ZettCode sessions — the messaging tools and the line to the broker — as a part of the agent that lives apart from it
|
|
5
|
+
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
|
|
6
|
+
Author: Chang-LeHung
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: agent,messaging,zettcode
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: zett-agent<0.3,>=0.2.7
|
|
19
|
+
Requires-Dist: zettcode-broker
|
|
20
|
+
Requires-Dist: zettcode-engine
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zettcode-messaging"
|
|
3
|
+
description = "Talking to the other ZettCode sessions — the messaging tools and the line to the broker — as a part of the agent that lives apart from it"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
dynamic = ["version"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{name = "Chang-LeHung"}]
|
|
8
|
+
keywords = ["zettcode", "messaging", "agent"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 5 - Production/Stable",
|
|
11
|
+
"Programming Language :: Python :: 3.10",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Programming Language :: Python :: 3.15",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
# The plugin base classes, the service names, and the modes a message is sent with.
|
|
21
|
+
"zettcode-engine",
|
|
22
|
+
# The protocol, the client and the errors the tools speak.
|
|
23
|
+
"zettcode-broker",
|
|
24
|
+
# The tools this subclasses.
|
|
25
|
+
"zett-agent>=0.2.7,<0.3",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# A part of the program, not a plugin: the `zettcode.plugins` group is what the plugin release line looks for.
|
|
29
|
+
[project.entry-points."zettcode.agent"]
|
|
30
|
+
messaging = "zettcode.messaging.plugin:Messaging"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/Chang-LeHung/zettcode"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
# One version for the whole repository: the engine ships the file.
|
|
41
|
+
path = "../zettcode-engine/src/zettcode/version.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/zettcode"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["/src", "/pyproject.toml"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Talking to the other ZettCode sessions: the tools, the line to the broker, and later the inbox.
|
|
2
|
+
|
|
3
|
+
A part of the program that lives in a distribution of its own, found through the ``zettcode.agent`` entry point. The
|
|
4
|
+
runtime hands it the line to the broker by name (``MESSENGER``); without one it registers nothing, which is the case in
|
|
5
|
+
a program that never joins the sessions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .messenger import Messenger
|
|
11
|
+
from .plugin import Messaging
|
|
12
|
+
|
|
13
|
+
__all__ = ["Messenger", "Messaging"]
|
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The inbox: what an event outside the conversation hands the agent, steered in or queued for the next turn.
|
|
2
|
+
|
|
3
|
+
The engine's :class:`~zettcode.engine.delivery.Delivery` knows two things — whether a request is running, and how to
|
|
4
|
+
hand a message to it — but not what to do with a message that arrives when nothing runs. That is this class: a steer
|
|
5
|
+
goes straight into the request; anything else waits, in order, and the front end that owns the turn loop starts the next
|
|
6
|
+
one when the inbox says so (``start_with``), which is the same starter it uses for a reader's prompt.
|
|
7
|
+
|
|
8
|
+
This is the one queue. A message another session sent (see :mod:`zettcode.messaging.relay`) is a :class:`Waiting` whose
|
|
9
|
+
``payload`` is that message: the front-end starter hands it back to the code that knows how to run it, and messages that
|
|
10
|
+
waited in a window that was busy are answered together, as one turn, the way the broker has always done it.
|
|
11
|
+
|
|
12
|
+
The queue is one per runtime, provided as the ``INBOX`` service, so the broker's incoming path, a trigger and a program
|
|
13
|
+
using the SDK all deliver the same way and cannot start two turns at once.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from zettcode.engine.delivery import Delivery
|
|
23
|
+
from zettcode.modes import MODE_STEER, MODES
|
|
24
|
+
from zettcode.relay import Relayed, batch_size, merge
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
27
|
+
from zett_agent.messages import UserMessage
|
|
28
|
+
|
|
29
|
+
__all__ = ["FULL", "INBOX", "MAX_WAITING", "QUEUED", "STARTED", "STEERED", "Inbox", "Waiting"]
|
|
30
|
+
|
|
31
|
+
#: The service name the inbox is provided under, for a front end that delivers into the agent.
|
|
32
|
+
INBOX = "inbox"
|
|
33
|
+
|
|
34
|
+
#: How many messages may wait; the rest are refused (:data:`FULL`) and the broker offers them again later.
|
|
35
|
+
MAX_WAITING = 64
|
|
36
|
+
|
|
37
|
+
#: What ``deliver`` did: the running request took it, it was queued, a turn was started with it, or there was no room.
|
|
38
|
+
STEERED = "steered"
|
|
39
|
+
QUEUED = "queued"
|
|
40
|
+
STARTED = "started"
|
|
41
|
+
FULL = "full"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Waiting:
|
|
46
|
+
"""One message the inbox holds for the next turn.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
message: What to run: text, or a message when it carries pictures.
|
|
50
|
+
hint: Text appended after it, as for a prompt.
|
|
51
|
+
origin: Where it came from, for the transcript and the stored message.
|
|
52
|
+
payload: What the front end needs to run it its own way — the
|
|
53
|
+
:class:`~zettcode.relay.Relayed` a message from another session came in as, or ``None`` for a plain event.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
message: str | UserMessage
|
|
57
|
+
hint: str | None = None
|
|
58
|
+
origin: str = "event"
|
|
59
|
+
payload: object | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Inbox:
|
|
63
|
+
"""Steer the request in flight, or hold messages until one ends and a front end can start the next turn.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
delivery: The runtime's delivery: whether a request runs, and how a message reaches it.
|
|
67
|
+
cap: How many may wait. The rest are refused with :data:`FULL`, which the broker turns into "busy, offer it
|
|
68
|
+
again", so a front end never holds an unbounded list.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, delivery: Delivery, *, cap: int = MAX_WAITING) -> None:
|
|
72
|
+
self._delivery = delivery
|
|
73
|
+
self._cap = cap
|
|
74
|
+
self._waiting: list[Waiting] = []
|
|
75
|
+
self._starter: Callable[[Waiting], bool] | None = None
|
|
76
|
+
self._forget: Callable[[], None] | None = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def waiting(self) -> tuple[Waiting, ...]:
|
|
80
|
+
"""Return the messages held for the next turn, in the order they arrived."""
|
|
81
|
+
return tuple(self._waiting)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def full(self) -> bool:
|
|
85
|
+
"""Return whether the queue is at its cap, so a new message would be refused."""
|
|
86
|
+
return len(self._waiting) >= self._cap
|
|
87
|
+
|
|
88
|
+
def start_with(self, starter: Callable[[Waiting], bool]) -> None:
|
|
89
|
+
"""Register what starts a turn from a waiting message, and drain anything already waiting.
|
|
90
|
+
|
|
91
|
+
The front end that owns the turn loop passes its own starter — the one a reader's prompt goes through — so a
|
|
92
|
+
follow-up is an ordinary turn and not a second kind of run. A starter that returns ``False`` says it could not
|
|
93
|
+
start one now; the message then stays waiting for the next end of a request.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
starter: Called with one :class:`Waiting`; ``True`` once a turn is under way.
|
|
97
|
+
"""
|
|
98
|
+
self._starter = starter
|
|
99
|
+
if self._waiting and not self._delivery.busy:
|
|
100
|
+
self._drain()
|
|
101
|
+
|
|
102
|
+
def deliver(
|
|
103
|
+
self,
|
|
104
|
+
message: str | UserMessage,
|
|
105
|
+
*,
|
|
106
|
+
mode: str = MODE_STEER,
|
|
107
|
+
hint: str | None = None,
|
|
108
|
+
origin: str = "event",
|
|
109
|
+
payload: object | None = None,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Hand one message to the agent, as a steer or as the next turn.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
message: Text, or a message when it carries pictures (see :meth:`zettcode.engine.agent.ZettCodeAgent.turn`).
|
|
115
|
+
mode: ``"steer"`` (the default) hands it to the request that runs, so it changes course;
|
|
116
|
+
``"followup"`` waits until that request ends. An idle agent starts a turn either way.
|
|
117
|
+
hint: Text appended after a follow-up turn, as for a normal prompt.
|
|
118
|
+
origin: Where it came from, for the transcript and the stored message.
|
|
119
|
+
payload: What a front end needs to run it its own way; a message from another session passes the
|
|
120
|
+
:class:`~zettcode.relay.Relayed`, which the starter hands back to the code that frames it.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
:data:`STEERED`, :data:`QUEUED`, :data:`STARTED` or :data:`FULL`.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: When ``mode`` is neither ``"steer"`` nor ``"followup"``.
|
|
127
|
+
"""
|
|
128
|
+
if mode not in MODES:
|
|
129
|
+
raise ValueError(f"mode is 'steer' or 'followup' (got {mode!r})")
|
|
130
|
+
if mode == MODE_STEER and self._delivery.busy and self._delivery.steer(message) is not None:
|
|
131
|
+
return STEERED
|
|
132
|
+
if self.full:
|
|
133
|
+
return FULL
|
|
134
|
+
self._waiting.append(Waiting(message, hint, origin, payload))
|
|
135
|
+
if self._delivery.busy:
|
|
136
|
+
self._arm()
|
|
137
|
+
return QUEUED
|
|
138
|
+
return STARTED if self._drain() else QUEUED
|
|
139
|
+
|
|
140
|
+
def requeue(self, items: Sequence[Waiting]) -> None:
|
|
141
|
+
"""Put messages back that were already accepted, past the cap if it takes it.
|
|
142
|
+
|
|
143
|
+
The cap is for what new senders may add; a message the front end had already taken — a steer the running
|
|
144
|
+
request never read — must not be lost to a full queue. They keep their place at the end, in order.
|
|
145
|
+
"""
|
|
146
|
+
self._waiting.extend(items)
|
|
147
|
+
if self._delivery.busy:
|
|
148
|
+
self._arm()
|
|
149
|
+
else:
|
|
150
|
+
self._drain()
|
|
151
|
+
|
|
152
|
+
def drain(self) -> bool:
|
|
153
|
+
"""Start the next waiting turn if the front end is free; say whether anything is left waiting."""
|
|
154
|
+
return self._drain()
|
|
155
|
+
|
|
156
|
+
def _arm(self) -> None:
|
|
157
|
+
"""Ask the runtime to tell the inbox when the last request ends, once — only while one runs.
|
|
158
|
+
|
|
159
|
+
Arming while nothing runs would be the same as calling back at once, and the drain that follows would arm
|
|
160
|
+
again: the queue waits for the next end of a request, which is what ``start_with`` and the next ``deliver``
|
|
161
|
+
look at instead.
|
|
162
|
+
"""
|
|
163
|
+
if self._forget is None and self._delivery.busy:
|
|
164
|
+
self._forget = self._delivery.when_idle(self._woke)
|
|
165
|
+
|
|
166
|
+
def _woke(self) -> None:
|
|
167
|
+
"""The request ended: drain what waited, and arm again if anything is left."""
|
|
168
|
+
self._forget = None
|
|
169
|
+
self._drain()
|
|
170
|
+
|
|
171
|
+
def _drain(self) -> bool:
|
|
172
|
+
"""Start one turn from the queue, if a starter exists and nothing runs.
|
|
173
|
+
|
|
174
|
+
One at a time, and not one per call of this method only: a starter that schedules the turn instead of running
|
|
175
|
+
it (the SDK's creates a task) returns before the runtime is busy, so the loop must not look at ``busy`` again —
|
|
176
|
+
the next start waits for the next request to end, as the queue's ``waiting`` order promises.
|
|
177
|
+
|
|
178
|
+
Two messages another session sent, waiting one after the other, start as one turn: a window that was busy
|
|
179
|
+
answers them together instead of once per message (see :func:`~zettcode.relay.batch_size`). A plain event is
|
|
180
|
+
always a turn of its own.
|
|
181
|
+
"""
|
|
182
|
+
while self._waiting and self._starter is not None and not self._delivery.busy:
|
|
183
|
+
item, taken = self._next()
|
|
184
|
+
if not self._starter(item):
|
|
185
|
+
break
|
|
186
|
+
del self._waiting[:taken]
|
|
187
|
+
break
|
|
188
|
+
if self._waiting:
|
|
189
|
+
self._arm()
|
|
190
|
+
return not self._waiting
|
|
191
|
+
|
|
192
|
+
def _next(self) -> tuple[Waiting, int]:
|
|
193
|
+
"""Return the waiting item to start next, and how many of the queue's head it stands for."""
|
|
194
|
+
head = self._waiting[0]
|
|
195
|
+
if not isinstance(head.payload, Relayed):
|
|
196
|
+
return head, 1
|
|
197
|
+
run: list[Relayed] = []
|
|
198
|
+
for item in self._waiting:
|
|
199
|
+
if not isinstance(item.payload, Relayed):
|
|
200
|
+
break
|
|
201
|
+
run.append(item.payload)
|
|
202
|
+
count = batch_size(run)
|
|
203
|
+
if count <= 1:
|
|
204
|
+
return head, 1
|
|
205
|
+
merged = merge(run[:count])
|
|
206
|
+
return Waiting(merged.text, head.hint, head.origin, merged), count
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""This session's line to the others: who is active, a message sent, a message received.
|
|
2
|
+
|
|
3
|
+
The broker client (:mod:`zettcode.broker.client`) knows the wire; this is the part the rest of the
|
|
4
|
+
application holds. The shell tells it what the session is now and gives it a place to hand a received
|
|
5
|
+
message to, and the ``send_message`` tool and the ``@`` completion ask it who else is there.
|
|
6
|
+
|
|
7
|
+
It owns one fact the client cannot know: which message the running turn is answering. An agent that
|
|
8
|
+
is answering another agent carries that message's hop count on, which is what stops two sessions
|
|
9
|
+
that keep replying to each other; a turn that began with the reader's own words starts at none.
|
|
10
|
+
|
|
11
|
+
Nothing here blocks or raises into the shell. Without a broker every method answers as if nobody
|
|
12
|
+
else were running, and a send says so.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from zettcode.broker.client import BrokerClient, Delivered, Incoming, Receipt, RosterEntry, SessionInfo
|
|
22
|
+
from zettcode.broker.models import GroupDelivered, GroupInfo
|
|
23
|
+
from zettcode.broker.protocol import VIA_AGENT, ImageData
|
|
24
|
+
from zettcode.config import ZettCodeConfig
|
|
25
|
+
from zettcode.modes import MODE_STEER
|
|
26
|
+
from zettcode.relay import Relayed
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: How long pieces of text are gathered before they are sent as one, so a stream is a few lines a second and not one per token.
|
|
33
|
+
STREAM_BATCH = 0.06
|
|
34
|
+
|
|
35
|
+
#: The most characters of text sent in one stream line, well inside what the broker passes (see ``MAX_STREAM``).
|
|
36
|
+
STREAM_CHUNK = 4000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Messenger:
|
|
40
|
+
"""The shell's and the agent's one handle on the other sessions."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, client: BrokerClient | None = None, *, workspace: str | Path = "") -> None:
|
|
43
|
+
"""Wrap a client; one is built for the default broker when none is given.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
client: The connection to use. Its callbacks are taken over, so give a client that nothing
|
|
47
|
+
else is listening to.
|
|
48
|
+
workspace: The directory this session works in, which other sessions are shown.
|
|
49
|
+
"""
|
|
50
|
+
self.workspace = str(workspace)
|
|
51
|
+
self.client = client if client is not None else BrokerClient()
|
|
52
|
+
self.client.on_message = self._received
|
|
53
|
+
self.client.on_roster = self._roster_changed
|
|
54
|
+
self.client.on_conflict = self._conflicted
|
|
55
|
+
self.client.on_problem = self._troubled
|
|
56
|
+
#: Where a received message goes; the shell sets it. It returns what this session says to the
|
|
57
|
+
#: sender: whether it took the message, and what will become of it.
|
|
58
|
+
self.receiver: Callable[[Relayed], Receipt] | None = None
|
|
59
|
+
#: Called, on the loop, whenever the roster or the connection changes, so a view can repaint.
|
|
60
|
+
self.on_change: Callable[[], None] | None = None
|
|
61
|
+
#: Called with a sentence when another process holds this session's id, and with ``None`` after.
|
|
62
|
+
self.on_conflict: Callable[[str | None], None] | None = None
|
|
63
|
+
#: Called with a sentence when the broker has been out of reach for a while, and with ``None`` when it is back.
|
|
64
|
+
self.on_problem: Callable[[str | None], None] | None = None
|
|
65
|
+
#: The message the running turn answers, if it answers one.
|
|
66
|
+
self.answering: Relayed | None = None
|
|
67
|
+
#: Whether a message the sender marked ``direct`` is run as this session's own prompt; said in the roster.
|
|
68
|
+
self.direct = False
|
|
69
|
+
self._started = False
|
|
70
|
+
self._piece: tuple[str, list[str]] | None = None
|
|
71
|
+
self._flush: asyncio.TimerHandle | None = None
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def create(cls, config: ZettCodeConfig) -> Messenger | None:
|
|
75
|
+
"""Return the messenger this configuration asks for, or ``None`` when sessions are switched off."""
|
|
76
|
+
if not config.sessions_enabled:
|
|
77
|
+
return None
|
|
78
|
+
return cls(workspace=config.workspace)
|
|
79
|
+
|
|
80
|
+
# -- the life of the connection ---------------------------------------------------------------
|
|
81
|
+
@property
|
|
82
|
+
def started(self) -> bool:
|
|
83
|
+
"""Return whether :meth:`start` has run, which is when it is safe to tell it about the session."""
|
|
84
|
+
return self._started
|
|
85
|
+
|
|
86
|
+
async def start(self) -> None:
|
|
87
|
+
"""Begin connecting in the background. Returns at once, whether or not a broker exists."""
|
|
88
|
+
self._started = True
|
|
89
|
+
await self.client.start()
|
|
90
|
+
|
|
91
|
+
async def close(self) -> None:
|
|
92
|
+
"""Leave the broker. Quick, and safe to call twice."""
|
|
93
|
+
self._started = False
|
|
94
|
+
self.receiver = None
|
|
95
|
+
await self.client.close()
|
|
96
|
+
|
|
97
|
+
# -- what this session is ---------------------------------------------------------------------
|
|
98
|
+
def describe(self, session_id: str, title: str | None, *, busy: bool) -> None:
|
|
99
|
+
"""Say what this session is now. Cheap when nothing changed, so callers need not check.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
session_id: The active session.
|
|
103
|
+
title: Its title, or ``None`` while it has none.
|
|
104
|
+
busy: Whether a request or command is running.
|
|
105
|
+
"""
|
|
106
|
+
info = SessionInfo(
|
|
107
|
+
session_id=session_id,
|
|
108
|
+
title=title or "",
|
|
109
|
+
workspace=self.workspace,
|
|
110
|
+
status="working" if busy else "idle",
|
|
111
|
+
direct=self.direct,
|
|
112
|
+
)
|
|
113
|
+
current = self.client.session
|
|
114
|
+
if current is not None and (current.session_id, current.title, current.status, current.direct) == (
|
|
115
|
+
info.session_id,
|
|
116
|
+
info.title,
|
|
117
|
+
info.status,
|
|
118
|
+
info.direct,
|
|
119
|
+
):
|
|
120
|
+
return
|
|
121
|
+
self.client.set_session(info)
|
|
122
|
+
|
|
123
|
+
def retire(self) -> None:
|
|
124
|
+
"""Say that this process has no session to be reached at: it only shows one that another process has open."""
|
|
125
|
+
if self.client.session is not None:
|
|
126
|
+
self.client.set_session(None)
|
|
127
|
+
|
|
128
|
+
# -- the others -------------------------------------------------------------------------------
|
|
129
|
+
@property
|
|
130
|
+
def connected(self) -> bool:
|
|
131
|
+
"""Return whether the broker is reachable right now."""
|
|
132
|
+
return self.client.connected
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def conflict(self) -> str | None:
|
|
136
|
+
"""Return why this session is not registered, or ``None``."""
|
|
137
|
+
return self.client.conflict
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def handle(self) -> str:
|
|
141
|
+
"""Return the name the others call this session by, or ``""`` before it is registered."""
|
|
142
|
+
mine = self.client.session
|
|
143
|
+
if mine is None:
|
|
144
|
+
return ""
|
|
145
|
+
return next((entry.handle for entry in self.client.roster if entry.session_id == mine.session_id), "")
|
|
146
|
+
|
|
147
|
+
def peers(self) -> tuple[RosterEntry, ...]:
|
|
148
|
+
"""Return the other active sessions, from memory: this is called as a draft is typed."""
|
|
149
|
+
return self.client.others()
|
|
150
|
+
|
|
151
|
+
def groups(self) -> tuple[GroupInfo, ...]:
|
|
152
|
+
"""Return the group chats this session may write to, from memory."""
|
|
153
|
+
return self.client.groups
|
|
154
|
+
|
|
155
|
+
async def manage_group(self, action: str, name: str, members: list[str] | None = None) -> GroupInfo | None:
|
|
156
|
+
"""Create a group, change who is in it, leave it or delete it (see :meth:`BrokerClient.manage_group`)."""
|
|
157
|
+
return await self.client.manage_group(action, name, members)
|
|
158
|
+
|
|
159
|
+
async def join_group(self, name: str) -> GroupInfo | None:
|
|
160
|
+
"""Put this session in a group as the broker's owner may (see :meth:`BrokerClient.join_group`)."""
|
|
161
|
+
return await self.client.join_group(name)
|
|
162
|
+
|
|
163
|
+
async def history(self, *, with_: str = "", group: str = "", limit: int = 20) -> tuple[list[dict[str, Any]], str]:
|
|
164
|
+
"""Return stored messages with one session, in a group, or all this session's own (see the client)."""
|
|
165
|
+
return await self.client.history(with_=with_, group=group, limit=limit)
|
|
166
|
+
|
|
167
|
+
# -- watching a turn --------------------------------------------------------------------------
|
|
168
|
+
@property
|
|
169
|
+
def on_stream(self) -> Callable[[str, dict[str, Any]], None] | None:
|
|
170
|
+
"""Called with a watched session's id and one thing its turn said."""
|
|
171
|
+
return self.client.on_stream
|
|
172
|
+
|
|
173
|
+
@on_stream.setter
|
|
174
|
+
def on_stream(self, callback: Callable[[str, dict[str, Any]], None] | None) -> None:
|
|
175
|
+
self.client.on_stream = callback
|
|
176
|
+
|
|
177
|
+
def watch(self, session_id: str) -> None:
|
|
178
|
+
"""Ask to be told what a session's turns say as they happen."""
|
|
179
|
+
self.client.watch(session_id)
|
|
180
|
+
|
|
181
|
+
def unwatch(self, session_id: str) -> None:
|
|
182
|
+
"""Stop being told."""
|
|
183
|
+
self.client.unwatch(session_id)
|
|
184
|
+
|
|
185
|
+
def stream(self, kind: str, **fields: Any) -> None:
|
|
186
|
+
"""Say one thing about the running turn to whoever watches it; a no-op when nobody does.
|
|
187
|
+
|
|
188
|
+
Pieces of text are gathered for :data:`STREAM_BATCH` seconds and go as one; anything else sends what was
|
|
189
|
+
gathered first, so the order is the order they happened in.
|
|
190
|
+
"""
|
|
191
|
+
if not self.client.watched:
|
|
192
|
+
self._piece = None
|
|
193
|
+
if self._flush is not None:
|
|
194
|
+
self._flush.cancel()
|
|
195
|
+
self._flush = None
|
|
196
|
+
return
|
|
197
|
+
if kind in ("text", "thinking"):
|
|
198
|
+
text = fields.get("text", "")
|
|
199
|
+
if self._piece is not None and self._piece[0] != kind:
|
|
200
|
+
self._send_piece()
|
|
201
|
+
if self._piece is None:
|
|
202
|
+
self._piece = (kind, [])
|
|
203
|
+
self._piece[1].append(text)
|
|
204
|
+
if self._flush is None:
|
|
205
|
+
self._flush = asyncio.get_running_loop().call_later(STREAM_BATCH, self._send_piece)
|
|
206
|
+
return
|
|
207
|
+
self._send_piece()
|
|
208
|
+
self.client.publish({"t": kind, **fields})
|
|
209
|
+
|
|
210
|
+
def _send_piece(self) -> None:
|
|
211
|
+
if self._flush is not None:
|
|
212
|
+
self._flush.cancel()
|
|
213
|
+
self._flush = None
|
|
214
|
+
piece, self._piece = self._piece, None
|
|
215
|
+
if piece is None:
|
|
216
|
+
return
|
|
217
|
+
kind, parts = piece
|
|
218
|
+
if kind != "text":
|
|
219
|
+
self.client.publish({"t": kind})
|
|
220
|
+
return
|
|
221
|
+
# A burst of text is cut into lines the broker will pass: one longer than it allows is dropped there, whole.
|
|
222
|
+
text = "".join(parts)
|
|
223
|
+
for start in range(0, len(text), STREAM_CHUNK):
|
|
224
|
+
self.client.publish({"t": "text", "text": text[start : start + STREAM_CHUNK]})
|
|
225
|
+
|
|
226
|
+
# -- sending ----------------------------------------------------------------------------------
|
|
227
|
+
async def send(
|
|
228
|
+
self,
|
|
229
|
+
to: str,
|
|
230
|
+
text: str,
|
|
231
|
+
*,
|
|
232
|
+
via: str = VIA_AGENT,
|
|
233
|
+
direct: bool = False,
|
|
234
|
+
mode: str = MODE_STEER,
|
|
235
|
+
images: Sequence[ImageData] = (),
|
|
236
|
+
) -> Delivered:
|
|
237
|
+
"""Send a message and return once the other session has taken it.
|
|
238
|
+
|
|
239
|
+
A message sent by this session's agent while it is answering another one carries that message's
|
|
240
|
+
hop count on; a message the reader sent starts again at none.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
to: The session's handle or id.
|
|
244
|
+
text: What to say.
|
|
245
|
+
via: How it came: the agent's tool, or the reader.
|
|
246
|
+
direct: Ask a session that can to run it as its own reader's prompt and answer the sender.
|
|
247
|
+
mode: What a busy recipient does with it: ``"steer"`` (the default) hands it to the running request,
|
|
248
|
+
``"followup"`` waits until that request ends.
|
|
249
|
+
images: Pictures to send with a ``direct`` message.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
SendError: Nobody was reached, and why.
|
|
253
|
+
"""
|
|
254
|
+
hops = self.answering.hops if self.answering is not None and via == VIA_AGENT else 0
|
|
255
|
+
return await self.client.send(to, text, hops=hops, via=via, direct=direct, mode=mode, images=images)
|
|
256
|
+
|
|
257
|
+
async def send_group(
|
|
258
|
+
self, group: str, text: str, *, via: str = VIA_AGENT, mode: str = MODE_STEER
|
|
259
|
+
) -> GroupDelivered:
|
|
260
|
+
"""Write to a group chat this session is in; the hop count is carried on as for :meth:`send`.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
SendError: Nobody was reached, and why (``denied`` when this session is not in the group).
|
|
264
|
+
"""
|
|
265
|
+
hops = self.answering.hops if self.answering is not None and via == VIA_AGENT else 0
|
|
266
|
+
return await self.client.send_group(group, text, hops=hops, via=via, mode=mode)
|
|
267
|
+
|
|
268
|
+
# -- what the client reports ------------------------------------------------------------------
|
|
269
|
+
def _received(self, incoming: Incoming) -> Receipt:
|
|
270
|
+
"""Turn the client's message into the shell's and ask the shell to take it."""
|
|
271
|
+
receiver = self.receiver
|
|
272
|
+
if receiver is None:
|
|
273
|
+
return Receipt(False, "this session is not taking messages yet")
|
|
274
|
+
sender = incoming.sender
|
|
275
|
+
return receiver(
|
|
276
|
+
Relayed(
|
|
277
|
+
sender_id=sender.session_id,
|
|
278
|
+
sender=sender.handle,
|
|
279
|
+
text=incoming.text,
|
|
280
|
+
via=incoming.via,
|
|
281
|
+
hops=incoming.hops,
|
|
282
|
+
direct=incoming.direct,
|
|
283
|
+
mode=incoming.mode,
|
|
284
|
+
group=incoming.group,
|
|
285
|
+
images=incoming.images,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _roster_changed(self, roster: tuple[RosterEntry, ...]) -> None:
|
|
290
|
+
if self.on_change is not None:
|
|
291
|
+
self.on_change()
|
|
292
|
+
|
|
293
|
+
def _conflicted(self, message: str | None) -> None:
|
|
294
|
+
if self.on_conflict is not None:
|
|
295
|
+
self.on_conflict(message)
|
|
296
|
+
|
|
297
|
+
def _troubled(self, message: str | None) -> None:
|
|
298
|
+
if self.on_problem is not None:
|
|
299
|
+
self.on_problem(message)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The plugin that hands the agent the tools for talking to the other sessions open on this machine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from zettcode.engine.delivery import DELIVERY, Delivery
|
|
8
|
+
from zettcode.engine.plugins.plugin import Plugin
|
|
9
|
+
from zettcode.engine.plugins.services import MESSENGER
|
|
10
|
+
|
|
11
|
+
from .inbox import INBOX, Inbox
|
|
12
|
+
from .messenger import Messenger
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
15
|
+
from zett_agent.extensions.base import AgentExtension
|
|
16
|
+
|
|
17
|
+
from zettcode.engine.plugins.context import PluginContext
|
|
18
|
+
from zettcode.engine.plugins.state import AgentBuild
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Messaging(Plugin):
|
|
22
|
+
"""The tools that talk to the other sessions open on this machine."""
|
|
23
|
+
|
|
24
|
+
name = "messaging"
|
|
25
|
+
description = "The session messaging tools"
|
|
26
|
+
|
|
27
|
+
def activate(self, ctx: PluginContext) -> None:
|
|
28
|
+
"""Provide the inbox, and register the tools when the runtime gave this session a line to the others.
|
|
29
|
+
|
|
30
|
+
The inbox is provided whether or not there is a messenger: an event from a trigger or a program reaches the
|
|
31
|
+
agent the same way, and a process that never joins the broker still has one.
|
|
32
|
+
"""
|
|
33
|
+
delivery = ctx.find(DELIVERY, object)
|
|
34
|
+
if isinstance(delivery, Delivery):
|
|
35
|
+
ctx.inject(INBOX, Inbox(delivery))
|
|
36
|
+
|
|
37
|
+
messenger = ctx.find(MESSENGER, Messenger)
|
|
38
|
+
if messenger is None:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
def build(_: AgentBuild) -> AgentExtension:
|
|
42
|
+
from .extension import SessionMessagingExtension
|
|
43
|
+
|
|
44
|
+
return SessionMessagingExtension(messenger)
|
|
45
|
+
|
|
46
|
+
ctx.agent.register_extension_factory("SessionMessagingExtension", build)
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""What arrives from another session, and what this one does about it.
|
|
2
|
+
|
|
3
|
+
The broker hands one message at a time to the front end that owns the session's connection, and the answer is a
|
|
4
|
+
receipt the sender's agent reads. The decision has three parts and this class owns the two the inbox cannot know:
|
|
5
|
+
|
|
6
|
+
* whether the message can steer the request that runs — the front end supplies that answer, because steering a message
|
|
7
|
+
from another session goes through the framing a plain prompt does not have;
|
|
8
|
+
* whether it was adopted, so a steer the run never read waits for the next turn instead of being lost;
|
|
9
|
+
* what the sender is told, and what a front end draws while it waits.
|
|
10
|
+
|
|
11
|
+
Everything else — the queue, the order, the per-message cap and starting the turns — is the inbox's
|
|
12
|
+
(:class:`zettcode.messaging.inbox.Inbox`), so a message from another session and an event from elsewhere wait in one
|
|
13
|
+
place and a front end has one starter.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from zettcode.broker.client import Receipt
|
|
22
|
+
from zettcode.modes import MODE_STEER
|
|
23
|
+
from zettcode.relay import Relayed
|
|
24
|
+
|
|
25
|
+
from .inbox import FULL, QUEUED, STARTED, Inbox, Waiting
|
|
26
|
+
|
|
27
|
+
__all__ = ["PREVIEW", "Relay"]
|
|
28
|
+
|
|
29
|
+
#: Characters of a waiting message a front end shows, so a reader can tell which one it is.
|
|
30
|
+
PREVIEW = 60
|
|
31
|
+
|
|
32
|
+
#: Hand a message to the request in flight: the text the run will report it by, or ``None`` when it was not taken.
|
|
33
|
+
Steer = Callable[[Relayed], "str | None"]
|
|
34
|
+
|
|
35
|
+
#: The inbox to put messages in; ``None`` in a process whose messaging part did not load.
|
|
36
|
+
InboxOf = Callable[[], "Inbox | None"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class _Steered:
|
|
41
|
+
"""A message handed to the running request, and the text that run will report it by."""
|
|
42
|
+
|
|
43
|
+
key: str
|
|
44
|
+
relayed: Relayed
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Relay:
|
|
48
|
+
"""The receipt a message from another session gets, and the record of what the running request took.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
busy: Whether a request is running here.
|
|
52
|
+
steer: Hands one message to that request; the text it reports the message by, or ``None`` when it refused.
|
|
53
|
+
inbox: The runtime's inbox, read when a message is delivered (a front end may build its client lazily).
|
|
54
|
+
start: Runs one message as a turn now; only used while the front end says nothing runs.
|
|
55
|
+
on_waiting: Told about each message that starts waiting, so a front end can draw it.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
busy: Callable[[], bool],
|
|
62
|
+
steer: Steer,
|
|
63
|
+
inbox: InboxOf,
|
|
64
|
+
start: Callable[[Relayed], None] = lambda relayed: None,
|
|
65
|
+
on_waiting: Callable[[Relayed], None] = lambda relayed: None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._busy = busy
|
|
68
|
+
self._steer = steer
|
|
69
|
+
self._inbox_of = inbox
|
|
70
|
+
self._start = start
|
|
71
|
+
self._on_waiting = on_waiting
|
|
72
|
+
self._steered: list[_Steered] = []
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def waiting(self) -> tuple[Relayed, ...]:
|
|
76
|
+
"""Return the messages from other sessions waiting for the request in flight, oldest first."""
|
|
77
|
+
inbox = self._inbox_of()
|
|
78
|
+
if inbox is None:
|
|
79
|
+
return ()
|
|
80
|
+
return tuple(item.payload for item in inbox.waiting if isinstance(item.payload, Relayed))
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def steered(self) -> tuple[tuple[str, Relayed], ...]:
|
|
84
|
+
"""Return the messages handed to the request in flight, each with the text it will report it by."""
|
|
85
|
+
return tuple((item.key, item.relayed) for item in self._steered)
|
|
86
|
+
|
|
87
|
+
def receive(self, relayed: Relayed) -> Receipt:
|
|
88
|
+
"""Take one message another session sent: start a turn, steer the running one, or hold it.
|
|
89
|
+
|
|
90
|
+
Called on the loop with the sender waiting for the answer; it must not block.
|
|
91
|
+
"""
|
|
92
|
+
inbox = self._inbox_of()
|
|
93
|
+
if inbox is None and not self._busy():
|
|
94
|
+
# No queue to hand it to, and nothing running: the front end starts it, the way it starts a prompt.
|
|
95
|
+
self._start(relayed)
|
|
96
|
+
return Receipt(True, disposition="started")
|
|
97
|
+
if inbox is None:
|
|
98
|
+
return Receipt(False, "this session has no inbox: the messaging plugin did not load")
|
|
99
|
+
if self._busy() and relayed.mode == MODE_STEER:
|
|
100
|
+
key = self._steer(relayed)
|
|
101
|
+
if key is not None:
|
|
102
|
+
self._steered.append(_Steered(key, relayed))
|
|
103
|
+
return Receipt(True, disposition="steered")
|
|
104
|
+
outcome = self._queue(inbox, relayed)
|
|
105
|
+
if outcome == FULL:
|
|
106
|
+
return Receipt(
|
|
107
|
+
False,
|
|
108
|
+
f"this session already has {len(inbox.waiting)} messages waiting; try again when it is less busy",
|
|
109
|
+
busy=True,
|
|
110
|
+
)
|
|
111
|
+
if outcome == QUEUED:
|
|
112
|
+
self._on_waiting(relayed)
|
|
113
|
+
return Receipt(True, disposition="started" if outcome == STARTED else "queued")
|
|
114
|
+
|
|
115
|
+
def hold(self, key: str, relayed: Relayed) -> None:
|
|
116
|
+
"""Record a message handed to the request in flight, for a front end that steered it itself."""
|
|
117
|
+
self._steered.append(_Steered(key, relayed))
|
|
118
|
+
|
|
119
|
+
def adopted(self, text: str) -> Relayed | None:
|
|
120
|
+
"""Take the message the running request just adopted, so a front end can draw it as one."""
|
|
121
|
+
for index, item in enumerate(self._steered):
|
|
122
|
+
if item.key == text:
|
|
123
|
+
del self._steered[index]
|
|
124
|
+
return item.relayed
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def settle(self) -> tuple[Relayed, ...]:
|
|
128
|
+
"""Put back what the run never adopted, after it ended, and return them in the order handed over.
|
|
129
|
+
|
|
130
|
+
A message a run adopted is not here — it left when the run reported it — so nothing the model has read is run
|
|
131
|
+
again. They go back into the inbox in the order they were handed over, and the next turn answers them.
|
|
132
|
+
"""
|
|
133
|
+
if not self._steered:
|
|
134
|
+
return ()
|
|
135
|
+
inbox = self._inbox_of()
|
|
136
|
+
waiting = [item.relayed for item in self._steered]
|
|
137
|
+
self._steered.clear()
|
|
138
|
+
if inbox is not None:
|
|
139
|
+
# Already accepted, so the cap does not apply: only a new sender is refused when the queue is full.
|
|
140
|
+
inbox.requeue([Waiting(relayed.text, None, "session", relayed) for relayed in waiting])
|
|
141
|
+
for relayed in waiting:
|
|
142
|
+
self._on_waiting(relayed)
|
|
143
|
+
return tuple(waiting)
|
|
144
|
+
|
|
145
|
+
def start_next(self) -> bool:
|
|
146
|
+
"""Ask the inbox to start the next waiting turn, and say whether it did.
|
|
147
|
+
|
|
148
|
+
The front end's own chain calls this after a turn ends, as a second chance for the runtime's wake-up, which
|
|
149
|
+
arrives before the front end is idle.
|
|
150
|
+
"""
|
|
151
|
+
inbox = self._inbox_of()
|
|
152
|
+
if inbox is None:
|
|
153
|
+
return False
|
|
154
|
+
before = len(inbox.waiting)
|
|
155
|
+
inbox.drain()
|
|
156
|
+
return len(inbox.waiting) < before
|
|
157
|
+
|
|
158
|
+
def _queue(self, inbox: Inbox, relayed: Relayed) -> str:
|
|
159
|
+
"""Put one message in the inbox as a follow-up, whatever the sender asked for."""
|
|
160
|
+
return inbox.deliver(relayed.text, mode="followup", payload=relayed, origin="session")
|