Skip to content

Commit 65dabc3

Browse files
kevin9327claudedavidmckayv
authored
Give a duplicated Bot in the box the instructions it was copied from (#355)
#328 fixed the half of duplicate that a coworker with its own endpoint saw. This is the other half. A coworker that runs on this deployment's own Bot has no endpoint. Its configuration is `{ systemPrompt }`, and that prompt is the whole of what makes it that coworker. Duplicate rebuilt every copy from `source.endpoint` alone and wrote `type: "remote_ag_ui"` flat, so for one of these the endpoint read came back null, the copy fell through to the managed Bot, and the prompt was dropped on the floor. What comes back is the failure this repository already has a paragraph about. The copy looks identical on every screen and its entire instruction becomes standingRoleMessage - see the note above that function in copilot.ts, which names the compliance Bot that answered a filing question with invented thresholds because one sentence of role description was all that reached it. The default tenant package ships two built_in coworkers, General Assistant and Knowledge, and Knowledge's prompt is a careful do-not-fabricate instruction with the reasoning written out beside it in the package. Duplicate it and you get a coworker with the name, the title, the avatar, and none of that. The type is carried now, not only the configuration. A copy written as remote_ag_ui also could not be granted handoff for the rest of its life: agentRunsHere and botsReachableFrom both key on agents.type == "built_in", so the original could hand work on and its copy silently could not. And a built_in source no longer needs a managed Bot to fall back to. On a deployment with none, copying one used to be refused with advice to give the coworker an AG-UI endpoint it was never supposed to have - the same wrong refusal #328 removed for a coworker that brought its own endpoint. The decision is a pure function so it can be tested without a database. The stored row is read inside the transaction and after the access check, rather than widening AgentProfile: the DTO every surface receives should not start carrying a Bot's instructions. `auth` is still deliberately not copied. Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: David McKay <david@copilotkit.ai>
1 parent 6260b50 commit 65dabc3

3 files changed

Lines changed: 228 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### Duplicating a Bot in the box keeps its instructions
12+
13+
A coworker that runs on this deployment's own Bot has no endpoint — it has a prompt, which is the
14+
whole of what makes it that coworker. Duplicate rebuilt every copy from the endpoint alone, found
15+
none, and fell back to the managed Bot with the prompt dropped, so the copy carried the name, the
16+
title, the role and the avatar and none of the instructions. Its entire instruction became the one
17+
sentence of role description, which is the shape behind the compliance answer this repository
18+
already has a note about. The two coworkers the default package ships are both of this kind, and one
19+
of them is a careful do-not-fabricate instruction. A copy now keeps the prompt and stays a Bot in the
20+
box, which also means it can still be granted the right to hand work on — written as a hosted
21+
coworker it could never hold that grant, however the original was set up — and copying one no longer
22+
needs a managed Bot to fall back to.
1123
### Hiding a coworker no longer hides the grants pointing at it
1224

1325
Hiding a coworker is a preference about your own roster — one row per person — and the grants saying

‎server/src/agents/profile-store.ts‎

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,74 @@ function endpointOf(configuration: unknown): string | null {
183183
return typeof endpoint === "string" ? endpoint : null;
184184
}
185185

186+
/**
187+
* The instruction a Bot in the box runs on, read back out of its stored configuration.
188+
*
189+
* The mirror of {@link endpointOf}, and needed for the same reason: a copy has to be made of what
190+
* the original actually was, and for a `built_in` coworker the prompt IS the coworker. Trimmed and
191+
* required to be non-empty, matching `registeredAgentFromRow`, which will not build a Bot from a
192+
* blank one either.
193+
*/
194+
function systemPromptOf(configuration: unknown): string | null {
195+
if (!configuration || typeof configuration !== "object") return null;
196+
const prompt = (configuration as { systemPrompt?: unknown }).systemPrompt;
197+
if (typeof prompt !== "string") return null;
198+
const trimmed = prompt.trim();
199+
return trimmed.length > 0 ? trimmed : null;
200+
}
201+
202+
/** What a coworker is, and what it runs on: the two `agents` columns a copy has to reproduce. */
203+
export type AgentRun = {
204+
type: "built_in" | "remote_ag_ui";
205+
configuration: Record<string, unknown>;
206+
};
207+
208+
/**
209+
* What a duplicate runs on, decided from what the original ran on.
210+
*
211+
* WHY THIS IS NOT JUST THE ENDPOINT. Duplicate used to rebuild the copy from `source.endpoint` alone
212+
* and write `type: "remote_ag_ui"` flat. #328 fixed the half of that a coworker with its own endpoint
213+
* saw. The other half is a coworker that has no endpoint because it is not supposed to have one: a
214+
* `built_in` Bot's configuration is `{ systemPrompt }`, so the endpoint read came back null, the copy
215+
* fell through to the managed Bot, and the prompt was dropped on the floor.
216+
*
217+
* That copy is the failure this repository already has a paragraph about. It looks identical on every
218+
* screen and its whole instruction becomes `standingRoleMessage` — see the note above that function
219+
* in `copilot.ts`, which names the compliance Bot that answered a filing question with invented
220+
* thresholds because one sentence of role description was all that reached it. The default tenant
221+
* package ships two `built_in` coworkers, and one of them, `Knowledge`, is a careful
222+
* do-not-fabricate instruction. Copy it and you get a coworker with the name, the title, the avatar,
223+
* and none of that.
224+
*
225+
* The type is carried too, not only the configuration. A copy written as `remote_ag_ui` also cannot
226+
* be granted handoff for the rest of its life: `agentRunsHere` and `botsReachableFrom` both key on
227+
* `agents.type == "built_in"`, so the original may hand work on and its copy silently may not.
228+
*
229+
* `null` means there is nothing to run this copy on, which the caller turns into
230+
* {@link ManagedAgentUnavailableError}. That can now only happen for a source that had neither an
231+
* endpoint nor a prompt on a deployment with no managed Bot — never for a `built_in` source, which
232+
* brings its own instruction and needs no managed Bot to fall back to.
233+
*
234+
* `auth` is deliberately not carried: it is a reference into the vault, and two coworkers sharing one
235+
* credential would mean rotating either one's key silently changed the other's.
236+
*/
237+
export function runForDuplicate(
238+
source: { type: "built_in" | "remote_ag_ui"; configuration: unknown },
239+
managed: Record<string, unknown> | undefined,
240+
): AgentRun | null {
241+
const systemPrompt = systemPromptOf(source.configuration);
242+
if (source.type === "built_in" && systemPrompt) {
243+
return { type: "built_in", configuration: { systemPrompt } };
244+
}
245+
246+
const endpoint = endpointOf(source.configuration);
247+
if (endpoint) {
248+
return { type: "remote_ag_ui", configuration: { endpoint } };
249+
}
250+
251+
return managed ? { type: "remote_ag_ui", configuration: managed } : null;
252+
}
253+
186254
async function findAccessibleProfile(
187255
executor: DatabaseExecutor,
188256
actor: AgentActor,
@@ -437,20 +505,35 @@ export function createAgentProfileStore(
437505
const source = await findAccessibleProfile(transaction, actor, id);
438506
if (!source) throw new AgentNotFoundError(id);
439507

440-
// The endpoint alone: `auth` is a vault reference, and copying it shares one credential.
441-
const configuration = source.endpoint
442-
? { endpoint: source.endpoint }
443-
: managedConfiguration;
444-
// After the source read, so a source with its own endpoint needs no managed Bot to fall back to.
445-
if (!configuration) {
508+
/*
509+
* The stored row, because a profile does not carry what a copy has to reproduce.
510+
*
511+
* `AgentProfile` projects `endpoint` out of the configuration and nothing else, which is all
512+
* an edit form needs and half of what this needs: a `built_in` coworker has no endpoint and
513+
* a prompt instead. Read here rather than widened into the profile, so the DTO every surface
514+
* gets does not start carrying a Bot's instructions. Inside the transaction, and after the
515+
* access check, so this cannot read a row the caller may not see.
516+
*/
517+
const [stored] = await transaction
518+
.select({ type: agents.type, configuration: agents.configuration })
519+
.from(agents)
520+
.where(eq(agents.id, id))
521+
.limit(1);
522+
if (!stored) throw new AgentNotFoundError(id);
523+
524+
// `auth` is a vault reference and is deliberately not carried: see `runForDuplicate`.
525+
const run = runForDuplicate(stored, managedConfiguration);
526+
// After the source read, so a source that brings its own endpoint or its own prompt needs no
527+
// managed Bot to fall back to.
528+
if (!run) {
446529
throw new ManagedAgentUnavailableError();
447530
}
448531
const duplicateId = newAgentId();
449532
await transaction.insert(agents).values({
450533
id: duplicateId,
451534
name: source.name,
452-
type: "remote_ag_ui",
453-
configuration,
535+
type: run.type,
536+
configuration: run.configuration,
454537
});
455538
await transaction.insert(agentProfiles).values({
456539
agentId: duplicateId,

‎server/tests/duplicate-run.test.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { runForDuplicate } from "../src/agents/profile-store";
3+
4+
/**
5+
* What a duplicated coworker runs on.
6+
*
7+
* A pure decision, tested without a database, because the failure it exists to stop is silent: the
8+
* copy is created, appears on the roster, answers when asked, and is a different coworker from the
9+
* one that was copied.
10+
*/
11+
12+
const managed = { endpoint: "http://managed.invalid/ag-ui" };
13+
14+
describe("what a copy runs on", () => {
15+
/*
16+
* The bug. A Bot in the box keeps its whole instruction in `configuration.systemPrompt` and has no
17+
* endpoint at all, so an endpoint-only read came back empty, the copy fell through to the managed
18+
* Bot, and the prompt went nowhere. The default tenant package ships two of these.
19+
*/
20+
test("keeps a Bot-in-the-box's prompt, and stays a Bot in the box", () => {
21+
expect(
22+
runForDuplicate(
23+
{
24+
type: "built_in",
25+
configuration: { systemPrompt: "Never answer from memory." },
26+
},
27+
managed,
28+
),
29+
).toEqual({
30+
type: "built_in",
31+
configuration: { systemPrompt: "Never answer from memory." },
32+
});
33+
});
34+
35+
test("needs no managed Bot to copy one that brought its own prompt", () => {
36+
// The mirror of the endpoint case: a source that carries what it runs on does not fall back.
37+
expect(
38+
runForDuplicate(
39+
{ type: "built_in", configuration: { systemPrompt: "Be brief." } },
40+
undefined,
41+
),
42+
).toEqual({
43+
type: "built_in",
44+
configuration: { systemPrompt: "Be brief." },
45+
});
46+
});
47+
48+
test("keeps the endpoint a hosted coworker was copied from", () => {
49+
expect(
50+
runForDuplicate(
51+
{
52+
type: "remote_ag_ui",
53+
configuration: { endpoint: "https://theirs.invalid/ag-ui" },
54+
},
55+
managed,
56+
),
57+
).toEqual({
58+
type: "remote_ag_ui",
59+
configuration: { endpoint: "https://theirs.invalid/ag-ui" },
60+
});
61+
});
62+
63+
test("never carries the key: two coworkers must not share one credential", () => {
64+
expect(
65+
runForDuplicate(
66+
{
67+
type: "remote_ag_ui",
68+
configuration: {
69+
endpoint: "https://theirs.invalid/ag-ui",
70+
auth: { header: "Authorization", credentialId: "cred_1" },
71+
},
72+
},
73+
managed,
74+
),
75+
).toEqual({
76+
type: "remote_ag_ui",
77+
configuration: { endpoint: "https://theirs.invalid/ag-ui" },
78+
});
79+
});
80+
81+
test("falls back to the managed Bot only when the source runs on nothing of its own", () => {
82+
expect(
83+
runForDuplicate({ type: "built_in", configuration: {} }, managed),
84+
).toEqual({ type: "remote_ag_ui", configuration: managed });
85+
});
86+
87+
test("has nowhere to put a copy of a coworker with nothing, and no managed Bot", () => {
88+
// Null is what the caller turns into "give the coworker its own AG-UI endpoint". It must not be
89+
// reachable for a source that had a prompt, which is what the second case above pins.
90+
expect(
91+
runForDuplicate({ type: "remote_ag_ui", configuration: {} }, undefined),
92+
).toBeNull();
93+
});
94+
95+
test("treats a blank prompt as no prompt, the way the runtime does", () => {
96+
// `registeredAgentFromRow` refuses to build a Bot from a whitespace prompt, so copying one as
97+
// `built_in` would produce a coworker that cannot be built at all.
98+
expect(
99+
runForDuplicate(
100+
{ type: "built_in", configuration: { systemPrompt: " " } },
101+
managed,
102+
),
103+
).toEqual({ type: "remote_ag_ui", configuration: managed });
104+
});
105+
106+
test("does not read a prompt off a coworker that runs somewhere else", () => {
107+
// The type decides, not the presence of a key. A remote row carrying a stray `systemPrompt` is
108+
// still a remote Bot, and copying it as built-in would move it into this process.
109+
expect(
110+
runForDuplicate(
111+
{
112+
type: "remote_ag_ui",
113+
configuration: {
114+
endpoint: "https://theirs.invalid/ag-ui",
115+
systemPrompt: "ignored",
116+
},
117+
},
118+
managed,
119+
),
120+
).toEqual({
121+
type: "remote_ag_ui",
122+
configuration: { endpoint: "https://theirs.invalid/ag-ui" },
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)