Skip to content

Commit aa625f7

Browse files
committed
Make a coworker in a conversation, and grant it nothing
Making a coworker means answering a form, and the field that decides everything is the one a form is worst at. `role_description` is not a label: with no endpoint it becomes the standing instruction handed to a model on every turn in every channel that coworker is in. People write a sentence, get a coworker that answers vaguely, and never go back. The three questions that make one useful are the ones nobody answers unprompted — what it must never conclude, what it says when the evidence is thin, and whether something here already does the job. A form cannot ask a follow-up. A conversation can. So the deployment ships a `bot-creator` skill whose instruction is how to run that interview, and a Bot holding it is offered four tools: `list_bots`, `read_bot`, `list_bot_skills` and `save_bot`. The grant is the gate rather than a flag — four more tools on every run costs the per-run narrowing more than it buys, and a Bot for looking up transactions has no business making coworkers. The tools ride the signed-in session from the browser, so `POST /api/agents` answers who may and writes `bot.created` with the actor on it, unchanged. A server-side tool would have to carry an actor into runs that have none — a routine, a Slack thread, a schedule — and the first way that goes wrong is a coworker created under a name nobody chose. The card is the whole tool; there is no handler behind it. It shows the entire role description, unclipped, because that text will run on somebody's behalf and a clamp would hide half of what they are agreeing to. It grants skills and nothing else: no address, no connector, no tool, no boundary, no visibility. A card that could grant capability would make asking a Bot the fastest route around the screens that decide it. The create path grows a system prompt, because without it the interview would have to end by asking somebody for a URL. `store.create` writes a `built_in` coworker on its role description where there is neither an endpoint nor a Bot in the box — which on the one-container image is every coworker — and the route passes that text when no endpoint was given. `store.update` had to move with it, and this is the half worth reading. A `built_in` coworker is given its `configuration.systemPrompt` and no standing role message, so that column is the whole of what it is ever told and `agentProfiles.roleDescription` never reaches it. Update wrote only the profile, so editing such a coworker changed what every screen showed and nothing the Bot followed — permanently, silently. It now carries the text into the prompt for `built_in` rows and only those: a remote Bot must not acquire a prompt it never had, and the package's own Bots, whose `system_prompt` is deliberately not their `role_description`, cannot reach `update` at all because `requireManageable` refuses anything the package owns. Two defects found by using it, both about state rather than logic. The first conversation answered "No skills exist here yet" to a deployment holding nine, because the handler read a query that had not loaded; an empty list and an unloaded list were sharing a code path, and the Bot repeated the tidier one as fact. The answers now fetch when asked, from a module a test calls against a cold client. And the link on a completed card lived in component state, so it would have drawn once and stopped for anybody who reloaded — a card is re-rendered from the transcript with the arguments and the answer and nothing else. The coworker's id travels in the answer now, read back by a function paired with the sentence. Verification: 2167 pass, 23 skip, 0 fail across 179 files; format, lint and typecheck clean. The card test follows this repository's DOM harness — register in `beforeAll`, unregister in `afterAll`, queries off `render()` — because a file that registers at module scope and queries the global `screen` passes alone and fails every case in a full run.
1 parent 257c128 commit aa625f7

15 files changed

