Every bot saw every builtin tool, and nothing outside the runtime's own declaration kept a run to the tools it was given: the executor ran any tool a runtime asked for, and the Pi runtime exposed all builtins whenever a caller passed an empty tool list, which the safety checker, the history summarizer and the team chat judge do on purpose. A specialist could not be kept to the tools of its lane, bots had no capability tags, and the main bot could only schedule routines for itself. - A bot can carry a builtin tool allowlist. Its runs see only the allowlisted builtins, and an empty allowlist means none. MAIN_ONLY_BUILTIN_TOOLS (empty until the user profile and delegation tools land) never reach other bots. applyTool refuses any tool the run did not expose, and an empty tool list now means no tools for the Pi runtime and its subagents. - A bot spawned by a restricted bot inherits the restriction and a duplicate keeps it, so neither path widens a bot's tools. - bots.create and bots.update accept tags, restrictBuiltinTools and allowedBuiltinTools; the router rejects names that are not builtin tools. bots.builtinTools lists the names a bot's allowlist can use. - Bot settings get a Tags field and a switch with a tool checklist under Advanced. - The main bot's schedule_create takes an optional bot: one of the user's bots in any space the user still belongs to. The routine belongs to that bot in its space and wakes in the main conversation when the bot is a member there, otherwise in its 1:1 thread. Routines record the bot that scheduled them (additive migration), so schedule_list and schedule_cancel also cover exactly the routines a bot scheduled for others. Cancelling by a name that matches several routines is refused. Tested with unit tests for the per-bot filter, the applyTool guard, the empty tool list, and on-behalf resolution, creation, listing and cancelling; with Postgres journeys for on-behalf routines waking across spaces and in the main conversation and for allowlist enforcement through the scripted runtime; and with a web E2E for the settings section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
import type { AgentRunRequest } from "@rakazo/adapter-kit";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
|
import { createRunExecutor, MAIN_ONLY_BUILTIN_TOOLS } from "./executor.js";
|
|
|
|
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof ComputerLifecycleModule>()),
|
|
acquireComputerExecutionLease: async () => null,
|
|
provisionComputer: async () => ({ id: "computer-1", kind: "desktop" }),
|
|
}));
|
|
|
|
type BotPolicy = { kind?: string; restrictBuiltinTools?: boolean; allowedBuiltinTools?: string[] };
|
|
type Call = { name: string; args?: Record<string, unknown> };
|
|
type Effect = { id: string; status: string; idempotencyKey: string } & Record<string, unknown>;
|
|
|
|
/** In-memory external effects, enough for the idempotency record of a mutating builtin tool. */
|
|
function effectStore() {
|
|
const effects: Effect[] = [];
|
|
const find = (where: { id?: string; idempotencyKey?: string }) =>
|
|
effects.find((effect) =>
|
|
where.id ? effect.id === where.id : effect.idempotencyKey === where.idempotencyKey,
|
|
);
|
|
return {
|
|
findMany: vi.fn(async () => []),
|
|
findUnique: vi.fn(async ({ where }: { where: { id?: string; idempotencyKey?: string } }) => {
|
|
const effect = find(where);
|
|
return effect ? { ...effect } : null;
|
|
}),
|
|
create: vi.fn(async ({ data }: { data: Omit<Effect, "id"> }) => {
|
|
const effect = { ...data, id: `effect-${effects.length + 1}` } as Effect;
|
|
effects.push(effect);
|
|
return { ...effect };
|
|
}),
|
|
update: vi.fn(async ({ where, data }: { where: { id: string }; data: Partial<Effect> }) => {
|
|
Object.assign(find(where) ?? {}, data);
|
|
}),
|
|
updateMany: vi.fn(
|
|
async ({ where, data }: { where: { id: string; status: string }; data: Partial<Effect> }) => {
|
|
const effect = find(where);
|
|
if (!effect || effect.status !== where.status) return { count: 0 };
|
|
Object.assign(effect, data);
|
|
return { count: 1 };
|
|
},
|
|
),
|
|
};
|
|
}
|
|
|
|
async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record<string, unknown> = {}) {
|
|
const run = {
|
|
id: "run-1",
|
|
botId: "bot-1",
|
|
threadId: "thread-1",
|
|
taskId: "task-1",
|
|
spaceId: "space-1",
|
|
userId: "user-1",
|
|
status: "queued",
|
|
trigger: "user",
|
|
leaseFence: 0,
|
|
};
|
|
const routineFindMany = vi.fn(async () => []);
|
|
const prisma = {
|
|
run: {
|
|
findUnique: vi.fn(async () => run),
|
|
findUniqueOrThrow: vi.fn(async () => run),
|
|
updateMany: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
Object.assign(run, data);
|
|
return { count: 1 };
|
|
}),
|
|
},
|
|
bot: {
|
|
findUniqueOrThrow: vi.fn(async () => ({
|
|
id: run.botId,
|
|
name: "Assistant",
|
|
title: "Assistant",
|
|
description: "Test assistant",
|
|
computerId: "computer-1",
|
|
computer: { id: "computer-1", scope: "dedicated" },
|
|
kind: "specialist",
|
|
restrictBuiltinTools: false,
|
|
allowedBuiltinTools: [],
|
|
...policy,
|
|
})),
|
|
findMany: vi.fn(async () => []),
|
|
},
|
|
attempt: {
|
|
create: vi.fn(async () => ({ id: "attempt-1" })),
|
|
update: vi.fn(),
|
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
|
},
|
|
thread: { findUniqueOrThrow: vi.fn(async () => ({ id: run.threadId, groupId: null })) },
|
|
message: { findMany: vi.fn(async () => []) },
|
|
task: { findUniqueOrThrow: vi.fn(async () => ({ id: run.taskId, prompt: "Work" })) },
|
|
connection: { findMany: vi.fn(async () => []) },
|
|
spaceModelPreference: { findFirst: vi.fn(async () => null) },
|
|
userModelCredential: { findFirst: vi.fn(async () => null) },
|
|
deploymentSettings: {
|
|
findUnique: vi.fn(async () => ({
|
|
defaultModelProvider: "scripted",
|
|
defaultModelId: "scripted",
|
|
})),
|
|
},
|
|
taughtSkill: { findMany: vi.fn(async () => []) },
|
|
agentSecret: { findMany: vi.fn(async () => []) },
|
|
agentSkill: { findMany: vi.fn(async () => []) },
|
|
scratchpadItem: { findMany: vi.fn(async () => []) },
|
|
actionApprovalRule: { findMany: vi.fn(async () => []) },
|
|
actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) },
|
|
externalEffect: effectStore(),
|
|
routine: { findMany: routineFindMany },
|
|
...models,
|
|
};
|
|
let request: AgentRunRequest | undefined;
|
|
const results: unknown[] = [];
|
|
const jobs = { enqueue: vi.fn(async () => undefined) };
|
|
const executor = createRunExecutor({
|
|
prisma,
|
|
runtime: {
|
|
describe: () => ({ capabilities: { scripted: false } }),
|
|
run: async function* (next: AgentRunRequest) {
|
|
request = next;
|
|
for (const [index, call] of calls.entries()) {
|
|
results.push(await next.executeTool!(call.name, call.args ?? {}, `call-${index}`));
|
|
}
|
|
yield { type: "done" as const, text: "Done" };
|
|
},
|
|
},
|
|
sandbox: { describe: () => ({ capabilities: { graphical: false } }) },
|
|
memory: { read: async () => ({ documents: [] }) },
|
|
memoryProviders: { resolve: async () => null },
|
|
events: {
|
|
append: vi.fn(async () => undefined),
|
|
pauseRunForInput: vi.fn(async () => true),
|
|
finalizeRun: vi.fn(async () => ({ continuationRunId: null })),
|
|
},
|
|
jobs,
|
|
secrets: [],
|
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
|
await executor.continueRun(run.id, "worker-1");
|
|
if (!request) throw new Error("the runtime was never started");
|
|
const tools = request.tools;
|
|
return { names: tools.map((tool) => tool.name), tools, results, routineFindMany };
|
|
}
|
|
|
|
describe("bot capabilities in runs", () => {
|
|
it("exposes every builtin to an unrestricted bot", async () => {
|
|
const { names } = await runBot({});
|
|
expect(names).toEqual(expect.arrayContaining(["write_file", "shell", "schedule_list"]));
|
|
});
|
|
|
|
it("exposes only the allowlisted builtins and refuses calls to any other", async () => {
|
|
const { names, results, routineFindMany } = await runBot(
|
|
{ restrictBuiltinTools: true, allowedBuiltinTools: ["schedule_list", "web_search"] },
|
|
[
|
|
{ name: "write_file", args: { path: "notes/x.txt", content: "nope" } },
|
|
{ name: "schedule_list" },
|
|
],
|
|
);
|
|
expect(names).toEqual(["web_search", "schedule_list"]);
|
|
expect(results[0]).toEqual({ error: "Tool write_file is not available in this run." });
|
|
expect(results[1]).toEqual({ routines: [] });
|
|
expect(routineFindMany).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("exposes no builtins when the allowlist is empty", async () => {
|
|
const { names, results } = await runBot({ restrictBuiltinTools: true }, [
|
|
{ name: "schedule_list" },
|
|
]);
|
|
expect(names).toEqual([]);
|
|
expect(results[0]).toEqual({ error: "Tool schedule_list is not available in this run." });
|
|
});
|
|
|
|
it("refuses a tool no run ever exposes", async () => {
|
|
const { results } = await runBot({}, [{ name: "made_up_tool" }]);
|
|
expect(results[0]).toEqual({ error: "Tool made_up_tool is not available in this run." });
|
|
});
|
|
|
|
it("offers schedule_create's bot option to the main bot only", async () => {
|
|
const properties = async (policy: BotPolicy) =>
|
|
Object.keys(
|
|
((await runBot(policy)).tools.find((tool) => tool.name === "schedule_create")?.inputSchema
|
|
.properties ?? {}) as object,
|
|
);
|
|
expect(await properties({ kind: "main" })).toContain("bot");
|
|
expect(await properties({})).not.toContain("bot");
|
|
});
|
|
|
|
it("withholds main-only tools from every other bot", async () => {
|
|
const mainOnly = MAIN_ONLY_BUILTIN_TOOLS as Set<string>;
|
|
// No main-only tool ships yet in this package; schedule_list stands in for one.
|
|
mainOnly.add("schedule_list");
|
|
try {
|
|
const specialist = await runBot({}, [{ name: "schedule_list" }]);
|
|
expect(specialist.names).not.toContain("schedule_list");
|
|
expect(specialist.results[0]).toEqual({
|
|
error: "Tool schedule_list is not available in this run.",
|
|
});
|
|
const main = await runBot({ kind: "main" }, [{ name: "schedule_list" }]);
|
|
expect(main.names).toContain("schedule_list");
|
|
expect(main.results[0]).toEqual({ routines: [] });
|
|
} finally {
|
|
mainOnly.delete("schedule_list");
|
|
}
|
|
});
|
|
|
|
it("schedules for a named specialist when the main bot asks, in that bot's space and thread", async () => {
|
|
const routineCreate = vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
|
id: "routine-1",
|
|
...data,
|
|
}));
|
|
const { results } = await runBot(
|
|
{ kind: "main" },
|
|
[
|
|
{
|
|
name: "schedule_create",
|
|
args: { name: "Digest", prompt: "Summarize", delayMinutes: 5, bot: "Atlas" },
|
|
},
|
|
],
|
|
{
|
|
bot: {
|
|
findUniqueOrThrow: vi.fn(async () => ({
|
|
id: "bot-1",
|
|
name: "Chief",
|
|
title: "",
|
|
description: "",
|
|
computerId: "computer-1",
|
|
computer: { id: "computer-1", scope: "dedicated" },
|
|
kind: "main",
|
|
restrictBuiltinTools: false,
|
|
allowedBuiltinTools: [],
|
|
})),
|
|
findMany: vi.fn(async () => [
|
|
{ id: "atlas-1", name: "Atlas", spaceId: "space-2", thread: { id: "atlas-dm" } },
|
|
]),
|
|
},
|
|
chatGroup: { findFirst: vi.fn(async () => null) },
|
|
routine: { findMany: vi.fn(async () => []), create: routineCreate },
|
|
},
|
|
);
|
|
expect(results[0]).toMatchObject({ ok: true, routineId: "routine-1", bot: "Atlas" });
|
|
expect(routineCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
spaceId: "space-2",
|
|
botId: "atlas-1",
|
|
threadId: "atlas-dm",
|
|
createdByBotId: "bot-1",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("refuses the bot option from any other bot", async () => {
|
|
const routineCreate = vi.fn();
|
|
const { results } = await runBot(
|
|
{},
|
|
[
|
|
{
|
|
name: "schedule_create",
|
|
args: { name: "x", prompt: "y", delayMinutes: 5, bot: "Atlas" },
|
|
},
|
|
],
|
|
{ routine: { findMany: vi.fn(async () => []), create: routineCreate } },
|
|
);
|
|
expect(results[0]).toEqual({ error: "Only the main agent can schedule for another bot." });
|
|
expect(routineCreate).not.toHaveBeenCalled();
|
|
});
|
|
});
|