Skip to content

Commit 2fd31d2

Browse files
committed
Make a coworker by talking to one, 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. Two changes to the create path come with it, because without them the interview would have to end by asking somebody for a URL. `store.create` accepts a system prompt and writes a `built_in` coworker when there is neither an endpoint nor a Bot in the box, and the route passes the role description when no endpoint was given. Every other path is untouched: give an address and it is a remote Bot, as before, and `update` cannot take a prompt at all — changing an existing Bot's type is a different act. 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: 2164 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 2fd31d2

14 files changed

Lines changed: 1543 additions & 30 deletions

File tree

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)