Lines changed: 1681 additions & 31 deletions
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { Link } from "@tanstack/react-router";
2+
import { useEffect, useRef, useState } from "react";
3+
import { Badge, GalleryFrame } from "@/components/gallery/frame";
4+
import { Button } from "@/components/ui/button";
5+
import type { AgentFormValues } from "@/lib/agents/form";
6+
import {
7+
botCardAnswer,
8+
checkProposal,
9+
createdBotIdIn,
10+
type ProposedBot,
11+
wasCreated,
12+
} from "@/lib/agents/proposal";
13+
14+
/**
15+
* The coworker a Bot has written, put in front of the person before it exists.
16+
*
17+
* WHY THERE IS A CARD AT ALL. A coworker is not a document: the role description below becomes a
18+
* standing instruction handed to a model on every turn in every channel it is in, and it was written
19+
* by something that was told about the job second-hand. So the same rule the template consent screen
20+
* follows applies here — the whole of that text is shown, unabridged and unclipped, before anything
21+
* is created, because a person cannot agree to instructions they were not shown.
22+
*
23+
* The whole tool is this card. There is no handler behind it: the run suspends here, and nothing is
24+
* written until a button is pressed.
25+
*
26+
* WHAT IT DELIBERATELY CANNOT DO. No address, no connector, no tool, no boundary and no visibility
27+
* change. The coworker arrives private, holding whatever skills were agreed and nothing else, and
28+
* everything it might reach is granted afterwards on its profile by somebody who may. A card that
29+
* could grant capability would make "ask the Bot for it" the fastest route around the screens that
30+
* exist to decide it.
31+
*/
32+
33+
/** What creating actually did, since creating and granting are two calls and one can fail alone. */
34+
export type CreatedBot = {
35+
agentId: string;
36+
name: string;
37+
granted: string[];
38+
failed: string[];
39+
};
40+
41+
export type ProposedBotCardProps = {
42+
/** Partial while the model is still streaming the arguments. */
43+
args: Partial<ProposedBot>;
44+
/**
45+
* A coworker already here whose name this one reuses, when there is one.
46+
*
47+
* Drives the wording and nothing else. Names do not have to be unique, so this is not a refusal —
48+
* it is the fact somebody needs in order to notice they are about to end up with two Renewal Desks
49+
* on one roster and no way to tell them apart in a channel list.
50+
*/
51+
clashes?: { title: string; ownership: string };
52+
/** Answering resumes the Bot. Absent while streaming, and once this card has been answered. */
53+
respond?: (result: unknown) => Promise<void>;
54+
/** The recorded answer, once there is one. Completed cards show it instead of controls. */
55+
result?: string;
56+
create: (values: AgentFormValues, skills: string[]) => Promise<CreatedBot>;
57+
};
58+
59+
export function ProposedBotCard({
60+
args,
61+
clashes,
62+
respond,
63+
result,
64+
create,
65+
}: ProposedBotCardProps) {
66+
const [sending, setSending] = useState<"create" | "decline" | null>(null);
67+
/**
68+
* A refusal from the server, kept on the card rather than answered with.
69+
*
70+
* The run stays suspended, because both things worth doing next need it to be — pressing again
71+
* after whatever the refusal named has been dealt with, or declining. Answering the tool with the
72+
* error would end the turn and leave the person retyping their request.
73+
*/
74+
const [refusal, setRefusal] = useState<string | null>(null);
75+
76+
if (result !== undefined) {
77+
/*
78+
* The coworker read back out of the answer rather than out of state. This card is re-rendered
79+
* from the transcript on every reload, when whatever this component remembered is gone; the id
80+
* is the server's, so unlike a skill's slug it is not in the arguments either. See
81+
* `createdBotIdIn`.
82+
*/
83+
const madeId = wasCreated(result) ? createdBotIdIn(result) : null;
84+
return (
85+
<GalleryFrame
86+
action={<Badge tone="neutral">Done</Badge>}
87+
title={titleFor(args.name, clashes)}
88+
>
89+
<p className="text-sm">{result}</p>
90+
{madeId ? <TalkToIt agentId={madeId} name={args.name} /> : null}
91+
</GalleryFrame>
92+
);
93+
}
94+
95+
if (!respond) {
96+
return (
97+
<GalleryFrame title={titleFor(args.name, clashes)}>
98+
<p className="text-muted-foreground text-sm">Writing the coworker…</p>
99+
</GalleryFrame>
100+
);
101+
}
102+
103+
const checked = checkProposal(args);
104+
if (!checked.ok) {
105+
return <Unwritable problems={checked.problems} respond={respond} />;
106+
}
107+
const { values, skills } = checked;
108+
109+
const make = async () => {
110+
setSending("create");
111+
setRefusal(null);
112+
let outcome: CreatedBot;
113+
try {
114+
outcome = await create(values, skills);
115+
} catch (cause) {
116+
// Left on the card with the buttons still live. See `refusal` above.
117+
setSending(null);
118+
setRefusal(
119+
cause instanceof Error
120+
? cause.message
121+
: "The coworker was not created.",
122+
);
123+
return;
124+
}
125+
/*
126+
* Pressing again is not offered from here on: the coworker exists, and a second press would make
127+
* a second one. The answer carries its id, which is what the completed card links to.
128+
*/
129+
await answer(respond, botCardAnswer.created(outcome));
130+
};
131+
132+
const decline = async () => {
133+
setSending("decline");
134+
await answer(respond, botCardAnswer.declined());
135+
};
136+
137+
return (
138+
<GalleryFrame
139+
action={<Badge tone="caution">Waiting on you</Badge>}
140+
caption={
141+
clashes
142+
? `There is already a ${clashes.title} here, which is ${clashes.ownership}. Names do not have to be unique, but two with the same name are hard to tell apart in a channel list.`
143+
: "Nothing is created until you press the button."
144+
}
145+
title={titleFor(values.name, clashes)}
146+
>
147+
<dl className="grid grid-cols-[minmax(0,7rem)_1fr] gap-x-4 gap-y-1.5 text-sm">
148+
<dt className="truncate text-muted-foreground">Name</dt>
149+
<dd className="min-w-0 break-words font-medium">{values.name}</dd>
150+
<dt className="truncate text-muted-foreground">Job</dt>
151+
<dd className="min-w-0 break-words">{values.title}</dd>
152+
<dt className="truncate text-muted-foreground">Skills</dt>
153+
<dd className="min-w-0 break-words">
154+
{skills.length > 0 ? (
155+
<span className="font-mono text-xs">
156+
{skills.map((slug) => `/${slug}`).join(", ")}
157+
</span>
158+
) : (
159+
<span className="text-muted-foreground">None</span>
160+
)}
161+
</dd>
162+
</dl>
163+
164+
<p className="mt-3 text-muted-foreground text-xs">
165+
Its standing instructions. This text is given to a model on every turn.
166+
</p>
167+
{/*
168+
* Scrolled rather than clamped, and the distinction is the whole point of the card. A clamp
169+
* hides the second half of an instruction that will run on somebody's behalf from the one
170+
* person being asked to agree to it; a scroller keeps every character reachable.
171+
*/}
172+
<p className="mt-1 max-h-56 overflow-y-auto whitespace-pre-wrap rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
173+
{values.roleDescription}
174+
</p>
175+
176+
<p className="mt-2 text-muted-foreground text-xs">
177+
It arrives private, with no address, no connector and no tool. Anything
178+
it needs to reach is granted on its profile afterwards.
179+
</p>
180+
181+
{refusal ? (
182+
<p className="mt-3 text-destructive text-sm" role="alert">
183+
{refusal}
184+
</p>
185+
) : null}
186+
187+
<div className="mt-4 flex gap-2">
188+
<Button
189+
disabled={Boolean(sending)}
190+
onClick={() => void make()}
191+
size="sm"
192+
>
193+
{sending === "create" ? "Creating…" : "Create it"}
194+
</Button>
195+
<Button
196+
disabled={Boolean(sending)}
197+
onClick={() => void decline()}
198+
size="sm"
199+
variant="outline"
200+
>
201+
{sending === "decline" ? "…" : "Don't create"}
202+
</Button>
203+
</div>
204+
</GalleryFrame>
205+
);
206+
}
207+
208+
function titleFor(
209+
name: string | undefined,
210+
clashes: { title: string } | undefined,
211+
): string {
212+
const called = name?.trim() ? name.trim() : "a new coworker";
213+
return clashes ? `Create ${called}, again` : `Create ${called}`;
214+
}
215+
216+
/**
217+
* Where the next step happens.
218+
*
219+
* The profile rather than a channel, because it is the screen that answers both remaining questions
220+
* — what this coworker may reach, and whether anybody else can see it — and it carries the control
221+
* that opens a conversation.
222+
*/
223+
function TalkToIt({
224+
agentId,
225+
name,
226+
}: {
227+
agentId: string;
228+
name: string | undefined;
229+
}) {
230+
return (
231+
<Link
232+
className="mt-2 inline-block text-sm underline underline-offset-4"
233+
search={{ agent: agentId }}
234+
to="/agents"
235+
>
236+
Open {name?.trim() || "the coworker"}
237+
</Link>
238+
);
239+
}
240+
241+
/**
242+
* A proposal that cannot be created as written.
243+
*
244+
* Answered rather than shown as a question, because there is nothing for the person to decide: the
245+
* fields are wrong in a way the model can fix, and the problems name their field so it can. The card
246+
* still appears, so the transcript has no silent gap where a coworker was nearly made.
247+
*/
248+
function Unwritable({
249+
problems,
250+
respond,
251+
}: {
252+
problems: string[];
253+
respond: (result: unknown) => Promise<void>;
254+
}) {
255+
/**
256+
* Answered once, guarded by a ref rather than by the dependency list.
257+
*
258+
* `problems` is rebuilt from the arguments on every render and the grant queries behind this card
259+
* poll, so a value-equal array arrives as a new identity every few seconds. Left to the deps, this
260+
* would answer the same tool call again on each of them.
261+
*/
262+
const answered = useRef(false);
263+
useEffect(() => {
264+
if (answered.current) return;
265+
answered.current = true;
266+
void answer(respond, botCardAnswer.unwritable(problems));
267+
}, [problems, respond]);
268+
269+
return (
270+
<GalleryFrame
271+
action={<Badge tone="neutral">Not created</Badge>}
272+
title="Create a coworker"
273+
>
274+
<p className="text-sm">
275+
The coworker could not be created as written, so nothing was asked of
276+
you. It is being redrafted.
277+
</p>
278+
</GalleryFrame>
279+
);
280+
}
281+
282+
/**
283+
* Answering, with a failure to answer swallowed deliberately.
284+
*
285+
* The only way `respond` rejects is a run that has already ended — a reload, a stop, a card answered
286+
* in another tab — and in every one of those the write has already happened or already not happened.
287+
* Throwing here would surface a React error over a decision the person has finished making.
288+
*/
289+
async function answer(
290+
respond: (result: unknown) => Promise<void>,
291+
text: string,
292+
): Promise<void> {
293+
try {
294+
await respond(text);
295+
} catch {
296+
// The run this card belonged to is gone. Nothing further to do.
297+
}
298+
}

