diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 7067820..f94b1d7 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -35,6 +35,7 @@ import { assertTeamWorkspacePathAllowed, buildMcpCredentialBlob, buildModelConnectPlaintext, + builtinToolNamesForBot, ComputerBusyError, cancelComputerRunWork, checkpointAndRecordComputerWorkspace, @@ -83,6 +84,7 @@ import { teamBotWorkspaceDirectory, toComputerRef, touchRunningComputer, + unknownBuiltinToolNames, verifyMcpInstall, } from "@rakazo/adapters"; import type { Auth } from "@rakazo/auth"; @@ -981,6 +983,7 @@ export function createRouter(deps: RouterDeps) { return found; }), create: authed.bots.create.handler(async ({ context, input }) => { + assertBuiltinToolNames(input.allowedBuiltinTools); try { return await repos.createBot(context.actor, input); } catch (error) { @@ -1001,6 +1004,9 @@ export function createRouter(deps: RouterDeps) { modelProvider: source.modelProvider, modelId: source.modelId, thinkingLevel: source.thinkingLevel, + tags: source.tags, + restrictBuiltinTools: source.restrictBuiltinTools, + allowedBuiltinTools: source.allowedBuiltinTools, }) .catch((error: unknown) => { throw mapSpaceLifecycleError(error); @@ -1032,6 +1038,7 @@ export function createRouter(deps: RouterDeps) { }), update: authed.bots.update.handler(async ({ context, input }) => { const existing = await repos.getBot(context.actor, input.botId); + assertBuiltinToolNames(input.allowedBuiltinTools); if (input.sectionId) { const section = await deps.prisma.botSection.findFirst({ where: { @@ -1133,6 +1140,9 @@ export function createRouter(deps: RouterDeps) { ? { teamChatAmbientEnabled: input.teamChatAmbientEnabled } : {}), ...(input.teamChatRules !== undefined ? { teamChatRules: input.teamChatRules } : {}), + tags: input.tags, + restrictBuiltinTools: input.restrictBuiltinTools, + allowedBuiltinTools: input.allowedBuiltinTools, }, }); const bots = await repos.listBots(context.actor); @@ -1140,6 +1150,14 @@ export function createRouter(deps: RouterDeps) { if (!bot) throw new IsolationError(); return bot; }), + builtinTools: authed.bots.builtinTools.handler(async ({ context, input }) => { + const bot = await repos.getBot(context.actor, input.botId); + const identity = await deps.prisma.messagingIdentity.findUnique({ + where: { botId: bot.id }, + select: { id: true }, + }); + return builtinToolNamesForBot({ kind: bot.kind, chatLinked: Boolean(identity) }); + }), setComputer: authed.bots.setComputer.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); @@ -5333,6 +5351,13 @@ function withViewOnly(url: string, viewOnly: boolean) { } } +function assertBuiltinToolNames(names: readonly string[] | undefined) { + const unknown = unknownBuiltinToolNames(names ?? []); + if (unknown.length > 0) { + throw new ORPCError("BAD_REQUEST", { message: `Unknown tools: ${unknown.join(", ")}` }); + } +} + function duplicateBotName(name: string) { return `${name.slice(0, 75)} copy`; } diff --git a/apps/web/e2e/bot-capabilities.spec.ts b/apps/web/e2e/bot-capabilities.spec.ts new file mode 100644 index 0000000..0a5135e --- /dev/null +++ b/apps/web/e2e/bot-capabilities.spec.ts @@ -0,0 +1,63 @@ +import { expect, type Page, test } from "@playwright/test"; +import type { Bot } from "@rakazo/contracts"; +import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +async function openCapabilities(page: Page) { + await page.getByTestId("bot-settings-trigger").click(); + const settings = page.getByTestId("bot-settings"); + await expect(settings).toBeVisible(); + await settings.getByText("Advanced", { exact: true }).click(); + const capabilities = settings.getByTestId("bot-capabilities"); + await capabilities.scrollIntoViewIfNeeded(); + return { settings, capabilities }; +} + +test("bot settings edit capability tags and the built-in tool allowlist", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `capabilities-${stamp}@rakazo.test`, "password12", "Capabilities"); + await completeOnboarding(page); + const botId = activeBotId(page); + + const { settings, capabilities } = await openCapabilities(page); + await capabilities.getByLabel("Tags").fill("research, writing, research"); + await expect(capabilities.getByRole("checkbox")).toHaveCount(0); + await capabilities.getByRole("switch", { name: "Limit built-in tools" }).click(); + const tools = capabilities.getByRole("group", { name: "Limit built-in tools" }); + await tools.getByRole("checkbox", { name: "web_fetch", exact: true }).click(); + await tools.getByRole("checkbox", { name: "web_search", exact: true }).click(); + await tools.getByRole("checkbox", { name: "remember", exact: true }).click(); + await tools.getByRole("checkbox", { name: "remember", exact: true }).click(); + await expect(tools.getByRole("checkbox", { name: "web_search", exact: true })).toBeChecked(); + await expect(tools.getByRole("checkbox", { name: "shell", exact: true })).not.toBeChecked(); + await captureScreenshot(page, testInfo, "bot-capabilities-settings"); + + const saved = page.waitForResponse( + (response) => response.url().includes("/rpc/bots/update") && response.ok(), + ); + await settings.getByRole("button", { name: "Save", exact: true }).click(); + await saved; + expect(await rpc(page, "bots/get", { botId })).toMatchObject({ + tags: ["research", "writing"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["web_search", "web_fetch"], + }); + + await page.reload(); + const reopened = await openCapabilities(page); + await expect(reopened.capabilities.getByLabel("Tags")).toHaveValue("research, writing"); + await expect( + reopened.capabilities.getByRole("switch", { name: "Limit built-in tools" }), + ).toBeChecked(); + const reopenedTools = reopened.capabilities.getByRole("group", { + name: "Limit built-in tools", + }); + await expect( + reopenedTools.getByRole("checkbox", { name: "web_fetch", exact: true }), + ).toBeChecked(); + await expect( + reopenedTools.getByRole("checkbox", { name: "remember", exact: true }), + ).not.toBeChecked(); + await captureScreenshot(page, testInfo, "bot-capabilities-persisted"); +}); diff --git a/apps/web/src/pages/shell/bot-capabilities.tsx b/apps/web/src/pages/shell/bot-capabilities.tsx new file mode 100644 index 0000000..937d9c6 --- /dev/null +++ b/apps/web/src/pages/shell/bot-capabilities.tsx @@ -0,0 +1,81 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Checkbox, Input, Switch } from "@rakazo/ui-web"; +import { useId } from "react"; + +/** Capability tags and the builtin tool allowlist of one bot, edited inside its settings. */ +export function BotCapabilitiesFields({ + tags, + onTagsChange, + restrictBuiltinTools, + onRestrictBuiltinToolsChange, + allowedBuiltinTools, + onAllowedBuiltinToolsChange, + toolNames, +}: { + tags: string; + onTagsChange: (tags: string) => void; + restrictBuiltinTools: boolean; + onRestrictBuiltinToolsChange: (restrict: boolean) => void; + allowedBuiltinTools: string[]; + onAllowedBuiltinToolsChange: (names: string[]) => void; + toolNames: string[]; +}) { + const { t } = useLingui(); + const ids = useId(); + return ( +
+ + + {restrictBuiltinTools ? ( +
+ {toolNames.map((name) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/pages/shell/bot-panel.tsx b/apps/web/src/pages/shell/bot-panel.tsx index 74c336c..8f0837a 100644 --- a/apps/web/src/pages/shell/bot-panel.tsx +++ b/apps/web/src/pages/shell/bot-panel.tsx @@ -14,6 +14,7 @@ import { BOT_DESCRIPTION_MAX_LENGTH, BOT_NAME_MAX_LENGTH, BOT_TITLE_MAX_LENGTH, + normalizeBotTags, } from "@rakazo/contracts"; import { Button, @@ -29,6 +30,7 @@ import { Suspense, useEffect, useId, useRef, useState } from "react"; import { lazyWithRecovery } from "../../lib/lazy-recovery"; import { rpc } from "../../lib/rpc"; import { AvatarStudioPopover } from "./avatar-studio-popover"; +import { BotCapabilitiesFields } from "./bot-capabilities"; const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () => import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })), @@ -212,6 +214,9 @@ export function BotSettings({ modelProvider?: string | null; modelId?: string | null; thinkingLevel?: ThinkingLevel | null; + tags?: string[]; + restrictBuiltinTools?: boolean; + allowedBuiltinTools?: string[]; }) => Promise; onExport: () => Promise; onClear: () => void; @@ -233,6 +238,10 @@ export function BotSettings({ bot.modelProvider && bot.modelId ? modelOptionKey(bot.modelProvider, bot.modelId) : "", ); const [thinkingLevel, setThinkingLevel] = useState(bot.thinkingLevel ?? ""); + const [tags, setTags] = useState(bot.tags.join(", ")); + const [restrictBuiltinTools, setRestrictBuiltinTools] = useState(bot.restrictBuiltinTools); + const [allowedBuiltinTools, setAllowedBuiltinTools] = useState(bot.allowedBuiltinTools); + const [toolNames, setToolNames] = useState(null); const [credentials, setCredentials] = useState([]); const [catalog, setCatalog] = useState([]); const [me, setMe] = useState(null); @@ -254,6 +263,10 @@ export function BotSettings({ .voices({}) .then(setVoices) .catch(() => setVoices([])); + void rpc.bots + .builtinTools({ botId: bot.id }) + .then(setToolNames) + .catch(() => undefined); void Promise.all([rpc.models.credentials(), rpc.models.list(), rpc.me()]) .then(([nextCredentials, nextCatalog, nextMe]) => { setCredentials(nextCredentials); @@ -344,9 +357,12 @@ export function BotSettings({ const nextNotify = patchOverrides?.notifyOnFinish !== undefined ? patchOverrides.notifyOnFinish : notifyOnFinish; + const nextTags = normalizeBotTags(tags.split(",")); + if (nextName) setName(nextName); setTitle(nextTitle); setDescription(nextDescription); + setTags(nextTags.join(", ")); try { setSaving(true); @@ -364,6 +380,12 @@ export function BotSettings({ voiceId: voiceId || null, modelProvider: selected?.provider ?? null, modelId: selected?.modelId ?? null, + tags: nextTags, + restrictBuiltinTools, + // Names a newer release no longer ships would make the whole save fail validation. + allowedBuiltinTools: toolNames + ? allowedBuiltinTools.filter((tool) => toolNames.includes(tool)) + : allowedBuiltinTools, ...(modelMetaReady ? { thinkingLevel: thinkingOptions.length @@ -598,6 +620,15 @@ export function BotSettings({ ) : null} + {error ?

{error}

: null}
diff --git a/packages/adapters/src/executor-capabilities.test.ts b/packages/adapters/src/executor-capabilities.test.ts new file mode 100644 index 0000000..1d3e480 --- /dev/null +++ b/packages/adapters/src/executor-capabilities.test.ts @@ -0,0 +1,267 @@ +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()), + acquireComputerExecutionLease: async () => null, + provisionComputer: async () => ({ id: "computer-1", kind: "desktop" }), +})); + +type BotPolicy = { kind?: string; restrictBuiltinTools?: boolean; allowedBuiltinTools?: string[] }; +type Call = { name: string; args?: Record }; +type Effect = { id: string; status: string; idempotencyKey: string } & Record; + +/** 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 }) => { + 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 }) => { + Object.assign(find(where) ?? {}, data); + }), + updateMany: vi.fn( + async ({ where, data }: { where: { id: string; status: string }; data: Partial }) => { + 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 = {}) { + 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 }) => { + 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[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; + // 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 }) => ({ + 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(); + }); +}); diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts new file mode 100644 index 0000000..ee03485 --- /dev/null +++ b/packages/adapters/src/executor-tools.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; +import { + builtinToolNamesForBot, + filterBuiltinToolsForBot, + MAIN_ONLY_BUILTIN_TOOLS, + selectBuiltinToolsForRun, + unknownBuiltinToolNames, +} from "./executor-tools.js"; + +const unrestricted = { kind: "specialist", restrictBuiltinTools: false, allowedBuiltinTools: [] }; + +function select( + bot: { kind: string; restrictBuiltinTools: boolean; allowedBuiltinTools: string[] }, + options: { graphicalToolsAllowed?: boolean; agentConnectionsEnabled?: boolean } = {}, +) { + return selectBuiltinToolsForRun({ + graphicalToolsAllowed: options.graphicalToolsAllowed ?? true, + groupId: null, + trigger: "user", + semanticMemoryEnabled: false, + messagingChannelRun: false, + agentConnectionsEnabled: options.agentConnectionsEnabled, + bot, + }).map((tool) => tool.name); +} + +describe("builtin tools per bot", () => { + it("adds agent connection tools only for chat-linked bots", () => { + expect(select(unrestricted)).not.toContain("message_agent"); + expect(select(unrestricted, { agentConnectionsEnabled: true })).toEqual( + expect.arrayContaining(agentConnectionTools.map((tool) => tool.name)), + ); + }); + + it("applies the allowlist to agent connection tools too", () => { + const bot = { + kind: "specialist", + restrictBuiltinTools: true, + allowedBuiltinTools: ["message_agent", "read_file"], + }; + expect(select(bot, { agentConnectionsEnabled: true })).toEqual(["read_file", "message_agent"]); + }); + + it("never brings back a tool the run itself withholds", () => { + const bot = { + kind: "specialist", + restrictBuiltinTools: true, + allowedBuiltinTools: ["computer_observe", "read_file"], + }; + expect(select(bot, { graphicalToolsAllowed: false })).toEqual(["read_file"]); + }); + + it("ignores the allowlist while the restriction is off", () => { + const bot = { ...unrestricted, allowedBuiltinTools: ["read_file"] }; + expect(filterBuiltinToolsForBot(builtinAgentTools, bot)).toHaveLength(builtinAgentTools.length); + }); + + it("lists main-only tools for the main bot alone", () => { + const mainOnly = MAIN_ONLY_BUILTIN_TOOLS as Set; + // No main-only tool ships yet in this package; remember stands in for one. + mainOnly.add("remember"); + try { + expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: false })).not.toContain( + "remember", + ); + expect(builtinToolNamesForBot({ kind: "main", chatLinked: false })).toContain("remember"); + } finally { + mainOnly.delete("remember"); + } + }); + + it("lists agent connection tools only for chat-linked bots", () => { + expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: false })).toEqual( + builtinAgentTools.map((tool) => tool.name), + ); + expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: true })).toEqual( + [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name), + ); + }); + + it("flags names that are not builtin tools", () => { + expect( + unknownBuiltinToolNames(["web_search", "message_agent", "web_serach", "mcp__notion__search"]), + ).toEqual(["web_serach", "mcp__notion__search"]); + }); +}); diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 88fad49..26733d8 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -1,8 +1,13 @@ -import { builtinAgentTools } from "./builtin-tools.js"; +import type { ConnectorTool } from "@rakazo/adapter-kit"; +import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; import { selectMemoryTools } from "./memory-tools.js"; import { filterImageReturningComputerTools } from "./model-vision.js"; -import { filterBuiltinToolsForRun, filterBuiltinToolsForThread } from "./schedule-tools.js"; +import { + filterBuiltinToolsForRun, + filterBuiltinToolsForThread, + withScheduleTargetBot, +} from "./schedule-tools.js"; /** Browser tools that require a graphical computer (Chrome). */ export const PAGE_BROWSER_TOOL_NAMES = new Set([ @@ -11,6 +16,52 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([ "browser_act", ]); +/** Builtin tools only the user's main bot may use; every other bot's runs never see them. */ +export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set(); + +const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name); + +/** The bot fields that decide which builtin tools its runs may use. */ +export interface BuiltinToolPolicy { + kind: string; + restrictBuiltinTools: boolean; + allowedBuiltinTools: readonly string[]; +} + +/** + * Builtin tool names a bot's allowlist can choose from, in catalog order. Agent connection tools + * only ever reach chat-linked bots, so only those list them. + */ +export function builtinToolNamesForBot(bot: { kind: string; chatLinked: boolean }): string[] { + return [...builtinAgentTools, ...(bot.chatLinked ? agentConnectionTools : [])] + .map((tool) => tool.name) + .filter((name) => bot.kind === "main" || !MAIN_ONLY_BUILTIN_TOOLS.has(name)); +} + +/** Names that are not builtin tools; an allowlist must not carry typos or connector tools. */ +export function unknownBuiltinToolNames(names: readonly string[]): string[] { + return names.filter((name) => !BUILTIN_TOOL_NAMES.includes(name)); +} + +/** + * Least privilege per bot: main-only tools stay with the main bot, a restricted bot keeps only + * its allowlist (empty means none), and the main bot may schedule for another bot. + */ +export function filterBuiltinToolsForBot( + tools: ConnectorTool[], + bot: BuiltinToolPolicy, +): ConnectorTool[] { + const main = bot.kind === "main"; + const allowed = new Set(bot.allowedBuiltinTools); + return tools + .filter( + (tool) => + (main || !MAIN_ONLY_BUILTIN_TOOLS.has(tool.name)) && + (!bot.restrictBuiltinTools || allowed.has(tool.name)), + ) + .map((tool) => (main && tool.name === "schedule_create" ? withScheduleTargetBot(tool) : tool)); +} + export function filterPageBrowserTools( tools: T[], pageBrowserAllowed: boolean, @@ -28,8 +79,11 @@ export function selectBuiltinToolsForRun(options: { semanticMemoryEnabled: boolean; cloudAgentEnabled?: boolean; messagingChannelRun: boolean; + /** Cross-owner agent connections only exist for chat-linked bots. */ + agentConnectionsEnabled?: boolean; + bot: BuiltinToolPolicy; }) { - return selectCloudAgentTools( + const selected = selectCloudAgentTools( selectMemoryTools( filterBuiltinToolsForRun( filterBuiltinToolsForThread( @@ -50,4 +104,8 @@ export function selectBuiltinToolsForRun(options: { (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && !tool.name.startsWith("scratchpad_")), ); + return filterBuiltinToolsForBot( + options.agentConnectionsEnabled ? [...selected, ...agentConnectionTools] : selected, + options.bot, + ); } diff --git a/packages/adapters/src/executor.test.ts b/packages/adapters/src/executor.test.ts index 5fd1d90..5a41e07 100644 --- a/packages/adapters/src/executor.test.ts +++ b/packages/adapters/src/executor.test.ts @@ -141,6 +141,8 @@ describe("run workspace checkpoint", () => { }); }); +const specialist = { kind: "specialist", restrictBuiltinTools: false, allowedBuiltinTools: [] }; + describe("run tool selection", () => { it.each([ [false, false], @@ -156,6 +158,7 @@ describe("run tool selection", () => { trigger: "message", semanticMemoryEnabled: false, messagingChannelRun: false, + bot: specialist, }).map((tool) => tool.name); expect(names.includes("browser_snapshot")).toBe(page); expect(names.includes("cloud_agent_status")).toBe(cloud); @@ -174,6 +177,7 @@ describe("run tool selection", () => { trigger, semanticMemoryEnabled: false, messagingChannelRun: false, + bot: specialist, }).map((tool) => tool.name); it("keeps page browser tools without vision, and hides them without a graphical computer", () => { @@ -491,6 +495,7 @@ describe("createRunExecutor", () => { trigger: "messaging", semanticMemoryEnabled: true, messagingChannelRun: true, + bot: specialist, }).map((tool) => tool.name); expect(tools).not.toContain("recall_memory"); expect(tools).not.toContain("remember"); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 399906e..bf2b80e 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -166,7 +166,7 @@ import { browserNavigateFromTool, browserSnapshotFromTool, } from "./browser-tools.js"; -import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; +import { builtinAgentTools } from "./builtin-tools.js"; import { archiveSpawnedBot, spawnBot } from "./child-bots.js"; import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-factory.js"; import { executeCloudAgentTool } from "./cloud-agent-service.js"; @@ -324,6 +324,7 @@ import { filterBuiltinToolsForRun, filterBuiltinToolsForThread, listSchedulesFromTool, + resolveScheduleOwner, } from "./schedule-tools.js"; import { loadAgentScratchpadContext } from "./scratchpad-context.js"; import { @@ -1400,19 +1401,17 @@ export function createRunExecutor(deps: ExecutorDeps) { const graphicalToolsAllowed = graphical && acceptsImages && !heldForTakeover; const pageBrowserAllowed = graphical && browser.describe().capabilities.page && !heldForTakeover; - const builtins = [ - ...selectBuiltinToolsForRun({ - graphicalToolsAllowed, - pageBrowserAllowed, - groupId: thread.groupId, - trigger: run.trigger, - semanticMemoryEnabled, - cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId), - messagingChannelRun, - }), - // Cross-owner agent connections only exist for chat-linked bots. - ...(hasMessagingIdentity ? agentConnectionTools : []), - ]; + const builtins = selectBuiltinToolsForRun({ + graphicalToolsAllowed, + pageBrowserAllowed, + groupId: thread.groupId, + trigger: run.trigger, + semanticMemoryEnabled, + cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId), + messagingChannelRun, + agentConnectionsEnabled: hasMessagingIdentity, + bot, + }); const exposedConnectorTools = discovered.filter( (tool) => !builtinAgentTools.some((builtin) => builtin.name === tool.name), ); @@ -1450,6 +1449,7 @@ export function createRunExecutor(deps: ExecutorDeps) { return autoReviewPreferencePromise; }; const tools = [...builtins, ...exposedConnectorTools]; + const exposedToolNames = new Set(tools.map((tool) => tool.name)); const approvedEffects = await deps.prisma.externalEffect.findMany({ where: { runId, status: "approved" }, orderBy: APPROVED_EFFECT_REPLAY_ORDER, @@ -1609,6 +1609,11 @@ export function createRunExecutor(deps: ExecutorDeps) { executionId: string, ) => { context.signal.throwIfAborted(); + // The runtime declaration is not the boundary: a runtime may still emit a call for a + // tool this run never exposed (an allowlisted-out or main-only tool, a scripted call). + if (!exposedToolNames.has(name)) { + return { error: `Tool ${name} is not available in this run.` }; + } if (handedOff) { return { error: "This stage was handed off. End the turn without more tool calls." }; } @@ -2568,11 +2573,20 @@ export function createRunExecutor(deps: ExecutorDeps) { return finish(removed); } if (name === "schedule_create") { + const owner = await resolveScheduleOwner( + deps.prisma, + { + botId: bot.id, + kind: bot.kind, + spaceId: run.spaceId, + userId: run.userId, + threadId: thread.id, + }, + args.bot ? String(args.bot) : undefined, + ); + if ("error" in owner) return finish(owner); const created = await createScheduleFromTool(deps, { - spaceId: run.spaceId, - botId: bot.id, - userId: run.userId, - threadId: thread.id, + ...owner, name: String(args.name ?? ""), prompt: String(args.prompt ?? ""), timezone: args.timezone ? String(args.timezone) : undefined, @@ -3482,7 +3496,7 @@ export function createRunExecutor(deps: ExecutorDeps) { const approvalContinuation = buildApprovalContinuation( approvedEffects, (request) => redactSecrets(JSON.stringify(request), runSecrets), - { exposedToolNames: new Set(tools.map((tool) => tool.name)) }, + { exposedToolNames }, ); const replyContext = await loadReplyContext(deps.prisma, thread.id, run.sourceMessageId); const prompt = [replyContext, basePrompt, takeoverResume?.promptNote, approvalContinuation] @@ -4347,10 +4361,15 @@ export { } from "./executor-computer.js"; export { completeEffect, recordEffect, uncertainEffectError } from "./executor-effects.js"; export { isProtectedComputerLifecycleCommand } from "./executor-shell-guard.js"; +export type { BuiltinToolPolicy } from "./executor-tools.js"; export { + builtinToolNamesForBot, + filterBuiltinToolsForBot, filterPageBrowserTools, + MAIN_ONLY_BUILTIN_TOOLS, PAGE_BROWSER_TOOL_NAMES, selectBuiltinToolsForRun, + unknownBuiltinToolNames, } from "./executor-tools.js"; export type { ExecutorDeps } from "./executor-types.js"; export { loadCurrentTurnImages, selectRunConnections } from "./executor-utils.js"; diff --git a/packages/adapters/src/pi-runtime-cancellation.test.ts b/packages/adapters/src/pi-runtime-cancellation.test.ts index 164a4c5..0c8706f 100644 --- a/packages/adapters/src/pi-runtime-cancellation.test.ts +++ b/packages/adapters/src/pi-runtime-cancellation.test.ts @@ -75,6 +75,7 @@ vi.mock("./pi-openai-compatible-provider.js", () => ({ registerOpenAiCompatibleRuntime: (models: unknown) => models, })); +import { builtinAgentTools } from "./builtin-tools.js"; import { PiAgentRuntime } from "./pi-runtime.js"; const request: AgentRunRequest = { @@ -84,7 +85,7 @@ const request: AgentRunRequest = { prompt: "fake task", instructions: "test", history: [], - tools: [], + tools: builtinAgentTools.filter((tool) => tool.name === "run_subagent" || tool.name === "shell"), model: { provider: "test", id: "fake-model" }, }; diff --git a/packages/adapters/src/pi-runtime-thinking-level.test.ts b/packages/adapters/src/pi-runtime-thinking-level.test.ts index 74839a5..7177d47 100644 --- a/packages/adapters/src/pi-runtime-thinking-level.test.ts +++ b/packages/adapters/src/pi-runtime-thinking-level.test.ts @@ -99,6 +99,7 @@ vi.mock("./pi-openai-compatible-provider.js", () => ({ registerOpenAiCompatibleRuntime: (models: unknown) => models, })); +import { builtinAgentTools } from "./builtin-tools.js"; import { PiAgentRuntime } from "./pi-runtime.js"; async function runWithModel( @@ -127,7 +128,7 @@ async function runWithModel( prompt: "hello", instructions: "", history: [], - tools: [], + tools: builtinAgentTools.filter((tool) => tool.name === "run_subagent"), model: { provider, id: modelId, thinkingLevel, maxImagesPerPrompt }, executeTool: vi.fn(async () => ({ ok: true })), resolveModel, diff --git a/packages/adapters/src/pi-runtime-tool-scope.test.ts b/packages/adapters/src/pi-runtime-tool-scope.test.ts new file mode 100644 index 0000000..50b7422 --- /dev/null +++ b/packages/adapters/src/pi-runtime-tool-scope.test.ts @@ -0,0 +1,86 @@ +import type { AgentRunRequest } from "@rakazo/adapter-kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ toolNames: [] as string[][] })); + +type FakeTool = { + name: string; + execute: (toolCallId: string, params: Record) => Promise; +}; + +vi.mock("@earendil-works/pi-agent-core", () => ({ + Agent: class { + state = { errorMessage: undefined, messages: [] }; + private readonly tools: FakeTool[]; + + constructor(options: { initialState: { tools: FakeTool[] } }) { + this.tools = options.initialState.tools; + state.toolNames.push(this.tools.map((tool) => tool.name)); + } + + subscribe() {} + async prompt() { + await this.tools + .find((tool) => tool.name === "run_subagent") + ?.execute("subagent-call", { name: "helper", task: "help" }); + } + async waitForIdle() {} + abort() {} + }, +})); +vi.mock("@earendil-works/pi-ai/providers/all", () => ({ + builtinModels: () => ({ + getModel: () => ({ provider: "test", id: "fake-model" }), + streamSimple: () => { + throw new Error("No model requests are allowed"); + }, + }), +})); +vi.mock("./pi-local-provider.js", () => ({ + registerLocalProvider: (models: unknown) => models, +})); +vi.mock("./pi-openai-compatible-provider.js", () => ({ + OPENAI_COMPATIBLE_PROVIDER_ID: "openai-compatible", + registerOpenAiCompatibleCatalog: (models: unknown) => models, + registerOpenAiCompatibleRuntime: (models: unknown) => models, +})); + +import { builtinAgentTools } from "./builtin-tools.js"; +import { PiAgentRuntime } from "./pi-runtime.js"; + +async function run(tools: AgentRunRequest["tools"]) { + for await (const _event of new PiAgentRuntime().run( + { + botId: "bot", + threadId: "thread", + runId: "run", + prompt: "work", + instructions: "test", + history: [], + tools, + model: { provider: "test", id: "fake-model" }, + executeTool: vi.fn(async () => ({ ok: true })), + }, + { signal: new AbortController().signal }, + )) { + // Exhaust the runtime event stream. + } +} + +describe("Pi runtime tool scope", () => { + beforeEach(() => { + state.toolNames = []; + }); + + it("exposes no tools when the request lists none", async () => { + await run([]); + expect(state.toolNames).toEqual([[]]); + }); + + it("gives a subagent only the parent's tools, minus delegation", async () => { + await run( + builtinAgentTools.filter((tool) => ["run_subagent", "read_file"].includes(tool.name)), + ); + expect(state.toolNames).toEqual([["read_file", "run_subagent"], ["read_file"]]); + }); +}); diff --git a/packages/adapters/src/pi-runtime.ts b/packages/adapters/src/pi-runtime.ts index 13f8f97..5d0ef59 100644 --- a/packages/adapters/src/pi-runtime.ts +++ b/packages/adapters/src/pi-runtime.ts @@ -16,7 +16,7 @@ import type { } from "@rakazo/adapter-kit"; import { getLogger } from "@rakazo/logging"; import { isToolPauseResult } from "./approval-effect.js"; -import { builtinAgentTools, DELEGATION_TOOL_NAMES } from "./builtin-tools.js"; +import { DELEGATION_TOOL_NAMES } from "./builtin-tools.js"; import { normalizeOpenAiToolParameters, openAiToolParametersNeedNormalization, @@ -156,7 +156,9 @@ export class PiAgentRuntime implements AgentRuntime { return; } const { models, model, apiKey } = selectedModel; - const toolDefs = request.tools.length ? request.tools : builtinAgentTools; + // The request's list is the whole tool set. An empty list means no tools: checkers and + // summarizers pass [] on purpose, and so does a bot whose allowlist is empty. + const toolDefs = request.tools; const nestedAgents = new Set(); const completionModel = modelForCompletion(model, request.model.maxTokens); trackedBudget = toolCallBudgetFor(request.runId); @@ -851,9 +853,7 @@ async function executeSubagent(host: ToolHost, executionId: string, args: Record } const subagentModel = modelForCompletion(selectedModel.model, requestModel.maxTokens); - const childDefs = (host.request.tools.length ? host.request.tools : builtinAgentTools).filter( - (tool) => !DELEGATION_TOOL_NAMES.has(tool.name), - ); + const childDefs = host.request.tools.filter((tool) => !DELEGATION_TOOL_NAMES.has(tool.name)); const nestedHost: ToolHost = { ...host, models: selectedModel.models, diff --git a/packages/adapters/src/schedule-tools.test.ts b/packages/adapters/src/schedule-tools.test.ts index 84e487a..09122f8 100644 --- a/packages/adapters/src/schedule-tools.test.ts +++ b/packages/adapters/src/schedule-tools.test.ts @@ -7,8 +7,10 @@ import { filterBuiltinToolsForThread, isOneShotRoutineCron, listSchedulesFromTool, + resolveScheduleOwner, resolveScheduleTiming, SCHEDULE_TOOL_NAMES, + withScheduleTargetBot, } from "./schedule-tools.js"; describe("resolveScheduleTiming", () => { @@ -360,14 +362,14 @@ describe("schedule tool persistence", () => { it("scopes group list and cancel to the current thread without narrowing DMs", async () => { const findMany = vi.fn(async () => []); - const findFirst = vi.fn(async () => null); const deps = { prisma: { - routine: { findMany, findFirst, delete: vi.fn(async () => undefined) }, + routine: { findMany, delete: vi.fn(async () => undefined) }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; + const scheduledForOthers = { userId: "user-1", createdByBotId: "bot-1" }; await listSchedulesFromTool(deps, { spaceId: "ws-1", @@ -378,10 +380,10 @@ describe("schedule tool persistence", () => { expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { - spaceId: "ws-1", - botId: "bot-1", - userId: "user-1", - threadId: "group-thread-1", + OR: [ + { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1" }, + scheduledForOthers, + ], }, }), ); @@ -394,13 +396,13 @@ describe("schedule tool persistence", () => { routineId: "routine-1", }); expect(cancelled).toEqual({ error: "Schedule not found." }); - expect(findFirst).toHaveBeenCalledWith( + expect(findMany).toHaveBeenLastCalledWith( expect.objectContaining({ where: { - spaceId: "ws-1", - botId: "bot-1", - userId: "user-1", - threadId: "group-thread-1", + OR: [ + { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1" }, + scheduledForOthers, + ], id: "routine-1", }, }), @@ -410,21 +412,251 @@ describe("schedule tool persistence", () => { await listSchedulesFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1" }); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, + where: { + OR: [{ spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, scheduledForOthers], + }, }), ); - findFirst.mockClear(); + findMany.mockClear(); await cancelScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", routineId: "routine-1", }); - expect(findFirst).toHaveBeenCalledWith( + expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { spaceId: "ws-1", botId: "bot-1", userId: "user-1", id: "routine-1" }, + where: { + OR: [{ spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, scheduledForOthers], + id: "routine-1", + }, }), ); }); }); + +describe("scheduling on behalf of another bot", () => { + const main = { + botId: "main-1", + kind: "main", + spaceId: "home", + userId: "user-1", + threadId: "main-thread", + }; + const bots = [ + { id: "main-1", name: "Chief", spaceId: "home", thread: { id: "chief-dm" } }, + { id: "writer-1", name: "Writer", spaceId: "home", thread: { id: "writer-dm" } }, + { id: "scout-1", name: "Scout", spaceId: "work", thread: { id: "scout-dm" } }, + { id: "scout-2", name: "Scout", spaceId: "lab", thread: { id: "scout-lab-dm" } }, + { id: "atlas-1", name: "Atlas", spaceId: "work", thread: { id: "atlas-dm" } }, + { id: "atlas-2", name: "atlas", spaceId: "home", thread: { id: "atlas-home-dm" } }, + ]; + + function prismaWith(mainConversationThreadId: string | null) { + return { + bot: { findMany: vi.fn(async () => bots) }, + chatGroup: { + findFirst: vi.fn(async () => + mainConversationThreadId ? { thread: { id: mainConversationThreadId } } : null, + ), + }, + } as unknown as Parameters[0]; + } + + it("keeps a schedule with its creator when no bot is named", async () => { + const prisma = prismaWith(null); + await expect(resolveScheduleOwner(prisma, main, undefined)).resolves.toEqual({ + spaceId: "home", + botId: "main-1", + userId: "user-1", + threadId: "main-thread", + }); + await expect(resolveScheduleOwner(prisma, main, "Chief")).resolves.toMatchObject({ + botId: "main-1", + threadId: "main-thread", + }); + expect(prisma.chatGroup.findFirst).not.toHaveBeenCalled(); + }); + + it("lets only the main bot schedule for another bot", async () => { + const prisma = prismaWith(null); + await expect( + resolveScheduleOwner(prisma, { ...main, kind: "specialist" }, "Writer"), + ).resolves.toEqual({ error: "Only the main agent can schedule for another bot." }); + expect(prisma.bot.findMany).not.toHaveBeenCalled(); + }); + + it("wakes in the target's 1:1 thread in its own space unless it is in the main conversation", async () => { + const prisma = prismaWith(null); + await expect(resolveScheduleOwner(prisma, main, "atlas-1")).resolves.toEqual({ + spaceId: "work", + botId: "atlas-1", + userId: "user-1", + threadId: "atlas-dm", + createdByBotId: "main-1", + botName: "Atlas", + }); + expect(prisma.bot.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + archivedAt: null, + space: { deletingAt: null, memberships: { some: { userId: "user-1" } } }, + }, + }), + ); + expect(prisma.chatGroup.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + kind: "main", + archivedAt: null, + spaceId: "work", + members: { some: { botId: "atlas-1" } }, + }, + }), + ); + }); + + it("wakes in the main conversation when the target is a member", async () => { + await expect(resolveScheduleOwner(prismaWith("main-thread"), main, "Writer")).resolves.toEqual({ + spaceId: "home", + botId: "writer-1", + userId: "user-1", + threadId: "main-thread", + createdByBotId: "main-1", + botName: "Writer", + }); + }); + + it("resolves names exactly first, then case-insensitively, preferring the creator's space", async () => { + const prisma = prismaWith(null); + await expect(resolveScheduleOwner(prisma, main, "Atlas")).resolves.toMatchObject({ + botId: "atlas-1", + }); + await expect(resolveScheduleOwner(prisma, main, "ATLAS")).resolves.toMatchObject({ + botId: "atlas-2", + }); + await expect(resolveScheduleOwner(prisma, main, "Scout")).resolves.toEqual({ + error: "Several bots are named Scout. Use the bot id.", + }); + await expect(resolveScheduleOwner(prisma, main, "scout-2")).resolves.toMatchObject({ + botId: "scout-2", + spaceId: "lab", + }); + await expect(resolveScheduleOwner(prisma, main, "Nobody")).resolves.toEqual({ + error: "No bot named Nobody.", + }); + }); + + it("records the creating bot and names the target in the result", async () => { + const create = vi.fn(async (args: { data: Record }) => ({ + id: "routine-9", + ...args.data, + })); + const append = vi.fn(async () => undefined); + const deps = { + prisma: { routine: { create } }, + events: { append }, + jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, + } as unknown as Parameters[0]; + + const result = await createScheduleFromTool(deps, { + spaceId: "work", + botId: "atlas-1", + userId: "user-1", + threadId: "atlas-dm", + createdByBotId: "main-1", + botName: "Atlas", + name: "Weekly digest", + prompt: "Summarize the week", + schedule: { cron: "0 9 * * 1" }, + }); + + expect(result).toMatchObject({ ok: true, routineId: "routine-9", bot: "Atlas" }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + spaceId: "work", + botId: "atlas-1", + threadId: "atlas-dm", + createdByBotId: "main-1", + }), + }), + ); + expect(append).toHaveBeenCalledWith( + expect.objectContaining({ spaceId: "work", threadId: "atlas-dm", botId: "atlas-1" }), + ); + }); + + it("lists routines scheduled for other bots with their bot's name", async () => { + const row = (id: string, botId: string, botName: string) => ({ + id, + botId, + name: `${id} name`, + prompt: "p", + crons: ["0 9 * * *"], + active: true, + nextRunAt: null, + bot: { name: botName }, + }); + const deps = { + prisma: { + routine: { + findMany: vi.fn(async () => [ + row("own", "main-1", "Chief"), + row("for-atlas", "atlas-1", "Atlas"), + ]), + }, + }, + } as unknown as Parameters[0]; + + const listed = await listSchedulesFromTool(deps, { + spaceId: "home", + botId: "main-1", + userId: "user-1", + }); + expect(listed.routines[0]).not.toHaveProperty("bot"); + expect(listed.routines[1]).toMatchObject({ routineId: "for-atlas", bot: "Atlas" }); + }); + + it("refuses to cancel by a name that matches several routines", async () => { + const remove = vi.fn(async () => undefined); + const deps = { + prisma: { + routine: { + findMany: vi.fn(async () => [ + { id: "a", name: "Digest" }, + { id: "b", name: "Digest" }, + ]), + delete: remove, + }, + }, + events: { append: vi.fn(async () => undefined) }, + jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, + } as unknown as Parameters[0]; + + await expect( + cancelScheduleFromTool(deps, { + spaceId: "home", + botId: "main-1", + userId: "user-1", + name: "Digest", + }), + ).resolves.toEqual({ error: "Several schedules have that name. Cancel by routineId." }); + expect(remove).not.toHaveBeenCalled(); + }); + + it("adds the bot option to schedule_create without touching the original definition", () => { + const tool = { + name: "schedule_create", + description: "Create a schedule", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + }; + const extended = withScheduleTargetBot(tool); + expect(Object.keys(extended.inputSchema.properties as object)).toEqual(["name", "bot"]); + expect(extended.inputSchema.required).toEqual(["name"]); + expect(Object.keys(tool.inputSchema.properties)).toEqual(["name"]); + }); +}); diff --git a/packages/adapters/src/schedule-tools.ts b/packages/adapters/src/schedule-tools.ts index f3e1b3f..37ce593 100644 --- a/packages/adapters/src/schedule-tools.ts +++ b/packages/adapters/src/schedule-tools.ts @@ -1,4 +1,4 @@ -import type { JobPublisher } from "@rakazo/adapter-kit"; +import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; import { cronFromPreset, @@ -13,6 +13,25 @@ export { isOneShotRoutineCron, ONCE_ROUTINE_CRON }; export const SCHEDULE_TOOL_NAMES = new Set(["schedule_create", "schedule_list", "schedule_cancel"]); +/** schedule_create as the main bot sees it: it may also schedule for one of the user's bots. */ +export function withScheduleTargetBot(tool: ConnectorTool): ConnectorTool { + const properties = (tool.inputSchema.properties ?? {}) as Record; + return { + ...tool, + inputSchema: { + ...tool.inputSchema, + properties: { + ...properties, + bot: { + type: "string", + description: + "Optional name or id of one of the user's specialists who should run this schedule. Omit to schedule for yourself.", + }, + }, + }, + }; +} + export function filterBuiltinToolsForRun( tools: T[], runTrigger: string, @@ -171,14 +190,95 @@ export interface ScheduleToolDeps { jobs: JobPublisher; } +/** The bot a schedule belongs to and the thread it wakes in. */ +export interface ScheduleOwner { + spaceId: string; + botId: string; + userId: string; + threadId: string; + /** Set when the main bot schedules for another bot. */ + createdByBotId?: string; + botName?: string; +} + +/** + * Without `botRef` a schedule belongs to the creating bot and wakes in its current thread. The + * main bot may name one of the user's other bots in any space the user still belongs to: the + * schedule then belongs to that bot in its space and wakes in the main conversation when the bot + * is a member there, otherwise in the bot's 1:1 thread (`wakeRoutine` accepts only those). + */ +export async function resolveScheduleOwner( + prisma: PrismaClient, + creator: { botId: string; kind: string; spaceId: string; userId: string; threadId: string }, + botRef: string | undefined, +): Promise { + const own = { + spaceId: creator.spaceId, + botId: creator.botId, + userId: creator.userId, + threadId: creator.threadId, + }; + const ref = botRef?.trim(); + if (!ref) return own; + if (creator.kind !== "main") + return { error: "Only the main agent can schedule for another bot." }; + const candidates = await prisma.bot.findMany({ + where: { + userId: creator.userId, + archivedAt: null, + space: { deletingAt: null, memberships: { some: { userId: creator.userId } } }, + }, + select: { id: true, name: true, spaceId: true, thread: { select: { id: true } } }, + }); + const target = pickScheduleTarget(candidates, ref, creator.spaceId); + if ("error" in target) return target; + if (target.id === creator.botId) return own; + if (!target.thread) return { error: `${target.name} has no chat to run in.` }; + const mainConversation = await prisma.chatGroup.findFirst({ + where: { + userId: creator.userId, + kind: "main", + archivedAt: null, + spaceId: target.spaceId, + members: { some: { botId: target.id } }, + }, + select: { thread: { select: { id: true } } }, + }); + return { + spaceId: target.spaceId, + botId: target.id, + userId: creator.userId, + threadId: mainConversation?.thread?.id ?? target.thread.id, + createdByBotId: creator.botId, + botName: target.name, + }; +} + +/** Id first, then exact name, then a case-insensitive name; ties prefer the creator's space. */ +function pickScheduleTarget( + candidates: T[], + ref: string, + preferredSpaceId: string, +): T | { error: string } { + const byId = candidates.find((candidate) => candidate.id === ref); + if (byId) return byId; + for (const matches of [ + candidates.filter((candidate) => candidate.name === ref), + candidates.filter((candidate) => candidate.name.toLowerCase() === ref.toLowerCase()), + ]) { + if (matches.length === 1) return matches[0]!; + if (matches.length === 0) continue; + const local = matches.filter((candidate) => candidate.spaceId === preferredSpaceId); + if (local.length === 1) return local[0]!; + return { error: `Several bots are named ${ref}. Use the bot id.` }; + } + return { error: `No bot named ${ref}.` }; +} + /** Persist and enqueue a bot routine that wakes in the originating thread. */ export async function createScheduleFromTool( deps: ScheduleToolDeps, - input: { - spaceId: string; - botId: string; - userId: string; - threadId: string; + input: ScheduleOwner & { name: string; prompt: string; timezone?: string; @@ -200,6 +300,7 @@ export async function createScheduleFromTool( botId: input.botId, userId: input.userId, threadId: input.threadId, + createdByBotId: input.createdByBotId ?? null, name, prompt, crons: [resolved.cron], @@ -244,22 +345,42 @@ export async function createScheduleFromTool( cron: row.crons[0], nextRunAt: row.nextRunAt?.toISOString() ?? null, oneShot: resolved.oneShot, + ...(input.createdByBotId ? { bot: input.botName } : {}), }; } -/** List bot routines, optionally restricted to the current group thread. */ +/** + * Routines a bot manages: its own (in a group only the ones of that thread) and the ones it + * scheduled for other bots, wherever they wake. + */ +function managedRoutines(input: { + spaceId: string; + botId: string; + userId: string; + threadId?: string; +}) { + return { + OR: [ + { + spaceId: input.spaceId, + botId: input.botId, + userId: input.userId, + ...(input.threadId ? { threadId: input.threadId } : {}), + }, + { userId: input.userId, createdByBotId: input.botId }, + ], + }; +} + +/** List the routines a bot manages; ones it scheduled for another bot name that bot. */ export async function listSchedulesFromTool( deps: Pick, input: { spaceId: string; botId: string; userId: string; threadId?: string }, ) { const rows = await deps.prisma.routine.findMany({ - where: { - spaceId: input.spaceId, - botId: input.botId, - userId: input.userId, - ...(input.threadId ? { threadId: input.threadId } : {}), - }, + where: managedRoutines(input), orderBy: { createdAt: "desc" }, + include: { bot: { select: { name: true } } }, }); return { routines: rows.map((row) => ({ @@ -270,11 +391,12 @@ export async function listSchedulesFromTool( active: row.active, nextRunAt: row.nextRunAt?.toISOString() ?? null, oneShot: isOneShotRoutineCrons(row.crons), + ...(row.botId === input.botId ? {} : { bot: row.bot.name }), })), }; } -/** Cancel a bot routine, optionally restricted to the current group thread. */ +/** Cancel a routine the bot manages by id or by a name that matches exactly one of them. */ export async function cancelScheduleFromTool( deps: ScheduleToolDeps, input: { @@ -292,15 +414,13 @@ export async function cancelScheduleFromTool( return { error: "Provide routineId or the exact schedule name to cancel." }; } - const existing = await deps.prisma.routine.findFirst({ - where: { - spaceId: input.spaceId, - botId: input.botId, - userId: input.userId, - ...(input.threadId ? { threadId: input.threadId } : {}), - ...(routineId ? { id: routineId } : { name: name! }), - }, + const matches = await deps.prisma.routine.findMany({ + where: { ...managedRoutines(input), ...(routineId ? { id: routineId } : { name: name! }) }, + take: 2, }); + if (matches.length > 1) + return { error: "Several schedules have that name. Cancel by routineId." }; + const existing = matches[0]; if (!existing) return { error: "Schedule not found." }; await deps.prisma.routine.delete({ where: { id: existing.id } }); diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 18fe1c7..fb4ff0f 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -320,6 +320,28 @@ export const BOT_NAME_MAX_LENGTH = 80; export const BOT_TITLE_MAX_LENGTH = 500; export const BOT_DESCRIPTION_MAX_LENGTH = 4000; export const BOT_INSTRUCTIONS_MAX_LENGTH = 20000; +export const BOT_TAG_MAX_LENGTH = 40; +export const BOT_TAGS_MAX = 20; +/** Upper bound for an allowlist; the router checks each name against the builtin tools. */ +export const BOT_ALLOWED_TOOLS_MAX = 100; + +const distinct = (values: string[]) => new Set(values).size === values.length; + +const BotTags = z + .array(z.string().trim().min(1).max(BOT_TAG_MAX_LENGTH)) + .max(BOT_TAGS_MAX) + .refine(distinct, { error: "tags must be distinct" }); + +/** Trims tags and drops empty or repeated ones, within the limits the bot inputs enforce. */ +export function normalizeBotTags(values: readonly string[]): string[] { + const tags = values.map((value) => value.trim().slice(0, BOT_TAG_MAX_LENGTH).trim()); + return [...new Set(tags.filter(Boolean))].slice(0, BOT_TAGS_MAX); +} + +const BotAllowedBuiltinTools = z + .array(z.string().min(1).max(64)) + .max(BOT_ALLOWED_TOOLS_MAX) + .refine(distinct, { error: "allowedBuiltinTools must be distinct" }); export const CreateBotInput = z.object({ name: z.string().trim().min(1).max(BOT_NAME_MAX_LENGTH), @@ -331,6 +353,9 @@ export const CreateBotInput = z.object({ computerMode: ComputerModeSchema.default("team"), /** Idempotency key within a space (unique with spaceId). */ spawnKey: z.string().trim().min(1).max(120).optional(), + tags: BotTags.optional(), + restrictBuiltinTools: z.boolean().optional(), + allowedBuiltinTools: BotAllowedBuiltinTools.optional(), }); export type CreateBotInput = z.infer; @@ -365,6 +390,9 @@ export const UpdateBotInput = z thinkingLevel: ThinkingLevelSchema.nullable().optional(), teamChatAmbientEnabled: z.boolean().optional(), teamChatRules: z.string().max(TEAM_CHAT_RULES_MAX_LENGTH).optional(), + tags: BotTags.optional(), + restrictBuiltinTools: z.boolean().optional(), + allowedBuiltinTools: BotAllowedBuiltinTools.optional(), }) .superRefine((value, ctx) => { const providerProvided = value.modelProvider !== undefined; diff --git a/packages/contracts/src/index.test.ts b/packages/contracts/src/index.test.ts index 6f15467..1041bf4 100644 --- a/packages/contracts/src/index.test.ts +++ b/packages/contracts/src/index.test.ts @@ -12,6 +12,7 @@ import { MessageBlock, ModelConnectInputSchema, ModelOAuthBeginSchema, + normalizeBotTags, normalizeCreateBotProfile, ProductEventType, parseModelContextWindow, @@ -171,6 +172,50 @@ describe("contracts", () => { expect(UpdateBotInput.safeParse({ botId: "bot-1", name: " " }).success).toBe(false); }); + it("accepts capability tags and a builtin tool allowlist on bot create and update", () => { + expect( + CreateBotInput.parse({ + name: "Scout", + tags: [" research ", "web"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["web_search"], + }), + ).toMatchObject({ + tags: ["research", "web"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["web_search"], + }); + expect(UpdateBotInput.parse({ botId: "bot-1", allowedBuiltinTools: [] })).toEqual({ + botId: "bot-1", + allowedBuiltinTools: [], + }); + expect(UpdateBotInput.safeParse({ botId: "bot-1", tags: ["a", "a"] }).success).toBe(false); + expect(UpdateBotInput.safeParse({ botId: "bot-1", tags: [" "] }).success).toBe(false); + expect( + UpdateBotInput.safeParse({ + botId: "bot-1", + tags: Array.from({ length: 21 }, (_, index) => `tag-${index}`), + }).success, + ).toBe(false); + expect( + UpdateBotInput.safeParse({ botId: "bot-1", allowedBuiltinTools: ["shell", "shell"] }).success, + ).toBe(false); + }); + + it("normalizes typed tags into what the bot inputs accept", () => { + expect(normalizeBotTags([" research", "", "web ", "research", " "])).toEqual([ + "research", + "web", + ]); + const tags = normalizeBotTags([ + "t".repeat(60), + ...Array.from({ length: 30 }, (_, index) => `tag-${index}`), + ]); + expect(tags).toHaveLength(20); + expect(tags[0]).toHaveLength(40); + expect(UpdateBotInput.safeParse({ botId: "bot-1", tags }).success).toBe(true); + }); + it("rejects partial model override clears on bot update", () => { expect(UpdateBotInput.safeParse({ botId: "bot-1", modelId: null }).success).toBe(false); expect(UpdateBotInput.safeParse({ botId: "bot-1", modelProvider: null }).success).toBe(false); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b3a7091..0442839 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -254,6 +254,8 @@ export const appContract = { duplicate: oc.input(botId).output(BotSchema), reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })), update: oc.input(UpdateBotInput).output(BotSchema), + /** Builtin tool names this bot's allowlist can choose from. */ + builtinTools: oc.input(botId).output(z.array(z.string())), setComputer: oc.input(z.object({ botId: Id, mode: ComputerModeSchema })).output(BotSchema), archive: oc.input(botId).output(z.object({ ok: z.literal(true) })), restore: oc.input(botId).output(z.object({ ok: z.literal(true) })), diff --git a/packages/db/prisma/migrations/20261002210600_ws6_routine_created_by_bot/migration.sql b/packages/db/prisma/migrations/20261002210600_ws6_routine_created_by_bot/migration.sql new file mode 100644 index 0000000..4ef7b17 --- /dev/null +++ b/packages/db/prisma/migrations/20261002210600_ws6_routine_created_by_bot/migration.sql @@ -0,0 +1,7 @@ +-- The main bot can schedule routines for the user's other bots. Remember which bot created a +-- routine so the main bot can list and cancel exactly the ones it scheduled. Like the other +-- provenance ids (user_facts.sourceBotId, delegated_tasks.requesterBotId) it has no foreign key. +ALTER TABLE "routines" ADD COLUMN "createdByBotId" TEXT; + +-- CreateIndex +CREATE INDEX "routines_createdByBotId_idx" ON "routines"("createdByBotId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 93f9a61..a679e5e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -753,6 +753,8 @@ model Routine { userId String threadId String? thread Thread? @relation(fields: [threadId], references: [id], onDelete: SetNull) + /// The main bot when it scheduled this routine for another bot; null when the bot or the user created it. + createdByBotId String? name String prompt String crons String[] @@ -770,6 +772,7 @@ model Routine { @@index([active, nextRunAt]) @@index([spaceId, botId]) + @@index([createdByBotId]) @@map("routines") } diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index c8d1c0c..086a950 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -401,6 +401,9 @@ export function createRepos(prisma: PrismaClient) { spawnKey?: string; /** Only the main-agent helpers pass "main"; every other caller creates specialists. */ kind?: BotKind; + tags?: string[]; + restrictBuiltinTools?: boolean; + allowedBuiltinTools?: string[]; modelProvider?: string | null; modelId?: string | null; thinkingLevel?: string | null; @@ -421,6 +424,8 @@ export function createRepos(prisma: PrismaClient) { let modelProvider = input.modelProvider ?? null; let modelId = input.modelId ?? null; let thinkingLevel = input.thinkingLevel ?? null; + let restrictBuiltinTools = input.restrictBuiltinTools ?? false; + let allowedBuiltinTools = input.allowedBuiltinTools ?? []; if (input.parentBotId) { const parent = await prisma.bot.findFirst({ where: { @@ -435,6 +440,13 @@ export function createRepos(prisma: PrismaClient) { modelId = parent.modelId ?? null; } if (thinkingLevel == null) thinkingLevel = parent.thinkingLevel ?? null; + // A bot it creates never gets builtin tools the restricted parent lacks. + if (parent.restrictBuiltinTools) { + allowedBuiltinTools = restrictBuiltinTools + ? allowedBuiltinTools.filter((name) => parent.allowedBuiltinTools.includes(name)) + : parent.allowedBuiltinTools; + restrictBuiltinTools = true; + } } const settings = await prisma.deploymentSettings.findUnique({ where: { id: "default" } }); const envKind = process.env.SANDBOX_PROVIDER ?? "docker"; @@ -471,6 +483,9 @@ export function createRepos(prisma: PrismaClient) { computerId: teamComputer.id, spawnKey: input.spawnKey, kind: input.kind ?? "specialist", + tags: input.tags ?? [], + restrictBuiltinTools, + allowedBuiltinTools, modelProvider, modelId, thinkingLevel, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 6fe0dd9..f28d592 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -83,6 +83,7 @@ describeWithDatabase("API authorization and resource isolation", () => { ["bots/create", botInput("Unauthenticated")], ["bots/duplicate", { botId: "missing-bot" }], ["bots/update", { botId: "missing-bot", name: "Nope" }], + ["bots/builtinTools", { botId: "missing-bot" }], ["bots/archive", { botId: "missing-bot" }], ["bots/restore", { botId: "missing-bot" }], ["bots/remove", { botId: "missing-bot" }], @@ -310,6 +311,7 @@ describeWithDatabase("API authorization and resource isolation", () => { ["bots/get", { botId: ownerBot.id }], ["bots/duplicate", { botId: ownerBot.id }], ["bots/update", { botId: ownerBot.id, name: "Stolen Bot" }], + ["bots/builtinTools", { botId: ownerBot.id }], ["bots/archive", { botId: ownerBot.id }], ["bots/restore", { botId: ownerBot.id }], ["threads/get", { botId: ownerBot.id }], diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index 217ec22..91b48e0 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -3,18 +3,24 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator, + cancelScheduleFromTool, createScheduleFromTool, DesktopSandboxProvider, FakeSandboxProvider, handoffToGroupBot, + listSchedulesFromTool, ManagedSandboxEmulator, + resolveScheduleOwner, toComputerRef, } from "@rakazo/adapters"; import { ACTIVE_RUN_STATUSES, ONCE_ROUTINE_CRON } from "@rakazo/core"; import { appendEvent, + createRepos, createThreadEvents, createThreadMessage, + ensureMainBot, + joinMainConversation, RunHistoryWriteError, } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -1233,6 +1239,218 @@ describeJourneys("required product journeys", () => { ).toBe(1); }); + it("5c: the main bot schedules for a specialist in its own space or in the main conversation", async () => { + const cookie = await signup(app, `schedule-behalf-j-${stamp}@rakazo.test`, "Schedule Behalf"); + const me = await rpc(app, cookie, "me"); + const main = await ensureMainBot(prisma, me.userId); + const writer = await rpc(app, cookie, "bots/create", { + name: "Writer", + title: "", + description: "", + instructions: "", + notifyOnFinish: false, + }); + const lab = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Lab" }); + const scout = await createRepos(prisma).createBot( + { userId: me.userId, spaceId: lab.id, email: "", isDeploymentOwner: false }, + { name: "Scout", title: "", description: "", instructions: "", notifyOnFinish: false }, + ); + const scheduleDeps = { prisma, events: createThreadEvents(prisma), jobs }; + const creator = { + botId: main.id, + kind: "main", + spaceId: main.spaceId, + userId: me.userId, + threadId: main.threadId, + }; + + async function scheduleAndWake(botName: string, routineName: string) { + const owner = await resolveScheduleOwner(prisma, creator, botName); + if ("error" in owner) throw new Error(owner.error); + const created = await createScheduleFromTool(scheduleDeps, { + ...owner, + name: routineName, + prompt: `say ${routineName}-ok`, + schedule: { delaySeconds: 30 }, + }); + if (!("ok" in created)) throw new Error(created.error); + expect(created.bot).toBe(botName); + const routine = await prisma.routine.findUniqueOrThrow({ where: { id: created.routineId } }); + const dueAt = new Date(Date.now() - 1_000); + await prisma.routine.update({ where: { id: routine.id }, data: { nextRunAt: dueAt } }); + await executor.wakeRoutine(routine.id, dueAt.toISOString()); + await waitForDatabase(async () => + Boolean(await prisma.run.findFirst({ where: { routineId: routine.id } })), + ); + const run = await prisma.run.findFirstOrThrow({ where: { routineId: routine.id } }); + return { routine, run }; + } + + const inLab = await scheduleAndWake("Scout", "lab-digest"); + expect(inLab.routine).toMatchObject({ + botId: scout.id, + spaceId: lab.id, + threadId: scout.threadId, + createdByBotId: main.id, + }); + expect(inLab.run).toMatchObject({ + botId: scout.id, + spaceId: lab.id, + threadId: scout.threadId, + trigger: "routine", + }); + + await joinMainConversation(prisma, { userId: me.userId, botIds: [writer.id] }); + const conversation = await prisma.chatGroup.findFirstOrThrow({ + where: { userId: me.userId, kind: "main" }, + include: { thread: true }, + }); + const inMain = await scheduleAndWake("Writer", "main-digest"); + expect(inMain.routine).toMatchObject({ + botId: writer.id, + threadId: conversation.thread?.id, + createdByBotId: main.id, + }); + expect(inMain.run).toMatchObject({ botId: writer.id, threadId: conversation.thread?.id }); + + const listed = await listSchedulesFromTool(scheduleDeps, { + spaceId: main.spaceId, + botId: main.id, + userId: me.userId, + threadId: conversation.thread?.id, + }); + expect(listed.routines.map((routine) => [routine.name, routine.bot])).toEqual( + expect.arrayContaining([ + ["lab-digest", "Scout"], + ["main-digest", "Writer"], + ]), + ); + expect( + await listSchedulesFromTool(scheduleDeps, { + spaceId: lab.id, + botId: scout.id, + userId: me.userId, + }), + ).toMatchObject({ routines: [{ name: "lab-digest" }] }); + + expect( + await cancelScheduleFromTool(scheduleDeps, { + spaceId: main.spaceId, + botId: main.id, + userId: me.userId, + name: "lab-digest", + }), + ).toMatchObject({ ok: true, routineId: inLab.routine.id }); + expect(await prisma.routine.findUnique({ where: { id: inLab.routine.id } })).toBeNull(); + // Another specialist neither owns nor scheduled the Writer's routine. + expect( + await cancelScheduleFromTool(scheduleDeps, { + spaceId: lab.id, + botId: scout.id, + userId: me.userId, + name: "main-digest", + }), + ).toEqual({ error: "Schedule not found." }); + expect(await prisma.routine.findUnique({ where: { id: inMain.routine.id } })).not.toBeNull(); + + const retired = await rpc(app, cookie, "bots/create", { + name: "Retired", + title: "", + description: "", + instructions: "", + notifyOnFinish: false, + }); + await rpc(app, cookie, "bots/archive", { botId: retired.id }); + expect(await resolveScheduleOwner(prisma, creator, "Retired")).toEqual({ + error: "No bot named Retired.", + }); + expect( + await resolveScheduleOwner( + prisma, + { ...creator, botId: scout.id, kind: "specialist" }, + "Writer", + ), + ).toEqual({ error: "Only the main agent can schedule for another bot." }); + }); + + it("5d: a bot's builtin tool allowlist holds in its runs and in bots it creates", async () => { + const cookie = await signup(app, `allowlist-j-${stamp}@rakazo.test`, "Allowlist"); + const bot = await rpc( + app, + cookie, + "bots/create", + { + name: "Reader", + title: "", + description: "", + instructions: "", + notifyOnFinish: false, + tags: ["research"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["read_file"], + }, + ); + expect(bot).toMatchObject({ + tags: ["research"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["read_file"], + }); + expect(await rpc(app, cookie, "bots/builtinTools", { botId: bot.id })).toEqual( + expect.arrayContaining(["read_file", "write_file", "schedule_create"]), + ); + + await sendAndWait( + app, + cookie, + bot.id, + "write a file in your home called notes/blocked.txt that says blocked", + ); + await expect( + rpc(app, cookie, "computer/readFile", { botId: bot.id, path: "notes/blocked.txt" }), + ).rejects.toThrow(); + + await expect( + rpc(app, cookie, "bots/update", { botId: bot.id, allowedBuiltinTools: ["write_fiel"] }), + ).rejects.toThrow(/Unknown tools: write_fiel/); + await rpc(app, cookie, "bots/update", { + botId: bot.id, + allowedBuiltinTools: ["write_file", "spawn_bot"], + }); + await sendAndWait( + app, + cookie, + bot.id, + "write a file in your home called notes/allowed.txt that says allowed-ok", + ); + expect( + ( + await rpc<{ content: string }>(app, cookie, "computer/readFile", { + botId: bot.id, + path: "notes/allowed.txt", + }) + ).content, + ).toContain("allowed-ok"); + + await sendAndWait(app, cookie, bot.id, "create a bot named Junior"); + const child = await prisma.bot.findFirstOrThrow({ where: { parentBotId: bot.id } }); + expect(child).toMatchObject({ + name: "Junior", + restrictBuiltinTools: true, + allowedBuiltinTools: ["write_file", "spawn_bot"], + }); + + const copy = await rpc<{ restrictBuiltinTools: boolean; allowedBuiltinTools: string[] }>( + app, + cookie, + "bots/duplicate", + { botId: bot.id }, + ); + expect(copy).toMatchObject({ + restrictBuiltinTools: true, + allowedBuiltinTools: ["write_file", "spawn_bot"], + }); + }); + it("allocates event and message cursors atomically under concurrent writes", async () => { const cookie = await signup(app, `sequence-j-${stamp}@rakazo.test`, "Sequence"); const actor = await rpc(app, cookie, "me");