In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.
13 files+452−440/13 viewed
| 544 | 544 | </Conversation> | |
| 545 | 545 | ||
| 546 | 546 | In a group message with several agents, a message that mentions some of | |
| 547 | − | them goes only to those; one that mentions none goes to all of them. | |
| 547 | + | them goes only to those. One that mentions none goes to the agent you are | |
| 548 | + | talking with: whichever agents answered you last, or, before any has, the | |
| 549 | + | ones you last mentioned; your first message, before you have addressed | |
| 550 | + | anyone, goes to all of them. Every agent there is told who you are | |
| 551 | + | talking with, so one woken beside that agent, by a message that names | |
| 552 | + | them both, answers only what is clearly for it. | |
| 548 | 553 | ||
| 549 | 554 | In a workspace that has hired Sam from **Support Specialist** and Margo | |
| 550 | 555 | from **QA Engineer**: | |
| ⋯ | |||
| 583 | 588 | ||
| 584 | 589 | - **DM it.** In a direct message, it answers every message you send. In a | |
| 585 | 590 | group message with several agents, mention the ones you want; a message | |
| 586 | − | that mentions none of them goes to all of them. | |
| 591 | + | that mentions none of them goes to the agent you are talking with (the | |
| 592 | + | one that answered you last, or that you last mentioned), and to all of | |
| 593 | + | them only before you have addressed any. | |
| 587 | 594 | - **Mention it** in a channel it is a member of: `@margo …`. In a channel, | |
| 588 | 595 | an agent answers only when it is mentioned, and replies in the thread. | |
| 589 | 596 | ||
| ⋯ | |||
| 592 | 599 | one person's message stops after six hops, so agents can't keep each other | |
| 593 | 600 | busy without a person. | |
| 594 | 601 | ||
| 595 | − | While it writes, the agent shows as typing. Its answer is charged to its | |
| 596 | − | own budget; see [what an agent costs](#what-an-agent-costs). | |
| 602 | + | While it works, the agent shows as typing, and as still working on it once | |
| 603 | + | that has taken more than a few seconds. Its answer is charged to its own | |
| 604 | + | budget; see [what an agent costs](#what-an-agent-costs). | |
| 597 | 605 | ||
| 598 | 606 | <Aside type="note" title="What a reply can see"> | |
| 599 | 607 | A reply reads the conversation it is in: the thread, or the latest 30 | |
| 198 | 198 | - The same group of members always gets the same conversation, whatever | |
| 199 | 199 | order you pick them in. | |
| 200 | 200 | - A DM with only yourself is a place for notes. | |
| 201 | − | - **In a DM, every agent in it answers every message from a person.** You | |
| 201 | + | - **In a DM with one agent, it answers every message from a person.** You | |
| 202 | 202 | don't need to mention it. That is the quickest way to talk to an agent: | |
| 203 | 203 | open a DM with it and say what you need. | |
| 204 | 204 | - **In a group message with several agents,** mention the ones you want | |
| 205 | − | an answer from. A message that mentions none of them goes to all of them. | |
| 205 | + | an answer from. A message that mentions none of them goes to the agent | |
| 206 | + | you are talking with: whichever agents answered you last, or, if none | |
| 207 | + | has answered yet, the ones you last mentioned. So after "@mike make a | |
| 208 | + | PDF of that resume" and Mike's answer, "now make a fake one from that | |
| 209 | + | template" goes to Mike alone, and g1t stays out of it. Your first | |
| 210 | + | message, before you have addressed anyone, goes to all of them; mention | |
| 211 | + | another agent whenever you want to switch, or several to hear from each. | |
| 206 | 212 | - **An agent can open a group message for you** when it hands work to a | |
| 207 | 213 | colleague who isn't where you asked: you, it and the colleague. A card | |
| 208 | 214 | where you asked links to it. See | |
| ⋯ | |||
| 361 | 367 | </Aside> | |
| 362 | 368 | ||
| 363 | 369 | While an agent works on a reply, the channel shows it as typing, the same | |
| 364 | − | as a person. If it is out of budget, it says so in the thread instead of | |
| 365 | − | answering, and tells you who can raise its limit. | |
| 370 | + | as a person, for as long as it works: a reply that reads code, consults a | |
| 371 | + | colleague or starts a session can take a minute or more, and after ten | |
| 372 | + | seconds the line says the agent is still working on it. If it is out of | |
| 373 | + | budget, it says so in the thread instead of answering, and tells you who | |
| 374 | + | can raise its limit. | |
| 366 | 375 | ||
| 367 | 376 | ### Session cards | |
| 368 | 377 | ||
| ⋯ | |||
| 446 | 455 | channel the moment they happen, without reloading. You also see: | |
| 447 | 456 | ||
| 448 | 457 | - **Typing.** Who is writing right now, people and agents, in the channel | |
| 449 | − | or in a thread. | |
| 458 | + | or in a thread. An agent shows for as long as it works on its reply, and | |
| 459 | + | as *still working on it* once that has been a while. | |
| 450 | 460 | - **Channel changes.** A new name, topic, or archiving. | |
| 451 | 461 | - **Cards.** An agent's session card, or any other card, changing state. | |
| 452 | 462 | - **Read state.** Your sidebar shows unread counts per channel, and the | |
| 65 | 65 | import { Button } from "../ui/button"; | |
| 66 | 66 | import { Card } from "../ui/card"; | |
| 67 | 67 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 68 | − | import { type Mentionable, type ShownMessage, agentsAmong, channelPath, dmPlaceholder, isMixed, mentionNames, mergeMessages, mixedNotice, shownHandle, shownName, timeline } from "../../lib/chat"; | |
| 68 | + | import { | |
| 69 | + | type Mentionable, | |
| 70 | + | type ShownMessage, | |
| 71 | + | type Typer, | |
| 72 | + | WORKING_AFTER_MS, | |
| 73 | + | agentsAmong, | |
| 74 | + | channelPath, | |
| 75 | + | dmPlaceholder, | |
| 76 | + | isMixed, | |
| 77 | + | mentionNames, | |
| 78 | + | mergeMessages, | |
| 79 | + | mixedNotice, | |
| 80 | + | shownHandle, | |
| 81 | + | shownName, | |
| 82 | + | timeline, | |
| 83 | + | typingLabel, | |
| 84 | + | } from "../../lib/chat"; | |
| 69 | 85 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| 70 | 86 | import { conversationCache } from "./conversation-cache"; | |
| 71 | 87 | import { atBottom, useKeepAtBottom } from "./stick"; | |
| ⋯ | |||
| 129 | 145 | // The message the open thread is under, as its page says, for when it is | |
| 130 | 146 | // further back than the conversation has loaded (a long-running session's card). | |
| 131 | 147 | const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null); | |
| 132 | − | const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map()); | |
| 148 | + | // Who is typing, until when, and since when (so an agent still at it after a while is said to be working). | |
| 149 | + | const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number; since: number }>>(new Map()); | |
| 133 | 150 | const [joined, setJoined] = useState(data.joined); | |
| 134 | 151 | const [members, setMembers] = useState<ChannelMember[]>(data.members); | |
| 135 | 152 | // The channel as it is now: renamed, its topic changed, archived, live. | |
| ⋯ | |||
| 339 | 356 | } else if (event.type === "typing") { | |
| 340 | 357 | if (event.channel_id !== data.channel.id) return; | |
| 341 | 358 | if (event.member.kind === "user" && event.member.id === me?.id) return; | |
| 342 | − | setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() })); | |
| 359 | + | setTyping((now) => { | |
| 360 | + | const key = `${event.member.kind}:${event.member.id}`; | |
| 361 | + | // An agent working on a long reply says it is typing again every few seconds: one stretch, from its first. | |
| 362 | + | return new Map(now).set(key, { member: event.member, until: new Date(event.until).getTime(), since: now.get(key)?.since ?? Date.now() }); | |
| 363 | + | }); | |
| 343 | 364 | } else if (event.type === "channel.updated") { | |
| 344 | 365 | // Renamed, a new topic, archived or back: everyone looking sees it at once. | |
| 345 | 366 | if (event.channel.id !== data.channel.id) return; | |
| ⋯ | |||
| 600 | 621 | }} | |
| 601 | 622 | /> | |
| 602 | 623 | ); | |
| 603 | − | const typers = [...typing.values()].map((t) => t.member); | |
| 624 | + | const typers = [...typing.values()]; | |
| 604 | 625 | const name = channel.name ?? ""; | |
| 605 | 626 | const archived = channel.kind === "channel" && !!channel.archived_at; | |
| 606 | 627 | // A mixed direct message names each member and says which are agents. | |
| ⋯ | |||
| 698 | 719 | </div> | |
| 699 | 720 | <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))] in-data-[keyboard=open]:pb-2"> | |
| 700 | 721 | <div className="mx-auto max-w-[56rem]"> | |
| 701 | − | <TypingLine members={typers} /> | |
| 722 | + | <TypingLine typers={typers} /> | |
| 702 | 723 | {archived ? ( | |
| 703 | 724 | <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> | |
| 704 | 725 | <p className="flex items-center gap-2 text-sm text-muted"> | |
| ⋯ | |||
| 1490 | 1511 | ); | |
| 1491 | 1512 | } | |
| 1492 | 1513 | ||
| 1493 | − | /** "reviewer is typing…", for whoever is. */ | |
| 1494 | − | /** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */ | |
| 1495 | − | const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"]; | |
| 1496 | − | ||
| 1497 | − | function agentDoing(id: string): string { | |
| 1498 | − | let hash = 0; | |
| 1499 | − | for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0; | |
| 1500 | − | return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!; | |
| 1501 | − | } | |
| 1502 | − | ||
| 1503 | − | function TypingLine({ members }: { members: MemberProfile[] }) { | |
| 1504 | − | const names = members.map(shownName); | |
| 1505 | − | const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : ""; | |
| 1514 | + | /** | |
| 1515 | + | * "reviewer is typing…", for whoever is; "Mike is still working on it…" | |
| 1516 | + | * for an agent that has been at a reply a while (`typingLabel`). The line | |
| 1517 | + | * redraws itself the moment the first of them crosses that mark. | |
| 1518 | + | */ | |
| 1519 | + | function TypingLine({ typers }: { typers: Typer[] }) { | |
| 1520 | + | const [, redraw] = useState(0); | |
| 1521 | + | const now = Date.now(); | |
| 1522 | + | useEffect(() => { | |
| 1523 | + | const waits = typers.map((t) => t.since + WORKING_AFTER_MS - now).filter((wait) => wait > 0); | |
| 1524 | + | if (!waits.length) return; | |
| 1525 | + | const timer = setTimeout(() => redraw((n) => n + 1), Math.min(...waits)); | |
| 1526 | + | return () => clearTimeout(timer); | |
| 1527 | + | }, [typers, now]); | |
| 1528 | + | const label = typingLabel(typers, now); | |
| 1506 | 1529 | return ( | |
| 1507 | 1530 | <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted"> | |
| 1508 | − | {who && ( | |
| 1531 | + | {label && ( | |
| 1509 | 1532 | <> | |
| 1510 | 1533 | <span aria-hidden="true" className="flex gap-0.5"> | |
| 1511 | 1534 | {[0, 1, 2].map((i) => ( | |
| ⋯ | |||
| 1513 | 1536 | ))} | |
| 1514 | 1537 | </span> | |
| 1515 | 1538 | <span> | |
| 1516 | − | <span className="font-medium text-fg-soft">{who}</span>{" "} | |
| 1517 | − | {names.length === 1 ? (members[0]!.kind === "agent" ? agentDoing(members[0]!.id) : "is typing") : "are typing"}… | |
| 1539 | + | <span className="font-medium text-fg-soft">{label.who}</span> {label.doing}… | |
| 1518 | 1540 | </span> | |
| 1519 | 1541 | </> | |
| 1520 | 1542 | )} | |
| 22 | 22 | shownHandle, | |
| 23 | 23 | shownName, | |
| 24 | 24 | timeline, | |
| 25 | + | typingLabel, | |
| 25 | 26 | unreadTotals, | |
| 27 | + | WORKING_AFTER_MS, | |
| 28 | + | agentDoing, | |
| 26 | 29 | type ShownMessage, | |
| 27 | 30 | } from "./chat.ts"; | |
| 28 | 31 | ||
| ⋯ | |||
| 207 | 210 | assert.equal(names.get("reviewer"), "Reviewer"); | |
| 208 | 211 | assert.equal(names.get("cy"), undefined); | |
| 209 | 212 | }); | |
| 213 | + | ||
| 214 | + | test("the typing line: people type, an agent does what its phrase says, then is still working on it", () => { | |
| 215 | + | const now = 100_000; | |
| 216 | + | const mike = { kind: "agent" as const, id: "a2", name: "mike", display_name: "Mike", avatar: null, role: null }; | |
| 217 | + | assert.equal(typingLabel([], now), null); | |
| 218 | + | assert.deepEqual(typingLabel([{ member: ana, since: now - 60_000 }], now), { who: "Ana", doing: "is typing" }, "a person is never 'working'"); | |
| 219 | + | assert.deepEqual(typingLabel([{ member: bot, since: now - 2_000 }], now), { who: "Reviewer", doing: agentDoing("a1") }); | |
| 220 | + | assert.match(agentDoing("a1"), /^is /); | |
| 221 | + | assert.deepEqual(typingLabel([{ member: bot, since: now - WORKING_AFTER_MS }], now), { who: "Reviewer", doing: "is still working on it" }); | |
| 222 | + | // Mike and @g1t on one reply each: on it, then still working, as the user saw them vanish after eight seconds before. | |
| 223 | + | assert.deepEqual(typingLabel([{ member: mike, since: now - 3_000 }, { member: bot, since: now - 1_000 }], now), { who: "Mike and Reviewer", doing: "are on it" }); | |
| 224 | + | assert.deepEqual(typingLabel([{ member: mike, since: now - 30_000 }, { member: bot, since: now - 1_000 }], now), { who: "Mike and Reviewer", doing: "are still working on it" }); | |
| 225 | + | assert.deepEqual(typingLabel([{ member: ana, since: now }, { member: bot, since: now - 30_000 }], now), { who: "Ana and Reviewer", doing: "are typing" }, "people and agents together"); | |
| 226 | + | const third = { ...ana, id: "u3", name: "bo", display_name: "Bo" }; | |
| 227 | + | assert.deepEqual(typingLabel([{ member: ana, since: now }, { member: bot, since: now }, { member: third, since: now }], now), { who: "Several people", doing: "are typing" }); | |
| 228 | + | assert.deepEqual(typingLabel([{ member: mike, since: now }, { member: bot, since: now }, { member: { ...mike, id: "a3", name: "sam", display_name: "Sam" }, since: now }], now), { who: "Several agents", doing: "are on it" }); | |
| 229 | + | }); | |
| 319 | 319 | return names; | |
| 320 | 320 | } | |
| 321 | 321 | ||
| 322 | + | /** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */ | |
| 323 | + | const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"]; | |
| 324 | + | ||
| 325 | + | export function agentDoing(id: string): string { | |
| 326 | + | let hash = 0; | |
| 327 | + | for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0; | |
| 328 | + | return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!; | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /** | |
| 332 | + | * After this long shown as typing, an agent is said to be still working | |
| 333 | + | * instead: a reply that reads code, consults a colleague or starts a | |
| 334 | + | * session takes a minute or more, and it keeps saying so the whole time. | |
| 335 | + | */ | |
| 336 | + | export const WORKING_AFTER_MS = 10_000; | |
| 337 | + | ||
| 338 | + | /** Someone shown as typing, and since when (the first typing event for them, kept across the ones that follow). */ | |
| 339 | + | export type Typer = { member: Pick<MemberProfile, "kind" | "id" | "display_name" | "name"> & { display_username?: string | null }; since: number }; | |
| 340 | + | ||
| 341 | + | /** | |
| 342 | + | * The typing line: who, and what they are doing. A person is typing. An | |
| 343 | + | * agent is doing what its phrase says, then, past `WORKING_AFTER_MS`, | |
| 344 | + | * still working on it; several agents are on it together, and a mix of | |
| 345 | + | * people and agents are typing. Null when nobody is. | |
| 346 | + | */ | |
| 347 | + | export function typingLabel(typers: readonly Typer[], now: number): { who: string; doing: string } | null { | |
| 348 | + | if (!typers.length) return null; | |
| 349 | + | const names = typers.map((t) => shownName(t.member)); | |
| 350 | + | const agents = typers.every((t) => t.member.kind === "agent"); | |
| 351 | + | const who = names.length === 1 ? names[0]! : names.length === 2 ? `${names[0]} and ${names[1]}` : agents ? "Several agents" : "Several people"; | |
| 352 | + | const long = typers.some((t) => now - t.since >= WORKING_AFTER_MS); | |
| 353 | + | if (typers.length === 1) { | |
| 354 | + | const [one] = typers; | |
| 355 | + | return { who, doing: one!.member.kind === "agent" ? (long ? "is still working on it" : agentDoing(one!.member.id)) : "is typing" }; | |
| 356 | + | } | |
| 357 | + | return { who, doing: agents ? (long ? "are still working on it" : "are on it") : "are typing" }; | |
| 358 | + | } | |
| 359 | + | ||
| 322 | 360 | /** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */ | |
| 323 | 361 | export function backoff(attempt: number, random: () => number = Math.random): number { | |
| 324 | 362 | const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt)); |
| 4 | 4 | import type { User } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { askerAccess } from "../../../packages/contracts/src/workspace-agents.ts"; | |
| 7 | − | import { audienceFor, systemPrompt, turns } from "./prompt.ts"; | |
| 7 | + | import { audienceFor, lastAddressed, systemPrompt, turns } from "./prompt.ts"; | |
| 8 | 8 | import type { SurfaceMessage } from "./surface.ts"; | |
| 9 | 9 | ||
| 10 | 10 | const agent = { | |
| ⋯ | |||
| 216 | 216 | assert.match(handed, /## Handed to you\n\n@g1t \(an agent\) handed you this work for @dana/); | |
| 217 | 217 | assert.match(handed, /Don't hand it back to @g1t\./); | |
| 218 | 218 | }); | |
| 219 | + | ||
| 220 | + | // ── Who the person is talking with, among several agents ───────────────── | |
| 221 | + | ||
| 222 | + | const chase = (body: string, id = "m") => message(id, "user", "usr_syntaqx", "syntaqx", body); | |
| 223 | + | const mikeSays = (body = "Here it is.", id = "m") => message(id, "agent", "agt_mike", "mike", body); | |
| 224 | + | const g1tSays = (body = "Done.", id = "m") => message(id, "agent", "agt_g1t", "g1t", body); | |
| 225 | + | const groupDm = { | |
| 226 | + | kind: "group_dm" as const, | |
| 227 | + | name: null, | |
| 228 | + | people: 1, | |
| 229 | + | agents: 2, | |
| 230 | + | members: [member("agent", "g1t", "g1t"), member("agent", "mike", "Mike", "Technical Recruiter"), member("user", "syntaqx", "Chase Pierce")], | |
| 231 | + | }; | |
| 232 | + | ||
| 233 | + | test("who the person is talking with: the last exchange, else their last mention, else nobody yet", () => { | |
| 234 | + | // The conversation as it happened: Mike the recruiter and @g1t in one group message. | |
| 235 | + | const transcript = [chase("Are you able to generate PDFs now?"), mikeSays(), g1tSays(), chase("@Mike Make a PDF of that example resume for me"), mikeSays("Made it.")]; | |
| 236 | + | assert.deepEqual(lastAddressed([...transcript, chase("Let's take that template and make a fake resume for some fake person")], groupDm, "usr_syntaqx"), ["mike"]); | |
| 237 | + | assert.deepEqual(lastAddressed(transcript.slice(0, 3), groupDm, "usr_syntaqx"), ["g1t", "mike"], "both answered the first question"); | |
| 238 | + | assert.deepEqual(lastAddressed([chase("hi")], groupDm, "usr_syntaqx"), [], "nobody yet"); | |
| 239 | + | assert.deepEqual(lastAddressed([chase("@g1t and @mike, compare notes"), chase("quickly")], groupDm, "usr_syntaqx"), ["g1t", "mike"], "mentioned, not yet answered"); | |
| 240 | + | assert.deepEqual(lastAddressed([chase("ask @sam"), g1tSays()], groupDm, "usr_syntaqx"), ["g1t"], "a mention of someone not here decides nothing"); | |
| 241 | + | assert.deepEqual(lastAddressed([message("x", "user", "usr_bo", "bo", "@mike hi"), chase("hello")], groupDm, "usr_syntaqx"), [], "only this person's mentions"); | |
| 242 | + | }); | |
| 243 | + | ||
| 244 | + | test("with several agents here, the prompt says who the person is talking with, and what that means for an unaddressed message", () => { | |
| 245 | + | const prompt = (addressed: string[] | null) => systemPrompt({ ...g1t, channel: { kind: "dm", name: null }, canHandOff: true, conversation: groupDm, addressed }); | |
| 246 | + | assert.match(prompt(["mike"]), /@syntaqx is talking with @mike, not you: a message that names no agent is theirs, so answer only what is clearly for you, briefly, and don't redo their work\./); | |
| 247 | + | assert.match(prompt(["g1t"]), /@syntaqx is talking with you: a message that names no agent is yours to answer\./); | |
| 248 | + | assert.match(prompt(["g1t", "mike"]), /@syntaqx is talking with you and @mike: a message that names no agent is yours to answer\./); | |
| 249 | + | assert.match(prompt([]), /@syntaqx hasn't addressed one yet\. Answer what fits your role/); | |
| 250 | + | assert.doesNotMatch(prompt(null), /Several agents are here/); | |
| 251 | + | // Alone with the person, there is nothing to say about it. | |
| 252 | + | const alone = systemPrompt({ ...g1t, channel: { kind: "dm", name: null }, addressed: ["g1t"], conversation: { ...groupDm, kind: "dm", agents: 1, members: groupDm.members.filter((m) => m.name !== "mike") } }); | |
| 253 | + | assert.doesNotMatch(alone, /Several agents are here/); | |
| 254 | + | }); | |
| 72 | 72 | recentSessions?: string | null; | |
| 73 | 73 | /** The conversation and everyone in it, said every turn; absent when it couldn't be read. */ | |
| 74 | 74 | conversation?: Conversation | null; | |
| 75 | + | /** | |
| 76 | + | * In a conversation with other agents: the handles of the agents the | |
| 77 | + | * person is talking with (`lastAddressed`), empty when they haven't | |
| 78 | + | * addressed one yet. Absent when the agent is the only one here. | |
| 79 | + | */ | |
| 80 | + | addressed?: string[] | null; | |
| 75 | 81 | /** Whether the agent can hand work to a colleague from here (the hand_off tool). */ | |
| 76 | 82 | canHandOff?: boolean; | |
| 77 | 83 | /** When a colleague handed this work over: that agent's handle. */ | |
| ⋯ | |||
| 214 | 220 | const more = conversation.people - shownPeople; | |
| 215 | 221 | if (more > 0) lines.push(`- and ${more} more ${more === 1 ? "person" : "people"}`); | |
| 216 | 222 | const open = conversation.kind === "public_channel"; | |
| 223 | + | const addressed = input.addressed && conversation.agents > 1 ? addressedLine(input) : null; | |
| 217 | 224 | return [ | |
| 218 | 225 | "", | |
| 219 | 226 | open | |
| ⋯ | |||
| 222 | 229 | ...lines, | |
| 223 | 230 | "", | |
| 224 | 231 | `- ${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"}.`, | |
| 232 | + | ...(addressed ? [addressed] : []), | |
| 225 | 233 | delegate, | |
| 226 | 234 | honest, | |
| 227 | 235 | ]; | |
| 228 | 236 | } | |
| 229 | 237 | ||
| 238 | + | /** | |
| 239 | + | * With other agents here, who the person is talking with, so an agent woken | |
| 240 | + | * beside the one they addressed (a message naming both, say) knows whose | |
| 241 | + | * question it is. In a direct message the chat service already wakes only | |
| 242 | + | * the agent addressed (docs.g1t.sh/guides/chat/, "Direct messages"). | |
| 243 | + | */ | |
| 244 | + | function addressedLine(input: PromptInput): string { | |
| 245 | + | const handles = input.addressed ?? []; | |
| 246 | + | const person = `@${input.asker.name}`; | |
| 247 | + | if (!handles.length) return `- Several agents are here and ${person} hasn't addressed one yet. Answer what fits your role, and leave what fits a colleague's role to them.`; | |
| 248 | + | const me = handles.some((handle) => handle.toLowerCase() === input.agent.handle.toLowerCase()); | |
| 249 | + | const others = handles.filter((handle) => handle.toLowerCase() !== input.agent.handle.toLowerCase()).map((handle) => `@${handle}`); | |
| 250 | + | if (me) return `- Several agents are here. ${person} is talking with you${others.length ? ` and ${listOf(others)}` : ""}: a message that names no agent is yours to answer.`; | |
| 251 | + | return `- Several agents are here. ${person} is talking with ${listOf(others)}, not you: a message that names no agent is theirs, so answer only what is clearly for you, briefly, and don't redo their work.`; | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /** | |
| 255 | + | * The agents the person is talking with, by handle, from the conversation | |
| 256 | + | * so far: the same rule as the chat service's `addressedAgents` | |
| 257 | + | * (services/chat/src/delivery.ts), which decides who an unaddressed | |
| 258 | + | * message wakes. Walking back from the latest message, the agents whose | |
| 259 | + | * messages come before any person's are the last exchange; with none, the | |
| 260 | + | * person's own latest message that @mentions agents here names them; | |
| 261 | + | * otherwise nobody has been addressed yet (empty). | |
| 262 | + | */ | |
| 263 | + | export function lastAddressed(history: SurfaceMessage[], conversation: Pick<Conversation, "members">, personId: string): string[] { | |
| 264 | + | const agents = conversation.members.filter((m) => m.kind === "agent"); | |
| 265 | + | const byId = new Map(agents.map((agent) => [agent.id, agent.name])); | |
| 266 | + | const byHandle = new Map(agents.map((agent) => [agent.name.toLowerCase(), agent.name])); | |
| 267 | + | const exchange: string[] = []; | |
| 268 | + | for (let i = history.length - 1; i >= 0; i--) { | |
| 269 | + | const { author, body } = history[i]; | |
| 270 | + | if (author.kind === "agent") { | |
| 271 | + | const handle = byId.get(author.id); | |
| 272 | + | if (handle && !exchange.includes(handle)) exchange.push(handle); | |
| 273 | + | continue; | |
| 274 | + | } | |
| 275 | + | if (exchange.length) break; | |
| 276 | + | if (author.id !== personId) continue; | |
| 277 | + | const named = mentionedIn(body).map((handle) => byHandle.get(handle)).filter((found): found is string => !!found); | |
| 278 | + | if (named.length) return named; | |
| 279 | + | } | |
| 280 | + | return exchange; | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /** `@handle` after the start or a character no address or word has (the chat service's rule), lowercased, each once. */ | |
| 284 | + | const MENTION = /(^|[^a-z0-9_.@-])@([a-z0-9](?:[a-z0-9_-]{0,38}[a-z0-9_])?)/gi; | |
| 285 | + | ||
| 286 | + | function mentionedIn(body: string): string[] { | |
| 287 | + | const found = new Set<string>(); | |
| 288 | + | for (const match of body.matchAll(MENTION)) found.add(match[2].toLowerCase()); | |
| 289 | + | return [...found]; | |
| 290 | + | } | |
| 291 | + | ||
| 230 | 292 | /** What the agent can read and do, said honestly: with tools, within the audience rules; without, only this conversation. */ | |
| 231 | 293 | function readingRules(tools: { code: boolean } | null, session = false, computer = false): string[] { | |
| 232 | 294 | if (!tools) { | |
| 24 | 24 | import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts"; | |
| 25 | 25 | import type { Tokens } from "./budget.ts"; | |
| 26 | 26 | import { handOffPort } from "./handoff.ts"; | |
| 27 | − | import { HISTORY_LIMIT, systemPrompt, turns } from "./prompt.ts"; | |
| 27 | + | import { HISTORY_LIMIT, lastAddressed, systemPrompt, turns } from "./prompt.ts"; | |
| 28 | 28 | import { loadShelf, skillsSection, teamSlugs } from "./skills.ts"; | |
| 29 | 29 | import { readVersion } from "./skill-library.ts"; | |
| 30 | 30 | import { type Specialist, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts"; | |
| ⋯ | |||
| 34 | 34 | import { REPLY_TIER, allowedProviders, effortOf, effortPlan, replyModel } from "./routing.ts"; | |
| 35 | 35 | import { type SessionEnv, type SessionRow, actionPorts, sessionRow, startSession, steer } from "./sessions.ts"; | |
| 36 | 36 | import { type Row, definitionOf, periods, selectAgents, toAgent } from "./store.ts"; | |
| 37 | − | import { type SurfaceMessage, surfaceFor } from "./surface.ts"; | |
| 37 | + | import { type SurfaceMessage, keepTyping, surfaceFor } from "./surface.ts"; | |
| 38 | 38 | import { Audience } from "./audience.ts"; | |
| 39 | 39 | import { audiencePorts, loadTeams, teamsOfAgents, toolPorts } from "./ports.ts"; | |
| 40 | 40 | import { type ToolCall, type ToolPorts, ToolBox } from "./tools.ts"; | |
| ⋯ | |||
| 273 | 273 | // What the tools did, for the audit table, and who the audience was. | |
| 274 | 274 | let toolCalls: ToolCall[] = []; | |
| 275 | 275 | let audienceHash: string | null = null; | |
| 276 | + | // Stops the typing heartbeat (surface.ts `keepTyping`), once the reply reads the conversation. | |
| 277 | + | let stopTyping: () => void = () => undefined; | |
| 276 | 278 | ||
| 277 | 279 | const finish = async (outcome: Outcome) => { | |
| 280 | + | stopTyping(); | |
| 278 | 281 | const statements = [ | |
| 279 | 282 | db | |
| 280 | 283 | .prepare( | |
| ⋯ | |||
| 338 | 341 | } | |
| 339 | 342 | } | |
| 340 | 343 | ||
| 341 | − | // Read the conversation while showing that the agent is on it. | |
| 344 | + | // Read the conversation while showing that the agent is on it, and keep | |
| 345 | + | // showing it until the answer is posted or the reply ends another way. | |
| 342 | 346 | // And who is here: said every turn, so the agent knows who reads it and who doesn't. | |
| 343 | 347 | // And its teams, from their pages: who leads, who owns what, who is around. | |
| 344 | − | const [, history, conversationHere, teamsHere] = await Promise.all([ | |
| 345 | − | surface.typing(), | |
| 348 | + | stopTyping = keepTyping(surface); | |
| 349 | + | const [history, conversationHere, teamsHere] = await Promise.all([ | |
| 346 | 350 | surface.history(HISTORY_LIMIT), | |
| 347 | 351 | surface.conversation(), | |
| 348 | 352 | loadTeams(env, slug, row.workspace_id, { id: row.id }).catch(() => null), | |
| ⋯ | |||
| 368 | 372 | const chain = delivery.chain ?? []; | |
| 369 | 373 | const sender = chain.length ? await db.prepare("SELECT handle FROM agents WHERE id = ?").bind(chain[chain.length - 1]).first<{ handle: string }>() : null; | |
| 370 | 374 | const asker = { name: askerName ?? "someone", display_name: author?.display_name ?? null, access: delivery.asker ?? null }; | |
| 375 | + | // With other agents here: who the person is talking with (prompt.ts `lastAddressed`). | |
| 376 | + | const addressed = conversationHere && conversationHere.agents > 1 ? lastAddressed(history, conversationHere, delivery.asked_by) : null; | |
| 371 | 377 | let posted: string | null = null; | |
| 372 | 378 | let spinOffs = 0; | |
| 373 | 379 | ||
| ⋯ | |||
| 515 | 521 | colleagues: row.builtin ? null : rosterLines(specialists), | |
| 516 | 522 | recentSessions: recent, | |
| 517 | 523 | conversation: conversationHere, | |
| 524 | + | addressed, | |
| 518 | 525 | teams: teamsSection(row.id, teamsHere, now), | |
| 519 | 526 | canHandOff: !!toolbox?.definitions().some((tool) => tool.name === "hand_off"), | |
| 520 | 527 | abilities: abilitiesText, | |
| ⋯ | |||
| 531 | 538 | // Post as soon as there is an answer; the bill is settled after. | |
| 532 | 539 | // It wakes nobody, @mentions or not: colleagues get work only through hand_off. | |
| 533 | 540 | if (answer.text) posted = await surface.post(answer.text); | |
| 541 | + | // Posted, or nothing to post: no longer typing. | |
| 542 | + | stopTyping(); | |
| 534 | 543 | return { | |
| 535 | 544 | text: answer.text, | |
| 536 | 545 | tokens: addTokens(answer.tokens, consulted.tokens), | |
| ⋯ | |||
| 553 | 562 | await finish({ status: "failed", error: message, reply_id: posted, ...usage }).catch((failure: unknown) => | |
| 554 | 563 | console.error("agents: a failed reply was not recorded", id, String(failure)), | |
| 555 | 564 | ); | |
| 565 | + | } finally { | |
| 566 | + | // However it ended, the heartbeat ends with it. | |
| 567 | + | stopTyping(); | |
| 556 | 568 | } | |
| 557 | 569 | } | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | − | import { test } from "node:test"; | |
| 2 | + | import { mock, test } from "node:test"; | |
| 3 | 3 | ||
| 4 | 4 | import type { AgentDelivery } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { g1tSurface } from "./surface.ts"; | |
| 6 | + | import { TYPING_HEARTBEAT_MS, g1tSurface, keepTyping } from "./surface.ts"; | |
| 7 | 7 | ||
| 8 | 8 | const delivery: AgentDelivery = { | |
| 9 | 9 | workspace: "acme", | |
| ⋯ | |||
| 176 | 176 | await s2.settle("done"); | |
| 177 | 177 | assert.equal(refused.calls.length, 1, "no ✅ or removal after a refused 👀"); | |
| 178 | 178 | }); | |
| 179 | + | ||
| 180 | + | test("the typing heartbeat says so at once and every few seconds until stopped, and never throws", () => { | |
| 181 | + | mock.timers.enable({ apis: ["setInterval"] }); | |
| 182 | + | try { | |
| 183 | + | let beats = 0; | |
| 184 | + | const stop = keepTyping({ typing: async () => void beats++ }); | |
| 185 | + | assert.equal(beats, 1, "shown as typing the moment the reply starts"); | |
| 186 | + | mock.timers.tick(TYPING_HEARTBEAT_MS * 3); | |
| 187 | + | assert.equal(beats, 4, "and again each heartbeat while it works"); | |
| 188 | + | stop(); | |
| 189 | + | mock.timers.tick(TYPING_HEARTBEAT_MS * 10); | |
| 190 | + | assert.equal(beats, 4, "nothing after the reply ends"); | |
| 191 | + | // A surface that fails, rejecting or throwing, stops nothing and throws nothing. | |
| 192 | + | const rejecting = keepTyping({ typing: () => Promise.reject(new Error("down")) }); | |
| 193 | + | const throwing = keepTyping({ | |
| 194 | + | typing: () => { | |
| 195 | + | throw new Error("down"); | |
| 196 | + | }, | |
| 197 | + | }); | |
| 198 | + | mock.timers.tick(TYPING_HEARTBEAT_MS); | |
| 199 | + | rejecting(); | |
| 200 | + | throwing(); | |
| 201 | + | } finally { | |
| 202 | + | mock.timers.reset(); | |
| 203 | + | } | |
| 204 | + | }); | |
| 104 | 104 | export const DONE = "✅"; | |
| 105 | 105 | ||
| 106 | 106 | /** | |
| 107 | + | * How often an agent still working says so again. Chat shows each call | |
| 108 | + | * for `AGENT_TYPING_MS` (8 s, services/chat/src/messages.ts), so this stays | |
| 109 | + | * under it and the indicator never flickers off between beats. | |
| 110 | + | */ | |
| 111 | + | export const TYPING_HEARTBEAT_MS = 5_000; | |
| 112 | + | ||
| 113 | + | /** | |
| 114 | + | * Keeps the agent shown as typing while it works: `typing()` now and every | |
| 115 | + | * `everyMs` until the function it returns is called. A reply that reads | |
| 116 | + | * code, consults a colleague or starts a session takes a minute or more, | |
| 117 | + | * and one call alone would fade after seconds while the agent is still on | |
| 118 | + | * it. Never throws: a failed beat is simply the next one's turn. | |
| 119 | + | */ | |
| 120 | + | export function keepTyping(surface: Pick<Surface, "typing">, everyMs = TYPING_HEARTBEAT_MS): () => void { | |
| 121 | + | let on = true; | |
| 122 | + | const beat = () => { | |
| 123 | + | if (!on) return; | |
| 124 | + | try { | |
| 125 | + | void surface.typing().catch(() => undefined); | |
| 126 | + | } catch { | |
| 127 | + | // A courtesy only. | |
| 128 | + | } | |
| 129 | + | }; | |
| 130 | + | beat(); | |
| 131 | + | const timer = setInterval(beat, everyMs); | |
| 132 | + | return () => { | |
| 133 | + | on = false; | |
| 134 | + | clearInterval(timer); | |
| 135 | + | }; | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /** | |
| 107 | 139 | * Whether an agent reacts in a conversation: in channels and in direct | |
| 108 | 140 | * messages with more than one person. In a DM with one person, the typing | |
| 109 | 141 | * indicator says enough. |
| 1 | 1 | import { test } from "node:test"; | |
| 2 | 2 | import assert from "node:assert/strict"; | |
| 3 | 3 | ||
| 4 | − | import { MAX_HOPS, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal } from "./delivery.ts"; | |
| 4 | + | import { MAX_HOPS, type RecentMessage, addressedAgents, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal } from "./delivery.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const ship = { id: "a1", handle: "ship" }; | |
| 7 | 7 | const docs = { id: "a2", handle: "docs" }; | |
| 8 | 8 | ||
| 9 | − | test("a person's direct message wakes every agent in it, at hop 0", () => { | |
| 9 | + | test("a person's direct message with nothing said yet wakes every agent in it, at hop 0", () => { | |
| 10 | 10 | assert.deepEqual(deliveries({ author: "user:u", hops: 3, channelKind: "dm", agents: [ship, docs], mentioned: [] }), [ | |
| 11 | 11 | { agent_id: "a1", hops: 0 }, | |
| 12 | 12 | { agent_id: "a2", hops: 0 }, | |
| 13 | 13 | ]); | |
| 14 | + | assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents: [ship, docs], mentioned: [], recent: [] }), [ | |
| 15 | + | { agent_id: "a1", hops: 0 }, | |
| 16 | + | { agent_id: "a2", hops: 0 }, | |
| 17 | + | ]); | |
| 18 | + | // With one agent there is nobody else it could be for. | |
| 19 | + | assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents: [ship], mentioned: [], recent: [{ author: "user:u", mentioned: ["docs"] }] }), [{ agent_id: "a1", hops: 0 }]); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | const mike = { id: "a_mike", handle: "mike" }; | |
| 23 | + | const g1t = { id: "a_g1t", handle: "g1t" }; | |
| 24 | + | const says = (author: string, ...mentioned: string[]): RecentMessage => ({ author, mentioned }); | |
| 25 | + | ||
| 26 | + | test("in a group DM, an unaddressed message goes to the agent the person is talking with", () => { | |
| 27 | + | // The conversation as it happened: Chase, Mike the recruiter and @g1t in one group message. | |
| 28 | + | const transcript = [ | |
| 29 | + | says("user:chase"), // "Are you able to generate PDFs now?" | |
| 30 | + | says("agent:a_mike"), | |
| 31 | + | says("agent:a_g1t"), | |
| 32 | + | says("user:chase", "mike"), // "@Mike Make a PDF of that example resume for me" | |
| 33 | + | says("agent:a_mike"), | |
| 34 | + | ]; | |
| 35 | + | // "Let's take that template and make a fake resume for some fake person": Mike was helping. | |
| 36 | + | assert.deepEqual(deliveries({ author: "user:chase", hops: 0, channelKind: "dm", agents: [mike, g1t], mentioned: [], recent: transcript }), [{ agent_id: "a_mike", hops: 0 }]); | |
| 37 | + | // The first question, with nothing before it, went to both; so does a follow-up to both their answers. | |
| 38 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", []), [mike, g1t]); | |
| 39 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", transcript.slice(0, 3)), [mike, g1t]); | |
| 40 | + | // A mention still goes only to those named, whoever was talking. | |
| 41 | + | assert.deepEqual(deliveries({ author: "user:chase", hops: 0, channelKind: "dm", agents: [mike, g1t], mentioned: ["g1t"], recent: transcript }), [{ agent_id: "a_g1t", hops: 0 }]); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("who is addressed: the last exchange, else the person's last mention, else everyone", () => { | |
| 45 | + | // Mentioned but not yet answered: the mention stands, through the person's own follow-ups. | |
| 46 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "Mike"), says("user:chase")]), [mike]); | |
| 47 | + | // Answered by a colleague after the mention (a hand-off here, say): the colleague has the conversation now. | |
| 48 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "mike"), says("agent:a_mike", "g1t"), says("user:chase"), says("agent:a_g1t")]), [g1t]); | |
| 49 | + | // Both answered the last message: both are in the exchange, in the conversation's order. | |
| 50 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "g1t", "mike"), says("agent:a_g1t"), says("agent:a_mike")]), [mike, g1t]); | |
| 51 | + | // A thank-you in between changes nothing: the person's last mention still says who. | |
| 52 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "mike"), says("agent:a_mike"), says("user:chase"), says("user:chase")]), [mike]); | |
| 53 | + | // Another person's message ends the exchange without deciding it; only this person's mentions do. | |
| 54 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "mike"), says("agent:a_mike"), says("user:bo", "g1t")]), [mike]); | |
| 55 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:bo", "g1t"), says("agent:a_g1t")]), [g1t], "whoever answered last, whoever they answered"); | |
| 56 | + | // A second message before anyone answers the first goes where the first did. | |
| 57 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:bo", "g1t"), says("agent:a_g1t"), says("user:chase")]), [g1t]); | |
| 58 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase"), says("agent:a_mike"), says("user:chase")]), [mike], "only Mike answered the first"); | |
| 59 | + | // Mentions of agents that aren't here, and messages of agents that left, decide nothing. | |
| 60 | + | assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "sam"), says("agent:a_gone")]), [mike, g1t]); | |
| 61 | + | assert.deepEqual(addressedAgents([mike], "user:chase", [says("user:chase", "g1t")]), [mike], "one agent: nothing to decide"); | |
| 14 | 62 | }); | |
| 15 | 63 | ||
| 16 | 64 | test("in a group direct message, a person who mentions some of its agents wakes only those", () => { |
| 4 | 4 | * | |
| 5 | 5 | * The rules (docs.g1t.sh/guides/agents/, "Talk to an agent"): | |
| 6 | 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. | |
| 7 | + | * In a direct message it wakes the agents in it that it @mentions; one | |
| 8 | + | * that mentions none wakes the only agent there, or, with several, the | |
| 9 | + | * agent the person is talking with (`addressedAgents`), or all of them | |
| 10 | + | * when the conversation doesn't say. That starts a chain at hop 0. | |
| 9 | 11 | * - An agent's message wakes nobody, @mentions or not. An agent gets a | |
| 10 | 12 | * colleague working only by handing off (`handOffPlace`), which wakes | |
| 11 | 13 | * that one colleague, one hop further along the chain it was answering. | |
| ⋯ | |||
| 74 | 76 | }; | |
| 75 | 77 | } | |
| 76 | 78 | ||
| 79 | + | /** One earlier message of the conversation, as much of it as addressing needs. */ | |
| 80 | + | export type RecentMessage = { | |
| 81 | + | /** Who wrote it: `user:<id>` or `agent:<id>`. */ | |
| 82 | + | author: string; | |
| 83 | + | /** Handles it @mentions, lowercased. */ | |
| 84 | + | mentioned: string[]; | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | /** How many earlier messages decide who an unaddressed message is for. */ | |
| 88 | + | export const ADDRESSING_HISTORY = 30; | |
| 89 | + | ||
| 90 | + | /** | |
| 91 | + | * In a direct message with several agents, which of them a message from | |
| 92 | + | * `author` that mentions none of them is for: the agent the person is | |
| 93 | + | * talking with, read from the conversation so far (`recent`, oldest | |
| 94 | + | * first, the message itself left out). Walking back from the latest | |
| 95 | + | * message: | |
| 96 | + | * | |
| 97 | + | * 1. The agents whose messages come before any person's are the last | |
| 98 | + | * exchange: whoever answered last. The message is for them. | |
| 99 | + | * 2. With no answer yet, the person's own latest message that @mentions | |
| 100 | + | * agents of this conversation says who they addressed: it is for those. | |
| 101 | + | * Their messages that mention none are passed over on the way. | |
| 102 | + | * | |
| 103 | + | * So "@mike make a PDF", Mike's answer, then "now make a fake one" is for | |
| 104 | + | * Mike alone, however many agents are in the conversation, and so is a | |
| 105 | + | * second message sent before Mike answers; after a hand-off to a | |
| 106 | + | * colleague here, the colleague's answer makes the next message theirs. | |
| 107 | + | * Other people's mentions decide nothing, and agents no longer in the | |
| 108 | + | * conversation count for nothing. When nothing in `recent` decides, every | |
| 109 | + | * agent gets it, as every agent got the first message. | |
| 110 | + | */ | |
| 111 | + | export function addressedAgents(agents: AgentMember[], author: string, recent: RecentMessage[]): AgentMember[] { | |
| 112 | + | if (agents.length < 2) return agents; | |
| 113 | + | const byId = new Map(agents.map((agent) => [`agent:${agent.id}`, agent])); | |
| 114 | + | const byHandle = new Map(agents.map((agent) => [agent.handle.toLowerCase(), agent])); | |
| 115 | + | const exchange: AgentMember[] = []; | |
| 116 | + | for (let i = recent.length - 1; i >= 0; i--) { | |
| 117 | + | const message = recent[i]; | |
| 118 | + | const agent = byId.get(message.author); | |
| 119 | + | if (agent) { | |
| 120 | + | if (!exchange.includes(agent)) exchange.push(agent); | |
| 121 | + | continue; | |
| 122 | + | } | |
| 123 | + | if (!message.author.startsWith("user:")) continue; | |
| 124 | + | // A person's message: the agents that answered since are the exchange. | |
| 125 | + | if (exchange.length) break; | |
| 126 | + | if (message.author !== author) continue; | |
| 127 | + | const named = message.mentioned.map((handle) => byHandle.get(handle.toLowerCase())).filter((found): found is AgentMember => !!found); | |
| 128 | + | if (named.length) return agents.filter((agent) => named.includes(agent)); | |
| 129 | + | } | |
| 130 | + | if (exchange.length) return agents.filter((agent) => exchange.includes(agent)); | |
| 131 | + | return agents; | |
| 132 | + | } | |
| 133 | + | ||
| 77 | 134 | export function deliveries(input: { | |
| 78 | 135 | /** Who wrote it: `user:<id>` or `agent:<id>`. */ | |
| 79 | 136 | author: string; | |
| ⋯ | |||
| 84 | 141 | agents: AgentMember[]; | |
| 85 | 142 | /** Handles the message @mentions, lowercased. */ | |
| 86 | 143 | mentioned: string[]; | |
| 144 | + | /** | |
| 145 | + | * In a direct message with several agents: the conversation before this | |
| 146 | + | * message, oldest first (`addressedAgents`). Absent or empty, an | |
| 147 | + | * unaddressed message goes to every agent in it. | |
| 148 | + | */ | |
| 149 | + | recent?: RecentMessage[]; | |
| 87 | 150 | }): Wake[] { | |
| 88 | 151 | // Only a person's message wakes anyone; agents reach each other by hand-off. | |
| 89 | 152 | if (!input.author.startsWith("user:")) return []; | |
| 90 | 153 | const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase())); | |
| 91 | 154 | const named = input.agents.filter((agent) => mentioned.has(agent.handle.toLowerCase())); | |
| 92 | − | const woken = input.channelKind === "dm" && !named.length ? input.agents : named; | |
| 155 | + | const woken = input.channelKind === "dm" && !named.length ? addressedAgents(input.agents, input.author, input.recent ?? []) : named; | |
| 93 | 156 | return woken.map((agent) => ({ agent_id: agent.id, hops: 0 })); | |
| 94 | 157 | } | |
| 95 | 158 | ||
| 67 | 67 | ||
| 68 | 68 | import { type ActivityRow, activitySpan, chatActivity } from "./activity.ts"; | |
| 69 | 69 | import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts"; | |
| 70 | − | import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, type Chain, type Wake } from "./delivery.ts"; | |
| 71 | 70 | import { | |
| 71 | + | ADDRESSING_HISTORY, | |
| 72 | + | MAX_HOPS, | |
| 73 | + | addsOrchestrator, | |
| 74 | + | chainFor, | |
| 75 | + | deliveries, | |
| 76 | + | delivery, | |
| 77 | + | handOffPlace, | |
| 78 | + | handOffRefusal, | |
| 79 | + | personalAgentRefusal, | |
| 80 | + | type Chain, | |
| 81 | + | type RecentMessage, | |
| 82 | + | type Wake, | |
| 83 | + | } from "./delivery.ts"; | |
| 84 | + | import { | |
| 72 | 85 | MAX_REACTIONS_PER_MESSAGE, | |
| 73 | 86 | emojiImage, | |
| 74 | 87 | emojiName, | |
| ⋯ | |||
| 1217 | 1230 | channelKind: channel.kind, | |
| 1218 | 1231 | agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })), | |
| 1219 | 1232 | mentioned: handles, | |
| 1233 | + | // With several agents in a direct message, who the person is talking with decides an unaddressed message. | |
| 1234 | + | recent: channel.kind === "dm" && agents.length > 1 ? await this.recentBefore(row) : [], | |
| 1220 | 1235 | }); | |
| 1221 | 1236 | await this.deliverTo(place, row, chain, wakes); | |
| 1222 | 1237 | } | |
| 1223 | 1238 | ||
| 1239 | + | /** | |
| 1240 | + | * The conversation before `row`, oldest first, as addressing reads it | |
| 1241 | + | * (src/delivery.ts `addressedAgents`): the latest messages of its thread, | |
| 1242 | + | * or of the conversation's top level, who wrote each and whom it mentions. | |
| 1243 | + | */ | |
| 1244 | + | private async recentBefore(row: Pick<MessageRow, "id" | "channel_id" | "thread_root">): Promise<RecentMessage[]> { | |
| 1245 | + | const rows = await this.db | |
| 1246 | + | .prepare( | |
| 1247 | + | `SELECT author, mentions FROM messages WHERE channel_id = ?1 AND id < ?2 AND deleted_at IS NULL | |
| 1248 | + | AND ${row.thread_root ? "(thread_root = ?3 OR id = ?3)" : "thread_root IS NULL"} ORDER BY id DESC LIMIT ${ADDRESSING_HISTORY}`, | |
| 1249 | + | ) | |
| 1250 | + | .bind(...(row.thread_root ? [row.channel_id, row.id, row.thread_root] : [row.channel_id, row.id])) | |
| 1251 | + | .all<{ author: string; mentions: string | null }>(); | |
| 1252 | + | return rows.results.reverse().map((found) => ({ author: found.author, mentioned: (found.mentions ?? "").split(" ").filter(Boolean) })); | |
| 1253 | + | } | |
| 1254 | + | ||
| 1224 | 1255 | /** Hands a message to these agents (src/delivery.ts `delivery`). */ | |
| 1225 | 1256 | private async deliverTo(place: Place, row: { id: string; thread_root: string | null }, chain: Chain<AskerAccess>, wakes: Wake[]): Promise<void> { | |
| 1226 | 1257 | const { channel, workspace } = place; | |