app/src/lib/agents/answers.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { QueryClient } from "@tanstack/react-query";
2+
import { pluginsPageQueryOptions } from "@/lib/plugins/queries";
3+
import { describeBot, describeBots, describeGrantableSkills } from "./proposal";
4+
import { agentListQueryOptions } from "./queries";
5+
6+
/**
7+
* What the three reading tools answer with, fetched at the moment they are asked.
8+
*
9+
* THE BUG THIS SHAPE EXISTS TO PREVENT, which the very first conversation with this feature
10+
* produced. The obvious wiring is a `useQuery` in the component and handlers closing over
11+
* `data ?? []`. A run starts the moment somebody sends a message, and the model called
12+
* `list_bot_skills` before that query had come back — so the handler read an empty array and
13+
* answered "No skills exist here yet", which the Bot then told the person as a fact about their
14+
* deployment. Nine skills existed.
15+
*
16+
* An empty list and an unloaded list are different answers and must not share a code path.
17+
* `ensureQueryData` returns what is cached when there is something cached and fetches when there is
18+
* not, so a tool called in the first second of a run waits for the truth rather than inventing a
19+
* tidier one — and a run that never calls these pays for neither.
20+
*
21+
* They live here rather than inline in the hooks so that a test can call them cold, against a query
22+
* client nothing has rendered, which is precisely the state the bug needed.
23+
*/
24+
25+
export async function answerListBots(client: QueryClient): Promise<string> {
26+
return describeBots(await client.ensureQueryData(agentListQueryOptions()));
27+
}
28+
29+
export async function answerReadBot(
30+
client: QueryClient,
31+
name: string,
32+
): Promise<string> {
33+
const wanted = name.trim().toLowerCase();
34+
const known = await client.ensureQueryData(agentListQueryOptions());
35+
const agent = known.find(
36+
(candidate) => candidate.name.toLowerCase() === wanted,
37+
);
38+
if (!agent) {
39+
return `There is no coworker called ${name} that this person can see. Call list_bots for the ones there are.`;
40+
}
41+
return describeBot(agent);
42+
}
43+
44+
export async function answerListBotSkills(
45+
client: QueryClient,
46+
): Promise<string> {
47+
const page = await client.ensureQueryData(pluginsPageQueryOptions());
48+
return describeGrantableSkills(page.skills);
49+
}

0 commit comments

Comments
 (0)