Skip to content

Commit 39489d8

Browse files
authored
Let a Bot at its own endpoint hand work to another Bot (#718)
* Let a Bot at its own endpoint hand work to another Bot The grant route already accepted a remote grantee, and remote Bots call back for message_bot through the same coordination tools and handoff desk as built-in Bots. But botsReachableFrom kept only built-in grantees, so a remote Bot was never offered message_bot and a call it made anyway was refused as not granted. agentRunsHere did the same for the handoff panel, which hid the switches. botsReachableFrom now counts a grant whatever the grantee's type, and agentRunsHere becomes agentCanHandOn, true for any Bot that exists. * Clean up queued hops by the suite's actor in the handoff integration tests The handoff suites deleted work items by a key prefix, but a hop's key is hop:<hash>:<hash>, so nothing was deleted and every hop a suite sent stayed queued. The end-to-end suite's sweep claims any queued hop, so it delivered other suites' hops and its own assertions failed whenever it ran after them. Each suite now deletes the hops its own actor queued.
1 parent 4a20750 commit 39489d8

11 files changed

Lines changed: 256 additions & 43 deletions

‎CHANGELOG.md‎

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

99
## Unreleased
1010

11+
### A coworker at its own endpoint can hand work to another Bot
12+
13+
A grant letting a remote Bot (a coworker at its own endpoint) hand work to another Bot was accepted and stored, but the grant read kept only built-in Bots, so the remote Bot was never
14+
offered `message_bot` and a call it made anyway was refused as not granted. The read now counts a
15+
grant whatever the Bot's type, so a remote Bot hands work on through the same signed callback, grant
16+
check, caps and audit rows as a built-in one, to a built-in Bot or to another remote Bot. The
17+
coworker's handoff panel now offers it the same switches.
18+
1119
### A channel cursor with a malformed time reads as the first page, not a 500
1220

1321
`GET /api/channels` only checked that a cursor's time was a string before casting it with

‎app/src/components/agents/handoff-panel.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ export function HandoffPanel({ agentId }: { agentId: string }) {
106106
<ItemTitle>This coworker cannot hand work on</ItemTitle>
107107
{/* Unclamped for the same reason as above: three lines, and the third is the useful one. */}
108108
<ItemDescription className="line-clamp-none">
109-
Handing work on is a tool that runs inside this deployment's own
110-
loop, and this coworker runs as its own agent — so there is
111-
nothing to grant it. It can still be asked by Bots that can.
109+
This deployment has nowhere to record which Bots it may hand work
110+
to, so there is nothing to grant it. It can still be asked by Bots
111+
that can.
112112
</ItemDescription>
113113
</ItemContent>
114114
</Item>

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,8 @@ export type AgentRun = {
261261
* do-not-fabricate instruction. Copy it and you get a coworker with the name, the title, the avatar,
262262
* and none of that.
263263
*
264-
* The type is carried too, not only the configuration. A copy written as `remote_ag_ui` also cannot
265-
* be granted handoff for the rest of its life: `agentRunsHere` and `botsReachableFrom` both key on
266-
* `agents.type == "built_in"`, so the original may hand work on and its copy silently may not.
264+
* The type is carried too, not only the configuration, so a copy runs the same way its original
265+
* does.
267266
*
268267
* `null` means there is nothing to run this copy on, which the caller turns into
269268
* {@link ManagedAgentUnavailableError}. That can now only happen for a source that had neither an

‎server/src/agents/routes.ts‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,11 @@ export function createAgentRoutes(
264264
/** The Bots this one may address today, read per call so a revoked grant stops showing. */
265265
reachableFrom: (agentId: string) => Promise<readonly string[]>;
266266
/**
267-
* Whether this Bot can be a grantee at all — the handing-on tool executes inside this
268-
* deployment's own run loop, so only a Bot that runs in it can be offered one. Exposed so the
269-
* screen can say that once, instead of letting every switch fail with the same refusal.
270-
* Optional so a caller without a plugin store answers "no" rather than crashing the read.
267+
* Whether this Bot can be a grantee at all: any Bot that exists, built in or at its own
268+
* endpoint, since both reach the same handoff desk. Optional so a caller without a plugin store
269+
* answers "no" rather than crashing the read.
271270
*/
272-
runsHere?: (agentId: string) => Promise<boolean | undefined>;
271+
canHandOn?: (agentId: string) => Promise<boolean | undefined>;
273272
},
274273
/**
275274
* Whether a coworker can run on this deployment's own Bot, i.e. be created with no endpoint.
@@ -720,8 +719,8 @@ export function createAgentRoutes(
720719
reachable: handoff ? await handoff.reachableFrom(agentId) : [],
721720
// Whether this Bot can hold such a grant at all; the write path refuses one that cannot,
722721
// and the screen should say so before a person flips switches that can only bounce.
723-
grantable: handoff?.runsHere
724-
? ((await handoff.runsHere(agentId)) ?? false)
722+
grantable: handoff?.canHandOn
723+
? ((await handoff.canHandOn(agentId)) ?? false)
725724
: false,
726725
},
727726
});

‎server/src/app.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1308,7 +1308,7 @@ export function createApp(
13081308
reachableFrom: (agentId) =>
13091309
pluginStore.botsReachableFrom(agentId),
13101310
// The same answer the write path checks, read up front so the screen can say it once.
1311-
runsHere: (agentId) => pluginStore.agentRunsHere(agentId),
1311+
canHandOn: (agentId) => pluginStore.agentCanHandOn(agentId),
13121312
}
13131313
: undefined,
13141314
// Whether "built-in" is a kind of coworker this deployment can actually make: the create

‎server/src/plugins/store.ts‎

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4230,22 +4230,21 @@ export function createPluginStore(options: PluginStoreOptions) {
42304230
* "may this person put their skill on that Bot", and a whole profile is more than that needs.
42314231
*/
42324232
/**
4233-
* Whether this Bot's run happens in this process, rather than at an endpoint somewhere.
4233+
* Whether this Bot can be granted handing work on, or `undefined` if there is no such Bot.
42344234
*
4235-
* Undefined for a Bot nobody has heard of. Asked because a tool this deployment executes can
4236-
* only be offered to a run it builds: a Bot at an endpoint runs its own loop and is handed
4237-
* descriptions of what it may call back for, and handing work to another Bot is not one of them.
4235+
* Any Bot that exists can: a built-in Bot runs `message_bot` in this process, and a Bot at its
4236+
* own endpoint calls back for it through the signed tool callback, which reaches the same desk.
42384237
*/
4239-
async agentRunsHere(agentId: string): Promise<boolean | undefined> {
4238+
async agentCanHandOn(agentId: string): Promise<boolean | undefined> {
42404239
const [row] = await database
4241-
.select({ type: agents.type })
4240+
.select({ id: agents.id })
42424241
.from(agents)
42434242
.innerJoin(agentProfiles, eq(agentProfiles.agentId, agents.id))
42444243
// A deleted Bot is not one anybody may be given, and answering about it at all would say it
42454244
// had existed.
42464245
.where(and(eq(agents.id, agentId), isNull(agentProfiles.deletedAt)))
42474246
.limit(1);
4248-
return row ? row.type === "built_in" : undefined;
4247+
return row ? true : undefined;
42494248
},
42504249

42514250
/**
@@ -4418,29 +4417,23 @@ export function createPluginStore(options: PluginStoreOptions) {
44184417
* is a single indexed read, which is the right price for that.
44194418
*/
44204419
/**
4421-
* The Bots this one may hand work to, and can actually reach.
4420+
* The Bots this one may hand work to.
44224421
*
4423-
* FILTERED AT READ TIME, not only when the grant is made. Refusing a new grant to a Bot that
4424-
* runs at its own endpoint stops one being created; it does nothing about the ones already
4425-
* there, or about a Bot that was built in when it was granted and was pointed at an endpoint
4426-
* afterwards. Those rows read as configured and are inert, which is the shape of thing an
4427-
* administrator debugs for an afternoon: the grant is right there in the table and no hop ever
4428-
* happens.
4422+
* Whatever the grantee's type. A Bot at its own endpoint is offered `message_bot` as a tool it
4423+
* calls back for, and the call reaches the same handoff desk, grant check and caps as a built-in
4424+
* Bot's (see `createCoordinationTools`). This used to keep only built-in grantees, from before
4425+
* remote Bots could call back for coordination, which left every remote Bot's grant reading as
4426+
* configured while no hop ever happened.
44294427
*
4430-
* The asking side is the one that matters here — a Bot at an endpoint runs its own loop and is
4431-
* never offered this tool — so it is the grantee, `agent_id`, that is checked.
4428+
* The join keeps a grant held by a Bot that no longer exists from counting.
44324429
*/
44334430
async botsReachableFrom(agentId: string): Promise<string[]> {
44344431
const rows = await database
44354432
.select({ ref: pluginGrants.ref })
44364433
.from(pluginGrants)
44374434
.innerJoin(agents, eq(agents.id, pluginGrants.agentId))
44384435
.where(
4439-
and(
4440-
eq(pluginGrants.kind, "bot"),
4441-
eq(pluginGrants.agentId, agentId),
4442-
eq(agents.type, "built_in"),
4443-
),
4436+
and(eq(pluginGrants.kind, "bot"), eq(pluginGrants.agentId, agentId)),
44444437
);
44454438
return rows.map((row) => row.ref);
44464439
},

‎server/tests/agent-handoff-endtoend.integration.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
22
import { randomUUID } from "node:crypto";
3-
import { and, eq, like } from "drizzle-orm";
3+
import { and, eq, sql } from "drizzle-orm";
44
import { createHandoffDesk } from "../src/agents/handoff";
55
import {
66
createHandoffRunner,
@@ -67,7 +67,11 @@ const desk = createHandoffDesk({
6767
});
6868

6969
async function clean() {
70-
await database.delete(workItems).where(like(workItems.key, `${RUN}%`));
70+
// By the suite's own actor, which every hop's payload carries. A hop's key is `hop:<hash>:<hash>`,
71+
// so a key prefix matches nothing, and a hop left queued is claimed by the next suite's sweep.
72+
await database
73+
.delete(workItems)
74+
.where(sql`${workItems.payload}->>'actorId' = ${ACTOR}`);
7175
for (const id of [ASKER, TARGET]) {
7276
await database.delete(pluginGrants).where(eq(pluginGrants.agentId, id));
7377
await database.delete(agentProfiles).where(eq(agentProfiles.agentId, id));

‎server/tests/agent-handoff.integration.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
22
import { randomUUID } from "node:crypto";
3-
import { and, eq, like } from "drizzle-orm";
3+
import { and, eq, sql } from "drizzle-orm";
44
import { createHandoffDesk, HANDOFF_KIND } from "../src/agents/handoff";
55
import { createAgentProfileStore } from "../src/agents/profile-store";
66
import { createAuditStore } from "../src/audit";
@@ -53,7 +53,11 @@ const desk = createHandoffDesk({
5353
});
5454

5555
async function clean() {
56-
await database.delete(workItems).where(like(workItems.key, `run-${suite}%`));
56+
// By the suite's own actor, which every hop's payload carries. A hop's key is `hop:<hash>:<hash>`,
57+
// so a key prefix matches nothing, and a hop left queued is claimed by the next suite's sweep.
58+
await database
59+
.delete(workItems)
60+
.where(sql`${workItems.payload}->>'actorId' = ${ACTOR}`);
5761
for (const id of [ASKER, TARGET]) {
5862
await database.delete(pluginGrants).where(eq(pluginGrants.agentId, id));
5963
await database.delete(agentProfiles).where(eq(agentProfiles.agentId, id));

‎server/tests/agent-routes.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ describe("which Bots a Bot may hand work to", () => {
698698
enabled: true,
699699
canGrant: true,
700700
reachable: ["knowledge"],
701-
// No runsHere reader was wired, and a Bot nothing can vouch for is not offered grants.
701+
// No canHandOn reader was wired, and a Bot nothing can vouch for is not offered grants.
702702
grantable: false,
703703
});
704704
});

‎server/tests/plugin-routes.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,10 @@ describe("refreshing a server that cannot be resolved", () => {
377377
*/
378378
function grantsApp(
379379
role: "admin" | "user" = "admin",
380-
runsHere: (agentId: string) => boolean | undefined = (agentId) => {
380+
canHandOn: (agentId: string) => boolean | undefined = (agentId) => {
381381
// Undefined is "no such Bot", which is what the store answers for one nobody registered.
382382
if (agentId === "never-registered") return undefined;
383-
return agentId !== "at-an-endpoint";
383+
return true;
384384
},
385385
) {
386386
/**
@@ -416,7 +416,7 @@ function grantsApp(
416416
},
417417
skillOwner: async () => null,
418418
agentOwner: async () => null,
419-
agentRunsHere: async (agentId: string) => runsHere(agentId),
419+
agentCanHandOn: async (agentId: string) => canHandOn(agentId),
420420
agentIsRegistered: async (agentId: string) =>
421421
agentId !== "never-registered",
422422
});

0 commit comments

Comments
 (0)