An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.
32 files+1304−550/32 viewed
| 81 | 81 | | View | Shows | | |
| 82 | 82 | | --- | --- | | |
| 83 | 83 | | **By day** | Spend each day, UTC. | | |
| 84 | − | | **By agent** | Each agent's share, largest first. | | |
| 84 | + | | **By agent** | Each agent's share, largest first. An archived agent keeps its share; one [deleted for good](/guides/agents/#archive-restore-and-delete) leaves the breakdown, while what it spent stays in the month's total, on the statement and on Usage. | | |
| 85 | 85 | | **By team** | Each team's agents together, by the teams they are on. An agent on two teams counts towards both; agents on none are under **Not on a team**. | | |
| 86 | 86 | | **By kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. | | |
| 87 | 87 | | **By who asked** | The work done for each person. Routines and agent-to-agent work have no asker. | |
| 836 | 836 | - **Reset wipes the home.** The agent's memory and artifacts are kept; | |
| 837 | 837 | its files, clones and command history go. | |
| 838 | 838 | - **Archiving the agent** puts the computer to sleep and keeps its disk | |
| 839 | − | for 30 days, then deletes it. | |
| 839 | + | for 30 days, then deletes it. [Restoring](#archive-restore-and-delete) | |
| 840 | + | the agent keeps the disk; deleting the agent deletes it at once. | |
| 840 | 841 | ||
| 841 | 842 | Every command runs as the agent in `bash`, with the output returned when it | |
| 842 | 843 | ends and a timeout of 120 seconds unless the agent asks for more (up to 30 | |
| ⋯ | |||
| 873 | 874 | Its status is one of **Idle**, **Working**, **Waiting on you**, **Out of | |
| 874 | 875 | budget** or **Paused**, on the Agents page and beside its name in Chat. | |
| 875 | 876 | ||
| 877 | + | ## Archive, restore and delete | |
| 878 | + | ||
| 876 | 879 | To retire an agent, an owner **archives** it from its profile; a member can | |
| 877 | − | archive their own personal agent. It stops answering, leaves the member | |
| 878 | − | lists, and its history stays. | |
| 880 | + | archive their own personal agent. Archiving is like deactivating a person's | |
| 881 | + | account, not deleting it: | |
| 882 | + | ||
| 883 | + | - It stops answering, is never woken again, and shows as typing or working | |
| 884 | + | nowhere. | |
| 885 | + | - It leaves the Agents list, the People directory, the sidebar's **Agents** | |
| 886 | + | section, **New message** and `@` autocomplete, and its handle is free for | |
| 887 | + | a new agent. | |
| 888 | + | - Its conversations stay. A direct message with it is still in Chat's | |
| 889 | + | sidebar, dimmed and marked **Archived**, and opens to read; what it said | |
| 890 | + | keeps its name and face. See [archived and deleted | |
| 891 | + | agents](/guides/chat/#archived-and-deleted-agents) in the chat guide. | |
| 892 | + | - Its sessions, replies, memory, routines and spend are kept, and its | |
| 893 | + | computer's disk for 30 days. | |
| 894 | + | ||
| 895 | + | Archived agents are listed under **Archived** at the foot of the Agents | |
| 896 | + | page, folded, newest first: owners see the workspace's, a member their own | |
| 897 | + | archived personal agents. Each row shows who archived it and when, and what | |
| 898 | + | is kept about it, such as *14 sessions · 231 replies · 3 memories*. Two | |
| 899 | + | things can happen to it from there: | |
| 900 | + | ||
| 901 | + | | Action | What it does | Who may | | |
| 902 | + | | --- | --- | --- | | |
| 903 | + | | **Restore** | The agent is live again under its handle, with its definition, versions, memory, routines and conversations as they were; its computer's disk is kept after all. If a live agent has taken the handle meanwhile, you are asked for a new handle to restore it under, or you rename the live one first. A new handle is saved as a new version. | Owners, any agent; a member, their own personal agent | | |
| 904 | + | | **Delete permanently** | Type its handle back to confirm. Its definition and every version, sessions and their transcripts, replies, memory, routines, drafts, suggestions, budget roll-ups and its computer's disk are deleted, and it leaves every channel and every team. There is no undo. | Owners, any agent; a member, their own personal agent | | |
| 905 | + | ||
| 906 | + | Only an archived agent can be deleted: archive it first, so nothing goes | |
| 907 | + | by accident. `@g1t` can be neither archived nor deleted. | |
| 908 | + | ||
| 909 | + | What stays after a delete: its messages in chat, shown under the name it | |
| 910 | + | had and marked **Deleted agent**, and direct messages with it, readable by | |
| 911 | + | their people; what it spent, in the month's total | |
| 912 | + | against the agent budget, on the workspace's statement and on Usage (its | |
| 913 | + | own lines leave the Agents page's breakdown); and the audit log, where | |
| 914 | + | archiving, restoring and deleting are `archive_agent`, `restore_agent` and | |
| 915 | + | `delete_agent`. | |
| 879 | 916 | ||
| 880 | 917 | ## Coming soon | |
| 881 | 918 | ||
| 231 | 231 | they answer every message from a person, as in any direct message with an | |
| 232 | 232 | agent. | |
| 233 | 233 | ||
| 234 | + | ### Archived and deleted agents | |
| 235 | + | ||
| 236 | + | An owner can [archive an agent](/guides/agents/#archive-restore-and-delete), | |
| 237 | + | and a member their own personal agent. Until it is restored or deleted for | |
| 238 | + | good, an archived agent is deactivated, not gone: | |
| 239 | + | ||
| 240 | + | | Where | What you see | | |
| 241 | + | | --- | --- | | |
| 242 | + | | The sidebar's **Agents** section | It is no longer among the agents you haven't talked to yet, and **New message** and `@` autocomplete no longer offer it. A direct message you already had with it stays listed, dimmed, with **Archived** beside its name where its title was. | | |
| 243 | + | | A direct message with it alone | The header says it was archived, when and by whom. A note stands where the composer was: everything said stays to read, and an owner can restore it, or delete it for good, under **Archived** on the Agents page. | | |
| 244 | + | | A group message it is in | The composer stays, for the people and live agents there. The archived agent is never woken, never shows as typing, and is not the one an unaddressed message goes to. | | |
| 245 | + | | Its messages | Keep its name and face, everywhere. | | |
| 246 | + | ||
| 247 | + | Once an agent is deleted for good, it leaves every channel and every | |
| 248 | + | list. A direct message with it stays in its people's sidebar, marked | |
| 249 | + | **Deleted**, and opens to read, with a note where the composer was; its | |
| 250 | + | messages, there and in channels, show under the name it had, marked | |
| 251 | + | **Deleted agent**. | |
| 252 | + | ||
| 234 | 253 | ## Threads | |
| 235 | 254 | ||
| 236 | 255 | Reply to any message to start a thread under it. The thread keeps a side |
| 23 | 23 | export async function answer<T>(intent: string, call: Promise<Result<T>>): Promise<ActionResult> { | |
| 24 | 24 | const result = await call.catch(() => null); | |
| 25 | 25 | if (!result) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." }; | |
| 26 | − | if (!result.ok) return { ok: false, intent, error: result.error.message }; | |
| 26 | + | if (!result.ok) return { ok: false, intent, error: result.error.message, code: result.error.code }; | |
| 27 | 27 | return { ok: true, intent }; | |
| 28 | 28 | } | |
| 29 | 29 |
| 1 | + | import { Archive, ArchiveRestore, ChevronRight, Trash2 } from "lucide-react"; | |
| 2 | + | import { useEffect, useId, useState } from "react"; | |
| 3 | + | import { useFetcher, useLocation } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { ArchivedAgent } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { AgentAvatar } from "../agent-avatar"; | |
| 8 | + | import { | |
| 9 | + | AlertDialog, | |
| 10 | + | AlertDialogCancel, | |
| 11 | + | AlertDialogContent, | |
| 12 | + | AlertDialogDescription, | |
| 13 | + | AlertDialogFooter, | |
| 14 | + | AlertDialogHeader, | |
| 15 | + | AlertDialogTitle, | |
| 16 | + | AlertDialogTrigger, | |
| 17 | + | } from "../ui/alert-dialog"; | |
| 18 | + | import { Badge } from "../ui/badge"; | |
| 19 | + | import { Button } from "../ui/button"; | |
| 20 | + | import { Card } from "../ui/card"; | |
| 21 | + | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; | |
| 22 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; | |
| 23 | + | import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field"; | |
| 24 | + | import { Input, InputAddon, InputGroup } from "../ui/input"; | |
| 25 | + | import { TimeAgo } from "../ui"; | |
| 26 | + | import { confirmsHandle, countsLine } from "../../lib/agents-archived"; | |
| 27 | + | import { type ActionResult, useDialogFetcher } from "./dialogs"; | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * The Archived section at the foot of the Agents page | |
| 31 | + | * (docs.g1t.sh/guides/agents/, "Archive, restore and delete"): the | |
| 32 | + | * workspace's archived agents for an owner, a member's own archived | |
| 33 | + | * personal agents for anyone else. Folded by default with its count; | |
| 34 | + | * `#archived` in the address opens it. Each row restores the agent, or | |
| 35 | + | * deletes it for good after its handle is typed back. | |
| 36 | + | */ | |
| 37 | + | export function ArchivedSection({ slug, agents, owner }: { slug: string; agents: ArchivedAgent[]; owner: boolean }) { | |
| 38 | + | const { hash } = useLocation(); | |
| 39 | + | const [open, setOpen] = useState(false); | |
| 40 | + | useEffect(() => { | |
| 41 | + | if (hash === "#archived") setOpen(true); | |
| 42 | + | }, [hash]); | |
| 43 | + | if (agents.length === 0) return null; | |
| 44 | + | return ( | |
| 45 | + | <section id="archived" aria-labelledby="archived-heading"> | |
| 46 | + | <Collapsible open={open} onOpenChange={setOpen}> | |
| 47 | + | <div className="flex items-baseline justify-between gap-3"> | |
| 48 | + | <h2 id="archived-heading" className="text-sm font-medium"> | |
| 49 | + | <CollapsibleTrigger className="font-medium text-fg"> | |
| 50 | + | <ChevronRight size={14} className="shrink-0 text-faint" /> | |
| 51 | + | <Archive size={14} className="text-faint" /> | |
| 52 | + | Archived <span className="font-normal text-faint">· {agents.length}</span> | |
| 53 | + | </CollapsibleTrigger> | |
| 54 | + | </h2> | |
| 55 | + | <span className="text-xs text-faint">{owner ? "Restore one, or delete it for good" : "Your archived personal agents"}</span> | |
| 56 | + | </div> | |
| 57 | + | <CollapsibleContent> | |
| 58 | + | <Card asChild divided className="mt-3 overflow-hidden"> | |
| 59 | + | <ul> | |
| 60 | + | {agents.map((agent) => ( | |
| 61 | + | <ArchivedRow key={agent.id} slug={slug} agent={agent} /> | |
| 62 | + | ))} | |
| 63 | + | </ul> | |
| 64 | + | </Card> | |
| 65 | + | </CollapsibleContent> | |
| 66 | + | </Collapsible> | |
| 67 | + | </section> | |
| 68 | + | ); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /** One archived agent: its face, dimmed, what it was, when it was archived, what is kept about it, and the two ways out. */ | |
| 72 | + | function ArchivedRow({ slug, agent }: { slug: string; agent: ArchivedAgent }) { | |
| 73 | + | return ( | |
| 74 | + | <li className="flex flex-wrap items-center gap-3 px-4 py-3 sm:flex-nowrap"> | |
| 75 | + | <span className="shrink-0 opacity-60 grayscale-[0.4]"> | |
| 76 | + | <AgentAvatar agent={{ ...agent, status: null }} size={32} /> | |
| 77 | + | </span> | |
| 78 | + | <div className="min-w-0 grow basis-48"> | |
| 79 | + | <p className="flex items-center gap-2 text-sm"> | |
| 80 | + | <span className="truncate font-medium text-muted">{agent.display_name}</span> | |
| 81 | + | <span className="truncate text-faint">@{agent.handle}</span> | |
| 82 | + | {agent.scope === "personal" && ( | |
| 83 | + | <Badge tone="neutral" className="shrink-0 font-normal"> | |
| 84 | + | {agent.personal_owner ? `@${agent.personal_owner}'s` : "Personal"} | |
| 85 | + | </Badge> | |
| 86 | + | )} | |
| 87 | + | </p> | |
| 88 | + | <p className="truncate text-xs text-faint"> | |
| 89 | + | {agent.title || agent.role} | |
| 90 | + | {" · "} | |
| 91 | + | Archived <TimeAgo at={agent.archived_at} /> | |
| 92 | + | {agent.archived_by && ` by @${agent.archived_by}`} | |
| 93 | + | </p> | |
| 94 | + | <p className="truncate text-xs text-faint tabular-nums">{countsLine(agent.counts)}</p> | |
| 95 | + | </div> | |
| 96 | + | <div className="flex shrink-0 flex-wrap items-center gap-2"> | |
| 97 | + | <RestoreButton slug={slug} agent={agent} /> | |
| 98 | + | <DeleteDialog slug={slug} agent={agent} /> | |
| 99 | + | </div> | |
| 100 | + | </li> | |
| 101 | + | ); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /** | |
| 105 | + | * Restore, in one press. When a live agent holds the handle now, the | |
| 106 | + | * service says so (`conflict`) and a dialog asks for a new handle to | |
| 107 | + | * restore under instead. | |
| 108 | + | */ | |
| 109 | + | function RestoreButton({ slug, agent }: { slug: string; agent: ArchivedAgent }) { | |
| 110 | + | const fetcher = useFetcher<ActionResult>({ key: `restore-${agent.id}` }); | |
| 111 | + | const [rename, setRename] = useState(false); | |
| 112 | + | const idle = fetcher.state === "idle"; | |
| 113 | + | const refused = idle && fetcher.data && !fetcher.data.ok ? fetcher.data : null; | |
| 114 | + | useEffect(() => { | |
| 115 | + | if (refused?.code === "conflict") setRename(true); | |
| 116 | + | }, [refused]); | |
| 117 | + | const busy = !idle; | |
| 118 | + | return ( | |
| 119 | + | <> | |
| 120 | + | <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="flex flex-col items-end gap-1"> | |
| 121 | + | <input type="hidden" name="intent" value="restore" /> | |
| 122 | + | <input type="hidden" name="id" value={agent.id} /> | |
| 123 | + | <input type="hidden" name="handle" value={agent.handle} /> | |
| 124 | + | <Button type="submit" variant="outline" size="sm" disabled={busy}> | |
| 125 | + | <ArchiveRestore size={14} /> | |
| 126 | + | {busy ? "Restoring…" : "Restore"} | |
| 127 | + | </Button> | |
| 128 | + | {refused && refused.code !== "conflict" && ( | |
| 129 | + | <p role="alert" className="text-xs text-danger"> | |
| 130 | + | {refused.error} | |
| 131 | + | </p> | |
| 132 | + | )} | |
| 133 | + | </fetcher.Form> | |
| 134 | + | <RenameDialog slug={slug} agent={agent} open={rename} onOpenChange={setRename} why={refused?.code === "conflict" ? refused.error : null} /> | |
| 135 | + | </> | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /** The handle is taken by a live agent: restore under a new one. */ | |
| 140 | + | function RenameDialog({ slug, agent, open, onOpenChange, why }: { slug: string; agent: ArchivedAgent; open: boolean; onOpenChange: (open: boolean) => void; why: string | null }) { | |
| 141 | + | const fetcher = useFetcher<ActionResult>({ key: `restore-as-${agent.id}` }); | |
| 142 | + | const id = useId(); | |
| 143 | + | useEffect(() => { | |
| 144 | + | if (fetcher.state === "idle" && fetcher.data?.ok) onOpenChange(false); | |
| 145 | + | }, [fetcher.state, fetcher.data, onOpenChange]); | |
| 146 | + | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 147 | + | const busy = fetcher.state !== "idle"; | |
| 148 | + | return ( | |
| 149 | + | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 150 | + | <DialogContent> | |
| 151 | + | <DialogHeader> | |
| 152 | + | <DialogTitle>Restore {agent.display_name} under a new handle</DialogTitle> | |
| 153 | + | <DialogDescription>{why ?? `A live agent is called @${agent.handle} now. Rename that one first, or restore this one under a new handle.`}</DialogDescription> | |
| 154 | + | </DialogHeader> | |
| 155 | + | <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="grid gap-5"> | |
| 156 | + | <input type="hidden" name="intent" value="restore" /> | |
| 157 | + | <input type="hidden" name="id" value={agent.id} /> | |
| 158 | + | <input type="hidden" name="handle" value={agent.handle} /> | |
| 159 | + | <Field> | |
| 160 | + | <FieldLabel htmlFor={`${id}-handle`}>New handle</FieldLabel> | |
| 161 | + | <InputGroup> | |
| 162 | + | <InputAddon>@</InputAddon> | |
| 163 | + | <Input id={`${id}-handle`} name="new_handle" defaultValue={`${agent.handle}-2`} required autoComplete="off" autoFocus /> | |
| 164 | + | </InputGroup> | |
| 165 | + | <FieldDescription>2 to 32 lowercase letters, digits and hyphens. People mention it by this; its name and face stay.</FieldDescription> | |
| 166 | + | </Field> | |
| 167 | + | <FieldError>{error}</FieldError> | |
| 168 | + | <DialogFooter> | |
| 169 | + | <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> | |
| 170 | + | Cancel | |
| 171 | + | </Button> | |
| 172 | + | <Button type="submit" variant="accent" disabled={busy}> | |
| 173 | + | {busy ? "Restoring…" : "Restore"} | |
| 174 | + | </Button> | |
| 175 | + | </DialogFooter> | |
| 176 | + | </fetcher.Form> | |
| 177 | + | </DialogContent> | |
| 178 | + | </Dialog> | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /** | |
| 183 | + | * Delete permanently: asked once more, with what goes and what stays | |
| 184 | + | * spelled out, and the handle typed back before the button works. | |
| 185 | + | */ | |
| 186 | + | function DeleteDialog({ slug, agent }: { slug: string; agent: ArchivedAgent }) { | |
| 187 | + | const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`destroy-${agent.id}`); | |
| 188 | + | const [typed, setTyped] = useState(""); | |
| 189 | + | const id = useId(); | |
| 190 | + | const kept = countsLine(agent.counts); | |
| 191 | + | return ( | |
| 192 | + | <AlertDialog | |
| 193 | + | open={open} | |
| 194 | + | onOpenChange={(next) => { | |
| 195 | + | setOpen(next); | |
| 196 | + | if (!next) setTyped(""); | |
| 197 | + | }} | |
| 198 | + | > | |
| 199 | + | <AlertDialogTrigger asChild> | |
| 200 | + | <Button type="button" variant="destructive" size="sm"> | |
| 201 | + | <Trash2 size={14} /> | |
| 202 | + | Delete permanently | |
| 203 | + | </Button> | |
| 204 | + | </AlertDialogTrigger> | |
| 205 | + | <AlertDialogContent> | |
| 206 | + | <AlertDialogHeader> | |
| 207 | + | <AlertDialogTitle>Delete {agent.display_name} permanently?</AlertDialogTitle> | |
| 208 | + | <AlertDialogDescription asChild> | |
| 209 | + | <div className="space-y-3"> | |
| 210 | + | <p> | |
| 211 | + | This deletes <span className="font-medium text-fg">@{agent.handle}</span> for good: its definition and every version, {kept === "Nothing recorded" ? "what little it recorded" : kept.toLowerCase()}, its drafts and suggestions, its budget and its computer's disk. There is no undo. | |
| 212 | + | </p> | |
| 213 | + | <p>What stays: its messages in chat, shown under the name it had; what it spent, in the month's totals on the workspace's statement and on Usage; and the audit log.</p> | |
| 214 | + | </div> | |
| 215 | + | </AlertDialogDescription> | |
| 216 | + | </AlertDialogHeader> | |
| 217 | + | <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="grid gap-5"> | |
| 218 | + | <input type="hidden" name="intent" value="destroy" /> | |
| 219 | + | <input type="hidden" name="id" value={agent.id} /> | |
| 220 | + | <input type="hidden" name="handle" value={agent.handle} /> | |
| 221 | + | <Field> | |
| 222 | + | <FieldLabel htmlFor={`${id}-confirm`}> | |
| 223 | + | Type <span className="font-mono text-fg">@{agent.handle}</span> to confirm | |
| 224 | + | </FieldLabel> | |
| 225 | + | <Input id={`${id}-confirm`} name="confirm" value={typed} onChange={(event) => setTyped(event.target.value)} autoComplete="off" spellCheck={false} aria-describedby={`${id}-confirm-hint`} /> | |
| 226 | + | <FieldDescription id={`${id}-confirm-hint`}>Its handle, so nothing goes by accident.</FieldDescription> | |
| 227 | + | </Field> | |
| 228 | + | <FieldError>{error}</FieldError> | |
| 229 | + | <AlertDialogFooter> | |
| 230 | + | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 231 | + | <Button type="submit" variant="destructive" disabled={busy || !confirmsHandle(typed, agent.handle)}> | |
| 232 | + | {busy ? "Deleting…" : "Delete"} | |
| 233 | + | </Button> | |
| 234 | + | </AlertDialogFooter> | |
| 235 | + | </fetcher.Form> | |
| 236 | + | </AlertDialogContent> | |
| 237 | + | </AlertDialog> | |
| 238 | + | ); | |
| 239 | + | } |
| 23 | 23 | import { PrivateTitle, SpendOfCap } from "./parts"; | |
| 24 | 24 | ||
| 25 | 25 | /** What Agents mode's actions answer. */ | |
| 26 | − | export type ActionResult = { ok: true; intent?: string } | { ok: false; error: string; field?: string; intent?: string }; | |
| 26 | + | export type ActionResult = { ok: true; intent?: string } | { ok: false; error: string; field?: string; intent?: string; code?: string }; | |
| 27 | 27 | ||
| 28 | 28 | /** A fetcher whose dialog closes once what it sent has worked. */ | |
| 29 | 29 | export function useDialogFetcher(key?: string) { |
| 71 | 71 | type Typer, | |
| 72 | 72 | WORKING_AFTER_MS, | |
| 73 | 73 | agentsAmong, | |
| 74 | + | archivedAgentLine, | |
| 75 | + | archivedAgentWhen, | |
| 76 | + | archivedAgentsDm, | |
| 74 | 77 | channelPath, | |
| 75 | 78 | dmPlaceholder, | |
| 76 | 79 | isMixed, | |
| ⋯ | |||
| 206 | 209 | viewingConversation(data.channel.id); | |
| 207 | 210 | return () => viewingConversation(null); | |
| 208 | 211 | }, [data.channel.id]); | |
| 209 | − | const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member); | |
| 212 | + | const everyoneElse = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member); | |
| 213 | + | // An agent deleted for good is no longer in the conversation, unless it was only ever the two of you: then it is who the conversation was with. | |
| 214 | + | const stillHere = everyoneElse.filter((m) => m.deleted !== true); | |
| 215 | + | const others = stillHere.length > 0 ? stillHere : everyoneElse; | |
| 210 | 216 | const isDm = data.channel.kind === "dm"; | |
| 217 | + | // A direct message with one archived agent opens read-only: a note stands where the composer was (lib/chat.ts). | |
| 218 | + | const archivedAgent = isDm && others.length === 1 ? archivedAgentsDm({ channel: data.channel, others }) : null; | |
| 219 | + | const owner = chatData?.role === "owner"; | |
| 211 | 220 | ||
| 212 | 221 | // The details panel: open by default on a wide screen, as last left. | |
| 213 | 222 | useEffect(() => { | |
| ⋯ | |||
| 740 | 749 | </Button> | |
| 741 | 750 | )} | |
| 742 | 751 | </Card> | |
| 752 | + | ) : archivedAgent ? ( | |
| 753 | + | <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> | |
| 754 | + | <p className="flex items-start gap-2 text-sm text-muted"> | |
| 755 | + | <Archive size={15} className="mt-0.5 shrink-0 text-faint" /> | |
| 756 | + | <span> | |
| 757 | + | {archivedAgent.deleted ? ( | |
| 758 | + | <>{shownName(archivedAgent)} was deleted for good. Everything it said here stays to read.</> | |
| 759 | + | ) : ( | |
| 760 | + | <> | |
| 761 | + | {archivedAgentLine(archivedAgent)} Everything said here stays to read.{" "} | |
| 762 | + | {owner ? ( | |
| 763 | + | <> | |
| 764 | + | Restore it, or delete it for good, under <span className="font-medium text-fg">Archived</span> on the Agents page. | |
| 765 | + | </> | |
| 766 | + | ) : ( | |
| 767 | + | "An owner can restore it from the Agents page." | |
| 768 | + | )} | |
| 769 | + | </> | |
| 770 | + | )} | |
| 771 | + | </span> | |
| 772 | + | </p> | |
| 773 | + | {owner && !archivedAgent.deleted && ( | |
| 774 | + | <Button asChild variant="outline" className="px-3 text-fg/90 hover:bg-raised"> | |
| 775 | + | <Link to={`/${slug}/-/agents#archived`}> | |
| 776 | + | <ArchiveRestore size={15} /> | |
| 777 | + | Restore under Agents | |
| 778 | + | </Link> | |
| 779 | + | </Button> | |
| 780 | + | )} | |
| 781 | + | </Card> | |
| 743 | 782 | ) : joined ? ( | |
| 744 | 783 | <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus /> | |
| 745 | 784 | ) : ( | |
| ⋯ | |||
| 920 | 959 | <div className="min-w-0"> | |
| 921 | 960 | <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold"> | |
| 922 | 961 | <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span> | |
| 923 | − | {agentDm && <AgentPill />} | |
| 962 | + | {agentDm && <AgentPill deleted={agentDm.deleted === true} />} | |
| 963 | + | {agentDm?.archived_at && ( | |
| 964 | + | <Badge tone="neutral" className="shrink-0"> | |
| 965 | + | <Archive size={11} /> | |
| 966 | + | Archived | |
| 967 | + | </Badge> | |
| 968 | + | )} | |
| 924 | 969 | </h1> | |
| 925 | − | {agentDm && (titleOf(agentDm) ?? agentDm.role) && ( | |
| 926 | − | <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p> | |
| 970 | + | {agentDm?.archived_at ? ( | |
| 971 | + | <p className="truncate text-xs leading-tight text-muted">{archivedAgentWhen(agentDm)}</p> | |
| 972 | + | ) : ( | |
| 973 | + | agentDm && | |
| 974 | + | (titleOf(agentDm) ?? agentDm.role) && <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p> | |
| 927 | 975 | )} | |
| 928 | 976 | {mixed && <MixedChip agents={agentsAmong(others)} />} | |
| 929 | 977 | {/* One person: their status under their name, as their card has it. */} | |
| ⋯ | |||
| 1000 | 1048 | </div> | |
| 1001 | 1049 | <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 1002 | 1050 | {others.map(shownName).join(", ") || "Just you"} | |
| 1003 | − | {agent && <AgentPill />} | |
| 1051 | + | {agent && <AgentPill deleted={agent.deleted === true} />} | |
| 1004 | 1052 | {mixed && ( | |
| 1005 | 1053 | <Badge tone="accent" size="md" className="font-normal"> | |
| 1006 | 1054 | with an agent | |
| ⋯ | |||
| 1008 | 1056 | )} | |
| 1009 | 1057 | </h2> | |
| 1010 | 1058 | <p className="mt-1 max-w-xl text-sm text-muted"> | |
| 1011 | − | {agent | |
| 1059 | + | {agent?.deleted | |
| 1060 | + | ? "This agent was deleted. What it said stays here to read." | |
| 1061 | + | : agent?.archived_at | |
| 1062 | + | ? archivedAgentLine(agent) | |
| 1063 | + | : agent | |
| 1012 | 1064 | ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.` | |
| 1013 | 1065 | : mixed | |
| 1014 | 1066 | ? "A conversation between people and an agent. Everyone here, the agent too, reads every message." | |
| ⋯ | |||
| 1382 | 1434 | {shownName(author)} | |
| 1383 | 1435 | </MemberCard> | |
| 1384 | 1436 | {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />} | |
| 1385 | − | {author.kind === "agent" && <AgentPill className="self-center" />} | |
| 1437 | + | {author.kind === "agent" && <AgentPill className="self-center" deleted={author.deleted === true} />} | |
| 1386 | 1438 | <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums"> | |
| 1387 | 1439 | {time} | |
| 1388 | 1440 | </time> | |
| 64 | 64 | ); | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | − | /** AGENT, beside an agent's name wherever it speaks. */ | |
| 68 | − | export function AgentPill({ className }: { className?: string }) { | |
| 67 | + | /** | |
| 68 | + | * AGENT, beside an agent's name wherever it speaks; DELETED AGENT, in the | |
| 69 | + | * quiet colours, beside the name an agent deleted for good spoke under | |
| 70 | + | * (docs.g1t.sh/guides/chat/, "Archived and deleted agents"). | |
| 71 | + | */ | |
| 72 | + | export function AgentPill({ className, deleted = false }: { className?: string; deleted?: boolean }) { | |
| 69 | 73 | // The avatar beside it carries the agent marker; this is the word, for where the name stands alone. | |
| 70 | − | return <AgentWord className={className} />; | |
| 74 | + | return <AgentWord className={cn(deleted && "bg-raised text-muted", className)}>{deleted ? "Deleted agent" : "Agent"}</AgentWord>; | |
| 71 | 75 | } | |
| 72 | 76 | ||
| 73 | − | function AgentWord({ className }: { className?: string }) { | |
| 77 | + | function AgentWord({ className, children }: { className?: string; children: string }) { | |
| 74 | 78 | return ( | |
| 75 | 79 | <span | |
| 76 | 80 | className={cn( | |
| ⋯ | |||
| 78 | 82 | className, | |
| 79 | 83 | )} | |
| 80 | 84 | > | |
| 81 | − | Agent | |
| 85 | + | {children} | |
| 82 | 86 | </span> | |
| 83 | 87 | ); | |
| 84 | 88 | } | |
| 16 | 16 | import { Button } from "../ui/button"; | |
| 17 | 17 | import { Hint } from "../ui/hint"; | |
| 18 | 18 | import { Skeleton } from "../ui/skeleton"; | |
| 19 | − | import { type ChatFilter, agentDmOf, agentsAmong, channelPath, filterEntries, mixedDm, sections } from "../../lib/chat"; | |
| 19 | + | import { type ChatFilter, agentDmOf, agentsAmong, agentsToTalkTo, archivedAgentsDm, channelPath, filterEntries, mixedDm, sections } from "../../lib/chat"; | |
| 20 | 20 | import { feedConnected } from "../../lib/notify-client"; | |
| 21 | 21 | import { FALLBACK_REFRESH_MS } from "../../lib/notify-store"; | |
| 22 | 22 | import { setMutedConversations } from "../../lib/sound-events"; | |
| ⋯ | |||
| 95 | 95 | // The agents you have not talked to yet, g1t first: one click opens a | |
| 96 | 96 | // conversation, which then lists under Direct messages like anyone's. | |
| 97 | 97 | // Nothing of theirs is unread, so Unread and Mentions leave them out. | |
| 98 | − | const q = query.trim().toLowerCase().replace(/^@/, ""); | |
| 99 | − | const agents = | |
| 100 | − | filter !== "all" | |
| 101 | − | ? [] | |
| 102 | − | : [...(data?.agents ?? [])] | |
| 103 | − | .filter((agent) => !agentDms.has(agent.id)) | |
| 104 | − | .sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)) || a.display_name.localeCompare(b.display_name)) | |
| 105 | − | .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q)); | |
| 98 | + | // An archived agent is never offered (lib/chat.ts `agentsToTalkTo`). | |
| 99 | + | const agents = filter !== "all" ? [] : agentsToTalkTo(data?.agents ?? [], agentDms, query); | |
| 106 | 100 | ||
| 107 | 101 | // Folded sections, remembered per workspace on this device. A folded | |
| 108 | 102 | // section still shows what is unread and the conversation open now. | |
| ⋯ | |||
| 471 | 465 | const g1t = agent ? isOrchestrator(agent) : false; | |
| 472 | 466 | // A person and an agent together: both faces, the agent's marked, and the word. | |
| 473 | 467 | const mixed = mixedDm(entry); | |
| 468 | + | // With an archived agent (lib/chat.ts): still here to read, dimmed, and said to be archived in place of its title. | |
| 469 | + | const archived = archivedAgentsDm(entry); | |
| 474 | 470 | const icon = agent ? ( | |
| 475 | 471 | <AgentFace agent={agent} g1t={g1t} /> | |
| 476 | 472 | ) : mixed ? ( | |
| ⋯ | |||
| 491 | 487 | {...prefetch} | |
| 492 | 488 | aria-current={current ? "page" : undefined} | |
| 493 | 489 | className={`${ROW} ${ | |
| 494 | − | current ? "bg-sidebar-accent text-fg" : unread ? "text-fg hover:bg-sidebar-accent/60" : entry.muted ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg" | |
| 490 | + | current ? "bg-sidebar-accent text-fg" : unread ? "text-fg hover:bg-sidebar-accent/60" : entry.muted || archived ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg" | |
| 495 | 491 | }`} | |
| 496 | 492 | > | |
| 497 | − | <span className={`flex shrink-0 justify-center ${mixed ? "w-7" : "w-4.5"} ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span> | |
| 493 | + | <span className={`flex shrink-0 justify-center ${mixed ? "w-7" : "w-4.5"} ${current || unread ? "text-muted" : "text-faint"} ${archived ? "opacity-60" : ""}`}>{icon}</span> | |
| 498 | 494 | <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}> | |
| 499 | 495 | {agent ? agent.display_name : entry.title} | |
| 500 | 496 | {agent && <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>} | |
| ⋯ | |||
| 502 | 498 | <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert /> | |
| 503 | 499 | )} | |
| 504 | 500 | </span> | |
| 501 | + | {archived && ( | |
| 502 | + | <Badge tone="neutral" className="shrink-0 px-1.5 font-normal"> | |
| 503 | + | {archived.deleted ? "Deleted" : "Archived"} | |
| 504 | + | </Badge> | |
| 505 | + | )} | |
| 505 | 506 | {mixed && ( | |
| 506 | 507 | <Badge tone="accent" className="shrink-0 px-1.5 font-normal"> | |
| 507 | 508 | with an agent | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { confirmsHandle, countsLine } from "./agents-archived.ts"; | |
| 5 | + | ||
| 6 | + | test("an archived agent's row says what deleting it takes, zeros left out", () => { | |
| 7 | + | assert.equal(countsLine({ sessions: 14, replies: 231, memories: 3, routines: 0 }), "14 sessions · 231 replies · 3 memories"); | |
| 8 | + | assert.equal(countsLine({ sessions: 1, replies: 1, memories: 1, routines: 1 }), "1 session · 1 reply · 1 memory · 1 routine"); | |
| 9 | + | assert.equal(countsLine({ sessions: 1200, replies: 0, memories: 0, routines: 0 }), "1,200 sessions"); | |
| 10 | + | assert.equal(countsLine({ sessions: 0, replies: 0, memories: 0, routines: 0 }), "Nothing recorded"); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("deleting is confirmed by the handle, however it is typed", () => { | |
| 14 | + | assert.ok(confirmsHandle("sam", "sam")); | |
| 15 | + | assert.ok(confirmsHandle(" @Sam ", "sam")); | |
| 16 | + | assert.ok(!confirmsHandle("sam2", "sam")); | |
| 17 | + | assert.ok(!confirmsHandle("", "sam")); | |
| 18 | + | }); |
| 1 | + | /** | |
| 2 | + | * The Archived section of the Agents page (docs.g1t.sh/guides/agents/, | |
| 3 | + | * "Archive, restore and delete"): what each archived agent's row says | |
| 4 | + | * about what deleting it would take. Pure, so it is tested on its own. | |
| 5 | + | */ | |
| 6 | + | import type { ArchivedAgent } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | const COUNTED: [keyof ArchivedAgent["counts"], string, string][] = [ | |
| 9 | + | ["sessions", "session", "sessions"], | |
| 10 | + | ["replies", "reply", "replies"], | |
| 11 | + | ["memories", "memory", "memories"], | |
| 12 | + | ["routines", "routine", "routines"], | |
| 13 | + | ]; | |
| 14 | + | ||
| 15 | + | /** "14 sessions · 231 replies · 3 memories": what is kept about the agent, zeros left out. */ | |
| 16 | + | export function countsLine(counts: ArchivedAgent["counts"]): string { | |
| 17 | + | const parts = COUNTED.filter(([key]) => counts[key] > 0).map(([key, one, many]) => `${counts[key].toLocaleString("en-US")} ${counts[key] === 1 ? one : many}`); | |
| 18 | + | return parts.length ? parts.join(" · ") : "Nothing recorded"; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** Whether what was typed confirms deleting the agent: its handle, with or without the @, in any case. */ | |
| 22 | + | export function confirmsHandle(typed: string, handle: string): boolean { | |
| 23 | + | return typed.trim().replace(/^@/, "").toLowerCase() === handle.toLowerCase(); | |
| 24 | + | } |
| 4 | 4 | import type { ChatMessage, ChatSidebarEntry } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | + | agentsToTalkTo, | |
| 8 | + | archivedAgentLine, | |
| 9 | + | archivedAgentWhen, | |
| 10 | + | archivedAgentsDm, | |
| 7 | 11 | backoff, | |
| 8 | 12 | blocks, | |
| 9 | 13 | channelName, | |
| ⋯ | |||
| 174 | 178 | assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 }); | |
| 175 | 179 | }); | |
| 176 | 180 | ||
| 181 | + | test("an archived agent's conversation stays listed under Agents, read-only; the agent itself is no longer offered", () => { | |
| 182 | + | const archived = { ...bot, id: "a2", name: "scribe", display_name: "Scribe", archived_at: "2026-10-08T09:00:00Z", archived_by: "ana" }; | |
| 183 | + | const all = [ | |
| 184 | + | entry("d1", { kind: "dm", title: "reviewer", others: [bot], last: "2026-10-02T00:00:00Z" }), | |
| 185 | + | entry("d2", { kind: "dm", title: "scribe", others: [archived], last: "2026-10-05T00:00:00Z" }), | |
| 186 | + | // With a person in it, the conversation is theirs to go on with: not an archived one. | |
| 187 | + | entry("d3", { kind: "dm", title: "ana, scribe", others: [ana, archived], last: "2026-10-03T00:00:00Z" }), | |
| 188 | + | // Two agents, one archived: the live one still answers. | |
| 189 | + | entry("d4", { kind: "dm", title: "reviewer, scribe", others: [bot, archived], last: "2026-10-04T00:00:00Z" }), | |
| 190 | + | // Deleted for good: the conversation stays readable, under the name it had. | |
| 191 | + | entry("d5", { kind: "dm", title: "triage", others: [{ ...bot, id: "a9", name: "triage", display_name: "Triage", deleted: true }], last: "2026-10-01T00:00:00Z" }), | |
| 192 | + | ]; | |
| 193 | + | const { agents, agentDms } = sections(all); | |
| 194 | + | assert.deepEqual(agents.map((e) => e.title), ["scribe", "reviewer, scribe", "reviewer", "triage"]); | |
| 195 | + | assert.deepEqual(all.map((e) => archivedAgentsDm(e)?.name ?? null), [null, "scribe", null, null, "triage"]); | |
| 196 | + | assert.equal(archivedAgentsDm(all[4]!)?.deleted, true); | |
| 197 | + | // The agents you haven't talked to yet: live ones without a conversation, g1t first; never an archived one. | |
| 198 | + | const roster = [ | |
| 199 | + | { id: "a1", handle: "reviewer", display_name: "Reviewer", role: "Reviews", builtin: false, archived_at: null }, | |
| 200 | + | { id: "a2", handle: "scribe", display_name: "Scribe", role: "Writes", builtin: false, archived_at: "2026-10-08T09:00:00Z" }, | |
| 201 | + | { id: "a3", handle: "zed", display_name: "Zed", role: "Ships", builtin: false, archived_at: null }, | |
| 202 | + | { id: "a4", handle: "g1t", display_name: "g1t", role: "Orchestrates", builtin: true, archived_at: null }, | |
| 203 | + | { id: "a5", handle: "old", display_name: "Old", role: "Nothing", builtin: false, archived_at: "2026-09-01T00:00:00Z" }, | |
| 204 | + | ]; | |
| 205 | + | assert.deepEqual(agentsToTalkTo(roster, agentDms).map((a) => a.handle), ["g1t", "zed"]); | |
| 206 | + | assert.deepEqual(agentsToTalkTo(roster, agentDms, "@ship").map((a) => a.handle), ["zed"]); | |
| 207 | + | assert.deepEqual(agentsToTalkTo(roster, new Map()).map((a) => a.handle), ["g1t", "reviewer", "zed"]); | |
| 208 | + | const now = new Date("2026-10-10T12:00:00Z"); | |
| 209 | + | assert.equal(archivedAgentLine(archived, now), "Scribe was archived 2 days ago by @ana. It no longer answers here."); | |
| 210 | + | assert.equal(archivedAgentLine({ ...archived, archived_by: null, archived_at: "2026-10-10T11:30:00Z" }, now), "Scribe was archived 30 minutes ago. It no longer answers here."); | |
| 211 | + | assert.equal(archivedAgentLine({ ...archived, archived_by: null, archived_at: null }, now), "Scribe was archived. It no longer answers here."); | |
| 212 | + | assert.equal(archivedAgentWhen(archived, now), "Archived 2 days ago by @ana"); | |
| 213 | + | assert.equal(archivedAgentWhen({ archived_at: "2026-10-10T11:00:00Z", archived_by: null }, now), "Archived 1 hour ago"); | |
| 214 | + | }); | |
| 215 | + | ||
| 177 | 216 | test("a mixed conversation says who the agents are, in the composer and above its first message", () => { | |
| 178 | 217 | assert.equal(dmPlaceholder([ana, bot]), "Message @ana and @reviewer (agent)"); | |
| 179 | 218 | assert.equal(dmPlaceholder([{ ...ana, display_username: "Ana" }, bot, { ...bot, id: "a2", name: "scribe", display_name: "Scribe" }]), "Message @Ana, @reviewer (agent) and @scribe (agent)"); | |
| 7 | 7 | * Message bodies are never HTML: they are parsed here into a small tree | |
| 8 | 8 | * that React renders as text, so nothing anyone writes can run. | |
| 9 | 9 | */ | |
| 10 | − | import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts"; | |
| 10 | + | import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal, WorkspaceAgent } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | /** Two messages from the same author this close together share one header. */ | |
| 13 | 13 | export const GROUP_GAP_MS = 5 * 60 * 1000; | |
| ⋯ | |||
| 171 | 171 | type Members = Pick<MemberProfile, "kind">[]; | |
| 172 | 172 | ||
| 173 | 173 | /** Whether a direct message is with agents alone (one or several, nobody else). */ | |
| 174 | − | export function agentsOnlyDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean { | |
| 174 | + | export function agentsOnlyDm(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind">[] }): boolean { | |
| 175 | 175 | return entry.channel.kind === "dm" && entry.others.length > 0 && entry.others.every((member) => member.kind === "agent"); | |
| 176 | 176 | } | |
| 177 | 177 | ||
| ⋯ | |||
| 254 | 254 | }; | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | + | /** | |
| 258 | + | * The archived (or deleted) agent a direct message is with, when the | |
| 259 | + | * conversation is with agents alone and every one of them is archived or | |
| 260 | + | * deleted for good (docs.g1t.sh/guides/chat/, "Archived and deleted | |
| 261 | + | * agents"): the row is dimmed and says Archived (or Deleted, when the | |
| 262 | + | * agent returned is `deleted`), and a conversation with one agent opens | |
| 263 | + | * read-only. Null for any other conversation, a mixed one included, since | |
| 264 | + | * a person is still there to talk to. | |
| 265 | + | */ | |
| 266 | + | export function archivedAgentsDm<M extends Pick<MemberProfile, "kind" | "archived_at" | "deleted">>(entry: { channel: { kind: "channel" | "dm" }; others: readonly M[] }): M | null { | |
| 267 | + | if (!agentsOnlyDm(entry)) return null; | |
| 268 | + | return entry.others.every((member) => !!member.archived_at || member.deleted === true) ? (entry.others[0] ?? null) : null; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /** | |
| 272 | + | * The agents you have not talked to yet, for the sidebar's Agents section: | |
| 273 | + | * live ones (an archived agent is never offered, though its old conversation | |
| 274 | + | * stays listed), with no direct message yet, g1t first, then by name, | |
| 275 | + | * narrowed by what is typed in Jump to (handle, name or role). | |
| 276 | + | */ | |
| 277 | + | export function agentsToTalkTo<A extends Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "role" | "builtin" | "archived_at">>(agents: readonly A[], agentDms: ReadonlyMap<string, unknown>, query = ""): A[] { | |
| 278 | + | const q = query.trim().toLowerCase().replace(/^@/, ""); | |
| 279 | + | return agents | |
| 280 | + | .filter((agent) => !agent.archived_at && !agentDms.has(agent.id)) | |
| 281 | + | .sort((a, b) => Number(!!b.builtin) - Number(!!a.builtin) || a.display_name.localeCompare(b.display_name)) | |
| 282 | + | .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q)); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /** | |
| 286 | + | * What an archived agent's conversation says in place of its composer: | |
| 287 | + | * who archived it and when, as far as that is known. | |
| 288 | + | */ | |
| 289 | + | export function archivedAgentLine(agent: Pick<MemberProfile, "name" | "display_name" | "display_username" | "archived_at" | "archived_by">, now: Date = new Date()): string { | |
| 290 | + | return `${shownName(agent)} was ${archivedAgentWhen(agent, now).replace(/^A/, "a")}. It no longer answers here.`; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /** "Archived 2 days ago by @ana": under an archived agent's name, where its title was. */ | |
| 294 | + | export function archivedAgentWhen(agent: Pick<MemberProfile, "archived_at" | "archived_by">, now: Date = new Date()): string { | |
| 295 | + | const when = agent.archived_at ? ` ${timeSince(agent.archived_at, now)}` : ""; | |
| 296 | + | const by = agent.archived_by ? ` by @${agent.archived_by}` : ""; | |
| 297 | + | return `Archived${when}${by}`; | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /** "3 days ago", "2 hours ago", "just now": for a line of prose. */ | |
| 301 | + | export function timeSince(at: string, now: Date = new Date()): string { | |
| 302 | + | const elapsed = Math.max(0, now.getTime() - new Date(at).getTime()); | |
| 303 | + | const units: [string, number][] = [ | |
| 304 | + | ["day", 86_400_000], | |
| 305 | + | ["hour", 3_600_000], | |
| 306 | + | ["minute", 60_000], | |
| 307 | + | ]; | |
| 308 | + | for (const [unit, size] of units) { | |
| 309 | + | if (elapsed >= size) { | |
| 310 | + | const n = Math.floor(elapsed / size); | |
| 311 | + | return `${n} ${unit}${n === 1 ? "" : "s"} ago`; | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | return "just now"; | |
| 315 | + | } | |
| 316 | + | ||
| 257 | 317 | /** Where a conversation is, under the workspace. */ | |
| 258 | 318 | export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string { | |
| 259 | 319 | return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`; | |
| 1 | − | import { AlertTriangle, CalendarClock, Coins, Hourglass, Lock, Wallet } from "lucide-react"; | |
| 1 | + | import { AlertTriangle, Archive, CalendarClock, Coins, Hourglass, Lock, Wallet } from "lucide-react"; | |
| 2 | 2 | import { Link, data, useFetcher } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { AgentPolicy, AgentsOverview, WorkspaceAgent } from "@g1t/contracts"; | |
| 4 | + | import type { AgentPolicy, AgentsOverview, ArchivedAgent, WorkspaceAgent } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/agents"; | |
| 7 | 7 | import { AgentFace, useAgentsData } from "../../components/agents-mode"; | |
| 8 | 8 | import { SwitchCard } from "../../components/ui/switch"; | |
| 9 | 9 | import { agentsAction, answer, readOrNull } from "../../components/agents/actions.server"; | |
| 10 | + | import { ArchivedSection } from "../../components/agents/archived"; | |
| 10 | 11 | import { ApproveDialog, BudgetDialog } from "../../components/agents/dialogs"; | |
| 12 | + | import { Alert } from "../../components/ui/alert"; | |
| 11 | 13 | import { meterTone, monthName, scheduleInWords, shareOf, untilLabel } from "../../components/agents/format"; | |
| 12 | 14 | import { DailyBars, Meter, Panel, Quiet, SessionCard, SessionRow, SliceList, SpendOfCap } from "../../components/agents/parts"; | |
| 13 | 15 | import { StatusDot, statusLabel } from "../../components/chat/marks"; | |
| ⋯ | |||
| 26 | 28 | return page(args, { title: `Agents · ${params.owner} · g1t` }); | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 29 | − | /** Agents mode's front page: the budget, what is waiting and working, the roster, and where the month went. */ | |
| 30 | − | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ overview: AgentsOverview | null }> { | |
| 31 | + | /** | |
| 32 | + | * Agents mode's front page: the budget, what is waiting and working, the | |
| 33 | + | * roster, where the month went, and the archived agents, in the same round | |
| 34 | + | * of calls. `?archived=<handle>` is the way back from archiving one: the | |
| 35 | + | * page says where it went. | |
| 36 | + | */ | |
| 37 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ overview: AgentsOverview | null; archived: ArchivedAgent[]; justArchived: string | null }> { | |
| 31 | 38 | // Signed out, sign in first (as Chat and Docs do), rather than a 404. | |
| 32 | 39 | const viewer = requireUser(context, request); | |
| 33 | 40 | if (!viewer || !roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 34 | − | return { overview: await readOrNull(workspaceAgents.overview(params.owner.toLowerCase(), viewer)) }; | |
| 41 | + | const slug = params.owner.toLowerCase(); | |
| 42 | + | const [overview, archived] = await Promise.all([readOrNull(workspaceAgents.overview(slug, viewer)), readOrNull(workspaceAgents.listArchived(slug, viewer))]); | |
| 43 | + | const justArchived = new URL(request.url).searchParams.get("archived"); | |
| 44 | + | return { overview, archived: archived ?? [], justArchived: justArchived && /^[a-z0-9-]{1,40}$/.test(justArchived) ? justArchived : null }; | |
| 35 | 45 | } | |
| 36 | 46 | ||
| 37 | − | /** Owners set the workspace's agent budget. */ | |
| 47 | + | /** Owners set the workspace's agent budget; archived agents are restored or deleted for good from here. */ | |
| 38 | 48 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 39 | 49 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); | |
| 50 | + | const intent = String(form.get("intent") ?? ""); | |
| 40 | 51 | // Whether members create personal agents: on unless an owner turns it off. | |
| 41 | − | if (form.get("intent") === "personal_agents") { | |
| 52 | + | if (intent === "personal_agents") { | |
| 42 | 53 | return answer("personal_agents", workspaceAgents.setPolicy(slug, viewer, { members_create_agents: form.get("members_create_agents") === "on" })); | |
| 43 | 54 | } | |
| 55 | + | // The Archived section (components/agents/archived.tsx): back to life, under its handle or a new one; or gone for good, with its handle typed back. | |
| 56 | + | if (intent === "restore") { | |
| 57 | + | const newHandle = String(form.get("new_handle") ?? "").trim(); | |
| 58 | + | return answer("restore", workspaceAgents.restore(slug, String(form.get("handle") ?? ""), viewer, { id: String(form.get("id") ?? "") || null, handle: newHandle || null })); | |
| 59 | + | } | |
| 60 | + | if (intent === "destroy") { | |
| 61 | + | return answer("destroy", workspaceAgents.destroy(slug, String(form.get("handle") ?? ""), viewer, String(form.get("confirm") ?? ""), { id: String(form.get("id") ?? "") || null })); | |
| 62 | + | } | |
| 44 | 63 | if (form.get("intent") !== "policy") return { ok: false as const, error: "Unknown request." }; | |
| 45 | 64 | const monthly = microsFromDollars(form.get("monthly")); | |
| 46 | 65 | const agent = microsFromDollars(form.get("default_agent")); | |
| ⋯ | |||
| 52 | 71 | } | |
| 53 | 72 | ||
| 54 | 73 | export default function AgentsOverviewPage({ loaderData, params }: Route.ComponentProps) { | |
| 55 | − | const { overview } = loaderData; | |
| 74 | + | const { overview, archived, justArchived } = loaderData; | |
| 56 | 75 | const slug = params.owner; | |
| 57 | 76 | useRefreshWhile(Boolean(overview && (overview.live.length > 0 || overview.waiting_on_you.length > 0))); | |
| 58 | 77 | if (!overview) { | |
| ⋯ | |||
| 66 | 85 | const handles = new Map(overview.agents.map((agent) => [agent.id, agent])); | |
| 67 | 86 | return ( | |
| 68 | 87 | <div className="space-y-10"> | |
| 88 | + | {/* The way back from archiving: the agent is under Archived, below, not gone. */} | |
| 89 | + | {justArchived && archived.some((agent) => agent.handle === justArchived) && ( | |
| 90 | + | <Alert tone="info" role="status" className="flex items-center gap-2.5 px-3.5 py-2.5"> | |
| 91 | + | <Archive size={15} className="shrink-0 text-muted" /> | |
| 92 | + | <span> | |
| 93 | + | Archived <span className="font-medium">@{justArchived}</span>. It no longer answers; its conversations stay readable.{" "} | |
| 94 | + | <Link to={`/${slug}/-/agents#archived`} className="font-medium underline-offset-4 hover:underline"> | |
| 95 | + | Restore or delete it under Archived | |
| 96 | + | </Link> | |
| 97 | + | . | |
| 98 | + | </span> | |
| 99 | + | </Alert> | |
| 100 | + | )} | |
| 69 | 101 | <BudgetHeader overview={overview} slug={slug} liveCount={liveCount} /> | |
| 70 | 102 | ||
| 71 | 103 | {overview.waiting_on_you.length > 0 && ( | |
| ⋯ | |||
| 175 | 207 | )} | |
| 176 | 208 | </Panel> | |
| 177 | 209 | </div> | |
| 210 | + | ||
| 211 | + | {/* Archived agents, folded: owners see the workspace's, a member their own archived personal agents. */} | |
| 212 | + | <ArchivedSection slug={slug} agents={archived} owner={overview.can_manage} /> | |
| 178 | 213 | </div> | |
| 179 | 214 | ); | |
| 180 | 215 | } | |
| 107 | 107 | const archived = await workspaceAgents.archive(slug, params.handle.toLowerCase(), viewer).catch(() => null); | |
| 108 | 108 | if (!archived) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false }; | |
| 109 | 109 | if (!archived.ok) return { errors: { form: archived.error.message }, saved: false }; | |
| 110 | − | throw redirect(`/${slug}/-/agents`); | |
| 110 | + | // Back on the Agents page, which says where the agent went: Restore or delete it under Archived. | |
| 111 | + | throw redirect(`/${slug}/-/agents?archived=${encodeURIComponent(params.handle.toLowerCase())}`); | |
| 111 | 112 | } | |
| 112 | 113 | const read = readAgentForm(form, { orchestrator: params.handle.toLowerCase() === "g1t" }); | |
| 113 | 114 | if (!read.ok) return { errors: read.errors, saved: false }; |
| 54 | 54 | avatar_seed?: string | null; | |
| 55 | 55 | /** An agent's chosen face (./agent-look.ts); null for a person, or an agent wearing its seed's face. */ | |
| 56 | 56 | look?: AgentLook | null; | |
| 57 | + | /** | |
| 58 | + | * An archived agent (docs.g1t.sh/guides/chat/, "Archived and deleted | |
| 59 | + | * agents"): when, and by whom. Its conversations stay readable; it is | |
| 60 | + | * never woken and never listed to talk to. Null or absent for a live | |
| 61 | + | * agent and for a person. | |
| 62 | + | */ | |
| 63 | + | archived_at?: string | null; | |
| 64 | + | archived_by?: string | null; | |
| 65 | + | /** An agent deleted for good: its messages stay under the name it had. Absent otherwise. */ | |
| 66 | + | deleted?: boolean; | |
| 57 | 67 | }; | |
| 58 | 68 | ||
| 59 | 69 | /** | |
| ⋯ | |||
| 485 | 495 | openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>; | |
| 486 | 496 | join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; | |
| 487 | 497 | leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; | |
| 498 | + | /** | |
| 499 | + | * Internal, from the agents service: an agent was deleted for good. It | |
| 500 | + | * leaves every channel of the workspace; its direct messages stay their | |
| 501 | + | * people's, readable, and its messages everywhere stay, shown under the | |
| 502 | + | * name it had (`former_agents`) and marked deleted. | |
| 503 | + | */ | |
| 504 | + | agentDestroyed(input: { workspace_id: string; agent_id: string; handle: string; display_name: string; avatar_seed: string | null; look: AgentLook | null }): Promise<Result<null>>; | |
| 488 | 505 | /** Adds a person or an agent. An invite grants read, never write. */ | |
| 489 | 506 | invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>; | |
| 490 | 507 | /** | |
| ⋯ | |||
| 654 | 671 | openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }), | |
| 655 | 672 | join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }), | |
| 656 | 673 | leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }), | |
| 674 | + | agentDestroyed: (input) => call("agent_destroyed", input), | |
| 657 | 675 | invite: (workspace, channelId, viewer, member) => | |
| 658 | 676 | call("invite", { workspace, channel_id: channelId, viewer, member }), | |
| 659 | 677 | messages: (workspace, channelId, viewer, page) => | |
| 986 | 986 | computerSleep: (agentId) => call("computer_sleep", { agent_id: agentId }), | |
| 987 | 987 | computerReset: (agentId) => call("computer_reset", { agent_id: agentId }), | |
| 988 | 988 | computerForget: (agentId) => call("computer_forget", { agent_id: agentId }), | |
| 989 | + | computerKeep: (agentId) => call("computer_keep", { agent_id: agentId }), | |
| 990 | + | computerErase: (agentId) => call("computer_erase", { agent_id: agentId }), | |
| 989 | 991 | computerCommands: (agentId, sessionId) => call("computer_commands", { agent_id: agentId, session_id: sessionId ?? null }), | |
| 990 | 992 | }; | |
| 991 | 993 | } |
| 161 | 161 | computerReset(agentId: string): Promise<Result<AgentComputerStatus>>; | |
| 162 | 162 | /** An archived agent's: stopped now, its disk deleted after `COMPUTER_KEEP_DAYS`. */ | |
| 163 | 163 | computerForget(agentId: string): Promise<Result<AgentComputerStatus>>; | |
| 164 | + | /** The agent was restored: its disk is kept after all (undoes `computerForget`). */ | |
| 165 | + | computerKeep(agentId: string): Promise<Result<AgentComputerStatus>>; | |
| 166 | + | /** The agent was deleted for good: stopped, its saved home, commands and record deleted now. */ | |
| 167 | + | computerErase(agentId: string): Promise<Result<null>>; | |
| 164 | 168 | /** The most recent commands, newest first; `session_id` narrows them to one session's. */ | |
| 165 | 169 | computerCommands(agentId: string, sessionId?: string | null): Promise<Result<AgentComputerCommand[]>>; | |
| 166 | 170 | } |
| 175 | 175 | created_at: string; | |
| 176 | 176 | updated_at: string; | |
| 177 | 177 | archived_at: string | null; | |
| 178 | + | /** Who archived it, by username; null while it is live, or for one archived before this was kept. */ | |
| 179 | + | archived_by?: string | null; | |
| 180 | + | }; | |
| 181 | + | ||
| 182 | + | /** | |
| 183 | + | * An archived agent as the Agents page's Archived section lists it | |
| 184 | + | * (docs.g1t.sh/guides/agents/, "Archive, restore and delete"): enough to | |
| 185 | + | * draw its row and to say what deleting it for good takes with it. | |
| 186 | + | */ | |
| 187 | + | export type ArchivedAgent = { | |
| 188 | + | id: string; | |
| 189 | + | handle: string; | |
| 190 | + | display_name: string; | |
| 191 | + | title: string; | |
| 192 | + | role: string; | |
| 193 | + | avatar: string | null; | |
| 194 | + | avatar_seed: string; | |
| 195 | + | look: AgentLook | null; | |
| 196 | + | scope: WorkspaceAgentScope; | |
| 197 | + | personal_owner: string | null; | |
| 198 | + | archived_at: string; | |
| 199 | + | archived_by: string | null; | |
| 200 | + | /** What goes with it when it is deleted. */ | |
| 201 | + | counts: { sessions: number; replies: number; memories: number; routines: number }; | |
| 178 | 202 | }; | |
| 179 | 203 | ||
| 180 | 204 | /** | |
| ⋯ | |||
| 865 | 889 | ): Promise<Result<WorkspaceAgent>>; | |
| 866 | 890 | archive(workspace: string, handle: string, viewer: User): Promise<Result<null>>; | |
| 867 | 891 | /** | |
| 892 | + | * The workspace's archived agents, newest archived first: every one for | |
| 893 | + | * an owner, their own archived personal agents for anyone else. | |
| 894 | + | */ | |
| 895 | + | listArchived(workspace: string, viewer: User): Promise<Result<ArchivedAgent[]>>; | |
| 896 | + | /** | |
| 897 | + | * Brings an archived agent back, under its handle or `options.handle`. | |
| 898 | + | * `conflict` when a live agent holds the handle. Owners any agent, a | |
| 899 | + | * member their own personal one. | |
| 900 | + | */ | |
| 901 | + | restore(workspace: string, handle: string, viewer: User, options?: { handle?: string | null; id?: string | null }): Promise<Result<WorkspaceAgent>>; | |
| 902 | + | /** | |
| 903 | + | * Deletes an archived agent for good: its definition and versions, | |
| 904 | + | * sessions, replies, memories, routines, drafts and computer, and its | |
| 905 | + | * chat memberships. Its messages stay in chat under its last name, and | |
| 906 | + | * its spend on the workspace's statement. `confirm` must be the handle. | |
| 907 | + | * A live agent is archived first (`invalid`). Owners any agent, a member | |
| 908 | + | * their own personal one; a second call is `not_found`. | |
| 909 | + | */ | |
| 910 | + | destroy(workspace: string, handle: string, viewer: User, confirm: string, options?: { id?: string | null }): Promise<Result<null>>; | |
| 911 | + | /** | |
| 868 | 912 | * Drafts a whole agent from a description, with a fast model call charged | |
| 869 | 913 | * to the viewer. Nothing is saved. Whoever may create the agent may draft it. | |
| 870 | 914 | */ | |
| ⋯ | |||
| 1035 | 1079 | create: (workspace, viewer, input, options) => call("create", { workspace, viewer, input, teams: options?.teams ?? [] }), | |
| 1036 | 1080 | update: (workspace, handle, viewer, changes) => call("update", { workspace, handle, viewer, changes }), | |
| 1037 | 1081 | archive: (workspace, handle, viewer) => call("archive", { workspace, handle, viewer }), | |
| 1082 | + | listArchived: (workspace, viewer) => call("list_archived", { workspace, viewer }), | |
| 1083 | + | restore: (workspace, handle, viewer, options) => call("restore", { workspace, handle, viewer, id: options?.id ?? null, new_handle: options?.handle ?? null }), | |
| 1084 | + | destroy: (workspace, handle, viewer, confirm, options) => call("destroy", { workspace, handle, viewer, confirm, id: options?.id ?? null }), | |
| 1038 | 1085 | draft: (workspace, viewer, input) => call("draft", { workspace, viewer, description: input.description, scope: input.scope ?? null }), | |
| 1039 | 1086 | tryDraft: (workspace, viewer, input) => call("try_draft", { workspace, viewer, definition: input.definition, messages: input.messages }), | |
| 1040 | 1087 | redraft: (workspace, handle, viewer, request) => call("redraft", { workspace, handle, viewer, request }), | |
| 1 | + | -- Who archived an agent (docs.g1t.sh/guides/agents/, "Archive, restore and | |
| 2 | + | -- delete"), by username, so the Archived section and an archived agent's | |
| 3 | + | -- direct message can say so without reading the audit log. NULL for an | |
| 4 | + | -- agent archived before this column, and for a live one. | |
| 5 | + | ALTER TABLE agents ADD COLUMN archived_by TEXT; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | import type { User } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | import { DESTROYED, destroyRefusal, destroyStatements, listArchived, restoreHandle, restoreRefusal } from "./archived.ts"; | |
| 9 | + | ||
| 10 | + | /** D1, as far as archived agents use it, over node's SQLite with the service's migrations: statements, `first`, `all`, `run` and `batch`. */ | |
| 11 | + | function fakeD1(): D1Database & { raw: DatabaseSync } { | |
| 12 | + | const db = new DatabaseSync(":memory:"); | |
| 13 | + | const dir = new URL("../migrations/", import.meta.url); | |
| 14 | + | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 15 | + | const statement = (sql: string, params: unknown[] = []) => ({ | |
| 16 | + | bind: (...values: unknown[]) => statement(sql, values), | |
| 17 | + | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, | |
| 18 | + | run: async () => { | |
| 19 | + | const r = db.prepare(sql).run(...(params as never[])); | |
| 20 | + | return { meta: { changes: Number(r.changes) } }; | |
| 21 | + | }, | |
| 22 | + | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), | |
| 23 | + | }); | |
| 24 | + | return { prepare: (sql: string) => statement(sql), batch: async (statements: { run: () => Promise<unknown> }[]) => Promise.all(statements.map((s) => s.run())), raw: db } as unknown as D1Database & { raw: DatabaseSync }; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | const WS = "wsp_acme"; | |
| 28 | + | const owner = { id: "u_owner", kind: "user", username: "ana", verified: true, workspaces: [{ slug: "acme", role: "owner" }] } as unknown as User; | |
| 29 | + | const member = { id: "u_mem", kind: "user", username: "mike", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User; | |
| 30 | + | const other = { id: "u_other", kind: "user", username: "sam", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User; | |
| 31 | + | ||
| 32 | + | type Seed = { id: string; handle: string; archived_at?: string | null; archived_by?: string | null; scope?: string; owner_id?: string | null; owner_username?: string | null; builtin?: number }; | |
| 33 | + | ||
| 34 | + | /** An agent row with what the queries read; everything else is the default. */ | |
| 35 | + | function seed(db: { raw: DatabaseSync }, a: Seed): void { | |
| 36 | + | db.raw | |
| 37 | + | .prepare( | |
| 38 | + | `INSERT INTO agents (id, workspace_id, handle, display_name, avatar, role, instructions, routing, budget, autonomy, title, created_by, created_at, updated_at, archived_at, archived_by, scope, owner_id, owner_username, builtin) | |
| 39 | + | VALUES (?, ?, ?, ?, NULL, 'Helps', 'Help.', '{}', '{}', '{}', 'Helper', 'ana', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z', ?, ?, ?, ?, ?, ?)`, | |
| 40 | + | ) | |
| 41 | + | .run(a.id, WS, a.handle, a.handle[0]!.toUpperCase() + a.handle.slice(1), a.archived_at ?? null, a.archived_by ?? null, a.scope ?? "workspace", a.owner_id ?? null, a.owner_username ?? null, a.builtin ?? 0); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** Rows about one agent across the tables a delete reaches, so the test sees them go. */ | |
| 45 | + | function seedHistory(db: { raw: DatabaseSync }, agentId: string, n: number): void { | |
| 46 | + | const r = db.raw; | |
| 47 | + | r.prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, 1, '{}', 'ana', '2026-10-01T00:00:00Z')").run(agentId); | |
| 48 | + | for (let i = 0; i < n; i++) { | |
| 49 | + | r.prepare( | |
| 50 | + | `INSERT INTO agent_sessions (id, workspace_id, agent_id, kind, root_id, payer_agent_id, title, goal, status, workspace, channel_id, channel_kind, created_at, updated_at) | |
| 51 | + | VALUES (?1, ?2, ?3, 'chat', ?1, ?3, 'Did a thing', 'Do the thing', 'done', 'acme', 'chn_1', 'dm', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`, | |
| 52 | + | ).run(`ses_${agentId}_${i}`, WS, agentId); | |
| 53 | + | r.prepare("INSERT INTO agent_session_events (session_id, seq, kind, body, created_at) VALUES (?, 1, 'asked', 'do it', '2026-10-02T00:00:00Z')").run(`ses_${agentId}_${i}`); | |
| 54 | + | r.prepare( | |
| 55 | + | `INSERT INTO agent_replies (id, agent_id, workspace_id, channel_id, message_id, status, created_at) | |
| 56 | + | VALUES (?, ?, ?, 'chn_1', ?, 'done', '2026-10-02T00:00:00Z')`, | |
| 57 | + | ).run(`rep_${agentId}_${i}`, agentId, WS, `msg_${agentId}_${i}`); | |
| 58 | + | } | |
| 59 | + | r.prepare( | |
| 60 | + | `INSERT INTO agent_memories (id, agent_id, workspace_id, scope, scope_ref, body, source_kind, created_by, created_by_kind, created_at, updated_at) | |
| 61 | + | VALUES (?, ?, ?, 'workspace', '', 'Deploys are on Tuesdays', 'chat', 'ana', 'user', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`, | |
| 62 | + | ).run(`mem_${agentId}`, agentId, WS); | |
| 63 | + | r.prepare( | |
| 64 | + | `INSERT INTO agent_routines (id, agent_id, workspace_id, workspace, name, instructions, channel_id, sponsor, enabled, created_at, updated_at) | |
| 65 | + | VALUES (?, ?, ?, 'acme', 'Standup', 'Post the standup', 'chn_1', 'u_owner', 1, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`, | |
| 66 | + | ).run(`rtn_${agentId}`, agentId, WS); | |
| 67 | + | r.prepare("INSERT INTO agent_routine_runs (routine_id, run_key, created_at) VALUES (?, 'day:2026-10-03', '2026-10-03T00:00:00Z')").run(`rtn_${agentId}`); | |
| 68 | + | r.prepare("INSERT INTO agent_spend (agent_id, period, micros, replies) VALUES (?, '2026-10', 1200000, 3)").run(agentId); | |
| 69 | + | r.prepare("INSERT OR IGNORE INTO workspace_agent_spend (workspace_id, period, micros) VALUES (?, '2026-10', 1200000)").run(WS); | |
| 70 | + | r.prepare("INSERT INTO skill_attachments (id, workspace_id, skill_id, scope, target, version, attached_by, attached_at) VALUES (?, ?, 'skl_1', 'agent', ?, 1, 'ana', '2026-10-02T00:00:00Z')").run(`att_${agentId}`, WS, agentId); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | const count = (db: { raw: DatabaseSync }, table: string, where: string, ...params: unknown[]) => Number((db.raw.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...(params as never[])) as { n: number }).n); | |
| 74 | + | ||
| 75 | + | test("the archived list has archived agents only, newest archived first, with what deleting each would take", async () => { | |
| 76 | + | const db = fakeD1(); | |
| 77 | + | seed(db, { id: "agt_live", handle: "live" }); | |
| 78 | + | seed(db, { id: "agt_old", handle: "old", archived_at: "2026-09-01T00:00:00Z", archived_by: "ana" }); | |
| 79 | + | seed(db, { id: "agt_new", handle: "new", archived_at: "2026-10-05T00:00:00Z", archived_by: "mike" }); | |
| 80 | + | seed(db, { id: "agt_g1t", handle: "g1t", builtin: 1 }); | |
| 81 | + | seedHistory(db, "agt_new", 2); | |
| 82 | + | const listed = await listArchived(db, WS, { id: owner.id, owner: true }); | |
| 83 | + | assert.deepEqual( | |
| 84 | + | listed.map((a) => [a.handle, a.archived_by, a.counts]), | |
| 85 | + | [ | |
| 86 | + | ["new", "mike", { sessions: 2, replies: 2, memories: 1, routines: 1 }], | |
| 87 | + | ["old", "ana", { sessions: 0, replies: 0, memories: 0, routines: 0 }], | |
| 88 | + | ], | |
| 89 | + | ); | |
| 90 | + | assert.equal(listed[0]!.display_name, "New"); | |
| 91 | + | assert.equal(listed[0]!.avatar_seed, "new"); | |
| 92 | + | assert.deepEqual(await listArchived(db, "wsp_other", { id: owner.id, owner: true }), []); | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | test("a member sees only their own archived personal agents", async () => { | |
| 96 | + | const db = fakeD1(); | |
| 97 | + | seed(db, { id: "agt_ws", handle: "ws", archived_at: "2026-10-01T00:00:00Z" }); | |
| 98 | + | seed(db, { id: "agt_mine", handle: "mine", archived_at: "2026-10-02T00:00:00Z", scope: "personal", owner_id: member.id, owner_username: "mike" }); | |
| 99 | + | seed(db, { id: "agt_theirs", handle: "theirs", archived_at: "2026-10-03T00:00:00Z", scope: "personal", owner_id: other.id, owner_username: "sam" }); | |
| 100 | + | const mine = await listArchived(db, WS, { id: member.id, owner: false }); | |
| 101 | + | assert.deepEqual( | |
| 102 | + | mine.map((a) => [a.handle, a.scope, a.personal_owner]), | |
| 103 | + | [["mine", "personal", "mike"]], | |
| 104 | + | ); | |
| 105 | + | // Owners see every member's, and the workspace's. | |
| 106 | + | assert.deepEqual((await listArchived(db, WS, { id: owner.id, owner: true })).map((a) => a.handle), ["theirs", "mine", "ws"]); | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | test("restoring: owners any agent, a member their own personal one, and only an archived one", () => { | |
| 110 | + | const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" }; | |
| 111 | + | assert.equal(restoreRefusal(owner, "acme", archived), null); | |
| 112 | + | assert.equal(restoreRefusal(member, "acme", archived)?.code, "forbidden"); | |
| 113 | + | const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id }; | |
| 114 | + | assert.equal(restoreRefusal(member, "acme", personal), null); | |
| 115 | + | assert.equal(restoreRefusal(other, "acme", personal)?.code, "forbidden"); | |
| 116 | + | assert.equal(restoreRefusal(owner, "acme", { ...archived, archived_at: null })?.code, "invalid"); | |
| 117 | + | }); | |
| 118 | + | ||
| 119 | + | test("restoring under a handle a live agent holds is a conflict that says what to do; a new handle is checked", () => { | |
| 120 | + | const agent = { handle: "sam", archived_at: "2026-10-01T00:00:00Z" }; | |
| 121 | + | const taken = (h: string) => h === "sam" || h === "ana"; | |
| 122 | + | const conflict = restoreHandle(agent, null, taken); | |
| 123 | + | assert.equal(!conflict.ok && conflict.error.code, "conflict"); | |
| 124 | + | assert.match(!conflict.ok ? conflict.error.message : "", /Rename that one first, or restore this one under a new handle/); | |
| 125 | + | const again = restoreHandle(agent, "ana", taken); | |
| 126 | + | assert.match(!again.ok ? again.error.message : "", /already called @ana. Pick another/); | |
| 127 | + | assert.deepEqual(restoreHandle(agent, "@Sam-2", taken), { ok: true, handle: "sam-2" }); | |
| 128 | + | assert.deepEqual(restoreHandle(agent, "", () => false), { ok: true, handle: "sam" }); | |
| 129 | + | const bad = restoreHandle(agent, "a", taken); | |
| 130 | + | assert.equal(!bad.ok && bad.error.code, "invalid"); | |
| 131 | + | }); | |
| 132 | + | ||
| 133 | + | test("deleting refuses a live agent, the built-in @g1t, the wrong person and a confirmation that is not the handle", () => { | |
| 134 | + | const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" }; | |
| 135 | + | assert.equal(destroyRefusal(owner, "acme", archived, "sam"), null); | |
| 136 | + | assert.equal(destroyRefusal(owner, "acme", archived, "@Sam "), null); | |
| 137 | + | assert.match(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.message ?? "", /Archive @sam first/); | |
| 138 | + | assert.equal(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.code, "invalid"); | |
| 139 | + | assert.match(destroyRefusal(owner, "acme", { ...archived, handle: "g1t", builtin: 1 }, "g1t")?.message ?? "", /can't be deleted/); | |
| 140 | + | assert.equal(destroyRefusal(member, "acme", archived, "sam")?.code, "forbidden"); | |
| 141 | + | assert.equal(destroyRefusal(owner, "acme", archived, "same")?.code, "invalid"); | |
| 142 | + | assert.equal(destroyRefusal(owner, "acme", archived, undefined)?.code, "invalid"); | |
| 143 | + | const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id }; | |
| 144 | + | assert.equal(destroyRefusal(member, "acme", personal, "me"), null); | |
| 145 | + | assert.equal(destroyRefusal(other, "acme", personal, "me")?.code, "forbidden"); | |
| 146 | + | }); | |
| 147 | + | ||
| 148 | + | test("deleting removes every row about the agent, and nothing of another's or of the workspace's totals", async () => { | |
| 149 | + | const db = fakeD1(); | |
| 150 | + | seed(db, { id: "agt_gone", handle: "gone", archived_at: "2026-10-01T00:00:00Z" }); | |
| 151 | + | seed(db, { id: "agt_kept", handle: "kept" }); | |
| 152 | + | seedHistory(db, "agt_gone", 3); | |
| 153 | + | seedHistory(db, "agt_kept", 1); | |
| 154 | + | const results = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[]; | |
| 155 | + | assert.equal(results.length, DESTROYED.length); | |
| 156 | + | assert.equal(results[results.length - 1]!.meta.changes, 1, "the agents row went last"); | |
| 157 | + | for (const { table } of DESTROYED) { | |
| 158 | + | const column = table === "agents" ? "id" : table === "skill_attachments" ? "target" : table === "agent_session_events" ? "session_id" : table === "agent_routine_runs" ? "routine_id" : "agent_id"; | |
| 159 | + | const pattern = table === "agent_session_events" ? "ses_agt_gone_%" : table === "agent_routine_runs" ? "rtn_agt_gone" : "agt_gone"; | |
| 160 | + | assert.equal(count(db, table, `${column} LIKE ?`, pattern), 0, `${table} still has rows about the deleted agent`); | |
| 161 | + | } | |
| 162 | + | // The other agent is untouched, and the workspace's month total stays for the statement. | |
| 163 | + | assert.equal(count(db, "agents", "id = ?", "agt_kept"), 1); | |
| 164 | + | assert.equal(count(db, "agent_sessions", "agent_id = ?", "agt_kept"), 1); | |
| 165 | + | assert.equal(count(db, "agent_session_events", "session_id LIKE ?", "ses_agt_kept_%"), 1); | |
| 166 | + | assert.equal(count(db, "agent_replies", "agent_id = ?", "agt_kept"), 1); | |
| 167 | + | assert.equal(count(db, "agent_memories", "agent_id = ?", "agt_kept"), 1); | |
| 168 | + | assert.equal(count(db, "agent_routine_runs", "routine_id = ?", "rtn_agt_kept"), 1); | |
| 169 | + | assert.equal(count(db, "skill_attachments", "target = ?", "agt_kept"), 1); | |
| 170 | + | assert.equal(count(db, "workspace_agent_spend", "workspace_id = ?", WS), 1); | |
| 171 | + | // A second delete finds nothing: the agents row is gone. | |
| 172 | + | const again = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[]; | |
| 173 | + | assert.equal(again[again.length - 1]!.meta.changes, 0); | |
| 174 | + | }); | |
| 175 | + | ||
| 176 | + | test("deleting leaves a live agent's row alone even when asked by id", async () => { | |
| 177 | + | const db = fakeD1(); | |
| 178 | + | seed(db, { id: "agt_live", handle: "live" }); | |
| 179 | + | const results = (await db.batch(destroyStatements(db, "agt_live"))) as { meta: { changes: number } }[]; | |
| 180 | + | assert.equal(results[results.length - 1]!.meta.changes, 0); | |
| 181 | + | assert.equal(count(db, "agents", "id = ?", "agt_live"), 1); | |
| 182 | + | }); |
| 1 | + | /** | |
| 2 | + | * Archived agents (docs.g1t.sh/guides/agents/, "Archive, restore and | |
| 3 | + | * delete"): the Archived section's list, bringing one back, and deleting | |
| 4 | + | * one for good. Archiving itself is `archive` in index.ts. | |
| 5 | + | * | |
| 6 | + | * An archived agent is out of every list, every lookup by handle and every | |
| 7 | + | * conversation's wake, but its row and everything it did stay. Restoring | |
| 8 | + | * clears `archived_at`, under its old handle or a new one if a live agent | |
| 9 | + | * took the old. Deleting removes every row the service keeps about it, in | |
| 10 | + | * one batch (`destroyStatements`), and index.ts then tells the runner and | |
| 11 | + | * chat. What stays: its messages in chat (under the name it had), the | |
| 12 | + | * workspace's month totals (`workspace_agent_spend`), and the audit log. | |
| 13 | + | * | |
| 14 | + | * The decisions are pure and tested on their own (archived.test.ts); the | |
| 15 | + | * reads and the batch take the database. | |
| 16 | + | */ | |
| 17 | + | import type { ArchivedAgent, User } from "@g1t/contracts"; | |
| 18 | + | ||
| 19 | + | // Relative, with extensions, so Node runs the tests on this file as it is. | |
| 20 | + | import { readLook } from "../../../packages/contracts/src/agent-look.ts"; | |
| 21 | + | import type { Failure, FailureCode } from "../../../packages/contracts/src/result.ts"; | |
| 22 | + | import { MANAGE_REFUSAL, type Owned, canArchive, canManage, isOwnerOf } from "./access.ts"; | |
| 23 | + | import { checkHandle } from "./handle.ts"; | |
| 24 | + | ||
| 25 | + | const refusal = (code: FailureCode, message: string): Failure => ({ code, message }); | |
| 26 | + | ||
| 27 | + | type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> & { id?: string }; | |
| 28 | + | ||
| 29 | + | /** What the decisions read of an agent's row. */ | |
| 30 | + | export type ArchivedLike = Owned & { handle: string; archived_at: string | null }; | |
| 31 | + | ||
| 32 | + | /** The refusal for restoring `agent`, or null when `viewer` may. */ | |
| 33 | + | export function restoreRefusal(viewer: Viewer | null | undefined, workspace: string, agent: ArchivedLike): Failure | null { | |
| 34 | + | if (!agent.archived_at) return refusal("invalid", `@${agent.handle} isn't archived.`); | |
| 35 | + | if (!canArchive(viewer, workspace, agent)) return refusal("forbidden", MANAGE_REFUSAL); | |
| 36 | + | return null; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /** | |
| 40 | + | * The refusal for deleting `agent` for good, or null when `viewer` may: | |
| 41 | + | * owners any agent, a member their own personal one; never the built-in | |
| 42 | + | * @g1t; only an archived agent, and only with its handle typed back. | |
| 43 | + | */ | |
| 44 | + | export function destroyRefusal(viewer: Viewer | null | undefined, workspace: string, agent: ArchivedLike, confirm: unknown): Failure | null { | |
| 45 | + | if (agent.builtin) return refusal("invalid", "@g1t is every workspace's orchestrator and can't be deleted."); | |
| 46 | + | if (!canManage(viewer, workspace) && !isOwnerOf(viewer, agent)) return refusal("forbidden", "Only the workspace's owners delete an agent, and a member their own personal agent."); | |
| 47 | + | if (!agent.archived_at) return refusal("invalid", `Archive @${agent.handle} first. Deleting is for archived agents, so nothing goes by accident.`); | |
| 48 | + | if (typeof confirm !== "string" || confirm.trim().replace(/^@/, "").toLowerCase() !== agent.handle) { | |
| 49 | + | return refusal("invalid", `Type @${agent.handle} to confirm.`); | |
| 50 | + | } | |
| 51 | + | return null; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * The handle an agent is restored under: its own, or `given` when the | |
| 56 | + | * person chose a new one. `conflict` when the handle is taken by a live | |
| 57 | + | * agent (`taken` says so), with what to do about it. | |
| 58 | + | */ | |
| 59 | + | export function restoreHandle(agent: ArchivedLike, given: unknown, taken: (handle: string) => boolean): { ok: true; handle: string } | { ok: false; error: Failure } { | |
| 60 | + | let handle = agent.handle; | |
| 61 | + | if (given !== undefined && given !== null && given !== "") { | |
| 62 | + | const checked = checkHandle(given); | |
| 63 | + | if (!checked.ok) return { ok: false, error: refusal("invalid", checked.message) }; | |
| 64 | + | handle = checked.handle; | |
| 65 | + | } | |
| 66 | + | if (taken(handle)) { | |
| 67 | + | return { | |
| 68 | + | ok: false, | |
| 69 | + | error: refusal( | |
| 70 | + | "conflict", | |
| 71 | + | handle === agent.handle | |
| 72 | + | ? `A live agent is called @${handle} now. Rename that one first, or restore this one under a new handle.` | |
| 73 | + | : `A live agent is already called @${handle}. Pick another handle.`, | |
| 74 | + | ), | |
| 75 | + | }; | |
| 76 | + | } | |
| 77 | + | return { ok: true, handle }; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /** The table and the column each delete runs on, so the docs and the tests say the same. */ | |
| 81 | + | export const DESTROYED: readonly { table: string; where: string }[] = [ | |
| 82 | + | { table: "agent_session_events", where: "session_id IN (SELECT id FROM agent_sessions WHERE agent_id = ?)" }, | |
| 83 | + | { table: "agent_sessions", where: "agent_id = ?" }, | |
| 84 | + | { table: "agent_routine_runs", where: "routine_id IN (SELECT id FROM agent_routines WHERE agent_id = ?)" }, | |
| 85 | + | { table: "agent_routines", where: "agent_id = ?" }, | |
| 86 | + | { table: "agent_replies", where: "agent_id = ?" }, | |
| 87 | + | { table: "agent_tool_calls", where: "agent_id = ?" }, | |
| 88 | + | { table: "agent_memories", where: "agent_id = ?" }, | |
| 89 | + | { table: "agent_drafts", where: "agent_id = ?" }, | |
| 90 | + | { table: "agent_recommendations", where: "agent_id = ?" }, | |
| 91 | + | { table: "agent_ability_requests", where: "agent_id = ?" }, | |
| 92 | + | { table: "skill_attachments", where: "scope = 'agent' AND target = ?" }, | |
| 93 | + | { table: "agent_spend", where: "agent_id = ?" }, | |
| 94 | + | { table: "agent_versions", where: "agent_id = ?" }, | |
| 95 | + | // Last, and only while still archived: a restore that raced the delete keeps the agent and all of the above is already gone for it. | |
| 96 | + | { table: "agents", where: "id = ? AND archived_at IS NOT NULL" }, | |
| 97 | + | ]; | |
| 98 | + | ||
| 99 | + | /** Every row the service keeps about one agent, deleted in one batch. The `agents` row goes last. */ | |
| 100 | + | export function destroyStatements(db: D1Database, agentId: string): D1PreparedStatement[] { | |
| 101 | + | return DESTROYED.map(({ table, where }) => db.prepare(`DELETE FROM ${table} WHERE ${where}`).bind(agentId)); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | type ArchivedRow = { | |
| 105 | + | id: string; | |
| 106 | + | handle: string; | |
| 107 | + | display_name: string; | |
| 108 | + | title: string | null; | |
| 109 | + | role: string; | |
| 110 | + | avatar: string | null; | |
| 111 | + | avatar_seed: string | null; | |
| 112 | + | look: string | null; | |
| 113 | + | scope: string | null; | |
| 114 | + | owner_id: string | null; | |
| 115 | + | owner_username: string | null; | |
| 116 | + | archived_at: string; | |
| 117 | + | archived_by: string | null; | |
| 118 | + | sessions: number; | |
| 119 | + | replies: number; | |
| 120 | + | memories: number; | |
| 121 | + | routines: number; | |
| 122 | + | }; | |
| 123 | + | ||
| 124 | + | /** | |
| 125 | + | * The workspace's archived agents, newest archived first: every one for an | |
| 126 | + | * owner, the viewer's own archived personal agents for anyone else. Each | |
| 127 | + | * with what deleting it would take: its sessions, replies, memories and | |
| 128 | + | * routines, counted in the same read. | |
| 129 | + | */ | |
| 130 | + | export async function listArchived(db: D1Database, workspaceId: string, viewer: { id: string; owner: boolean }): Promise<ArchivedAgent[]> { | |
| 131 | + | const rows = await db | |
| 132 | + | .prepare( | |
| 133 | + | `SELECT a.id, a.handle, a.display_name, a.title, a.role, a.avatar, a.avatar_seed, a.look, a.scope, a.owner_id, a.owner_username, a.archived_at, a.archived_by, | |
| 134 | + | (SELECT COUNT(*) FROM agent_sessions s WHERE s.agent_id = a.id) AS sessions, | |
| 135 | + | (SELECT COUNT(*) FROM agent_replies r WHERE r.agent_id = a.id) AS replies, | |
| 136 | + | (SELECT COUNT(*) FROM agent_memories m WHERE m.agent_id = a.id) AS memories, | |
| 137 | + | (SELECT COUNT(*) FROM agent_routines t WHERE t.agent_id = a.id) AS routines | |
| 138 | + | FROM agents a | |
| 139 | + | WHERE a.workspace_id = ?1 AND a.archived_at IS NOT NULL AND a.builtin = 0 | |
| 140 | + | AND (?2 = 1 OR (a.scope = 'personal' AND a.owner_id = ?3)) | |
| 141 | + | ORDER BY a.archived_at DESC, a.handle`, | |
| 142 | + | ) | |
| 143 | + | .bind(workspaceId, viewer.owner ? 1 : 0, viewer.id) | |
| 144 | + | .all<ArchivedRow>(); | |
| 145 | + | return rows.results.map((row) => ({ | |
| 146 | + | id: row.id, | |
| 147 | + | handle: row.handle, | |
| 148 | + | display_name: row.display_name, | |
| 149 | + | title: row.title ?? "", | |
| 150 | + | role: row.role, | |
| 151 | + | avatar: row.avatar, | |
| 152 | + | avatar_seed: row.avatar_seed || row.handle, | |
| 153 | + | look: readLook(row.look), | |
| 154 | + | scope: row.scope === "personal" ? "personal" : "workspace", | |
| 155 | + | personal_owner: row.scope === "personal" ? (row.owner_username ?? null) : null, | |
| 156 | + | archived_at: row.archived_at, | |
| 157 | + | archived_by: row.archived_by ?? null, | |
| 158 | + | counts: { sessions: row.sessions ?? 0, replies: row.replies ?? 0, memories: row.memories ?? 0, routines: row.routines ?? 0 }, | |
| 159 | + | })); | |
| 160 | + | } |
| 145 | 145 | .computerForget(agentId) | |
| 146 | 146 | .catch((error: unknown) => console.error("agents: an archived agent's computer was not told", agentId, String(error))); | |
| 147 | 147 | } | |
| 148 | + | ||
| 149 | + | /** A restored agent's computer: its disk is kept after all (undoes `forgetComputer`). Never fails the restore. */ | |
| 150 | + | export async function keepComputer(env: ComputerEnv, agentId: string): Promise<void> { | |
| 151 | + | if (!env.RUNNER) return; | |
| 152 | + | await runnerComputerClient(env.RUNNER) | |
| 153 | + | .computerKeep(agentId) | |
| 154 | + | .catch((error: unknown) => console.error("agents: a restored agent's computer was not told", agentId, String(error))); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /** A deleted agent's computer: stopped, and its saved home, commands and record deleted now. Never fails the delete. */ | |
| 158 | + | export async function eraseComputer(env: ComputerEnv, agentId: string): Promise<void> { | |
| 159 | + | if (!env.RUNNER) return; | |
| 160 | + | await runnerComputerClient(env.RUNNER) | |
| 161 | + | .computerErase(agentId) | |
| 162 | + | .catch((error: unknown) => console.error("agents: a deleted agent's computer was not erased", agentId, String(error))); | |
| 163 | + | } |
| 23 | 23 | type InstallRequests, | |
| 24 | 24 | type McpServer, | |
| 25 | 25 | type AgentEffortCosts, | |
| 26 | + | type ArchivedAgent, | |
| 26 | 27 | type AgentRecommendation, | |
| 27 | 28 | type AgentRecommendations, | |
| 28 | 29 | type NewWorkspaceAgent, | |
| ⋯ | |||
| 66 | 67 | import { moveAgentTeams } from "./team-move.ts"; | |
| 67 | 68 | import { effortOf } from "./routing.ts"; | |
| 68 | 69 | import * as views from "./views.ts"; | |
| 69 | − | import { computerCommands, computerView, forgetComputer, resetComputer, sleepComputer, wakeComputer } from "./computer.ts"; | |
| 70 | + | import { computerCommands, computerView, eraseComputer, forgetComputer, keepComputer, resetComputer, sleepComputer, wakeComputer } from "./computer.ts"; | |
| 71 | + | import { destroyRefusal, destroyStatements, listArchived, restoreHandle, restoreRefusal } from "./archived.ts"; | |
| 70 | 72 | import { monthKey } from "./budget.ts"; | |
| 71 | 73 | import * as extensions from "./extensions.ts"; | |
| 72 | 74 | import { skillPushes, skillRpc } from "./skill-rpc.ts"; | |
| ⋯ | |||
| 525 | 527 | try { | |
| 526 | 528 | const [archived] = await this.db.batch([ | |
| 527 | 529 | // Archived first, from the version read, so the handle is free for the new one in the same step. | |
| 528 | − | this.db.prepare("UPDATE agents SET archived_at = ?, updated_at = ? WHERE id = ? AND version = ? AND archived_at IS NULL").bind(now, now, row.id, row.version), | |
| 530 | + | this.db.prepare("UPDATE agents SET archived_at = ?, archived_by = ?, updated_at = ? WHERE id = ? AND version = ? AND archived_at IS NULL").bind(now, by, now, row.id, row.version), | |
| 529 | 531 | insertAgent(this.db, id, managed.value, definition, { version, created_by: row.created_by, created_at: now, updated_at: now }), | |
| 530 | 532 | ...versions.results.map((v) => | |
| 531 | 533 | this.db.prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, ?, ?, ?, ?)").bind(id, v.version, v.definition, v.changed_by, v.created_at), | |
| ⋯ | |||
| 826 | 828 | if (!canArchive(a.viewer, a.workspace, row)) return fail("forbidden", MANAGE_REFUSAL); | |
| 827 | 829 | if (row.builtin) return fail("invalid", "@g1t is every workspace's orchestrator and can't be archived. You can set its budget to limit it."); | |
| 828 | 830 | const now = new Date().toISOString(); | |
| 829 | − | await this.db.prepare("UPDATE agents SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL").bind(now, now, row.id).run(); | |
| 831 | + | await this.db.prepare("UPDATE agents SET archived_at = ?, archived_by = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL").bind(now, a.viewer!.username, now, row.id).run(); | |
| 830 | 832 | this.audit(a.viewer!, a.workspace, "archive_agent", row.handle, `Archived @${row.handle}`); | |
| 831 | 833 | // Its computer goes to sleep now; its disk is kept 30 days, then deleted (computer.ts). | |
| 832 | 834 | this.defer(forgetComputer(this.env, row.id)); | |
| 833 | 835 | return ok(null); | |
| 834 | 836 | } | |
| 835 | 837 | ||
| 838 | + | // ── Archived agents (./archived.ts) ────────────────────────────────────── | |
| 839 | + | ||
| 840 | + | /** The archived agents the viewer may see: every one for an owner, their own archived personal agents for anyone else. */ | |
| 841 | + | async listArchived(a: { workspace: string; viewer: User | null }): Promise<Result<ArchivedAgent[]>> { | |
| 842 | + | if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED; | |
| 843 | + | const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null); | |
| 844 | + | if (!seen.ok) return seen; | |
| 845 | + | const viewer = a.viewer!; | |
| 846 | + | return ok(await listArchived(this.db, seen.value, { id: (viewer.kind ?? "user") === "user" ? viewer.id : "", owner: canManage(viewer, a.workspace) })); | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | /** | |
| 850 | + | * An archived agent the viewer may see, by id, else the one most recently | |
| 851 | + | * archived under `handle` (a handle is unique only among live agents). | |
| 852 | + | */ | |
| 853 | + | private async archivedRow(workspace: string, viewer: User | null, a: { id?: unknown; handle?: unknown }): Promise<Result<{ workspaceId: string; row: Row }>> { | |
| 854 | + | if (awaitsConfirmation(viewer)) return UNVERIFIED; | |
| 855 | + | const seen = await this.seen(workspace, viewer); | |
| 856 | + | if (!seen.ok) return seen; | |
| 857 | + | const now = new Date(); | |
| 858 | + | const handle = typeof a.handle === "string" ? a.handle.trim().replace(/^@/, "").toLowerCase() : ""; | |
| 859 | + | const row = | |
| 860 | + | typeof a.id === "string" && a.id | |
| 861 | + | ? await this.db.prepare(selectAgents("a.workspace_id = ?3 AND a.id = ?4 AND a.archived_at IS NOT NULL")).bind(...periods(now), seen.value, a.id).first<Row>() | |
| 862 | + | : handle | |
| 863 | + | ? await this.db | |
| 864 | + | .prepare(`${selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NOT NULL")} ORDER BY a.archived_at DESC LIMIT 1`) | |
| 865 | + | .bind(...periods(now), seen.value, handle) | |
| 866 | + | .first<Row>() | |
| 867 | + | : null; | |
| 868 | + | if (!row || !canSeeAgent(viewer, workspace, row)) return fail("not_found", `There is no archived agent called @${handle || String(a.id ?? "")}.`); | |
| 869 | + | return ok({ workspaceId: seen.value, row }); | |
| 870 | + | } | |
| 871 | + | ||
| 872 | + | /** | |
| 873 | + | * Brings an archived agent back, live again under its handle, or under | |
| 874 | + | * `new_handle` when a live agent took the old one (`conflict` says so). | |
| 875 | + | * Owners any agent, a member their own personal one. A new handle is a | |
| 876 | + | * new version, as any rename is. Its computer's disk is kept after all. | |
| 877 | + | */ | |
| 878 | + | async restore(a: { workspace: string; handle?: string; id?: string; viewer: User | null; new_handle?: unknown }): Promise<Result<WorkspaceAgent>> { | |
| 879 | + | const found = await this.archivedRow(a?.workspace ?? "", a?.viewer ?? null, a ?? {}); | |
| 880 | + | if (!found.ok) return found; | |
| 881 | + | const { workspaceId, row } = found.value; | |
| 882 | + | const refused = restoreRefusal(a.viewer, a.workspace, row); | |
| 883 | + | if (refused) return { ok: false, error: refused }; | |
| 884 | + | const live = await this.db | |
| 885 | + | .prepare("SELECT handle FROM agents WHERE workspace_id = ? AND archived_at IS NULL") | |
| 886 | + | .bind(workspaceId) | |
| 887 | + | .all<{ handle: string }>(); | |
| 888 | + | const taken = new Set(live.results.map((r) => r.handle)); | |
| 889 | + | const handle = restoreHandle(row, a.new_handle, (h) => taken.has(h)); | |
| 890 | + | if (!handle.ok) return handle; | |
| 891 | + | const now = new Date().toISOString(); | |
| 892 | + | const renamed = handle.handle !== row.handle; | |
| 893 | + | const version = renamed ? row.version + 1 : row.version; | |
| 894 | + | try { | |
| 895 | + | const [restored] = await this.db.batch([ | |
| 896 | + | this.db | |
| 897 | + | .prepare("UPDATE agents SET archived_at = NULL, archived_by = NULL, handle = ?, version = ?, updated_at = ? WHERE id = ? AND archived_at IS NOT NULL") | |
| 898 | + | .bind(handle.handle, version, now, row.id), | |
| 899 | + | ...(renamed ? [this.versionStatement(row.id, version, { ...definitionOf(row), handle: handle.handle }, a.viewer!.username, now)] : []), | |
| 900 | + | ]); | |
| 901 | + | if (!restored.meta.changes) return fail("not_found", `@${row.handle} was restored or deleted meanwhile.`); | |
| 902 | + | } catch (error) { | |
| 903 | + | if (String(error).includes("UNIQUE")) return fail("conflict", `A live agent is called @${handle.handle} now. Rename that one first, or restore this one under a new handle.`); | |
| 904 | + | throw error; | |
| 905 | + | } | |
| 906 | + | const was = renamed ? ` (was @${row.handle})` : ""; | |
| 907 | + | this.audit(a.viewer!, a.workspace, "restore_agent", handle.handle, `Restored @${handle.handle}${was}`, "agents", isPersonal(row) ? "member" : "owner"); | |
| 908 | + | this.defer(keepComputer(this.env, row.id)); | |
| 909 | + | const saved = await this.row(workspaceId, handle.handle); | |
| 910 | + | return ok(toAgent(saved!, new Date())); | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | /** | |
| 914 | + | * Deletes an archived agent for good (archived.ts `DESTROYED`): every row | |
| 915 | + | * the service keeps about it, in one batch; then its computer on the | |
| 916 | + | * runner, its chat memberships (its messages stay under the name it | |
| 917 | + | * had) and its team memberships. Owners any agent, a member their own | |
| 918 | + | * personal one; `confirm` is its handle typed back. A live agent is | |
| 919 | + | * archived first. A second call finds nothing. | |
| 920 | + | */ | |
| 921 | + | async destroy(a: { workspace: string; handle?: string; id?: string; viewer: User | null; confirm?: unknown }): Promise<Result<null>> { | |
| 922 | + | const found = await this.archivedRow(a?.workspace ?? "", a?.viewer ?? null, a ?? {}); | |
| 923 | + | if (!found.ok) { | |
| 924 | + | // A live agent of that handle: say what to do, rather than "no such archived agent". | |
| 925 | + | if (found.error.code === "not_found" && typeof a?.handle === "string") { | |
| 926 | + | const live = await this.visible(a.workspace, a.viewer, a.handle); | |
| 927 | + | if (live.ok) { | |
| 928 | + | const refused = destroyRefusal(a.viewer, a.workspace, live.value.row, a.confirm); | |
| 929 | + | return { ok: false, error: refused ?? found.error }; | |
| 930 | + | } | |
| 931 | + | } | |
| 932 | + | return found; | |
| 933 | + | } | |
| 934 | + | const { workspaceId, row } = found.value; | |
| 935 | + | const refused = destroyRefusal(a.viewer, a.workspace, row, a.confirm); | |
| 936 | + | if (refused) return { ok: false, error: refused }; | |
| 937 | + | const results = await this.db.batch(destroyStatements(this.db, row.id)); | |
| 938 | + | if (!results[results.length - 1]!.meta.changes) return fail("not_found", `@${row.handle} was restored or deleted meanwhile.`); | |
| 939 | + | this.audit(a.viewer!, a.workspace, "delete_agent", row.handle, `Deleted the archived agent @${row.handle} for good`, "agents", isPersonal(row) ? "member" : "owner"); | |
| 940 | + | const slug = a.workspace.toLowerCase(); | |
| 941 | + | const definition = definitionOf(row); | |
| 942 | + | this.defer(eraseComputer(this.env, row.id)); | |
| 943 | + | this.defer( | |
| 944 | + | chatClient(this.env.CHAT) | |
| 945 | + | .agentDestroyed({ workspace_id: workspaceId, agent_id: row.id, handle: row.handle, display_name: row.display_name, avatar_seed: definition.avatar_seed, look: definition.look }) | |
| 946 | + | .then((told) => (told.ok ? null : console.error("agents: chat was not told of a deleted agent", row.id, told.error.message))) | |
| 947 | + | .catch((error: unknown) => console.error("agents: chat was not told of a deleted agent", row.id, String(error))), | |
| 948 | + | ); | |
| 949 | + | this.defer(this.leaveTeams(slug, a.viewer!, row.id)); | |
| 950 | + | return ok(null); | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | /** A deleted agent comes off every team it was on (memberships live in identity). */ | |
| 954 | + | private async leaveTeams(slug: string, by: User, agentId: string): Promise<void> { | |
| 955 | + | try { | |
| 956 | + | const identity = identityClient(this.env.IDENTITY); | |
| 957 | + | const teams = await identity.agentTeams(slug, agentId); | |
| 958 | + | await Promise.all( | |
| 959 | + | teams.map((team) => identity.removeTeamAgent(by, slug, team.slug, agentId).catch((error: unknown) => console.error("agents: a deleted agent was not taken off its team", agentId, team.slug, String(error)))), | |
| 960 | + | ); | |
| 961 | + | } catch (error) { | |
| 962 | + | console.error("agents: a deleted agent's teams were not read", agentId, String(error)); | |
| 963 | + | } | |
| 964 | + | } | |
| 965 | + | ||
| 836 | 966 | /** | |
| 837 | 967 | * Makes the workspace's built-in @g1t if it does not exist yet: an | |
| 838 | 968 | * ordinary agent row, marked builtin, at version 1. Safe to call on | |
| ⋯ | |||
| 949 | 1079 | return Response.json(await service.update(args)); | |
| 950 | 1080 | case "archive": | |
| 951 | 1081 | return Response.json(await service.archive(args)); | |
| 1082 | + | case "list_archived": | |
| 1083 | + | return Response.json(await service.listArchived(args)); | |
| 1084 | + | case "restore": | |
| 1085 | + | return Response.json(await service.restore(args)); | |
| 1086 | + | case "destroy": | |
| 1087 | + | return Response.json(await service.destroy(args)); | |
| 952 | 1088 | case "draft": | |
| 953 | 1089 | return Response.json(await service.draft(args)); | |
| 954 | 1090 | case "try_draft": | |
| 55 | 55 | created_at: string; | |
| 56 | 56 | updated_at: string; | |
| 57 | 57 | archived_at: string | null; | |
| 58 | + | /** Who archived it, by username; missing before 0013. */ | |
| 59 | + | archived_by?: string | null; | |
| 58 | 60 | /** Joined from agent_spend: this month and today. */ | |
| 59 | 61 | spent_month?: number | null; | |
| 60 | 62 | spent_day?: number | null; | |
| ⋯ | |||
| 133 | 135 | created_at: row.created_at, | |
| 134 | 136 | updated_at: row.updated_at, | |
| 135 | 137 | archived_at: row.archived_at, | |
| 138 | + | archived_by: row.archived_by ?? null, | |
| 136 | 139 | }; | |
| 137 | 140 | } | |
| 138 | 141 | ||
| 558 | 558 | by_agent: slices(byAgent.results, (k) => { | |
| 559 | 559 | if (k === BUILDER_ID) return BUILDER_LABEL; | |
| 560 | 560 | const face = agents.get(k); | |
| 561 | − | return face ? `${face.display_name} (@${face.handle})` : "An archived agent"; | |
| 561 | + | // Archived agents are still in `agents`; one without a face was deleted for good (its spend stays in the month's total). | |
| 562 | + | return face ? `${face.display_name} (@${face.handle})` : "A deleted agent"; | |
| 562 | 563 | }), | |
| 563 | 564 | by_team: [...byTeamMap.values()].sort((a, b) => b.micros - a.micros), | |
| 564 | 565 | by_channel: channels, |
| 1 | + | -- Agents deleted for good (docs.g1t.sh/guides/chat/, "Archived and deleted | |
| 2 | + | -- agents"): the agents service forgets everything about one, but its | |
| 3 | + | -- messages stay in chat, so chat keeps the name and face it had to show | |
| 4 | + | -- them under. Written when the agents service says an agent is gone | |
| 5 | + | -- (`agent_destroyed`), read when a message's author is an agent the | |
| 6 | + | -- agents service no longer knows. | |
| 7 | + | CREATE TABLE former_agents ( | |
| 8 | + | agent_id TEXT PRIMARY KEY, | |
| 9 | + | workspace_id TEXT NOT NULL, | |
| 10 | + | handle TEXT NOT NULL, | |
| 11 | + | display_name TEXT NOT NULL, | |
| 12 | + | avatar_seed TEXT, | |
| 13 | + | -- Its chosen face (JSON, @g1t/contracts agent-look.ts), or NULL for the seed's. | |
| 14 | + | look TEXT, | |
| 15 | + | deleted_at TEXT NOT NULL | |
| 16 | + | ); |
| 1 | 1 | import { test } from "node:test"; | |
| 2 | 2 | import assert from "node:assert/strict"; | |
| 3 | 3 | ||
| 4 | − | import { MAX_HOPS, type RecentMessage, addressedAgents, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal } from "./delivery.ts"; | |
| 4 | + | import { MAX_HOPS, type RecentMessage, addressedAgents, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, wakeable } from "./delivery.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const ship = { id: "a1", handle: "ship" }; | |
| 7 | 7 | const docs = { id: "a2", handle: "docs" }; | |
| ⋯ | |||
| 139 | 139 | assert.equal(personalAgentRefusal({ id: "a1", handle: "ship", scope: "workspace" }, { kind: "channel", members: null }), null); | |
| 140 | 140 | assert.equal(personalAgentRefusal({ id: "a1", handle: "ship" }, { kind: "hand_off", members: null }), null, "agents from before personal ones are the workspace's"); | |
| 141 | 141 | }); | |
| 142 | + | ||
| 143 | + | test("an archived agent stays a member but is never woken; one the agents service no longer knows, or of another workspace, neither", () => { | |
| 144 | + | const live = { id: "a1", handle: "ship", workspace_id: "wsp_1", archived_at: null }; | |
| 145 | + | const archived = { id: "a2", handle: "docs", workspace_id: "wsp_1", archived_at: "2026-10-09T10:00:00Z" }; | |
| 146 | + | const elsewhere = { id: "a3", handle: "ops", workspace_id: "wsp_2", archived_at: null }; | |
| 147 | + | const found = new Map<string, typeof live | null>([ | |
| 148 | + | ["a1", live], | |
| 149 | + | ["a2", archived], | |
| 150 | + | ["a3", elsewhere], | |
| 151 | + | // Deleted for good: chat still has its membership row, the agents service has nothing. | |
| 152 | + | ["a4", null], | |
| 153 | + | ]); | |
| 154 | + | assert.deepEqual(wakeable(found.values(), "wsp_1"), [live]); | |
| 155 | + | // In a direct message with the two of them, an unaddressed message goes to the live one alone. | |
| 156 | + | const agents = wakeable(found.values(), "wsp_1").map((a) => ({ id: a.id, handle: a.handle })); | |
| 157 | + | assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents, mentioned: [] }), [{ agent_id: "a1", hops: 0 }]); | |
| 158 | + | // Mentioning the archived one by handle wakes nobody: it is not among the wakeable. | |
| 159 | + | assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "channel", agents, mentioned: ["docs"] }), []); | |
| 160 | + | assert.deepEqual(wakeable([], "wsp_1"), []); | |
| 161 | + | }); | |
| 131 | 131 | return agents; | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | + | /** An agent as the agents service hands it to chat, as far as waking reads it. */ | |
| 135 | + | export type KnownAgent = { id: string; handle: string; workspace_id: string; archived_at: string | null }; | |
| 136 | + | ||
| 137 | + | /** | |
| 138 | + | * The agents of a channel that may be woken at all: members the agents | |
| 139 | + | * service knows, of this workspace, and not archived | |
| 140 | + | * (docs.g1t.sh/guides/chat/, "Archived and deleted agents"). An archived | |
| 141 | + | * agent stays a member of its conversations, so they can be read, but it | |
| 142 | + | * never answers, types or is addressed; one deleted for good is unknown | |
| 143 | + | * to the agents service and is left out the same way. | |
| 144 | + | */ | |
| 145 | + | export function wakeable<A extends KnownAgent>(found: Iterable<A | null | undefined>, workspaceId: string): A[] { | |
| 146 | + | const out: A[] = []; | |
| 147 | + | for (const agent of found) if (agent && agent.workspace_id === workspaceId && !agent.archived_at) out.push(agent); | |
| 148 | + | return out; | |
| 149 | + | } | |
| 150 | + | ||
| 134 | 151 | export function deliveries(input: { | |
| 135 | 152 | /** Who wrote it: `user:<id>` or `agent:<id>`. */ | |
| 136 | 153 | author: string; |
| 23 | 23 | openD1, | |
| 24 | 24 | parsePrincipalKey, | |
| 25 | 25 | principalKey, | |
| 26 | + | readLook, | |
| 26 | 27 | workspaceAgentsClient, | |
| 27 | 28 | type AgentDelivery, | |
| 28 | 29 | type AgentFoundMessage, | |
| ⋯ | |||
| 77 | 78 | handOffPlace, | |
| 78 | 79 | handOffRefusal, | |
| 79 | 80 | personalAgentRefusal, | |
| 81 | + | wakeable, | |
| 80 | 82 | type Chain, | |
| 81 | 83 | type RecentMessage, | |
| 82 | 84 | type Wake, | |
| ⋯ | |||
| 183 | 185 | deleted_at: string | null; | |
| 184 | 186 | }; | |
| 185 | 187 | ||
| 188 | + | /** An agent deleted for good, as `former_agents` keeps it (migration 0004). */ | |
| 189 | + | type FormerAgentRow = { agent_id: string; workspace_id: string; handle: string; display_name: string; avatar_seed: string | null; look: string | null; deleted_at: string }; | |
| 190 | + | ||
| 186 | 191 | /** What a method found out about the channel it was asked about. */ | |
| 187 | 192 | type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null }; | |
| 188 | 193 | ||
| ⋯ | |||
| 239 | 244 | private readonly people = new Map<string, Promise<Map<string, Member>>>(); | |
| 240 | 245 | private readonly usernames = new Map<string, string>(); | |
| 241 | 246 | private readonly agents = new Map<string, WorkspaceAgent | null>(); | |
| 247 | + | /** Agents deleted for good, by id, from `former_agents`; null for one chat never heard of. */ | |
| 248 | + | private readonly former = new Map<string, FormerAgentRow | null>(); | |
| 242 | 249 | private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>(); | |
| 243 | 250 | ||
| 244 | 251 | /** `defer` runs work after the answer is sent: the request's waitUntil. */ | |
| ⋯ | |||
| 308 | 315 | return agent && agent.workspace_id === workspace.id && !agent.archived_at ? agent : null; | |
| 309 | 316 | } | |
| 310 | 317 | ||
| 311 | − | /** How each member key shows, for one workspace. */ | |
| 318 | + | /** | |
| 319 | + | * Agents deleted for good, by id, as `former_agents` remembers them: the | |
| 320 | + | * name and face their messages still show under. Asked only for agents | |
| 321 | + | * the agents service no longer knows, and once per request. | |
| 322 | + | */ | |
| 323 | + | private async formerAgents(ids: string[]): Promise<Map<string, FormerAgentRow>> { | |
| 324 | + | const wanted = [...new Set(ids)].filter((id) => !this.former.has(id)); | |
| 325 | + | if (wanted.length) { | |
| 326 | + | const rows = await this.db | |
| 327 | + | .prepare(`SELECT * FROM former_agents WHERE agent_id IN (${wanted.map(() => "?").join(", ")})`) | |
| 328 | + | .bind(...wanted) | |
| 329 | + | .all<FormerAgentRow>() | |
| 330 | + | .catch((error: unknown) => { | |
| 331 | + | console.error("chat could not read former agents", error); | |
| 332 | + | return { results: [] as FormerAgentRow[] }; | |
| 333 | + | }); | |
| 334 | + | for (const id of wanted) this.former.set(id, rows.results.find((row) => row.agent_id === id) ?? null); | |
| 335 | + | } | |
| 336 | + | const out = new Map<string, FormerAgentRow>(); | |
| 337 | + | for (const id of ids) { | |
| 338 | + | const row = this.former.get(id); | |
| 339 | + | if (row) out.set(id, row); | |
| 340 | + | } | |
| 341 | + | return out; | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | /** | |
| 345 | + | * How each member key shows, for one workspace. An archived agent's | |
| 346 | + | * profile says so (`archived_at`, `archived_by`); one deleted for good | |
| 347 | + | * shows under the name it had, marked `deleted`. | |
| 348 | + | */ | |
| 312 | 349 | private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> { | |
| 313 | 350 | const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p); | |
| 314 | 351 | const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id); | |
| ⋯ | |||
| 320 | 357 | this.agentsById(agentIds), | |
| 321 | 358 | ]); | |
| 322 | 359 | for (const [id, username] of Object.entries(named)) this.usernames.set(id, username); | |
| 360 | + | const gone = agentIds.filter((id) => !agents.get(id)); | |
| 361 | + | const former = gone.length ? await this.formerAgents(gone) : new Map<string, FormerAgentRow>(); | |
| 323 | 362 | const out = new Map<string, MemberProfile>(); | |
| 324 | 363 | for (const p of principals) { | |
| 325 | 364 | if (p.kind === "user") { | |
| ⋯ | |||
| 339 | 378 | }); | |
| 340 | 379 | } else { | |
| 341 | 380 | const agent = agents.get(p.id) ?? null; | |
| 381 | + | const was = agent ? null : (former.get(p.id) ?? null); | |
| 342 | 382 | out.set(principalKey(p), { | |
| 343 | 383 | ...p, | |
| 344 | − | name: agent?.handle ?? p.id, | |
| 345 | − | display_name: agent?.display_name ?? "Former agent", | |
| 384 | + | name: agent?.handle ?? was?.handle ?? p.id, | |
| 385 | + | // Never blank: a deleted agent's messages show under the name it had, else as a deleted agent. | |
| 386 | + | display_name: agent?.display_name ?? was?.display_name ?? "A deleted agent", | |
| 346 | 387 | avatar: agent?.avatar ?? null, | |
| 347 | 388 | role: agent?.role ?? null, | |
| 348 | 389 | title: agent?.title || null, | |
| 349 | − | avatar_seed: agent?.avatar_seed ?? null, | |
| 350 | − | look: agent?.look ?? null, | |
| 390 | + | avatar_seed: agent?.avatar_seed ?? was?.avatar_seed ?? null, | |
| 391 | + | look: agent?.look ?? (was ? readLook(was.look) : null), | |
| 392 | + | archived_at: agent?.archived_at ?? null, | |
| 393 | + | archived_by: agent?.archived_by ?? null, | |
| 394 | + | ...(agent ? {} : { deleted: true }), | |
| 351 | 395 | }); | |
| 352 | 396 | } | |
| 353 | 397 | } | |
| ⋯ | |||
| 848 | 892 | return ok(null); | |
| 849 | 893 | } | |
| 850 | 894 | ||
| 895 | + | /** | |
| 896 | + | * Internal, from the agents service: an agent was deleted for good. It | |
| 897 | + | * comes off every channel of its workspace, and its name and face are | |
| 898 | + | * kept (`former_agents`) so its messages, which stay, show under them. | |
| 899 | + | * Its direct messages keep their membership rows: a conversation is its | |
| 900 | + | * people's, stays in their lists and opens to read, with the agent shown | |
| 901 | + | * as deleted (`profiles`); it is never woken, since the agents service no | |
| 902 | + | * longer knows it (delivery.ts `wakeable`). | |
| 903 | + | */ | |
| 904 | + | async agentDestroyed(a: { workspace_id: string; agent_id: string; handle: string; display_name: string; avatar_seed?: string | null; look?: unknown }): Promise<Result<null>> { | |
| 905 | + | if (typeof a?.workspace_id !== "string" || !a.workspace_id || typeof a?.agent_id !== "string" || !a.agent_id) return fail("invalid", "Name the workspace and the agent by id."); | |
| 906 | + | const handle = typeof a.handle === "string" && a.handle ? a.handle : a.agent_id; | |
| 907 | + | const key = principalKey({ kind: "agent", id: a.agent_id }); | |
| 908 | + | await this.db.batch([ | |
| 909 | + | this.db | |
| 910 | + | .prepare("INSERT OR REPLACE INTO former_agents (agent_id, workspace_id, handle, display_name, avatar_seed, look, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)") | |
| 911 | + | .bind(a.agent_id, a.workspace_id, handle, typeof a.display_name === "string" && a.display_name.trim() ? a.display_name : handle, typeof a.avatar_seed === "string" ? a.avatar_seed : null, a.look ? JSON.stringify(a.look) : null, now()), | |
| 912 | + | this.db.prepare("DELETE FROM channel_members WHERE principal = ? AND channel_id IN (SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel')").bind(key, a.workspace_id), | |
| 913 | + | ]); | |
| 914 | + | this.agents.delete(a.agent_id); | |
| 915 | + | this.former.delete(a.agent_id); | |
| 916 | + | return ok(null); | |
| 917 | + | } | |
| 918 | + | ||
| 851 | 919 | async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> { | |
| 852 | 920 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "member"); | |
| 853 | 921 | if (!found.ok) return found; | |
| ⋯ | |||
| 1204 | 1272 | .all<{ principal: string }>(); | |
| 1205 | 1273 | const ids = members.results.map((m) => m.principal.slice("agent:".length)); | |
| 1206 | 1274 | let found = await this.agentsById(ids); | |
| 1207 | − | const orchestratorIsMember = [...found.values()].some((agent) => !!agent?.builtin && agent.workspace_id === workspace.id && !agent.archived_at); | |
| 1275 | + | const orchestratorIsMember = wakeable(found.values(), workspace.id).some((agent) => !!agent.builtin); | |
| 1208 | 1276 | if (addsOrchestrator({ channelKind: channel.kind, mentioned: handles, orchestratorIsMember })) { | |
| 1209 | 1277 | // Mentioning @g1t brings it in: every workspace has it, nobody invites it. | |
| 1210 | 1278 | const builtin = await workspaceAgentsClient(this.env.AGENTS) | |
| ⋯ | |||
| 1221 | 1289 | } | |
| 1222 | 1290 | } | |
| 1223 | 1291 | if (!ids.length) return; | |
| 1224 | − | const agents = [...found.values()].filter( | |
| 1225 | − | (agent): agent is WorkspaceAgent => !!agent && agent.workspace_id === workspace.id && !agent.archived_at, | |
| 1226 | − | ); | |
| 1292 | + | // Archived agents stay members, so their conversations can be read, but are never woken (delivery.ts `wakeable`). | |
| 1293 | + | const agents = wakeable(found.values(), workspace.id); | |
| 1227 | 1294 | const wakes = deliveries({ | |
| 1228 | 1295 | author: row.author, | |
| 1229 | 1296 | hops: chain.hops, | |
| ⋯ | |||
| 2091 | 2158 | return Response.json(await service.join(args)); | |
| 2092 | 2159 | case "leave": | |
| 2093 | 2160 | return Response.json(await service.leave(args)); | |
| 2161 | + | case "agent_destroyed": | |
| 2162 | + | return Response.json(await service.agentDestroyed(args)); | |
| 2094 | 2163 | case "invite": | |
| 2095 | 2164 | return Response.json(await service.invite(args)); | |
| 2096 | 2165 | case "messages": | |
| 587 | 587 | async purge(): Promise<void> { | |
| 588 | 588 | const kept = await this.ctx.storage.get<Kept>("computer"); | |
| 589 | 589 | if (!kept?.deleteAfter || Date.parse(kept.deleteAfter) > Date.now()) return; | |
| 590 | + | await this.erase(kept); | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | /** The agent was restored: its home is kept after all. The purge alarm finds nothing to do. */ | |
| 594 | + | async keep(agentId: string): Promise<Result<AgentComputerStatus>> { | |
| 595 | + | const kept = await this.kept(agentId); | |
| 596 | + | kept.deleteAfter = null; | |
| 597 | + | await this.save(kept); | |
| 598 | + | return ok(statusOf(kept, this.attached)); | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | /** The agent was deleted for good: stopped if awake, its saved home and everything kept here gone now. */ | |
| 602 | + | async eraseNow(agentId: string): Promise<Result<null>> { | |
| 603 | + | const kept = await this.ctx.storage.get<Kept>("computer"); | |
| 604 | + | await this.erase(kept ?? { agentId, state: "asleep" }); | |
| 605 | + | return ok(null); | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | private async erase(kept: Pick<Kept, "agentId" | "state">): Promise<void> { | |
| 590 | 609 | if (kept.state !== "asleep") await this.destroy().catch(() => undefined); | |
| 591 | 610 | if (this.env.HOMES) await this.env.HOMES.delete(homeKey(kept.agentId)).catch(() => undefined); | |
| 592 | 611 | await this.ctx.storage.deleteAll(); |
| 1265 | 1265 | // Workspace agents' own computers (computer.ts), for the agents service: | |
| 1266 | 1266 | // `computer_status`, `computer_wake`, `computer_exec`, `computer_read_file`, | |
| 1267 | 1267 | // `computer_write_file`, `computer_sleep`, `computer_reset`, | |
| 1268 | − | // `computer_forget` and `computer_commands`, each naming its `agent_id`. | |
| 1268 | + | // `computer_forget`, `computer_keep`, `computer_erase` and | |
| 1269 | + | // `computer_commands`, each naming its `agent_id`. | |
| 1269 | 1270 | if (request.method === "POST" && pathname.startsWith("/rpc/computer_")) { | |
| 1270 | 1271 | return Response.json(await this.computer(pathname.slice("/rpc/".length), (await request.json()) as Record<string, unknown>)); | |
| 1271 | 1272 | } | |
| ⋯ | |||
| 1297 | 1298 | return computer.reset(agentId); | |
| 1298 | 1299 | case "computer_forget": | |
| 1299 | 1300 | return computer.forget(agentId); | |
| 1301 | + | case "computer_keep": | |
| 1302 | + | return computer.keep(agentId); | |
| 1303 | + | case "computer_erase": | |
| 1304 | + | return computer.eraseNow(agentId); | |
| 1300 | 1305 | default: | |
| 1301 | 1306 | return fail("not_found", `There is no method called ${method}.`); | |
| 1302 | 1307 | } | |