zettcode-messaging 0.4.1__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.
@@ -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")
@@ -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,11 @@
1
+ zettcode/messaging/__init__.py,sha256=aqtbQfcApJWCiB4D750rL4xI0pXEGdVbdqgMj1_KnKE,520
2
+ zettcode/messaging/extension.py,sha256=LI4mXHKoc5JGANIVYoUVWyyAmU7XJAUD7W6mFeCrfAM,16215
3
+ zettcode/messaging/inbox.py,sha256=xkOCFZ1UaWRwLC0qJmKkVHT7HPqmCiEn7oemtTs1AbQ,9271
4
+ zettcode/messaging/messenger.py,sha256=FqHjN4Pzuh2bD_4fef0_eJMynyVF2AZH7TZZPKpbWbg,13416
5
+ zettcode/messaging/plugin.py,sha256=0WwDYsISiu0m0Kw4hYjAGAupIY311ggv-SD3omk_xqk,1678
6
+ zettcode/messaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ zettcode/messaging/relay.py,sha256=1wEa6JC0_TFFdZEhhUEwlpUCNu3xo_u7rDu6nuxYQZc,7023
8
+ zettcode_messaging-0.4.1.dist-info/METADATA,sha256=oguHjWZdl3yssedNJ4fxNLUherV4MfTbn6nnu2ExNL0,873
9
+ zettcode_messaging-0.4.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ zettcode_messaging-0.4.1.dist-info/entry_points.txt,sha256=su6mBUrs1EG_mCjOQ_wDJL-jKSuZytjZ1sE-KHHvbLg,65
11
+ zettcode_messaging-0.4.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [zettcode.agent]
2
+ messaging = zettcode.messaging.plugin:Messaging