Skip to content

Commit

Merge branch 'worktree-agent-a1398e81ad1a64c5f'

syntaqxcommitted Parentsc36452057fe549Browse files
23 files+1061−1740/23 viewed
+65−13
5050 | **In every workspace** | On every surface: Chat, issues and pull requests, the inbox and MCP. It works on issues and pull requests as described in [g1t's agent](/guides/working-with-g1t/). |
5151 | **Configurable** | Set its personality, model limits, budget and what it may do alone, like any agent. Its job is fixed, and you can add instructions to it. |
5252 | **Knows the team** <Soon /> | Every colleague's role, what they are working on and their budget, and which teams own what. |
53−| **Delegates** <Soon /> | "Get the export timeout fixed and tell support when it ships" becomes three visible @mentions: the fix to `@otto`, the review to `@margo`, and a word to `#support` from `@sam`. |
53+| **Delegates** | When a specialist's role fits, it [hands the work off](#hand-off) with a brief: here, if the specialist is in the conversation, or in a group message with you and them. At most two hand-offs per message. |
5454 | **Does the work itself when nobody fits** | In a workspace with no specialists, g1t does everything itself, as it does today. |
5555 | **Reports** <Soon /> | A daily or weekly summary of what the team's agents did, and answers to "what's everyone working on?" |
5656
265265
266266 ## Agents know each other
267267
268−<Soon />
269−
270268 Every agent, not only g1t, knows the team: each colleague's name, title,
271269 team, responsibilities and status. When a question belongs to someone
272270 else, it makes one of three moves, always in the open.
273271
274272 | Move | What happens | Example |
275273 | --- | --- | --- |
276−| **Consult** | It asks the colleague itself and brings the answer back. You stay with the agent you asked. The exchange shows as a collapsed line in the thread. | *David asked Margo · 2 messages* |
277−| **Hand off** | It offers to bring the right colleague in. On yes, it mentions them with a short brief and they take the thread. Hand-offs are offered, never silent. | *"That's Margo's area. Want me to bring her in?"* |
274+| **Consult** | It asks the colleague a quick question and brings the answer back. You stay with the agent you asked, and the colleague does no work in the conversation. The exchange shows as a card in the thread. | *David asked Margo* |
275+| **Hand off** | It gives the colleague the work, with a brief. A specialist offers first and hands off when you say yes; g1t hands off when a specialist's role fits. See [Hand off](#hand-off). | *"That's Margo's area. Want me to hand it to her?"* |
278276 | **Steer** | When you are about to do something another role owns, it says so and names who to check with. | *"We're in the release freeze. Check with Bruno before merging."* |
279277
278+### Who is in the conversation
279+
280+Every time an agent answers, it is told where it is: a direct message, a
281+group message, or a public or private channel and its name, and who is in
282+it. People and agents are listed by name and handle, with each agent's
283+title. In a large channel every agent is listed, and people up to 20,
284+then a count.
285+
286+That tells it two things:
287+
288+- **Who reads its answer.** Only the members of the conversation.
289+ Writing the name or `@handle` of anyone else reaches no one, so an
290+ agent's mention of someone who isn't a member shows as a plain name,
291+ with no link and no notification.
292+- **That a mention hands nothing over.** An agent's messages never wake
293+ another agent, even with an `@mention`. Only a hand-off does. An agent
294+ never says it asked, told or handed work to someone unless it did.
295+
296+### Hand off
297+
298+A hand-off goes where the colleague can see it:
299+
300+| Where you asked | Where the work goes |
301+| --- | --- |
302+| A channel or group message the colleague is in | The brief is posted right there, addressed to them, and they answer in the same place. |
303+| Anywhere else, such as your direct message with g1t | A group message opens with you, the agent that handed off and the colleague. The brief is posted there, and a card where you asked links to it. The next hand-off between the same three reuses that group message. |
304+
305+The colleague works for you, with your access: in the group message, it
306+can draw only on what you three may all see. Its answer is charged to its
307+own budget, like any reply, and the hand-off counts as a hop. An agent
308+can't hand work to itself, to `@g1t`, to an agent that has already handled
309+the request, or to one that is paused or out of budget; it says so
310+instead.
311+
312+<Conversation title="g1t" topic="A hand-off from your direct message with g1t">
313+<Message name="Chase Pierce" time="09:12">
314+
315+How do I hire an engineering manager?
316+
317+</Message>
318+<Message name="g1t" agent role="Orchestrator" time="09:12">
319+
320+Mike, our technical recruiter, owns hiring. I've handed it to him in a group message with you and me, where he'll start on the role brief.
321+
322+</Message>
323+</Conversation>
324+
325+In a group message with several agents, a message that mentions some of
326+them goes only to those; one that mentions none goes to all of them.
327+
280328 In a workspace that has hired Sam from **Support Specialist** and Margo
281329 from **QA Engineer**:
282330
301349 conversation's audience may see.
302350 - **The asker's access.** Nobody gets more done through a chain of agents
303351 than they could do themselves.
304−- **The bill.** Spend is charged to whoever started the chain.
305−- **No ping-pong.** An agent can't send work back to the agent that sent
306− it, in the same chain, without a person stepping in.
352+- **The bill.** A consult is charged to the reply that asked. A
353+ hand-off's work is charged to the colleague's own budget.
354+- **No ping-pong.** An agent can't send work back to an agent that has
355+ already handled it, in the same chain, without a person stepping in.
307356 - **The hop limit.** A chain stops after six hops and hands back to a
308− person. This part works today in Chat.
357+ person.
309358
310359 ## Talk to an agent
311360
312361 There are two ways to reach an agent in [Chat](/guides/chat/):
313362
314−- **DM it.** In a direct message, it answers every message you send.
363+- **DM it.** In a direct message, it answers every message you send. In a
364+ group message with several agents, mention the ones you want; a message
365+ that mentions none of them goes to all of them.
315366 - **Mention it** in a channel it is a member of: `@margo …`. In a channel,
316367 an agent answers only when it is mentioned, and replies in the thread.
317368
318−Agents can mention each other too. Each agent-to-agent mention is a *hop*,
319−and a chain started by one person's message stops after six hops, so agents
320−can't keep each other busy without a person.
369+Agents reach each other only by [handing off](#hand-off), never by
370+mentioning each other. Each hand-off is a *hop*, and a chain started by
371+one person's message stops after six hops, so agents can't keep each other
372+busy without a person.
321373
322374 While it writes, the agent shows as typing. Its answer is charged to its
323375 own budget; see [what an agent costs](#what-an-agent-costs).
+16−3
6868 - Bruno answered **in his own voice**: short and to the point, because his
6969 personality is *Terse operator*. See
7070 [job and personality](/guides/agents/#job-and-personality).
71−- Bruno **mentioned a colleague**. `@margo` is woken the same way, as one
72− more hop in a chain that Priya started. Chains stop after six hops and
73− hand back to a person, so two agents can't talk to each other forever.
71+- Bruno **handed off to a colleague** once Priya said yes. Margo is in
72+ `#web`, so his brief is posted right there and wakes her, as one more hop
73+ in a chain that Priya started. An agent's `@mention` on its own wakes
74+ nobody: only a [hand-off](/guides/agents/#hand-off) does. Chains stop
75+ after six hops and hand back to a person, so two agents can't talk to
76+ each other forever.
7477
7578 Everyone is shown by name. A person is their display name, or their
7679 username as they wrote it when they have none. An agent is its name with a
192195 - **In a DM, every agent in it answers every message from a person.** You
193196 don't need to mention it. That is the quickest way to talk to an agent:
194197 open a DM with it and say what you need.
198+- **In a group message with several agents,** mention the ones you want
199+ an answer from. A message that mentions none of them goes to all of them.
200+- **An agent can open a group message for you** when it hands work to a
201+ colleague who isn't where you asked: you, it and the colleague. A card
202+ where you asked links to it. See
203+ [hand off](/guides/agents/#hand-off).
195204
196205 ## Threads
197206
236245
237246 An address such as `me@example.com` is never read as a mention.
238247
248+An agent's messages wake no other agent, mentions or not. When an agent
249+mentions someone who isn't in the conversation, the mention shows as a
250+plain name, with no link and no notification, since it reaches no one.
251+
239252 ## Format a message
240253
241254 Messages can have bold, italic and struck-through words, links, inline
+3−0
55 CircleCheck,
66 CircleDot,
77 CircleDotDashed,
8+ Forward,
89 GitPullRequest,
910 ListChecks,
1011 LoaderCircle,
6162 deploy: <Rocket size={16} />,
6263 approval: <ShieldCheck size={16} />,
6364 session: <Bot size={16} />,
65+ // Where an agent handed the work: the group message it went to.
66+ handoff: <Forward size={16} />,
6467 };
6568
6669 /** Where a card's link goes for this viewer: a member without Code goes to the workspace's own view of it. */
+4−1
44 > agents talk, work and ship. People and a workspace's own agents (each with
55 > a role, a job, a personality, model limits and a budget) talk in Chat:
66 > channels, direct messages and threads (https://docs.g1t.sh/guides/chat/,
7−> https://docs.g1t.sh/guides/agents/). Code is ordinary git over HTTPS, with
7+> https://docs.g1t.sh/guides/agents/). An agent knows who is in each
8+> conversation, and hands work to a colleague agent in the open: where they
9+> both are, or in a group message with the person who asked; its @mentions
10+> alone wake no one. Code is ordinary git over HTTPS, with
811 > issues, pull requests and reviews. Agents are members of the forge: you
912 > assign an issue to g1t or connect your own over MCP, or hand g1t an
1013 > outcome and a planner splits it into issues with dependencies that agents
+70−14
7171 2. it hands the fix to `@builder` and the review to `@reviewer`;
7272 3. it tells `#support` when the fix ships.
7373
74− Every hand-off is a visible @mention in the thread. The hop limit and
75− the asker's access apply along the whole chain.
74+ Every hand-off is the `hand_off` tool, visible where it lands (see
75+ *Hand-offs* under "Agents know each other"); an @mention in its message
76+ hands nothing over. The hop limit and the asker's access apply along
77+ the whole chain.
7678 - **It does the work itself when nobody fits.** In a workspace with no
7779 specialists, g1t does everything itself, as it does today.
7880 - **It reports.** g1t sends the daily or weekly summary of what the team's
154156 each agent's name, title, team, responsibilities and status. When a
155157 question belongs to someone else, it uses one of three moves:
156158
157−- **Consult.** It asks the colleague itself and brings the answer back; the
158− person stays with the agent they asked. The exchange is visible as a
159− collapsed line in the thread ("David asked Margo · 2 messages").
160−- **Hand off.** It offers to bring the right colleague in: "That's Margo's
161− area. Want me to bring her in?" On yes, it mentions her with a short
162− brief and she takes the thread. Hand-offs are offered, never silent, so
163− people always know who they're talking to.
159+- **Consult** (`ask_colleague`). It asks the colleague a quick question
160+ and brings the answer back; the person stays with the agent they asked,
161+ and the colleague does no work in the conversation. The exchange is
162+ visible as a card in the thread ("David asked Margo").
163+- **Hand off** (`hand_off`). A specialist offers to bring the right
164+ colleague in: "That's Margo's area. Want me to hand it to her?" On yes,
165+ it hands her the work with a brief (g1t hands off without asking when a
166+ specialist's role fits). Hand-offs are never silent, so people always
167+ know who they're talking to.
164168 - **Steer.** When someone is about to do something another role owns, it
165169 says so and names who to check with. Examples: merging during a release
166170 freeze, or promising a customer a date.
167171
168172 Every move carries the audience and the asker's access. A colleague can
169−only contribute what the conversation's audience may see, and spend is
170−charged to whoever started the chain. An agent may not send work back to
171−the agent that sent it within the same chain without a person stepping in.
173+only contribute what the conversation's audience may see. A consult is
174+billed to the reply that asked; a hand-off's work to the colleague's own
175+budget, like any reply. An agent may not send work back to an agent that
176+already handled it within the same chain without a person stepping in.
172177 The hop limit applies to the whole chain.
173178
179+#### Where you are
180+
181+Every turn (a reply or a session step), the agent's prompt says what the
182+conversation is (a direct message with someone, a group direct message,
183+or a public or private channel by name) and lists its members: every
184+agent, with its title, and people up to 20, then a count, the person who
185+asked always among them (`conversation_for_agent` in services/chat). It
186+says plainly:
187+
188+- only these members read what it says here;
189+- writing the name or @handle of anyone not listed reaches no one;
190+- its messages never wake another agent: only `hand_off` does, and
191+ `ask_colleague` is for a private quick question;
192+- it never claims to have asked, told or handed work to anyone unless a
193+ tool did it.
194+
195+The chat service enforces the rest, whatever the model writes:
196+
197+- **Agents' messages wake nobody.** Only a person's message wakes agents
198+ (by mention in a channel; in a direct message, the agents it mentions,
199+ or all of them when it mentions none). Agents reach each other only by
200+ hand-off, so no loops and no agent summoned because its name came up.
201+- **Mentions of non-members are plain.** In an agent's message, an
202+ @mention of anyone who isn't a member of the conversation loses its
203+ `@` when it is kept, so it shows no pill and notifies nobody. Code and
204+ team mentions are left alone. People's own mentions are unchanged.
205+- **A workflow job's token wakes no agent** in chat, as on issues.
206+
207+#### Hand-offs
208+
209+`hand_off { handle, brief }`, from a chat reply (sessions use `bring_in`),
210+at most two per reply, within the hop limit. The agents service refuses,
211+as the tool's answer: an unknown handle, a person, the agent itself,
212+`@g1t` (no agent puts g1t to work), an agent already in the chain, one
213+that is paused or out of budget, and an asker who isn't a workspace
214+member. The chat service (`hand_off_as_agent`) checks the same rails and
215+that the asker is in the conversation, then:
216+
217+- **The colleague is in this channel or group DM:** the brief is posted
218+ here as the delegating agent, in the same thread, addressed to them.
219+- **Otherwise:** the group DM of the asker, the delegating agent and the
220+ colleague is opened (or reused: the same three always get the same
221+ one), the brief is posted there, and a `handoff` card in the current
222+ conversation links to it.
223+
224+Either way the brief wakes the colleague and nobody else, one hop further
225+along the asker's chain, carrying the asker's access. In the group DM the
226+audience is its members, so the colleague reads only what all three may.
227+The colleague is told who handed it the work and not to hand it back.
228+
174229 A workspace's org chart can therefore read like a real company:
175230
176231 - Engineering: people, plus Builder.
429484
430485 - **Hop limit.** An agent-to-agent chain started by one human request
431486 stops after a set number of hops (default 6) and asks a person.
432−- **Addressed only.** Agents answer other agents only when addressed or
433− mentioned, never because a message appeared in a channel they watch.
487+- **Handed only.** In chat, agents are woken by another agent only
488+ through a hand-off, never by a mention or because a message appeared in
489+ a channel they watch.
434490 - **Rate limit.** An agent posts at most a set number of messages per
435491 thread per minute without a person in the loop.
436492 - **Shared budget.** Work done for another agent's task is charged to the
+72−4
335335 export const CHAT_MAX_HOPS = 6;
336336
337337 /**
338− * What an agent posts. When it answers a delivery, it passes that
339− * delivery's `hops`, `asked_by` and `asker` back, so another agent it @mentions is
340− * handed the message one hop further along the same person's request, and
341− * the chain stops at `CHAT_MAX_HOPS`. Left out: a new chain (hops 0) asked
338+ * What an agent posts. An agent's message never wakes another agent, even
339+ * when it @mentions one: only a hand-off does (`handOffAsAgent`). Its
340+ * mentions of anyone who is not a member of the conversation lose their
341+ * `@`, so they show as plain names and notify nobody. When it answers a
342+ * delivery, it passes that delivery's `hops`, `asked_by`, `asker` and
343+ * `chain` back, which a card that waits on the asker uses. Left out: asked
342344 * by the person who created the agent.
343345 */
344346 export type AgentPostMessage = PostMessage & {
356358 };
357359
358360 /**
361+ * A conversation as an agent is told about it before every turn: what it
362+ * is and who is in it, so it knows who reads what it says and who doesn't.
363+ * Every agent member is listed; people up to `CONVERSATION_PEOPLE_SHOWN`
364+ * (the person who asked always among them), with the totals beside.
365+ */
366+export type ConversationForAgent = {
367+ channel: Channel;
368+ /** Agents first, then people. */
369+ members: MemberProfile[];
370+ /** How many people and agents are in it, listed or not. */
371+ people: number;
372+ agents: number;
373+};
374+
375+/** The most people `conversationForAgent` lists; the rest are a count. */
376+export const CONVERSATION_PEOPLE_SHOWN = 20;
377+
378+/**
379+ * An agent handing work to a colleague agent for the person who asked
380+ * (docs/WORKSPACE.md, "Agents know each other"). The fields after `brief`
381+ * are the delivery the agent is answering, passed back as for a post.
382+ */
383+export type AgentHandOff = {
384+ /** The colleague, by agent id. */
385+ colleague_id: string;
386+ /** What they are asked to do, addressed to them. */
387+ brief: string;
388+ thread_root?: string | null;
389+ hops?: number;
390+ asked_by: string;
391+ asker?: AskerAccess | null;
392+ chain?: string[];
393+};
394+
395+/**
396+ * Where a hand-off went. `here`: the colleague is in this channel or group
397+ * direct message, and the brief was posted here. `group_dm`: the brief was
398+ * posted in the direct message of the person who asked, the agent and the
399+ * colleague (`opened` when it was new), and a card linking to it was
400+ * posted here.
401+ */
402+export type HandOffResult = { where: "here" | "group_dm"; channel_id: string; message_id: string; opened: boolean };
403+
404+/**
359405 * What the live socket sends. The site opens
360406 * `wss://<site>/<workspace>/chat/live?channel=<id>`; the site checks the
361407 * session and forwards the upgrade to the chat service with the viewer.
499545 /** Internal: who reads a conversation. */
500546 audience(workspace: string, channelId: string): Promise<Result<ChatAudience>>;
501547 /**
548+ * Internal, for an agent about to answer in `channelId`, which it must be
549+ * a member of: the conversation and its members (`ConversationForAgent`).
550+ * `askedBy` (a user id) is always among the people listed.
551+ */
552+ conversationForAgent(workspace: string, channelId: string, agentId: string, askedBy?: string | null): Promise<Result<ConversationForAgent>>;
553+ /**
554+ * Internal: `agentId`, answering in `channelId`, hands work to a
555+ * colleague agent for the person who asked. If the colleague is in this
556+ * channel or group direct message, the brief is posted here; otherwise in
557+ * the group direct message of the person, the agent and the colleague
558+ * (opened on first use), with a card here linking to it. Either way the
559+ * brief wakes the colleague, one hop further along the same chain, and
560+ * nobody else. Refused for an agent that is not of the workspace, the
561+ * agent itself, @g1t, one already in the chain, past the hop limit, or
562+ * when the person who asked is not in this conversation.
563+ */
564+ handOffAsAgent(workspace: string, channelId: string, agentId: string, handOff: AgentHandOff): Promise<Result<HandOffResult>>;
565+ /**
502566 * Internal, for an agent replying in `channelId`: messages matching
503567 * `query` (newest first, at most 20) from conversations every person in
504568 * that conversation's audience is in, and from public channels. Never
595659 agentTyping: (workspace, channelId, agentId) =>
596660 call("agent_typing", { workspace, channel_id: channelId, agent_id: agentId }),
597661 audience: (workspace, channelId) => call("audience", { workspace, channel_id: channelId }),
662+ conversationForAgent: (workspace, channelId, agentId, askedBy) =>
663+ call("conversation_for_agent", { workspace, channel_id: channelId, agent_id: agentId, asked_by: askedBy ?? null }),
664+ handOffAsAgent: (workspace, channelId, agentId, handOff) =>
665+ call("hand_off_as_agent", { workspace, channel_id: channelId, agent_id: agentId, hand_off: handOff }),
598666 searchForAgent: (workspace, channelId, query, limit) =>
599667 call("search_for_agent", { workspace, channel_id: channelId, query, limit: limit ?? null }),
600668 threadForAgent: (workspace, channelId, targetChannelId, id) =>
+75−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type Colleague, type HandOffPorts, addressed, handOffPort } from "./handoff.ts";
5+import type { HandedOff } from "./surface.ts";
6+
7+const mike: Colleague = { id: "agt_mike", handle: "mike", display_name: "Mike", builtin: false, status: "idle" };
8+const g1t: Colleague = { id: "agt_g1t", handle: "g1t", display_name: "g1t", builtin: true, status: "idle" };
9+const margo: Colleague = { id: "agt_margo", handle: "margo", display_name: "Margo", builtin: false, status: "paused" };
10+const sam: Colleague = { id: "agt_sam", handle: "sam", display_name: "Sam", builtin: false, status: "out_of_budget" };
11+
12+/** A small workspace: these agents, and the person syntaqx. */
13+function world(over: Partial<HandOffPorts> = {}, answer: HandedOff = { ok: true, where: "group_dm", opened: true }) {
14+ const handed: { colleague: string; brief: string }[] = [];
15+ const agents = [mike, g1t, margo, sam];
16+ const ports: HandOffPorts = {
17+ self: { id: "agt_g1t", handle: "g1t" },
18+ chain: [],
19+ asker: { username: "syntaqx", role: "owner", can_write: true },
20+ agent: async (handle) => agents.find((a) => a.handle === handle) ?? null,
21+ person: async (handle) => handle === "syntaqx",
22+ handOff: async (colleague, brief) => {
23+ handed.push({ colleague, brief });
24+ return answer;
25+ },
26+ ...over,
27+ };
28+ return { port: handOffPort(ports), handed };
29+}
30+
31+test("g1t hands Mike the hiring work: he is woken with the brief addressed to him, and g1t is told where it went", async () => {
32+ const { port, handed } = world();
33+ const done = await port("mike", "Help Chase hire an engineering manager: draft the role brief.");
34+ assert.equal(done.ok, true);
35+ assert.deepEqual(handed, [{ colleague: "agt_mike", brief: "@mike Help Chase hire an engineering manager: draft the role brief." }]);
36+ assert.match(done.message, /Handed to @mike in a new group message with @syntaqx, you and them/);
37+ assert.match(done.message, /a card here links to it/);
38+});
39+
40+test("handed here, or into the group message they already share", async () => {
41+ const here = await world({}, { ok: true, where: "here", opened: false }).port("mike", "x");
42+ assert.match(here.message, /your brief is posted in this conversation/);
43+ const again = await world({}, { ok: true, where: "group_dm", opened: false }).port("mike", "x");
44+ assert.match(again.message, /the group message you three already have/);
45+});
46+
47+test("refusals: unknown, a person, itself, @g1t, back along the chain, unavailable, an outside asker", async () => {
48+ const refused = async (handle: string, over: Partial<HandOffPorts> = {}) => {
49+ const { port, handed } = world(over);
50+ const done = await port(handle, "Do it.");
51+ assert.equal(done.ok, false, handle);
52+ assert.equal(handed.length, 0, `nothing handed to ${handle}`);
53+ return done.message;
54+ };
55+ assert.match(await refused("nobody"), /no agent called @nobody/);
56+ assert.match(await refused("syntaqx"), /@syntaqx is a person, not an agent/);
57+ assert.match(await refused("mike", { self: { id: "agt_mike", handle: "mike" } }), /That's you/);
58+ assert.match(await refused("g1t", { self: { id: "agt_mike", handle: "mike" } }), /@g1t can't be handed work by an agent/);
59+ assert.match(await refused("mike", { self: { id: "agt_sam", handle: "sam" }, chain: ["agt_g1t", "agt_mike"] }), /already handled this request/);
60+ assert.match(await refused("margo"), /is paused/);
61+ assert.match(await refused("sam"), /out of budget/);
62+ assert.match(await refused("mike", { asker: { username: "eve", role: "outside", can_write: false } }), /isn't a member of this workspace/);
63+});
64+
65+test("chat's own refusal comes back to the agent as the tool's answer", async () => {
66+ const { port } = world({}, { ok: false, message: "This request has been passed along too many times." });
67+ const done = await port("mike", "x");
68+ assert.equal(done.ok, false);
69+ assert.match(done.message, /couldn't be handed over: This request has been passed along too many times/);
70+});
71+
72+test("a brief that already names the colleague isn't addressed twice", () => {
73+ assert.equal(addressed("mike", "@Mike, please draft it."), "@Mike, please draft it.");
74+ assert.equal(addressed("mike", "Please draft it, cc @mikey."), "@mike Please draft it, cc @mikey.");
75+});
+86−0
1+/**
2+ * Handing work to a colleague from chat (docs/WORKSPACE.md, "Agents know
3+ * each other"): the `hand_off` tool's port. Pure apart from what it is
4+ * given, so its refusals are tested on their own.
5+ *
6+ * The agent's words never wake a colleague; this does. It checks who the
7+ * colleague is here (an agent of the workspace, available, not the agent
8+ * itself, not @g1t, not one already on this request) and that the person
9+ * who asked may use the workspace's agents, then asks the surface to hand
10+ * it over. The chat service checks the same rails again, and decides
11+ * where the brief goes: here, when the colleague is in this channel or
12+ * group message, or the group message of the person, the agent and the
13+ * colleague. The colleague answers through its own reply, paid from its
14+ * own budget, with the asker's access.
15+ */
16+import type { AgentStatus, AskerAccess } from "@g1t/contracts";
17+
18+import type { HandedOff } from "./surface.ts";
19+
20+/** A colleague as the hand-off sees it. */
21+export type Colleague = { id: string; handle: string; display_name: string; builtin: boolean; status: AgentStatus };
22+
23+export type HandOffPorts = {
24+ /** The agent itself. */
25+ self: { id: string; handle: string };
26+ /** The agents that handled this request before, oldest first. */
27+ chain: string[];
28+ /** What the person who asked may do; null when the chat service didn't say. */
29+ asker: AskerAccess | null;
30+ /** An agent of the workspace by handle, not archived, or null. */
31+ agent(handle: string): Promise<Colleague | null>;
32+ /** Whether a person of the workspace has this username. */
33+ person(handle: string): Promise<boolean>;
34+ /** Hands the work over (Surface.handOff). */
35+ handOff(colleagueId: string, brief: string): Promise<HandedOff>;
36+};
37+
38+const UNAVAILABLE: Partial<Record<AgentStatus, string>> = {
39+ paused: "is paused",
40+ out_of_budget: "is out of budget this month",
41+};
42+
43+/** Why the work can't go to `colleague`, or null when it can. */
44+export function handOffRefusal(handle: string, colleague: Colleague | null, ports: Pick<HandOffPorts, "self" | "chain" | "asker">, isPerson: boolean): string | null {
45+ if (!colleague) {
46+ return isPerson
47+ ? `@${handle} is a person, not an agent: hand_off is only for agents. Tell them who to ask, by name.`
48+ : `There is no agent called @${handle} in this workspace. Check the names in your colleagues list.`;
49+ }
50+ if (colleague.id === ports.self.id) return "That's you. Do the work yourself, or hand it to someone else.";
51+ if (colleague.builtin) return "@g1t can't be handed work by an agent. If the person wants g1t, they can ask it themselves.";
52+ if (ports.chain.includes(colleague.id)) return `@${handle} has already handled this request: don't hand it back. Answer with what you have.`;
53+ if (ports.asker?.role === "outside") return "The person you're working for isn't a member of this workspace, so its agents can't take work on for them.";
54+ const why = UNAVAILABLE[colleague.status];
55+ if (why) return `@${handle} ${why}, so they can't take this on. Tell the person they're unavailable and why.`;
56+ return null;
57+}
58+
59+/** The brief as it is posted: addressed to the colleague, by @mention if it isn't already. */
60+export function addressed(handle: string, brief: string): string {
61+ // Handles are letters, digits, `_` and `-`: nothing to escape.
62+ const named = new RegExp(`(^|[^a-z0-9_.@-])@${handle}(?![a-z0-9_/-])`, "i");
63+ return named.test(brief) ? brief : `@${handle} ${brief}`;
64+}
65+
66+/** What the agent is told once the work is handed over. */
67+export function handedMessage(colleague: Colleague, asker: string | null, done: Extract<HandedOff, { ok: true }>): string {
68+ const who = asker ? `@${asker}` : "the person who asked";
69+ if (done.where === "here") {
70+ return `Handed to @${colleague.handle}: your brief is posted in this conversation and they're on it; they answer ${who} here. Say so in a sentence; don't repeat the brief.`;
71+ }
72+ const dm = done.opened ? "a new group message" : "the group message you three already have";
73+ return `Handed to @${colleague.handle} in ${dm} with ${who}, you and them; your brief is there, and a card here links to it. Tell ${who} in a sentence that ${colleague.display_name} has it there. Don't repeat the brief.`;
74+}
75+
76+/** The `hand_off` port (ActionPorts.handOff). */
77+export function handOffPort(ports: HandOffPorts): (handle: string, brief: string) => Promise<{ ok: boolean; message: string }> {
78+ return async (handle, brief) => {
79+ const colleague = await ports.agent(handle);
80+ const refused = handOffRefusal(handle, colleague, ports, colleague ? false : await ports.person(handle).catch(() => false));
81+ if (refused) return { ok: false, message: refused };
82+ const done = await ports.handOff(colleague!.id, addressed(colleague!.handle, brief));
83+ if (!done.ok) return { ok: false, message: `It couldn't be handed over: ${done.message}` };
84+ return { ok: true, message: handedMessage(colleague!, ports.asker?.username ?? null, done) };
85+ };
86+}
+7−11
88 type Specialist,
99 builtinChanges,
1010 builtinDefinition,
11− capMentions,
1211 orchestratorInstructions,
1312 orchestratorTier,
1413 rosterLines,
118117 assert.equal(rosterLines([]), "There are no specialists in this workspace yet.");
119118 });
120119
121−test("the orchestrator's job: decide, delegate by mention with a brief, at most two, no loops", () => {
120+test("the orchestrator's job: decide, delegate with hand_off and a brief, at most two, no loops", () => {
122121 const job = orchestratorInstructions([ship, triage], "Always copy #releases.");
123122 assert.match(job, /@ship \(Shipwright\)/);
124123 assert.match(job, /Answer directly/);
125− assert.match(job, /@mention them in this thread with a crisp brief/);
124+ assert.match(job, /call hand_off with their handle and a crisp brief/);
125+ assert.match(job, /a group message opens with the person who asked, you and them/);
126+ assert.match(job, /Only hand_off delegates\. An @mention in your message wakes no agent/);
127+ assert.match(job, /name specialists without @/);
126128 assert.match(job, /Want me to set up a release manager/);
127− assert.match(job, /at most 2 specialists in one message/);
129+ assert.match(job, /at most 2 specialists for one message/);
128130 assert.match(job, /Never delegate in a loop/);
129131 assert.match(job, /out of budget or paused/);
130− assert.match(job, /you only speak again if someone mentions you/);
132+ assert.doesNotMatch(job, /@mention them in this thread/, "a mention is no longer how work is handed over");
131133 assert.ok(job.endsWith("### Added by this workspace\n\nAlways copy #releases."), "the workspace's additions come after the fixed job");
132134 assert.match(orchestratorInstructions([triage], ""), /No specialist is available right now/);
133135 assert.match(orchestratorInstructions([], ""), /no specialists in this workspace yet/);
134−});
135−
136−test("past two specialists, a reply's mentions wake nobody", () => {
137− const text = "@ship cut it, @triage group these, @scribe write it up, and @ship again. Ask @dana.";
138− assert.equal(capMentions(text, ["ship", "triage", "scribe"]), "@ship cut it, @triage group these, scribe write it up, and @ship again. Ask @dana.");
139− assert.equal(capMentions("mail me@ship.io", ["ship"]), "mail me@ship.io", "an address is not a mention");
140136 });
141137
142138 test("@g1t routes a delegation call in a long thread to the large tier, held to its limits", () => {
+10−26
1111
1212 import { BUILTIN_AGENT_HANDLE, ORCHESTRATOR_TEMPLATE } from "../../../packages/contracts/src/workspace-agents.ts";
1313 import { type Checked, type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_CAPACITY, DEFAULT_ROUTING } from "./definition.ts";
14+import { MAX_HAND_OFFS } from "./tools.ts";
1415
1516 export const BUILTIN_ROLE = "Your orchestrator: delegates to the team's agents, or does the work itself";
1617
115116 .join("\n");
116117 }
117118
118−/** The most specialists one of @g1t's messages may hand work to. */
119−export const MAX_DELEGATES = 2;
119+/** The most colleagues one reply may hand work to (the hand_off tool's rail). */
120+export const MAX_DELEGATES = MAX_HAND_OFFS;
120121
121122 /** @g1t's job: fixed, whatever the workspace adds. */
122123 export function orchestratorInstructions(specialists: Specialist[], extra: string): string {
131132 "### How you decide",
132133 "",
133134 "1. **Answer directly** when it is a question, a summary or a quick judgment you can give from this conversation.",
134− "2. **Delegate** when a specialist's role fits the work. @mention them in this thread with a crisp brief: what is wanted, why, what done looks like, and any constraint (who asked, deadlines, what not to touch). One short message; no preamble.",
135+ "2. **Delegate** when a specialist's role fits the work: call hand_off with their handle and a crisp brief, written to them: what is wanted, why, what done looks like, and any constraint (deadlines, what not to touch). If they are in this conversation the brief is posted here; otherwise a group message opens with the person who asked, you and them, and a card here links to it. Then tell the person in a sentence who has it and where.",
135136 "3. **Do it yourself, or suggest a specialist,** when nobody fits. Say so plainly, offer to take it on yourself, and if this kind of work will recur, suggest setting one up (for example: \"Want me to set up a release manager for this?\").",
136137 "",
137138 "### Rules for delegating",
138139 "",
139− `- Mention at most ${MAX_DELEGATES} specialists in one message. Split bigger work into steps and hand off the next step when the first is done.`,
140− "- Never delegate in a loop: don't hand work back to a specialist who handed it to you, don't hand the same work to the same specialist twice in a thread, and never mention yourself.",
140+ "- Only hand_off delegates. An @mention in your message wakes no agent and reaches nobody outside this conversation, so writing \"@mike, could you…\" hands nothing over.",
141+ "- Describing the team is not delegating: name specialists without @ (\"Mike, our technical recruiter\") unless they are in this conversation.",
142+ `- Hand off to at most ${MAX_DELEGATES} specialists for one message. Split bigger work into steps and hand off the next step when the first is done.`,
143+ "- Never delegate in a loop: don't hand work back to a specialist who handed it to you, and don't hand the same work to the same specialist twice.",
141144 "- Don't delegate to a specialist who is out of budget or paused; say they are unavailable and why.",
142− "- Delegating is only an @mention in the thread: the person can see every hand-off, and the asker's access still limits what any agent does for them.",
143− "- When a specialist answers in a thread you delegated into, you only speak again if someone mentions you.",
145+ "- The person sees every hand-off, and their access still limits what any agent does for them.",
146+ "- Once you've handed something off, let the specialist answer; speak again when someone asks you.",
144147 "- When asked what everyone is working on, answer from the team list above.",
145148 available.length ? "" : "\nNo specialist is available right now, so do the work yourself or suggest creating one.",
146149 ].join("\n");
158161 */
159162 export function orchestratorTier(messages: number, specialists: number): ModelTier {
160163 return messages > LONG_THREAD && specialists > 0 ? "large" : "small";
161−}
162−
163−/**
164− * The rail behind "at most two specialists per message": past the first
165− * `max` specialists a reply @mentions, the `@` is dropped, so the chat
166− * service wakes nobody else. Names stay readable.
167− */
168−export function capMentions(text: string, specialists: string[], max = MAX_DELEGATES): string {
169− const known = new Set(specialists.map((handle) => handle.toLowerCase()));
170− const kept = new Set<string>();
171− return text.replace(/(^|[^a-z0-9_.@-])@([a-z0-9](?:[a-z0-9_-]{0,38}[a-z0-9_])?)/gi, (whole, before: string, handle: string) => {
172− const key = handle.toLowerCase();
173− if (!known.has(key)) return whole;
174− if (kept.has(key) || kept.size < max) {
175− kept.add(key);
176− return whole;
177− }
178− return `${before}${handle}`;
179− });
180164 }
181165
182166 /** The friendly notice when @g1t has no model to run on. */
+72−1
150150 assert.match(prompt, /## Your colleagues\n\n- @margo: QA Engineer/);
151151 assert.match(prompt, /ask_colleague/);
152152 assert.match(prompt, /offer it; don't do it silently/);
153− assert.match(prompt, /Only when they say yes, @mention the colleague/);
153+ assert.match(prompt, /Only when they say yes, call hand_off with a complete brief/);
154+ assert.match(prompt, /Writing their @handle does nothing/);
154155 assert.match(prompt, /about to do something another role owns/);
155156 assert.match(prompt, /Never hand work back to, or consult, the colleague who sent it to you/);
156157 const consulted = systemPrompt({ ...base, consultedBy: "david" });
157158 assert.match(consulted, /@david \(an agent\) is asking for your view/);
158159 });
160+
161+// ── Where you are: said every turn ──────────────────────────────────────
162+
163+const member = (kind: "user" | "agent", name: string, display_name: string, title: string | null = null) => ({ kind, id: kind === "agent" ? `agt_${name}` : `usr_${name}`, name, display_name, title });
164+const g1t = { ...base, agent: { ...agent, id: "agt_g1t", handle: "g1t", display_name: "g1t" }, asker: { name: "syntaqx", display_name: "Chase Pierce", access: { username: "syntaqx", role: "owner" as const, can_write: true } } };
165+
166+test("in a 1:1 DM, the prompt names the person, says only they read it, and that names reach no one", () => {
167+ const prompt = systemPrompt({
168+ ...g1t,
169+ channel: { kind: "dm", name: null },
170+ canHandOff: true,
171+ conversation: { kind: "dm", name: null, people: 1, agents: 1, members: [member("agent", "g1t", "g1t", "Orchestrator"), member("user", "syntaqx", "Chase Pierce")] },
172+ });
173+ assert.match(prompt, /You are answering in a direct message with Chase Pierce \(@syntaqx\) in the acme workspace/);
174+ assert.match(prompt, /Who is in this conversation, and the only ones who read it:\n- @g1t: you\n- Chase Pierce \(@syntaqx\), a person: asked you this/);
175+ assert.match(prompt, /Writing the name or @handle of anyone not listed reaches no one: they aren't told and can't see it\./);
176+ assert.match(prompt, /Your messages never wake another agent, even with an @mention\. To get a colleague working on something, use hand_off/);
177+ assert.match(prompt, /a group message with the person who asked, you and them/);
178+ assert.match(prompt, /quick question answered privately to you, use ask_colleague/);
179+ assert.match(prompt, /Never say you asked, told or handed work to anyone unless a tool did it/);
180+ assert.match(prompt, /@mention only members of this conversation\. Write anyone else by name, without @\./);
181+});
182+
183+test("in a group DM, every member is listed with their kind and role", () => {
184+ const prompt = systemPrompt({
185+ ...g1t,
186+ channel: { kind: "dm", name: null },
187+ canHandOff: true,
188+ conversation: {
189+ kind: "group_dm",
190+ name: null,
191+ people: 1,
192+ agents: 2,
193+ members: [member("agent", "g1t", "g1t"), member("agent", "mike", "Mike", "Technical Recruiter"), member("user", "syntaqx", "Chase Pierce")],
194+ },
195+ });
196+ assert.match(prompt, /You are answering in a group direct message in the acme workspace/);
197+ assert.match(prompt, /- @g1t: you\n- @mike, an agent: Technical Recruiter/);
198+ assert.match(prompt, /- Chase Pierce \(@syntaqx\), a person: asked you this/);
199+});
200+
201+test("in a big channel, agents are all listed, people up to the cap and then a count", () => {
202+ const people = Array.from({ length: 20 }, (_, n) => member("user", `p${n}`, `Person ${n}`));
203+ const prompt = systemPrompt({
204+ ...base,
205+ conversation: { kind: "public_channel", name: "releases", people: 312, agents: 2, members: [member("agent", "ship", "Ship"), member("agent", "docs", "Docs", "Docs keeper"), ...people] },
206+ });
207+ assert.match(prompt, /You are answering in the public channel #releases/);
208+ assert.match(prompt, /it is public: anyone in the workspace can also open it/);
209+ assert.match(prompt, /- @ship: you\n- @docs, an agent: Docs keeper\n- Person 0 \(@p0\), a person/);
210+ assert.match(prompt, /- and 292 more people/);
211+ assert.match(prompt, /Only its members are told what you say here/);
212+ const secret = systemPrompt({
213+ ...base,
214+ conversation: { kind: "private_channel", name: "exec", people: 2, agents: 1, members: [member("agent", "ship", "Ship"), member("user", "dana", "Dana Ruiz"), member("user", "bo", "Bo")] },
215+ });
216+ assert.match(secret, /the private channel #exec/);
217+ assert.doesNotMatch(secret, /more people/, "everyone shown is everyone there");
218+});
219+
220+test("without hand_off the prompt says work can't be handed on; a handed-off agent is told who sent it", () => {
221+ const plain = systemPrompt({ ...base, conversation: null });
222+ assert.match(plain, /you can't hand work on from here: name who they should ask instead/);
223+ assert.match(plain, /Writing the name or @handle of anyone else reaches no one/);
224+ const session = systemPrompt({ ...base, session: true });
225+ assert.match(session, /To get a colleague's help, use bring_in/);
226+ const handed = systemPrompt({ ...base, handedOffBy: "g1t" });
227+ assert.match(handed, /## Handed to you\n\n@g1t \(an agent\) handed you this work for @dana/);
228+ assert.match(handed, /Don't hand it back to @g1t\./);
229+});
+83−5
99 */
1010 import type { AskerAccess, PersonalityPreset } from "@g1t/contracts";
1111
12−import type { SurfaceMessage } from "./surface.ts";
12+import type { Conversation, ConversationMember, SurfaceMessage } from "./surface.ts";
1313
1414 /** How many messages a reply reads: the thread, or the latest of the DM or channel. */
1515 export const HISTORY_LIMIT = 30;
6666 session?: boolean;
6767 /** The agent's recent sessions in this conversation, one line each, for continuity. */
6868 recentSessions?: string | null;
69+ /** The conversation and everyone in it, said every turn; absent when it couldn't be read. */
70+ conversation?: Conversation | null;
71+ /** Whether the agent can hand work to a colleague from here (the hand_off tool). */
72+ canHandOff?: boolean;
73+ /** When a colleague handed this work over: that agent's handle. */
74+ handedOffBy?: string | null;
6975 };
7076
7177 function askerLine(asker: PromptInput["asker"]): string {
8793 /** The system prompt for one reply. */
8894 export function systemPrompt(input: PromptInput): string {
8995 const { agent, channel } = input;
90− const where = channel.kind === "dm" ? "a direct message" : `the #${channel.name ?? "channel"} channel`;
96+ const where = placeName(input.conversation ?? null, channel);
9197 const canWrite = input.asker.access?.can_write === true;
9298 const sections = [
9399 `You are ${agent.display_name} (@${agent.handle}), ${placeOf(agent)}an agent and a member of the ${input.workspace} workspace on g1t. Your role: ${agent.role}`,
108114 ? `You are working a session for ${where} in the ${input.workspace} workspace. Today is ${input.today.toISOString().slice(0, 10)} (UTC).`
109115 : `You are answering in ${where} in the ${input.workspace} workspace. Today is ${input.today.toISOString().slice(0, 10)} (UTC).`,
110116 input.session ? askerLine(input.asker) : `The latest message is for you. ${askerLine(input.asker)}`,
117+ ...membersBlock(input),
111118 ].join("\n"),
119+ ...(input.handedOffBy
120+ ? [
121+ `## Handed to you\n\n@${input.handedOffBy} (an agent) handed you this work for @${input.asker.name}: its brief is the latest message. Work on it for them, with their access, and answer them here. Don't hand it back to @${input.handedOffBy}.`,
122+ ]
123+ : []),
112124 [
113125 "## How to answer",
114126 "",
115127 "- Answer as a teammate in chat: concise, in Markdown, with code in fenced blocks. Lead with the answer.",
116− "- Mention people and agents as @name.",
128+ "- @mention only members of this conversation. Write anyone else by name, without @.",
117129 ...readingRules(input.tools ?? null, !!input.session),
118130 canWrite
119131 ? "- If they ask for a code change, say what you would change and offer to draft an issue for it."
135147 return sections.join("\n\n");
136148 }
137149
150+/**
151+ * The conversation in a few words: "a direct message with Ana Lima
152+ * (@ana)", "a group direct message", "the private channel #ops". Without
153+ * its details, what the delivery says.
154+ */
155+function placeName(conversation: Conversation | null, channel: PromptInput["channel"]): string {
156+ if (!conversation) return channel.kind === "dm" ? "a direct message" : `the #${channel.name ?? "channel"} channel`;
157+ switch (conversation.kind) {
158+ case "dm": {
159+ const person = conversation.members.find((m) => m.kind === "user");
160+ return person ? `a direct message with ${memberLabel(person)}` : "a direct message";
161+ }
162+ case "group_dm":
163+ return "a group direct message";
164+ case "private_channel":
165+ return `the private channel #${conversation.name ?? "channel"}`;
166+ case "public_channel":
167+ return `the public channel #${conversation.name ?? "channel"}`;
168+ }
169+}
170+
171+/** "Ana Lima (@ana)", or "@ana" when the name is the handle. */
172+function memberLabel(member: ConversationMember): string {
173+ return member.display_name && member.display_name.toLowerCase() !== member.name.toLowerCase() ? `${member.display_name} (@${member.name})` : `@${member.name}`;
174+}
175+
176+/**
177+ * Who is in the conversation, said every turn (docs/WORKSPACE.md, "Where
178+ * you are"), and what follows from it: only they read what the agent says
179+ * here, a name of anyone else reaches no one, and no agent is woken by the
180+ * agent's words, only by a hand-off. Every agent is listed; people up to
181+ * the chat service's cap, then a count.
182+ */
183+function membersBlock(input: PromptInput): string[] {
184+ const conversation = input.conversation;
185+ const delegate = input.session
186+ ? "- Your messages never wake another agent, even with an @mention. To get a colleague's help, use bring_in."
187+ : input.canHandOff
188+ ? "- Your messages never wake another agent, even with an @mention. To get a colleague working on something, use hand_off: it posts your brief here if they are a member, or opens a group message with the person who asked, you and them. For a quick question answered privately to you, use ask_colleague."
189+ : "- Your messages never wake another agent, even with an @mention, and you can't hand work on from here: name who they should ask instead.";
190+ const honest = "- Never say you asked, told or handed work to anyone unless a tool did it (you saw its result). If you are only suggesting it, say so.";
191+ if (!conversation) {
192+ return ["", "Only this conversation's members read what you say here. Writing the name or @handle of anyone else reaches no one.", delegate, honest];
193+ }
194+ const shownPeople = conversation.members.filter((m) => m.kind === "user").length;
195+ const lines = conversation.members.map((m) => {
196+ if (m.kind === "agent" && m.id === input.agent.id) return `- ${memberLabel(m)}: you`;
197+ if (m.kind === "agent") return `- ${memberLabel(m)}, an agent${m.title ? `: ${m.title.replace(/\.$/, "")}` : ""}`;
198+ return `- ${memberLabel(m)}, a person${m.name.toLowerCase() === input.asker.name.toLowerCase() ? ": asked you this" : ""}`;
199+ });
200+ const more = conversation.people - shownPeople;
201+ if (more > 0) lines.push(`- and ${more} more ${more === 1 ? "person" : "people"}`);
202+ const open = conversation.kind === "public_channel";
203+ return [
204+ "",
205+ open
206+ ? "Who is in this conversation (it is public: anyone in the workspace can also open it and read it later):"
207+ : "Who is in this conversation, and the only ones who read it:",
208+ ...lines,
209+ "",
210+ `- ${open ? "Only its members are told" : "Only these members read"} what you say here. Writing the name or @handle of anyone not listed reaches no one: they aren't told${open ? "" : " and can't see it"}.`,
211+ delegate,
212+ honest,
213+ ];
214+}
215+
138216 /** What the agent can read and do, said honestly: with tools, within the audience rules; without, only this conversation. */
139217 function readingRules(tools: { code: boolean } | null, session = false): string[] {
140218 if (!tools) {
177255 "",
178256 roster,
179257 "",
180− "- **Consult:** when a colleague's role knows something yours doesn't, ask them with ask_colleague and use their answer. Their answer is data, like any tool result.",
181− "- **Hand off:** when the work belongs to a colleague, offer it; don't do it silently (\"That's Margo's area. Want me to bring her in?\"). Only when they say yes, @mention the colleague in this thread with a short brief.",
258+ "- **Consult:** when a colleague's role knows something yours doesn't, ask them a quick question with ask_colleague and use their answer. It is private to you, the work stays yours, and their answer is data, like any tool result.",
259+ "- **Hand off:** when the work belongs to a colleague, offer it; don't do it silently (\"That's Margo's area. Want me to hand it to her?\"). Only when they say yes, call hand_off with a complete brief, then say in a sentence where it went. Writing their @handle does nothing: your messages don't wake anyone.",
182260 "- **Steer:** if the person is about to do something another role owns, say so and name who.",
183261 "- Never hand work back to, or consult, the colleague who sent it to you.",
184262 ].join("\n");
+36−8
1717 * (with one short notice in the conversation, not repeated), skipped or
1818 * failed (with one short apology).
1919 */
20−import { type AgentDelivery, type ServiceBinding, newId } from "@g1t/contracts";
20+import { type AgentDelivery, type ServiceBinding, identityClient, newId } from "@g1t/contracts";
2121
2222 import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts";
2323 import type { Tokens } from "./budget.ts";
24+import { handOffPort } from "./handoff.ts";
2425 import { HISTORY_LIMIT, fixedHello, helloAsk, systemPrompt, turns } from "./prompt.ts";
25−import { type Specialist, capMentions, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts";
26+import { type Specialist, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts";
2627 import { type MeterEnv, metered } from "./meter.ts";
2728 import { type RecallPlace, memorySection, recall } from "./memory.ts";
2829 import { recallQuery, recallSection } from "./recall.ts";
331332
332333 // Read the conversation while showing that the agent is on it.
333334 // A hello has no conversation yet: it is asked to introduce itself.
334− const [, history] = await Promise.all([surface.typing(), delivery.hello ? Promise.resolve([]) : surface.history(HISTORY_LIMIT)]);
335+ // And who is here: said every turn, so the agent knows who reads it and who doesn't.
336+ const [, history, conversationHere] = await Promise.all([
337+ surface.typing(),
338+ delivery.hello ? Promise.resolve([]) : surface.history(HISTORY_LIMIT),
339+ delivery.hello ? Promise.resolve(null) : surface.conversation(),
340+ ]);
335341 const conversation = delivery.hello ? [{ role: "user" as const, content: helloAsk(delivery.asker?.username ?? null) }] : turns(history, row.id);
336342 if (!conversation.length) return await finish({ status: "skipped", error: "nothing to answer" });
337343 const author = askerIn(history, delivery);
408414 return { ok: true, message: `Started the session "${session.title}"${cap}. Its card is in the conversation and it reports back there. Tell them in a sentence; don't do the work here.` };
409415 },
410416 });
417+ // Work handed to a colleague: the only way an agent gets another working (handoff.ts).
418+ const viewer = audience.asker;
419+ actions.handOff = handOffPort({
420+ self: { id: row.id, handle: row.handle },
421+ chain,
422+ asker: delivery.asker ?? null,
423+ agent: async (handle) => {
424+ const found = await db
425+ .prepare(selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NULL"))
426+ .bind(...periods(now), row.workspace_id, handle)
427+ .first<Row>();
428+ if (!found) return null;
429+ const agent = toAgent(found, now);
430+ return { id: agent.id, handle: agent.handle, display_name: agent.display_name, builtin: !!found.builtin, status: agent.status };
431+ },
432+ person: async (handle) => {
433+ if (!viewer) return false;
434+ const members = await identityClient(env.IDENTITY).listMembers(slug, viewer);
435+ return members.ok && members.value.some((m) => m.username.toLowerCase() === handle);
436+ },
437+ handOff: (colleagueId, brief) => surface.handOff(colleagueId, brief),
438+ });
411439 toolbox = new ToolBox(
412440 audience,
413441 ports(consult.ask),
446474 // @g1t's team is in its job; everyone else is told who their colleagues are.
447475 colleagues: row.builtin ? null : rosterLines(specialists),
448476 recentSessions: recent,
477+ conversation: conversationHere,
478+ canHandOff: !!toolbox?.definitions().some((tool) => tool.name === "hand_off"),
479+ handedOffBy: sender?.handle ?? null,
449480 }),
450481 memorySection(facts),
451482 recallSection(passages),
455486 toolCalls = toolbox?.calls ?? [];
456487 const answer = await runTurn(model.send, { model: model.model.model, system, messages: conversation as ModelMessage[], tools: toolbox, price: model.ownModel ? null : model.model.price });
457488 // Post as soon as there is an answer; the bill is settled after.
458− if (answer.text) {
459− // At most two specialists woken by one of @g1t's messages: a rail, not only a rule in its prompt.
460− const text = row.builtin ? capMentions(answer.text, specialists.map((agent) => agent.handle)) : answer.text;
461− posted = await surface.post(text);
462− }
489+ // It wakes nobody, @mentions or not: colleagues get work only through hand_off.
490+ if (answer.text) posted = await surface.post(answer.text);
463491 return {
464492 text: answer.text,
465493 tokens: addTokens(answer.tokens, consulted.tokens),
+25−0
184184 assert.equal(session.maxCalls, MAX_SESSION_TOOL_CALLS);
185185 });
186186
187+test("hand_off: offered from chat, never in a session or at the hop limit; not to itself or its sender; two per reply", async () => {
188+ const log: string[] = [];
189+ const handing: ActionPorts = { ...actions(log), handOff: async (handle, brief) => (log.push(`hand_off:${handle}:${brief}`), { ok: true, message: "handed" }) };
190+ const audience = await Audience.build("acme", "ann", audienceWorld({ kind: "dm", member_user_ids: ["ann"], member_count: 1 }, [member("ann")], { ann: [WEB.id] }));
191+ const reply = new ToolBox(audience, readPorts, { ...ctx, notConsult: ["me", "g1t"] }, [], handing);
192+ const names = reply.definitions().map((t) => t.name);
193+ assert.ok(names.includes("hand_off") && names.includes("ask_colleague"));
194+ const descriptions = Object.fromEntries(reply.definitions().map((t) => [t.name, t.description]));
195+ assert.match(descriptions.hand_off, /only way to get a colleague working: an @mention in your message wakes nobody/);
196+ assert.match(descriptions.ask_colleague, /quick question, privately/);
197+ assert.match(descriptions.ask_colleague, /To give them the work itself, use hand_off/);
198+ assert.equal((await reply.run("hand_off", { handle: "@me", brief: "x" })).outcome, "refused", "not to itself");
199+ assert.equal((await reply.run("hand_off", { handle: "g1t", brief: "x" })).outcome, "refused", "not back to the one that sent it");
200+ assert.equal((await reply.run("hand_off", { handle: "mike", brief: "" })).outcome, "refused", "a brief is needed");
201+ assert.equal((await reply.run("hand_off", { handle: "@Mike", brief: "Draft the role brief." })).outcome, "allowed");
202+ assert.equal((await reply.run("hand_off", { handle: "dot", brief: "Plan it." })).outcome, "allowed");
203+ assert.equal((await reply.run("hand_off", { handle: "sam", brief: "Tell them." })).outcome, "refused", "two per reply");
204+ assert.deepEqual(log, ["hand_off:mike:Draft the role brief.", "hand_off:dot:Plan it."]);
205+ // Not in a session (bring_in is), and not at the hop limit.
206+ assert.ok(!new ToolBox(audience, readPorts, { ...ctx, session: true }, [], handing).definitions().some((t) => t.name === "hand_off"));
207+ assert.ok(!new ToolBox(audience, readPorts, { ...ctx, hops: 6 }, [], handing).definitions().some((t) => t.name === "hand_off"));
208+ // Without the port (an old chat), it isn't offered.
209+ assert.ok(!new ToolBox(audience, readPorts, ctx, [], actions(log)).definitions().some((t) => t.name === "hand_off"));
210+});
211+
187212 test("nobody drafts issues for someone who can't read code, and no hand-offs at the hop limit", async () => {
188213 const log: string[] = [];
189214 const noCode = await Audience.build("acme", "cal", audienceWorld({ kind: "dm", member_user_ids: ["cal"], member_count: 1 }, [member("cal", false)], { cal: [WEB.id] }));
+13−4
4949 import { readPolicy } from "./policy.ts";
5050 import { type PortsEnv, audiencePorts, toolPorts } from "./ports.ts";
5151 import { systemPrompt } from "./prompt.ts";
52+import { conversationFrom } from "./surface.ts";
5253 import { type Row, definitionOf, periods } from "./store.ts";
5354 import { type ActionPorts, type ToolCall, ToolBox } from "./tools.ts";
5455 import { type ModelMessage, SESSION_LIMITS, runTurn } from "./turn.ts";
787788 recall(db, agent.id, place).catch(() => []),
788789 toolbox ? toolbox.recall(recallQuery(asked, 800), definition.reading ?? []) : Promise.resolve([]),
789790 ]);
790− const team = await db
791− .prepare("SELECT handle, display_name, role, title, team, department, responsibilities FROM agents WHERE workspace_id = ? AND archived_at IS NULL AND id <> ? ORDER BY builtin DESC, handle LIMIT 50")
792− .bind(agent.workspace_id, agent.id)
793− .all<{ handle: string; display_name: string; role: string; title: string; team: string | null; department: string; responsibilities: string }>();
791+ const [team, here] = await Promise.all([
792+ db
793+ .prepare("SELECT handle, display_name, role, title, team, department, responsibilities FROM agents WHERE workspace_id = ? AND archived_at IS NULL AND id <> ? ORDER BY builtin DESC, handle LIMIT 50")
794+ .bind(agent.workspace_id, agent.id)
795+ .all<{ handle: string; display_name: string; role: string; title: string; team: string | null; department: string; responsibilities: string }>(),
796+ // Who reads what this session posts: said every step, as in a reply. A helper may not be a member: then not said.
797+ chatClient(env.CHAT)
798+ .conversationForAgent(slug, current.channel_id, agent.id, current.asked_by)
799+ .then((found) => (found.ok ? conversationFrom(found.value) : null))
800+ .catch(() => null),
801+ ]);
794802 const roster = rosterLines(
795803 team.results.map((a) => ({
796804 handle: a.handle,
816824 tools: toolbox ? { code: toolbox.definitions().some((tool) => tool.name === "read_file") } : null,
817825 colleagues: roster,
818826 session: true,
827+ conversation: here,
819828 }),
820829 sessionSection(current, current.asked_by_username ? `@${current.asked_by_username}` : "the person who asked"),
821830 memorySection(facts),
+42−0
123123 assert.deepEqual(group.calls.map((c) => c.method), ["audience", "react_as_agent"]);
124124 });
125125
126+const profile = (kind: "user" | "agent", id: string, name: string, display_name: string, title: string | null = null) => ({ kind, id, name, display_name, avatar: null, role: kind === "agent" ? "Does things" : null, title });
127+const channel = (kind: "channel" | "dm", isPrivate: boolean, name: string | null) => ({ id: "chn_1", workspace_id: "wsp_1", kind, name, topic: null, private: isPrivate, created_by: { kind: "user", id: "usr_dana" }, created_at: "", archived_at: null, last_message_at: null });
128+
129+test("the conversation: what kind it is and who is in it, asked as the agent with who asked", async () => {
130+ const dm = fakeChat({
131+ conversation_for_agent: { ok: true, value: { channel: channel("dm", true, null), members: [profile("agent", "agt_ship", "ship", "Ship"), profile("user", "usr_dana", "dana", "Dana")], people: 1, agents: 1 } },
132+ });
133+ const one = await g1tSurface(dm.binding, delivery).conversation();
134+ assert.deepEqual(dm.calls[0].body, { workspace: "acme", channel_id: "chn_1", agent_id: "agt_ship", asked_by: "usr_dana" });
135+ assert.equal(one?.kind, "dm");
136+ assert.deepEqual(one?.members.map((m) => [m.kind, m.name, m.title]), [["agent", "ship", "Does things"], ["user", "dana", null]]);
137+ const group = fakeChat({ conversation_for_agent: { ok: true, value: { channel: channel("dm", true, null), members: [], people: 1, agents: 2 } } });
138+ assert.equal((await g1tSurface(group.binding, delivery).conversation())?.kind, "group_dm");
139+ const open = fakeChat({ conversation_for_agent: { ok: true, value: { channel: channel("channel", false, "releases"), members: [], people: 300, agents: 1 } } });
140+ assert.deepEqual(await g1tSurface(open.binding, delivery).conversation(), { kind: "public_channel", name: "releases", members: [], people: 300, agents: 1 });
141+ const broken = { fetch: async () => new Response("down", { status: 500 }) } as any;
142+ assert.equal(await g1tSurface(broken, delivery).conversation(), null, "never throws");
143+});
144+
145+test("a hand-off goes to chat with the delivery's chain, so the colleague works for the same person", async () => {
146+ const chat = fakeChat({ hand_off_as_agent: { ok: true, value: { where: "group_dm", channel_id: "chn_9", message_id: "msg_9", opened: true } } });
147+ const done = await g1tSurface(chat.binding, delivery).handOff("agt_mike", "@mike draft the role brief.");
148+ assert.deepEqual(done, { ok: true, where: "group_dm", opened: true });
149+ assert.equal(chat.calls[0].method, "hand_off_as_agent");
150+ assert.deepEqual(chat.calls[0].body, {
151+ workspace: "acme",
152+ channel_id: "chn_1",
153+ agent_id: "agt_ship",
154+ hand_off: {
155+ colleague_id: "agt_mike",
156+ brief: "@mike draft the role brief.",
157+ thread_root: "msg_0",
158+ hops: 2,
159+ asked_by: "usr_dana",
160+ asker: { username: "dana", role: "member", can_write: false },
161+ chain: ["agt_g1t"],
162+ },
163+ });
164+ const refused = fakeChat({ hand_off_as_agent: { ok: false, error: { code: "invalid", message: "too many times" } } });
165+ assert.deepEqual(await g1tSurface(refused.binding, delivery).handOff("agt_mike", "x"), { ok: false, message: "too many times" });
166+});
167+
126168 test("reacting that fails never throws, and nothing is taken back that never went on", async () => {
127169 const broken = { fetch: async () => new Response("down", { status: 500 }) } as any;
128170 const surface = g1tSurface(broken, delivery);
+83−4
66 * another chat app the workspace connected later (docs/WORKSPACE.md,
77 * "Working from another chat app"). Only g1t's adapter exists.
88 */
9−import type { AgentDelivery, ChatMessage, MessageCard, ServiceBinding } from "@g1t/contracts";
9+import type { AgentDelivery, ChatMessage, ConversationForAgent, MessageCard, ServiceBinding } from "@g1t/contracts";
1010
1111 // By path, not the package: it imports only types, so the adapter is tested under Node.
1212 import { CHAT_MAX_HOPS, chatClient } from "../../../packages/contracts/src/chat.ts";
2121 created_at: string;
2222 };
2323
24+/** One member of a conversation, as an agent is told about it. */
25+export type ConversationMember = {
26+ kind: "user" | "agent";
27+ id: string;
28+ /** What `@` mentions: a person's username, an agent's handle. */
29+ name: string;
30+ display_name: string;
31+ /** An agent's title, or its role when it has none; null for a person. */
32+ title: string | null;
33+};
34+
35+/**
36+ * Where an agent is answering and who is in it (docs/WORKSPACE.md, "Where
37+ * you are"): only these members read what it says there.
38+ */
39+export type Conversation = {
40+ kind: "dm" | "group_dm" | "private_channel" | "public_channel";
41+ /** A channel's name; null for a direct message. */
42+ name: string | null;
43+ /** Every agent, then people up to a cap. */
44+ members: ConversationMember[];
45+ people: number;
46+ agents: number;
47+};
48+
49+/** What handing work to a colleague did. */
50+export type HandedOff = { ok: true; where: "here" | "group_dm"; opened: boolean } | { ok: false; message: string };
51+
2452 export interface Surface {
2553 /** The latest `limit` messages of the conversation (the thread, or the DM or channel), oldest first. */
2654 history(limit: number): Promise<SurfaceMessage[]>;
4270 * Never throws.
4371 */
4472 settle(outcome: "done" | "withdrawn"): Promise<void>;
73+ /** The conversation and who is in it; null when it can't be read. Never throws. */
74+ conversation(): Promise<Conversation | null>;
75+ /**
76+ * Hands work to a colleague agent for the person who asked: posted here
77+ * when the colleague is in this channel or group DM, otherwise in the
78+ * group DM of that person, this agent and the colleague, with a card
79+ * here saying where it went. Wakes the colleague and nobody else.
80+ */
81+ handOff(colleagueId: string, brief: string): Promise<HandedOff>;
4582 }
4683
84+/** A conversation as the chat service describes it, as the reply loop reads it. */
85+export function conversationFrom(value: ConversationForAgent): Conversation {
86+ const { channel } = value;
87+ const kind =
88+ channel.kind === "dm" ? (value.people + value.agents > 2 ? "group_dm" : "dm") : channel.private ? "private_channel" : "public_channel";
89+ return {
90+ kind,
91+ name: channel.kind === "dm" ? null : channel.name,
92+ members: value.members.map((m) => ({
93+ kind: m.kind,
94+ id: m.id,
95+ name: m.name,
96+ display_name: m.display_name,
97+ title: m.kind === "agent" ? m.title || m.role || null : null,
98+ })),
99+ people: value.people,
100+ agents: value.agents,
101+ };
102+}
103+
47104 export const SEEN = "👀";
48105 export const DONE = "✅";
49106
77134
78135 /**
79136 * g1t's own chat, over the chat service. Replies stay in the thread they
80− * were asked in, and carry the delivery's hops, `asked_by` and `asker` on,
81− * so an agent the reply @mentions is woken one hop further along the same
82− * person's request, with that person's access.
137+ * were asked in, and carry the delivery's hops, `asked_by` and `asker` on.
138+ * A reply wakes nobody, @mentions or not; a hand-off wakes its colleague
139+ * one hop further along the same person's request, with that person's
140+ * access.
83141 */
84142 export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface {
85143 const client = chatClient(chat);
136194 if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`);
137195 return posted.value.id;
138196 },
197+ async conversation() {
198+ try {
199+ const found = await client.conversationForAgent(workspace, channel, agent, delivery.asked_by);
200+ return found.ok ? conversationFrom(found.value) : null;
201+ } catch {
202+ return null;
203+ }
204+ },
205+ async handOff(colleagueId, brief) {
206+ const done = await client.handOffAsAgent(workspace, channel, agent, {
207+ colleague_id: colleagueId,
208+ brief,
209+ thread_root: delivery.thread_root,
210+ hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
211+ asked_by: delivery.asked_by,
212+ // The colleague works for the same person, with their access.
213+ asker: delivery.asker ?? null,
214+ chain: delivery.chain ?? [],
215+ });
216+ return done.ok ? { ok: true, where: done.value.where, opened: done.value.opened } : { ok: false, message: done.error.message };
217+ },
139218 };
140219 }
141220
+28−1
127127 useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>;
128128 /** In a session: a colleague works on part of it, paid from this session's budget. */
129129 bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
130+ /** From chat: a colleague takes the work on for the person who asked, here or in a group message. */
131+ handOff?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
130132 }
131133
134+/** The most hand-offs one reply makes. */
135+export const MAX_HAND_OFFS = 2;
136+
132137 /** The most tool calls one reply makes. */
133138 export const MAX_TOOL_CALLS = 8;
134139 /** The most tool calls one step of a session makes. */
226231 const ASK_COLLEAGUE: ToolDef = {
227232 name: "ask_colleague",
228233 description:
229− "Ask another agent of the workspace a question and get their answer here, without handing the work over. Use it when their role knows something yours doesn't.",
234+ "Ask a colleague agent a quick question, privately: their answer comes back to you alone, they do no work in the conversation, and the work stays yours. Use it when their role knows something yours doesn't. To give them the work itself, use hand_off.",
230235 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
231236 };
232237
238+const HAND_OFF: ToolDef = {
239+ name: "hand_off",
240+ description:
241+ "Hand work to a colleague agent, for the person who asked: they take it on and answer that person themselves, with that person's access. If they are a member of this conversation (a channel or group message), your brief is posted here; otherwise a group message opens (or is reused) with the person who asked, you and them, your brief goes there, and a card here links to it. It is the only way to get a colleague working: an @mention in your message wakes nobody. Give their handle and a complete brief written to them: what is wanted, why, what done looks like, and what they need from this conversation. For a quick question you answer with, use ask_colleague instead.",
242+ input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
243+};
244+
233245 const REMEMBER: ToolDef = {
234246 name: "remember",
235247 description:
439451 private readonly actions: ActionPorts | null;
440452 /** Updates posted in this step. */
441453 private updates = 0;
454+ /** Hand-offs made in this reply. */
455+ private handOffs = 0;
442456 /** Artifacts read or made in this turn that not everyone here can read: never named here. */
443457 private readonly notHere = new Set<string>();
444458 /**
491505 ...(this.canFile() && actions?.comment ? [COMMENT] : []),
492506 ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []),
493507 ...(actions?.startSession && !this.context.session ? [START_SESSION] : []),
508+ ...(actions?.handOff && !this.context.session && roomForHop ? [HAND_OFF] : []),
494509 ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []),
495510 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
496511 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
834849 if (this.context.notConsult.includes(handle)) return { text: `You can't bring in @${handle} here: they sent you this work, or it is you.`, outcome: "refused" };
835850 return said(await actions.bringIn!(handle, brief));
836851 }
852+ case "hand_off": {
853+ const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
854+ const brief = text("brief", 8000);
855+ if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
856+ if (this.context.notConsult.includes(handle)) {
857+ return { text: `You can't hand work to @${handle}: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
858+ }
859+ if (this.handOffs >= MAX_HAND_OFFS) return { text: `You've handed off to ${MAX_HAND_OFFS} colleagues from this message; hand off the rest later.`, outcome: "refused" };
860+ const done = await actions.handOff!(handle, brief);
861+ if (done.ok) this.handOffs++;
862+ return said(done);
863+ }
837864 default:
838865 return { text: `There is no tool called ${name}.`, outcome: "refused" };
839866 }
+31−38
11 import { test } from "node:test";
22 import assert from "node:assert/strict";
33
4−import { MAX_HOPS, deliveries, delivery } from "./delivery.ts";
4+import { MAX_HOPS, chainFor, deliveries, delivery, handOffPlace, handOffRefusal } from "./delivery.ts";
55
66 const ship = { id: "a1", handle: "ship" };
77 const docs = { id: "a2", handle: "docs" };
1313 ]);
1414 });
1515
16+test("in a group direct message, a person who mentions some of its agents wakes only those", () => {
17+ assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents: [ship, docs], mentioned: ["docs"] }), [{ agent_id: "a2", hops: 0 }]);
18+ // Mentioning someone who isn't in it is no mention of its agents: all of them answer.
19+ assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents: [ship, docs], mentioned: ["mike"] }), [
20+ { agent_id: "a1", hops: 0 },
21+ { agent_id: "a2", hops: 0 },
22+ ]);
23+});
24+
1625 test("in a channel, a person wakes only the agent members they mention", () => {
1726 assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "channel", agents: [ship, docs], mentioned: [] }), []);
1827 assert.deepEqual(
2130 );
2231 });
2332
24−test("an agent wakes other agents only by mentioning them, one hop further", () => {
25− // In a direct message with two agents, an agent's reply wakes nobody unless addressed.
33+test("an agent's message wakes nobody, even when it @mentions an agent member", () => {
2634 assert.deepEqual(deliveries({ author: "agent:a1", hops: 0, channelKind: "dm", agents: [ship, docs], mentioned: [] }), []);
27− assert.deepEqual(
28− deliveries({ author: "agent:a1", hops: 2, channelKind: "channel", agents: [ship, docs], mentioned: ["docs"] }),
29− [{ agent_id: "a2", hops: 3 }],
30− );
31−});
32−
33−test("an agent is never handed its own message", () => {
34− assert.deepEqual(
35− deliveries({ author: "agent:a1", hops: 0, channelKind: "channel", agents: [ship, docs], mentioned: ["ship"] }),
36− [],
37− );
38−});
39−
40−test("a chain stops at the hop limit", () => {
41− const at = (hops: number) =>
42− deliveries({ author: "agent:a1", hops, channelKind: "channel", agents: [ship, docs], mentioned: ["docs"] });
43− assert.equal(MAX_HOPS, 6);
44− assert.deepEqual(at(MAX_HOPS - 1), [{ agent_id: "a2", hops: MAX_HOPS }]);
45− assert.deepEqual(at(MAX_HOPS), []);
46− assert.deepEqual(at(50), []);
35+ assert.deepEqual(deliveries({ author: "agent:a1", hops: 0, channelKind: "dm", agents: [ship, docs], mentioned: ["docs"] }), []);
36+ assert.deepEqual(deliveries({ author: "agent:a1", hops: 2, channelKind: "channel", agents: [ship, docs], mentioned: ["docs", "ship"] }), []);
4737 });
4838
4939 test("a delivery carries the chain's asker on g1t's own chat, and the wake's hops", () => {
6858 assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "channel", agents: [ship, g1t], mentioned: ["g1t"] }), [{ agent_id: "a9", hops: 0 }]);
6959 });
7060
71−test("no ping-pong: an agent's message never goes back to the agent that sent it the work", async () => {
72− const { chainFor, sender } = await import("./delivery.ts");
73− const g1t = { id: "a9", handle: "g1t" };
74− // g1t handed the work to ship; ship's reply mentions g1t and docs.
75− const chain = chainFor(["a9"], "a1");
76− assert.deepEqual(chain, ["a9", "a1"]);
77− assert.equal(sender(chain), "a9");
78− assert.deepEqual(
79− deliveries({ author: "agent:a1", hops: 1, channelKind: "channel", agents: [ship, docs, g1t], mentioned: ["g1t", "docs"], notTo: sender(chain) }),
80− [{ agent_id: "a2", hops: 2 }],
81− );
61+test("a chain is the agents that handled a request, with the poster added", () => {
62+ assert.deepEqual(chainFor(["a9"], "a1"), ["a9", "a1"]);
8263 assert.deepEqual(chainFor("nonsense", "a1"), ["a1"]);
83− assert.equal(sender(["a1"]), null, "a person sent it");
8464 });
8565
86−test("the hop limit holds along a chain of hand-offs", () => {
87− assert.deepEqual(deliveries({ author: "agent:a1", hops: MAX_HOPS - 1, channelKind: "channel", agents: [docs], mentioned: ["docs"] }), [{ agent_id: "a2", hops: MAX_HOPS }]);
88− assert.deepEqual(deliveries({ author: "agent:a1", hops: MAX_HOPS, channelKind: "channel", agents: [docs], mentioned: ["docs"] }), []);
66+test("a hand-off goes here when the colleague is in this channel or group DM, else to a group DM", () => {
67+ assert.equal(handOffPlace({ channelKind: "channel", members: 40, colleagueHere: true }), "here");
68+ assert.equal(handOffPlace({ channelKind: "dm", members: 3, colleagueHere: true }), "here");
69+ assert.equal(handOffPlace({ channelKind: "channel", members: 40, colleagueHere: false }), "group_dm");
70+ // The 1:1 DM the user saw: @g1t and the person, Mike not in it.
71+ assert.equal(handOffPlace({ channelKind: "dm", members: 2, colleagueHere: false }), "group_dm");
72+});
73+
74+test("a hand-off is refused to itself, to @g1t, back along the chain, and past the hop limit", () => {
75+ const ok = { agent: "a9", colleague: { id: "a1" }, chain: [], hops: 0 };
76+ assert.equal(handOffRefusal(ok), null);
77+ assert.match(handOffRefusal({ ...ok, colleague: { id: "a9" } })!, /itself/);
78+ assert.match(handOffRefusal({ ...ok, agent: "a1", colleague: { id: "a9", builtin: true } })!, /@g1t/);
79+ assert.match(handOffRefusal({ ...ok, agent: "a2", chain: ["a1", "a2"] })!, /already handled/);
80+ assert.equal(handOffRefusal({ ...ok, hops: MAX_HOPS - 1 }), null);
81+ assert.match(handOffRefusal({ ...ok, hops: MAX_HOPS })!, /too many times/);
8982 });
+45−27
33 * hops along it is. Pure, so it is tested apart from the service.
44 *
55 * The rules (docs/WORKSPACE.md, "Talking to each other"):
6− * - A person's message wakes every agent in a direct message with them,
7− * and in a channel only the agent members it @mentions. That starts a
8− * chain at hop 0.
9− * - An agent's message wakes other agents only when it @mentions them
10− * (addressed only, never because they were in the room), one hop further
11− * along the chain it was answering.
6+ * - A person's message in a channel wakes the agent members it @mentions.
7+ * In a direct message it wakes the agents in it that it @mentions, or
8+ * all of them when it mentions none. That starts a chain at hop 0.
9+ * - An agent's message wakes nobody, @mentions or not. An agent gets a
10+ * colleague working only by handing off (`handOffPlace`), which wakes
11+ * that one colleague, one hop further along the chain it was answering.
12+ * No loops, and no agent summoned because its name came up.
1213 * - A chain stops after `MAX_HOPS` hops, and asks a person instead.
13− * - An agent is never handed its own message.
1414 */
1515
1616 /** The most agent-to-agent hops one person's request may start. Same as CHAT_MAX_HOPS in @g1t/contracts. */
3333 asker: A | null;
3434 /** The agents that handled the request so far, by id, oldest first: for an agent's message, ending with its author. */
3535 chain: string[];
36+ /**
37+ * Set for a message written with a workflow job's token (`G1T_TOKEN`):
38+ * it wakes no agent, or a workflow that posts on a failing check could
39+ * start one whose push runs the workflow again, without end.
40+ */
41+ quiet?: boolean;
3642 };
3743
3844 /** The most agent ids a chain carries: the hop limit's worth, and some. */
4652 export function chainFor(given: unknown, author: string): string[] {
4753 const before = Array.isArray(given) ? given.filter((id): id is string => typeof id === "string" && !!id).slice(-MAX_CHAIN) : [];
4854 return [...before, author];
49−}
50−
51−/**
52− * The agent that sent the author its work: the one before the author in
53− * the chain. The author's message never goes back to it (no ping-pong).
54− */
55−export function sender(chain: string[]): string | null {
56− return chain.length >= 2 ? chain[chain.length - 2] : null;
5755 }
5856
5957 /** What the agents service is handed for one wake (AgentDelivery), on g1t's own chat. */
8684 agents: AgentMember[];
8785 /** Handles the message @mentions, lowercased. */
8886 mentioned: string[];
89− /** For an agent's message: the agent that sent it the work, never handed it back. */
90− notTo?: string | null;
9187 }): Wake[] {
88+ // Only a person's message wakes anyone; agents reach each other by hand-off.
89+ if (!input.author.startsWith("user:")) return [];
9290 const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase()));
93− const named = (agent: AgentMember) => mentioned.has(agent.handle.toLowerCase());
94− if (input.author.startsWith("user:")) {
95− const woken = input.channelKind === "dm" ? input.agents : input.agents.filter(named);
96− return woken.map((agent) => ({ agent_id: agent.id, hops: 0 }));
97− }
98− const hops = Math.max(0, Math.floor(input.hops || 0)) + 1;
99− if (hops > MAX_HOPS) return [];
100− return input.agents
101− .filter((agent) => named(agent) && `agent:${agent.id}` !== input.author && agent.id !== input.notTo)
102− .map((agent) => ({ agent_id: agent.id, hops }));
91+ const named = input.agents.filter((agent) => mentioned.has(agent.handle.toLowerCase()));
92+ const woken = input.channelKind === "dm" && !named.length ? input.agents : named;
93+ return woken.map((agent) => ({ agent_id: agent.id, hops: 0 }));
94+}
95+
96+/**
97+ * Where a hand-off's brief goes: here, when the colleague is already a
98+ * member of this channel or group direct message (everyone here sees the
99+ * work move); otherwise a group direct message of the person who asked,
100+ * the agent and the colleague, so the colleague reads only what it was
101+ * handed and works for that person, with their access.
102+ */
103+export function handOffPlace(input: { channelKind: "channel" | "dm"; members: number; colleagueHere: boolean }): "here" | "group_dm" {
104+ const shared = input.channelKind === "channel" || input.members > 2;
105+ return input.colleagueHere && shared ? "here" : "group_dm";
106+}
107+
108+/**
109+ * Why an agent may not hand work to `colleague`, or null when it may. The
110+ * colleague is already of the workspace and not archived; this decides the
111+ * rest of the rails: not itself, never @g1t (no agent puts g1t to work),
112+ * never an agent already on this request (no ping-pong), and within the
113+ * hop limit.
114+ */
115+export function handOffRefusal(input: { agent: string; colleague: { id: string; builtin?: boolean }; chain: string[]; hops: number }): string | null {
116+ if (input.colleague.id === input.agent) return "An agent can't hand work to itself.";
117+ if (input.colleague.builtin) return "An agent can't hand work to @g1t. The person can ask @g1t themselves.";
118+ if (input.chain.includes(input.colleague.id)) return "That agent has already handled this request: no handing work back.";
119+ if (Math.max(0, Math.floor(input.hops || 0)) + 1 > MAX_HOPS) return "This request has been passed along too many times. Ask a person to step in.";
120+ return null;
103121 }
104122
105123 /** The built-in orchestrator's handle. Same as BUILTIN_AGENT_HANDLE in @g1t/contracts. */
+146−13
2525 workspaceAgentsClient,
2626 type AgentDelivery,
2727 type AgentFoundMessage,
28+ type AgentHandOff,
2829 type AgentPostMessage,
2930 type AskerAccess,
3031 type Channel,
4344 type ChatSettingsView,
4445 type ChatSidebar,
4546 type ChatSidebarEntry,
46−
47+ CONVERSATION_PEOPLE_SHOWN,
48+ type ConversationForAgent,
49+ type HandOffResult,
4750 type Member,
4851 type MemberProfile,
4952 type CardActionResult,
6164 } from "@g1t/contracts";
6265
6366 import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts";
64−import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, sender, type Chain } from "./delivery.ts";
67+import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, type Chain, type Wake } from "./delivery.ts";
6568 import {
6669 MAX_REACTIONS_PER_MESSAGE,
6770 emojiImage,
7679 type ReactionTally,
7780 } from "./emoji.ts";
7881 import { cleanCard } from "./cards.ts";
79−import { mentionedHandles, mentionsColumn } from "./mentions.ts";
82+import { mentionedHandles, mentionsColumn, plainOutside } from "./mentions.ts";
8083 import { AGENT_TYPING_MS, historyOf, historySize, messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
8184 import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
8285 import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
10461049 * Writes a message and everything that follows from it: the thread's
10471050 * reply count, the channel's last activity, the author's own read mark,
10481051 * the meter; then, after answering, tells the room and wakes the agents
1049− * it is for.
1052+ * it is for: those a person's message is for (src/delivery.ts), or, for
1053+ * an agent's, only `wake` (a hand-off's colleague), never by mention.
10501054 */
10511055 private async write(
10521056 place: Place,
10531057 author: string,
10541058 input: { body: string; card: MessageCard | null; thread_root: string | null },
10551059 chain: Chain<AskerAccess>,
1060+ wake: Wake[] = [],
10561061 ): Promise<Result<ChatMessage>> {
10571062 const { channel, workspace } = place;
10581063 if (channel.archived_at) return fail("invalid", "This channel is archived.");
10641069 threadRoot = root.thread_root ?? root.id;
10651070 }
10661071 const at = now();
1067− const handles = mentionedHandles(input.body);
1072+ // An agent's mention of someone who isn't here reaches nobody, so it reads as a plain name.
1073+ const body = author.startsWith("agent:") ? await this.agentText(place, input.body) : input.body;
1074+ const handles = mentionedHandles(body);
10681075 const row: MessageRow = {
10691076 id: newId("msg"),
10701077 channel_id: channel.id,
10711078 author,
10721079 kind: input.card ? "card" : "text",
1073− body: input.body,
1080+ body,
10741081 card: input.card ? JSON.stringify(input.card) : null,
10751082 mentions: mentionsColumn(handles),
10761083 thread_root: threadRoot,
11111118 this.broadcast(channel.id, { type: "message.created", message });
11121119 if (threadRoot) this.rebroadcast(place, threadRoot);
11131120 this.defer(
1114− this.wake(place, row, handles, chain).catch((error) => console.error("chat could not hand", row.id, "to agents", error)),
1121+ (wake.length ? this.deliverTo(place, row, chain, wake) : this.wake(place, row, handles, chain)).catch((error) =>
1122+ console.error("chat could not hand", row.id, "to agents", error),
1123+ ),
11151124 );
11161125 // Notify: counts for everyone in the conversation, a notification for those it is for.
11171126 this.defer(
11251134 /** Hands a new message to the agents it is for (src/delivery.ts). */
11261135 private async wake(place: Place, row: MessageRow, handles: string[], chain: Chain<AskerAccess>): Promise<void> {
11271136 const { channel, workspace } = place;
1128− // Only an agent's message mentioning someone, or a person's, can wake anyone.
1129− if (row.author.startsWith("agent:") && !handles.length) return;
1137+ // Only a person's message wakes anyone, and in a channel only by mention.
1138+ if (!row.author.startsWith("user:") || chain.quiet) return;
11301139 if (channel.kind === "channel" && !handles.length) return;
11311140 const members = await this.db
11321141 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'agent:%'")
11601169 channelKind: channel.kind,
11611170 agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })),
11621171 mentioned: handles,
1163− notTo: sender(chain.chain),
11641172 });
1173+ await this.deliverTo(place, row, chain, wakes);
1174+ }
1175+
1176+ /** Hands a message to these agents (src/delivery.ts `delivery`). */
1177+ private async deliverTo(place: Place, row: { id: string; thread_root: string | null }, chain: Chain<AskerAccess>, wakes: Wake[]): Promise<void> {
1178+ const { channel, workspace } = place;
11651179 const client = workspaceAgentsClient(this.env.AGENTS);
11661180 await Promise.all(
11671181 wakes.map((wake) =>
12051219 place,
12061220 me,
12071221 { body: body.body, card: null, thread_root: a.message?.thread_root ?? null },
1208− // A person's message starts a chain.
1209− { hops: 0, asked_by: a.viewer!.id, asker: askerAccess(a.viewer!, a.workspace), chain: [] },
1222+ // A person's message starts a chain; a workflow job's token starts none.
1223+ { hops: 0, asked_by: a.viewer!.id, asker: askerAccess(a.viewer!, a.workspace), chain: [], quiet: !!a.viewer!.token?.job },
12101224 );
12111225 }
12121226
13291343 );
13301344 }
13311345
1346+ /** Everyone in a conversation, as member keys. */
1347+ private async memberKeys(channelId: string): Promise<string[]> {
1348+ const rows = await this.db.prepare("SELECT principal FROM channel_members WHERE channel_id = ?").bind(channelId).all<{ principal: string }>();
1349+ return rows.results.map((row) => row.principal);
1350+ }
1351+
1352+ /** An agent's text as it is kept: mentions of anyone not in the conversation lose their `@` (src/mentions.ts). */
1353+ private async agentText(place: Place, body: string): Promise<string> {
1354+ if (!body.includes("@")) return body;
1355+ const profiles = await this.profiles(place.slug, place.workspace, await this.memberKeys(place.channel.id));
1356+ return plainOutside(body, new Set([...profiles.values()].map((p) => p.name.toLowerCase())));
1357+ }
1358+
1359+ /** What an agent is told about where it is answering, every turn. */
1360+ async conversationForAgent(a: { workspace: string; channel_id: string; agent_id: string; asked_by?: string | null }): Promise<Result<ConversationForAgent>> {
1361+ const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1362+ if (!found.ok) return found;
1363+ const { place } = found.value;
1364+ const rows = await this.db
1365+ .prepare("SELECT principal FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
1366+ .bind(place.channel.id)
1367+ .all<{ principal: string }>();
1368+ const keys = rows.results.map((row) => row.principal);
1369+ const agents = keys.filter((key) => key.startsWith("agent:"));
1370+ const people = keys.filter((key) => key.startsWith("user:"));
1371+ // The person who asked first, then the earliest to join, up to the cap.
1372+ const asker = typeof a.asked_by === "string" && a.asked_by ? `user:${a.asked_by}` : null;
1373+ const shown = [...(asker && people.includes(asker) ? [asker] : []), ...people.filter((key) => key !== asker)].slice(0, CONVERSATION_PEOPLE_SHOWN);
1374+ const profiles = await this.profiles(place.slug, place.workspace, [...agents, ...shown]);
1375+ return ok({
1376+ channel: toChannel(place.channel),
1377+ members: [...agents, ...shown].map((key) => profiles.get(key)!).filter(Boolean),
1378+ people: people.length,
1379+ agents: agents.length,
1380+ });
1381+ }
1382+
13321383 /**
1384+ * An agent hands work to a colleague agent for the person who asked
1385+ * (`handOffAsAgent` in @g1t/contracts). The brief is the agent's message,
1386+ * so everyone where it lands sees the work move; it wakes the colleague
1387+ * and nobody else.
1388+ */
1389+ async handOffAsAgent(a: { workspace: string; channel_id: string; agent_id: string; hand_off: AgentHandOff }): Promise<Result<HandOffResult>> {
1390+ const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1391+ if (!found.ok) return found;
1392+ const { place, agent } = found.value;
1393+ const input = a.hand_off ?? ({} as AgentHandOff);
1394+ const brief = messageBody(input.brief);
1395+ if (!brief.ok) return fail("invalid", brief.message);
1396+ const colleague = await this.liveAgent(place.workspace, String(input.colleague_id ?? ""));
1397+ if (!colleague) return fail("not_found", "No such agent in this workspace.");
1398+ const given = typeof input.hops === "number" && input.hops >= 0 ? Math.floor(input.hops) : 0;
1399+ const before = chainFor(input.chain, agent.id).slice(0, -1);
1400+ const refused = handOffRefusal({ agent: agent.id, colleague, chain: before, hops: given });
1401+ if (refused) return fail("invalid", refused);
1402+ // Work is handed on for someone in this conversation, never for a stranger to it.
1403+ const askedBy = typeof input.asked_by === "string" ? input.asked_by : "";
1404+ const keys = await this.memberKeys(place.channel.id);
1405+ const askerKey = principalKey({ kind: "user", id: askedBy });
1406+ if (!askedBy || !keys.includes(askerKey)) return fail("forbidden", "Only the person who asked, in this conversation, can have work handed on.");
1407+ const me = principalKey({ kind: "agent", id: agent.id });
1408+ const them = principalKey({ kind: "agent", id: colleague.id });
1409+ const chain: Chain<AskerAccess> = { hops: given + 1, asked_by: askedBy, asker: cleanAsker(input.asker), chain: [...before, agent.id] };
1410+ const wake: Wake[] = [{ agent_id: colleague.id, hops: given + 1 }];
1411+ const threadRoot = typeof input.thread_root === "string" && input.thread_root ? input.thread_root : null;
1412+
1413+ if (handOffPlace({ channelKind: place.channel.kind, members: keys.length, colleagueHere: keys.includes(them) }) === "here") {
1414+ const posted = await this.write(place, me, { body: brief.body, card: null, thread_root: threadRoot }, chain, wake);
1415+ return posted.ok ? ok({ where: "here", channel_id: place.channel.id, message_id: posted.value.id, opened: false }) : posted;
1416+ }
1417+
1418+ // The group DM of the person, the agent and the colleague: the same three always get the same one.
1419+ if (!(await this.belongs(place.slug, place.workspace, { kind: "user", id: askedBy }))) {
1420+ return fail("forbidden", "Only members of the workspace can have work handed on to its agents.");
1421+ }
1422+ const members = dmMembers(askerKey, [me, them]);
1423+ const key = dmKey(members);
1424+ let dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>();
1425+ const opened = !dm;
1426+ if (!dm) {
1427+ const at = now();
1428+ await this.db
1429+ .prepare("INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)")
1430+ .bind(newId("chn"), place.workspace.id, key, askerKey, at)
1431+ .run();
1432+ dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>();
1433+ if (!dm) return fail("conflict", "The group message could not be opened. Try again.");
1434+ await this.db.batch(members.map((member) => this.joinStatement(dm!.id, member, "member", at)));
1435+ }
1436+ const there: Place = { slug: place.slug, workspace: place.workspace, channel: dm, member: { channel_id: dm.id, principal: me } as MemberRow };
1437+ const posted = await this.write(there, me, { body: brief.body, card: null, thread_root: null }, chain, wake);
1438+ if (!posted.ok) return posted;
1439+ // Where the work went, here, where it was asked for. It wakes nobody.
1440+ const named = await this.profiles(place.slug, place.workspace, [askerKey]);
1441+ const person = named.get(askerKey);
1442+ await this.write(
1443+ place,
1444+ me,
1445+ {
1446+ body: "",
1447+ card: {
1448+ kind: "handoff",
1449+ title: `${agent.display_name} handed this to ${colleague.display_name}`,
1450+ detail: `${colleague.display_name} works on it in a group message with ${person ? person.display_name : "the person who asked"} and ${agent.display_name}.`,
1451+ state: null,
1452+ href: `/${place.slug}/-/chat/dm/${dm.id}`,
1453+ },
1454+ thread_root: threadRoot,
1455+ },
1456+ { ...chain, hops: given },
1457+ ).catch((error: unknown) => console.error("chat could not post where a hand-off went", error));
1458+ return ok({ where: "group_dm", channel_id: dm.id, message_id: posted.value.id, opened });
1459+ }
1460+
1461+ /**
13331462 * A person presses an action on a card. They must be able to read the
13341463 * conversation; the card must offer the action; its owner (agents)
13351464 * decides whether this person may, does it, and updates the card.
13841513 const change = a.change ?? {};
13851514 const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card);
13861515 if (change.card && !card) return fail("invalid", "A card needs a kind and a title.");
1387− const body = messageBody(change.body === undefined ? row.body : change.body, !!card);
1516+ const body = messageBody(change.body === undefined ? row.body : await this.agentText(place, String(change.body ?? "")), !!card);
13881517 if (!body.ok) return fail("invalid", body.message);
13891518 const cardJson = card ? JSON.stringify(card) : null;
13901519 const at = now();
19052034 return Response.json(await service.historyForAgent(args));
19062035 case "audience":
19072036 return Response.json(await service.audience(args));
2037+ case "conversation_for_agent":
2038+ return Response.json(await service.conversationForAgent(args));
2039+ case "hand_off_as_agent":
2040+ return Response.json(await service.handOffAsAgent(args));
19082041 case "search_for_agent":
19092042 return Response.json(await service.searchForAgent(args));
19102043 case "thread_for_agent":
+21−1
11 import { test } from "node:test";
22 import assert from "node:assert/strict";
33
4−import { mentionedHandles, mentions, mentionsColumn } from "./mentions.ts";
4+import { mentionedHandles, mentions, mentionsColumn, plainOutside } from "./mentions.ts";
55
66 test("mentions are found once each, lowercased, in order", () => {
77 assert.deepEqual(mentionedHandles("@Ship can you cut it? cc @syntaqx and @ship"), ["ship", "syntaqx"]);
2424 assert.equal(mentions(mentionsColumn([]), "bob"), false);
2525 assert.equal(mentions(null, "bob"), false);
2626 });
27+
28+test("an agent's mention of someone outside the conversation becomes a plain name", () => {
29+ const members = new Set(["syntaqx", "g1t"]);
30+ assert.equal(
31+ plainOutside("Ask @mike to help @syntaqx. @Triage, @bruno and @david know too; I'm @g1t.", members),
32+ "Ask mike to help @syntaqx. Triage, bruno and david know too; I'm @g1t.",
33+ );
34+ // Matching is by whole handle, whatever its case.
35+ assert.equal(plainOutside("@SYNTAQX and @syntaqxx", members), "@SYNTAQX and syntaqxx");
36+});
37+
38+test("code, addresses and team mentions are left as written", () => {
39+ const none = new Set<string>();
40+ assert.equal(
41+ plainOutside("Use `@mike` or\n```py\n@property\ndef x(): ...\n```\nthen @mike", none),
42+ "Use `@mike` or\n```py\n@property\ndef x(): ...\n```\nthen mike",
43+ );
44+ assert.equal(plainOutside("mail me@example.com, ping @acme/web", none), "mail me@example.com, ping @acme/web");
45+ assert.equal(plainOutside("```\n@open fence", none), "```\n@open fence");
46+});
+28−0
2020 return [...found];
2121 }
2222
23+/**
24+ * The same, ending where a handle ends: `@ana/web` (a team) is not a
25+ * mention of `@ana`, and a shorter match can't be taken from a longer one.
26+ */
27+const MENTION_WHOLE = /(^|[^a-z0-9_.@-])@([a-z0-9](?:[a-z0-9_-]{0,38}[a-z0-9_])?)(?![a-z0-9_/-])/gi;
28+
29+/** Code, which is never a mention: fenced blocks (to the end, if unclosed) and inline spans. */
30+const CODE = /```[\s\S]*?(?:```|$)|`[^`\n]*`/g;
31+
32+/**
33+ * An agent's message as it is kept: a mention of anyone who is not a
34+ * member of the conversation (`members`, lowercased handles and
35+ * usernames) loses its `@`, so it reads as a plain name, shows no pill and
36+ * notifies nobody. Code and team mentions are left as written.
37+ */
38+export function plainOutside(body: string, members: ReadonlySet<string>): string {
39+ const text = String(body ?? "");
40+ const plain = (part: string) =>
41+ part.replace(MENTION_WHOLE, (whole, before: string, handle: string) => (members.has(handle.toLowerCase()) ? whole : `${before}${handle}`));
42+ let out = "";
43+ let at = 0;
44+ for (const code of text.matchAll(CODE)) {
45+ out += plain(text.slice(at, code.index)) + code[0];
46+ at = code.index! + code[0].length;
47+ }
48+ return out + plain(text.slice(at));
49+}
50+
2351 /** How a message's mentions are kept: `` (none) or ` a b `. */
2452 export function mentionsColumn(handles: string[]): string {
2553 return handles.length ? ` ${handles.join(" ")} ` : "";