From d682b604de6ce90a55d575778c9358839cc90cb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 06:28:09 +0000 Subject: [PATCH] fix(tools): harden tool schemas for strict providers --- AGENTS.md | 1 + src/agents/bash-tools.ts | 14 -- src/agents/schema/typebox.ts | 27 +++ src/agents/tools/browser-tool.ts | 60 ++++--- src/agents/tools/canvas-tool.ts | 109 +++++------- src/agents/tools/cron-tool.ts | 109 ++++-------- src/agents/tools/discord-actions-guild.ts | 1 + src/agents/tools/discord-actions.test.ts | 38 ++++ src/agents/tools/gateway-tool.ts | 8 +- src/agents/tools/message-tool.ts | 37 ++-- src/agents/tools/nodes-tool.ts | 200 +++++++--------------- src/agents/tools/sessions-spawn-tool.ts | 5 +- src/providers/plugins/actions/discord.ts | 17 +- 13 files changed, 253 insertions(+), 373 deletions(-) create mode 100644 src/agents/schema/typebox.ts diff --git a/AGENTS.md b/AGENTS.md index 3fcf5c00c..076fe8688 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,7 @@ - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). +- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`. - When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index 97fa45cc2..259b89d29 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -40,20 +40,6 @@ const DEFAULT_PATH = process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; -// NOTE: Using Type.Unsafe with enum instead of Type.Union([Type.Literal(...)]) -// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. -// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] } -// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works. -const _stringEnum = ( - values: T, - options?: { description?: string }, -) => - Type.Unsafe({ - type: "string", - enum: values as unknown as string[], - ...options, - }); - export type ExecToolDefaults = { backgroundMs?: number; timeoutSec?: number; diff --git a/src/agents/schema/typebox.ts b/src/agents/schema/typebox.ts new file mode 100644 index 000000000..982f0fe77 --- /dev/null +++ b/src/agents/schema/typebox.ts @@ -0,0 +1,27 @@ +import { Type } from "@sinclair/typebox"; + +type StringEnumOptions = { + description?: string; + title?: string; + default?: T[number]; +}; + +// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf. +// Some providers reject anyOf in tool schemas; a flat string enum is safer. +export function stringEnum( + values: T, + options: StringEnumOptions = {}, +) { + return Type.Unsafe({ + type: "string", + enum: [...values], + ...options, + }); +} + +export function optionalStringEnum( + values: T, + options: StringEnumOptions = {}, +) { + return Type.Optional(stringEnum(values, options)); +} diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 0f0bcb433..b3c89d139 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -22,6 +22,7 @@ import { import { resolveBrowserConfig } from "../../browser/config.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; import { loadConfig } from "../../config/config.js"; +import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, imageResultFromFile, @@ -43,16 +44,35 @@ const BROWSER_ACT_KINDS = [ "close", ] as const; -type BrowserActKind = (typeof BROWSER_ACT_KINDS)[number]; +const BROWSER_TOOL_ACTIONS = [ + "status", + "start", + "stop", + "tabs", + "open", + "focus", + "close", + "snapshot", + "screenshot", + "navigate", + "console", + "pdf", + "upload", + "dialog", + "act", +] as const; + +const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const; + +const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const; + +const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const; // NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. // The discriminator (kind) determines which properties are relevant; runtime validates. const BrowserActSchema = Type.Object({ - kind: Type.Unsafe({ - type: "string", - enum: [...BROWSER_ACT_KINDS], - }), + kind: stringEnum(BROWSER_ACT_KINDS), // Common fields targetId: Type.Optional(Type.String()), ref: Type.Optional(Type.String()), @@ -89,37 +109,15 @@ const BrowserActSchema = Type.Object({ // A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`), // which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object. const BrowserToolSchema = Type.Object({ - action: Type.Union([ - Type.Literal("status"), - Type.Literal("start"), - Type.Literal("stop"), - Type.Literal("tabs"), - Type.Literal("open"), - Type.Literal("focus"), - Type.Literal("close"), - Type.Literal("snapshot"), - Type.Literal("screenshot"), - Type.Literal("navigate"), - Type.Literal("console"), - Type.Literal("pdf"), - Type.Literal("upload"), - Type.Literal("dialog"), - Type.Literal("act"), - ]), - target: Type.Optional( - Type.Union([ - Type.Literal("sandbox"), - Type.Literal("host"), - Type.Literal("custom"), - ]), - ), + action: stringEnum(BROWSER_TOOL_ACTIONS), + target: optionalStringEnum(BROWSER_TARGETS), profile: Type.Optional(Type.String()), controlUrl: Type.Optional(Type.String()), targetUrl: Type.Optional(Type.String()), targetId: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), maxChars: Type.Optional(Type.Number()), - format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])), + format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS), interactive: Type.Optional(Type.Boolean()), compact: Type.Optional(Type.Boolean()), depth: Type.Optional(Type.Number()), @@ -128,7 +126,7 @@ const BrowserToolSchema = Type.Object({ fullPage: Type.Optional(Type.Boolean()), ref: Type.Optional(Type.String()), element: Type.Optional(Type.String()), - type: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")])), + type: optionalStringEnum(BROWSER_IMAGE_TYPES), level: Type.Optional(Type.String()), paths: Type.Optional(Type.Array(Type.String())), inputRef: Type.Optional(Type.String()), diff --git a/src/agents/tools/canvas-tool.ts b/src/agents/tools/canvas-tool.ts index 3fbef2db1..66c4f869d 100644 --- a/src/agents/tools/canvas-tool.ts +++ b/src/agents/tools/canvas-tool.ts @@ -8,6 +8,7 @@ import { parseCanvasSnapshotPayload, } from "../../cli/nodes-canvas.js"; import { imageMimeFromFormat } from "../../media/mime.js"; +import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, imageResult, @@ -17,76 +18,44 @@ import { import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { resolveNodeId } from "./nodes-utils.js"; -const CanvasToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("present"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - target: Type.Optional(Type.String()), - x: Type.Optional(Type.Number()), - y: Type.Optional(Type.Number()), - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("hide"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("navigate"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - url: Type.String(), - }), - Type.Object({ - action: Type.Literal("eval"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - javaScript: Type.String(), - }), - Type.Object({ - action: Type.Literal("snapshot"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - format: Type.Optional( - Type.Union([ - Type.Literal("png"), - Type.Literal("jpg"), - Type.Literal("jpeg"), - ]), - ), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("a2ui_push"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - jsonl: Type.Optional(Type.String()), - jsonlPath: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("a2ui_reset"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - }), -]); +const CANVAS_ACTIONS = [ + "present", + "hide", + "navigate", + "eval", + "snapshot", + "a2ui_push", + "a2ui_reset", +] as const; + +const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const; + +// Flattened schema: runtime validates per-action requirements. +const CanvasToolSchema = Type.Object({ + action: stringEnum(CANVAS_ACTIONS), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + node: Type.Optional(Type.String()), + // present + target: Type.Optional(Type.String()), + x: Type.Optional(Type.Number()), + y: Type.Optional(Type.Number()), + width: Type.Optional(Type.Number()), + height: Type.Optional(Type.Number()), + // navigate + url: Type.Optional(Type.String()), + // eval + javaScript: Type.Optional(Type.String()), + // snapshot + format: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS), + maxWidth: Type.Optional(Type.Number()), + quality: Type.Optional(Type.Number()), + delayMs: Type.Optional(Type.Number()), + // a2ui_push + jsonl: Type.Optional(Type.String()), + jsonlPath: Type.Optional(Type.String()), +}); export function createCanvasTool(): AnyAgentTool { return { diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 519b707cd..7d331a78c 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -3,89 +3,42 @@ import { normalizeCronJobCreate, normalizeCronJobPatch, } from "../../cron/normalize.js"; +import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch -// instead of CronAddParamsSchema/CronJobPatchSchema because: -// -// 1. CronAddParamsSchema contains nested Type.Union (for schedule, payload, etc.) -// 2. TypeBox compiles Type.Union to JSON Schema `anyOf` -// 3. pi-ai's sanitizeSchemaForGoogle() strips `anyOf` from nested properties -// 4. This leaves empty schemas `{}` which Claude rejects as invalid -// -// The actual validation happens at runtime via normalizeCronJobCreate/Patch -// and the gateway's validateCronAddParams. This schema just needs to accept -// any object so the AI can pass through the job definition. -// -// See: https://github.com/anthropics/anthropic-cookbook/blob/main/misc/tool_use_best_practices.md -// Claude requires valid JSON Schema 2020-12 with explicit types. +// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas +// contain nested unions. Tool schemas need to stay provider-friendly, so we +// accept "any object" here and validate at runtime. -const CronToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("status"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("list"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - includeDisabled: Type.Optional(Type.Boolean()), - }), - Type.Object({ - action: Type.Literal("add"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - job: Type.Object({}, { additionalProperties: true }), - }), - Type.Object({ - action: Type.Literal("update"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.Optional(Type.String()), - id: Type.Optional(Type.String()), - patch: Type.Object({}, { additionalProperties: true }), - }), - Type.Object({ - action: Type.Literal("remove"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.Optional(Type.String()), - id: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("run"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.Optional(Type.String()), - id: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("runs"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.Optional(Type.String()), - id: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("wake"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - text: Type.String(), - mode: Type.Optional( - Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]), - ), - }), -]); +const CRON_ACTIONS = [ + "status", + "list", + "add", + "update", + "remove", + "run", + "runs", + "wake", +] as const; + +const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const; + +// Flattened schema: runtime validates per-action requirements. +const CronToolSchema = Type.Object({ + action: stringEnum(CRON_ACTIONS), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + includeDisabled: Type.Optional(Type.Boolean()), + job: Type.Optional(Type.Object({}, { additionalProperties: true })), + jobId: Type.Optional(Type.String()), + id: Type.Optional(Type.String()), + patch: Type.Optional(Type.Object({}, { additionalProperties: true })), + text: Type.Optional(Type.String()), + mode: optionalStringEnum(CRON_WAKE_MODES), +}); export function createCronTool(): AnyAgentTool { return { diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 48efa1a15..9650f8360 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -31,6 +31,7 @@ import { function readParentIdParam( params: Record, ): string | null | undefined { + if (params.clearParent === true) return null; if (params.parentId === null) return null; return readStringParam(params, "parentId"); } diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index 6ba0627ad..ecc8424c3 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -218,6 +218,26 @@ describe("handleDiscordGuildAction - channel management", () => { }); }); + it("clears the channel parent when clearParent is true", async () => { + await handleDiscordGuildAction( + "channelEdit", + { + channelId: "C1", + clearParent: true, + }, + channelsEnabled, + ); + expect(editChannelDiscord).toHaveBeenCalledWith({ + channelId: "C1", + name: undefined, + topic: undefined, + position: undefined, + parentId: null, + nsfw: undefined, + rateLimitPerUser: undefined, + }); + }); + it("deletes a channel", async () => { await handleDiscordGuildAction( "channelDelete", @@ -264,6 +284,24 @@ describe("handleDiscordGuildAction - channel management", () => { }); }); + it("clears the channel parent on move when clearParent is true", async () => { + await handleDiscordGuildAction( + "channelMove", + { + guildId: "G1", + channelId: "C1", + clearParent: true, + }, + channelsEnabled, + ); + expect(moveChannelDiscord).toHaveBeenCalledWith({ + guildId: "G1", + channelId: "C1", + parentId: null, + position: undefined, + }); + }); + it("creates a category with type=4", async () => { await handleDiscordGuildAction( "categoryCreate", diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index e3b83e851..914f79318 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -7,6 +7,7 @@ import { type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; +import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool } from "./gateway.js"; @@ -18,16 +19,11 @@ const GATEWAY_ACTIONS = [ "update.run", ] as const; -type GatewayAction = (typeof GATEWAY_ACTIONS)[number]; - // NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. // The discriminator (action) determines which properties are relevant; runtime validates. const GatewayToolSchema = Type.Object({ - action: Type.Unsafe({ - type: "string", - enum: [...GATEWAY_ACTIONS], - }), + action: stringEnum(GATEWAY_ACTIONS), // restart delayMs: Type.Optional(Type.Number()), reason: Type.Optional(Type.String()), diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4ba4b062f..e1c2590ac 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -16,6 +16,7 @@ import { type ProviderMessageActionName, } from "../../providers/plugins/types.js"; import { normalizeAccountId } from "../../routing/session-key.js"; +import { stringEnum } from "../schema/typebox.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; @@ -90,12 +91,17 @@ const MessageToolCommonSchema = { timeoutMs: Type.Optional(Type.Number()), name: Type.Optional(Type.String()), type: Type.Optional(Type.Number()), - parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + parentId: Type.Optional(Type.String()), topic: Type.Optional(Type.String()), position: Type.Optional(Type.Number()), nsfw: Type.Optional(Type.Boolean()), rateLimitPerUser: Type.Optional(Type.Number()), categoryId: Type.Optional(Type.String()), + clearParent: Type.Optional( + Type.Boolean({ + description: "Clear the parent/category when supported by the provider.", + }), + ), }; function buildMessageToolSchemaFromActions( @@ -105,31 +111,10 @@ function buildMessageToolSchemaFromActions( const props: Record = { ...MessageToolCommonSchema }; if (!options.includeButtons) delete props.buttons; - const schemas: Array> = []; - if (actions.includes("send")) { - schemas.push( - Type.Object({ - action: Type.Literal("send"), - to: Type.String(), - message: Type.String(), - ...props, - }), - ); - } - - const nonSendActions = actions.filter((action) => action !== "send"); - if (nonSendActions.length > 0) { - schemas.push( - Type.Object({ - action: Type.Union( - nonSendActions.map((action) => Type.Literal(action)), - ), - ...props, - }), - ); - } - - return schemas.length === 1 ? schemas[0] : Type.Union(schemas); + return Type.Object({ + action: stringEnum(actions), + ...props, + }); } const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index b3c47dfa2..ee0ad6828 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -18,151 +18,73 @@ import { } from "../../cli/nodes-screen.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { imageMimeFromFormat } from "../../media/mime.js"; +import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { sanitizeToolResultImages } from "../tool-images.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { resolveNodeId } from "./nodes-utils.js"; -const NodesToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("status"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), +const NODES_TOOL_ACTIONS = [ + "status", + "describe", + "pending", + "approve", + "reject", + "notify", + "camera_snap", + "camera_list", + "camera_clip", + "screen_record", + "location_get", + "run", +] as const; + +const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const; +const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const; +const CAMERA_FACING = ["front", "back", "both"] as const; +const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const; + +// Flattened schema: runtime validates per-action requirements. +const NodesToolSchema = Type.Object({ + action: stringEnum(NODES_TOOL_ACTIONS), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + node: Type.Optional(Type.String()), + requestId: Type.Optional(Type.String()), + // notify + title: Type.Optional(Type.String()), + body: Type.Optional(Type.String()), + sound: Type.Optional(Type.String()), + priority: optionalStringEnum(NOTIFY_PRIORITIES), + delivery: optionalStringEnum(NOTIFY_DELIVERIES), + // camera_snap / camera_clip + facing: optionalStringEnum(CAMERA_FACING, { + description: "camera_snap: front/back/both; camera_clip: front/back only.", }), - Type.Object({ - action: Type.Literal("describe"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - }), - Type.Object({ - action: Type.Literal("pending"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("approve"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - requestId: Type.String(), - }), - Type.Object({ - action: Type.Literal("reject"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - requestId: Type.String(), - }), - Type.Object({ - action: Type.Literal("notify"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - title: Type.Optional(Type.String()), - body: Type.Optional(Type.String()), - sound: Type.Optional(Type.String()), - priority: Type.Optional( - Type.Union([ - Type.Literal("passive"), - Type.Literal("active"), - Type.Literal("timeSensitive"), - ]), - ), - delivery: Type.Optional( - Type.Union([ - Type.Literal("system"), - Type.Literal("overlay"), - Type.Literal("auto"), - ]), - ), - }), - Type.Object({ - action: Type.Literal("camera_snap"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - facing: Type.Optional( - Type.Union([ - Type.Literal("front"), - Type.Literal("back"), - Type.Literal("both"), - ]), - ), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), - deviceId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("camera_list"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - }), - Type.Object({ - action: Type.Literal("camera_clip"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - facing: Type.Optional( - Type.Union([Type.Literal("front"), Type.Literal("back")]), - ), - duration: Type.Optional(Type.String()), - durationMs: Type.Optional(Type.Number()), - includeAudio: Type.Optional(Type.Boolean()), - deviceId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("screen_record"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - duration: Type.Optional(Type.String()), - durationMs: Type.Optional(Type.Number()), - fps: Type.Optional(Type.Number()), - screenIndex: Type.Optional(Type.Number()), - includeAudio: Type.Optional(Type.Boolean()), - outPath: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("location_get"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - maxAgeMs: Type.Optional(Type.Number()), - locationTimeoutMs: Type.Optional(Type.Number()), - desiredAccuracy: Type.Optional( - Type.Union([ - Type.Literal("coarse"), - Type.Literal("balanced"), - Type.Literal("precise"), - ]), - ), - }), - Type.Object({ - action: Type.Literal("run"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - command: Type.Array(Type.String()), - cwd: Type.Optional(Type.String()), - env: Type.Optional(Type.Array(Type.String())), - commandTimeoutMs: Type.Optional(Type.Number()), - invokeTimeoutMs: Type.Optional(Type.Number()), - needsScreenRecording: Type.Optional(Type.Boolean()), - }), -]); + maxWidth: Type.Optional(Type.Number()), + quality: Type.Optional(Type.Number()), + delayMs: Type.Optional(Type.Number()), + deviceId: Type.Optional(Type.String()), + duration: Type.Optional(Type.String()), + durationMs: Type.Optional(Type.Number()), + includeAudio: Type.Optional(Type.Boolean()), + // screen_record + fps: Type.Optional(Type.Number()), + screenIndex: Type.Optional(Type.Number()), + outPath: Type.Optional(Type.String()), + // location_get + maxAgeMs: Type.Optional(Type.Number()), + locationTimeoutMs: Type.Optional(Type.Number()), + desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY), + // run + command: Type.Optional(Type.Array(Type.String())), + cwd: Type.Optional(Type.String()), + env: Type.Optional(Type.Array(Type.String())), + commandTimeoutMs: Type.Optional(Type.Number()), + invokeTimeoutMs: Type.Optional(Type.Number()), + needsScreenRecording: Type.Optional(Type.Boolean()), +}); export function createNodesTool(): AnyAgentTool { return { diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index b0abee9f0..aba183b71 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -12,6 +12,7 @@ import { import type { GatewayMessageProvider } from "../../utils/message-provider.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; +import { optionalStringEnum } from "../schema/typebox.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { registerSubagentRun } from "../subagent-registry.js"; import type { AnyAgentTool } from "./common.js"; @@ -30,9 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({ runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), // Back-compat alias. Prefer runTimeoutSeconds. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), - cleanup: Type.Optional( - Type.Union([Type.Literal("delete"), Type.Literal("keep")]), - ), + cleanup: optionalStringEnum(["delete", "keep"] as const), }); function normalizeModelSelection(value: unknown): string | undefined { diff --git a/src/providers/plugins/actions/discord.ts b/src/providers/plugins/actions/discord.ts index 0960352cf..1038224bb 100644 --- a/src/providers/plugins/actions/discord.ts +++ b/src/providers/plugins/actions/discord.ts @@ -13,6 +13,14 @@ import type { const providerId = "discord"; +function readParentIdParam( + params: Record, +): string | null | undefined { + if (params.clearParent === true) return null; + if (params.parentId === null) return null; + return readStringParam(params, "parentId"); +} + export const discordMessageActions: ProviderMessageActionAdapter = { listActions: ({ cfg }) => { const accounts = listEnabledDiscordAccounts(cfg).filter( @@ -462,8 +470,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = { const guildId = readStringParam(params, "guildId", { required: true }); const name = readStringParam(params, "name", { required: true }); const type = readNumberParam(params, "type", { integer: true }); - const parentId = - params.parentId === null ? null : readStringParam(params, "parentId"); + const parentId = readParentIdParam(params); const topic = readStringParam(params, "topic"); const position = readNumberParam(params, "position", { integer: true }); const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined; @@ -489,8 +496,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = { const name = readStringParam(params, "name"); const topic = readStringParam(params, "topic"); const position = readNumberParam(params, "position", { integer: true }); - const parentId = - params.parentId === null ? null : readStringParam(params, "parentId"); + const parentId = readParentIdParam(params); const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined; const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", { integer: true, @@ -525,8 +531,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = { const channelId = readStringParam(params, "channelId", { required: true, }); - const parentId = - params.parentId === null ? null : readStringParam(params, "parentId"); + const parentId = readParentIdParam(params); const position = readNumberParam(params, "position", { integer: true }); return await handleDiscordAction( {