fix(tools): harden tool schemas for strict providers

This commit is contained in:
Peter Steinberger
2026-01-13 06:28:09 +00:00
parent fa75d84b75
commit d682b604de
13 changed files with 253 additions and 373 deletions

View File

@@ -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. - **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. - 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). - 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. - 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. - 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. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.

View File

@@ -40,20 +40,6 @@ const DEFAULT_PATH =
process.env.PATH ?? process.env.PATH ??
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; "/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 = <T extends readonly string[]>(
values: T,
options?: { description?: string },
) =>
Type.Unsafe<T[number]>({
type: "string",
enum: values as unknown as string[],
...options,
});
export type ExecToolDefaults = { export type ExecToolDefaults = {
backgroundMs?: number; backgroundMs?: number;
timeoutSec?: number; timeoutSec?: number;

View File

@@ -0,0 +1,27 @@
import { Type } from "@sinclair/typebox";
type StringEnumOptions<T extends readonly string[]> = {
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<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
export function optionalStringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Optional(stringEnum(values, options));
}

View File

@@ -22,6 +22,7 @@ import {
import { resolveBrowserConfig } from "../../browser/config.js"; import { resolveBrowserConfig } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { import {
type AnyAgentTool, type AnyAgentTool,
imageResultFromFile, imageResultFromFile,
@@ -43,16 +44,35 @@ const BROWSER_ACT_KINDS = [
"close", "close",
] as const; ] 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(...), ...]) // 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. // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates. // The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({ const BrowserActSchema = Type.Object({
kind: Type.Unsafe<BrowserActKind>({ kind: stringEnum(BROWSER_ACT_KINDS),
type: "string",
enum: [...BROWSER_ACT_KINDS],
}),
// Common fields // Common fields
targetId: Type.Optional(Type.String()), targetId: Type.Optional(Type.String()),
ref: 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`), // A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object. // which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
const BrowserToolSchema = Type.Object({ const BrowserToolSchema = Type.Object({
action: Type.Union([ action: stringEnum(BROWSER_TOOL_ACTIONS),
Type.Literal("status"), target: optionalStringEnum(BROWSER_TARGETS),
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"),
]),
),
profile: Type.Optional(Type.String()), profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()), controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()), targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()), targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()),
maxChars: 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()), interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()), compact: Type.Optional(Type.Boolean()),
depth: Type.Optional(Type.Number()), depth: Type.Optional(Type.Number()),
@@ -128,7 +126,7 @@ const BrowserToolSchema = Type.Object({
fullPage: Type.Optional(Type.Boolean()), fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()), ref: Type.Optional(Type.String()),
element: 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()), level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())), paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()), inputRef: Type.Optional(Type.String()),

View File

@@ -8,6 +8,7 @@ import {
parseCanvasSnapshotPayload, parseCanvasSnapshotPayload,
} from "../../cli/nodes-canvas.js"; } from "../../cli/nodes-canvas.js";
import { imageMimeFromFormat } from "../../media/mime.js"; import { imageMimeFromFormat } from "../../media/mime.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { import {
type AnyAgentTool, type AnyAgentTool,
imageResult, imageResult,
@@ -17,76 +18,44 @@ import {
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js"; import { resolveNodeId } from "./nodes-utils.js";
const CanvasToolSchema = Type.Union([ const CANVAS_ACTIONS = [
Type.Object({ "present",
action: Type.Literal("present"), "hide",
gatewayUrl: Type.Optional(Type.String()), "navigate",
gatewayToken: Type.Optional(Type.String()), "eval",
timeoutMs: Type.Optional(Type.Number()), "snapshot",
node: Type.Optional(Type.String()), "a2ui_push",
target: Type.Optional(Type.String()), "a2ui_reset",
x: Type.Optional(Type.Number()), ] as const;
y: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number()), const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
height: Type.Optional(Type.Number()),
}), // Flattened schema: runtime validates per-action requirements.
Type.Object({ const CanvasToolSchema = Type.Object({
action: Type.Literal("hide"), action: stringEnum(CANVAS_ACTIONS),
gatewayUrl: Type.Optional(Type.String()), gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()), node: Type.Optional(Type.String()),
}), // present
Type.Object({ target: Type.Optional(Type.String()),
action: Type.Literal("navigate"), x: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()), y: Type.Optional(Type.Number()),
gatewayToken: Type.Optional(Type.String()), width: Type.Optional(Type.Number()),
timeoutMs: Type.Optional(Type.Number()), height: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()), // navigate
url: Type.String(), url: Type.Optional(Type.String()),
}), // eval
Type.Object({ javaScript: Type.Optional(Type.String()),
action: Type.Literal("eval"), // snapshot
gatewayUrl: Type.Optional(Type.String()), format: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
gatewayToken: Type.Optional(Type.String()), maxWidth: Type.Optional(Type.Number()),
timeoutMs: Type.Optional(Type.Number()), quality: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()), delayMs: Type.Optional(Type.Number()),
javaScript: Type.String(), // a2ui_push
}), jsonl: Type.Optional(Type.String()),
Type.Object({ jsonlPath: Type.Optional(Type.String()),
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()),
}),
]);
export function createCanvasTool(): AnyAgentTool { export function createCanvasTool(): AnyAgentTool {
return { return {

View File

@@ -3,89 +3,42 @@ import {
normalizeCronJobCreate, normalizeCronJobCreate,
normalizeCronJobPatch, normalizeCronJobPatch,
} from "../../cron/normalize.js"; } from "../../cron/normalize.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
// instead of CronAddParamsSchema/CronJobPatchSchema because: // instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
// // contain nested unions. Tool schemas need to stay provider-friendly, so we
// 1. CronAddParamsSchema contains nested Type.Union (for schedule, payload, etc.) // accept "any object" here and validate at runtime.
// 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.
const CronToolSchema = Type.Union([ const CRON_ACTIONS = [
Type.Object({ "status",
action: Type.Literal("status"), "list",
gatewayUrl: Type.Optional(Type.String()), "add",
gatewayToken: Type.Optional(Type.String()), "update",
timeoutMs: Type.Optional(Type.Number()), "remove",
}), "run",
Type.Object({ "runs",
action: Type.Literal("list"), "wake",
gatewayUrl: Type.Optional(Type.String()), ] as const;
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
includeDisabled: Type.Optional(Type.Boolean()),
}), // Flattened schema: runtime validates per-action requirements.
Type.Object({ const CronToolSchema = Type.Object({
action: Type.Literal("add"), action: stringEnum(CRON_ACTIONS),
gatewayUrl: Type.Optional(Type.String()), gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
job: Type.Object({}, { additionalProperties: true }), includeDisabled: Type.Optional(Type.Boolean()),
}), job: Type.Optional(Type.Object({}, { additionalProperties: true })),
Type.Object({ jobId: Type.Optional(Type.String()),
action: Type.Literal("update"), id: Type.Optional(Type.String()),
gatewayUrl: Type.Optional(Type.String()), patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
gatewayToken: Type.Optional(Type.String()), text: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), mode: optionalStringEnum(CRON_WAKE_MODES),
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")]),
),
}),
]);
export function createCronTool(): AnyAgentTool { export function createCronTool(): AnyAgentTool {
return { return {

View File

@@ -31,6 +31,7 @@ import {
function readParentIdParam( function readParentIdParam(
params: Record<string, unknown>, params: Record<string, unknown>,
): string | null | undefined { ): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null; if (params.parentId === null) return null;
return readStringParam(params, "parentId"); return readStringParam(params, "parentId");
} }

View File

@@ -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 () => { it("deletes a channel", async () => {
await handleDiscordGuildAction( await handleDiscordGuildAction(
"channelDelete", "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 () => { it("creates a category with type=4", async () => {
await handleDiscordGuildAction( await handleDiscordGuildAction(
"categoryCreate", "categoryCreate",

View File

@@ -7,6 +7,7 @@ import {
type RestartSentinelPayload, type RestartSentinelPayload,
writeRestartSentinel, writeRestartSentinel,
} from "../../infra/restart-sentinel.js"; } from "../../infra/restart-sentinel.js";
import { stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js"; import { callGatewayTool } from "./gateway.js";
@@ -18,16 +19,11 @@ const GATEWAY_ACTIONS = [
"update.run", "update.run",
] as const; ] as const;
type GatewayAction = (typeof GATEWAY_ACTIONS)[number];
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) // 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. // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (action) determines which properties are relevant; runtime validates. // The discriminator (action) determines which properties are relevant; runtime validates.
const GatewayToolSchema = Type.Object({ const GatewayToolSchema = Type.Object({
action: Type.Unsafe<GatewayAction>({ action: stringEnum(GATEWAY_ACTIONS),
type: "string",
enum: [...GATEWAY_ACTIONS],
}),
// restart // restart
delayMs: Type.Optional(Type.Number()), delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),

View File

@@ -16,6 +16,7 @@ import {
type ProviderMessageActionName, type ProviderMessageActionName,
} from "../../providers/plugins/types.js"; } from "../../providers/plugins/types.js";
import { normalizeAccountId } from "../../routing/session-key.js"; import { normalizeAccountId } from "../../routing/session-key.js";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js";
@@ -90,12 +91,17 @@ const MessageToolCommonSchema = {
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
name: Type.Optional(Type.String()), name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()), type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()), topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()), position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()), nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()), rateLimitPerUser: Type.Optional(Type.Number()),
categoryId: Type.Optional(Type.String()), categoryId: Type.Optional(Type.String()),
clearParent: Type.Optional(
Type.Boolean({
description: "Clear the parent/category when supported by the provider.",
}),
),
}; };
function buildMessageToolSchemaFromActions( function buildMessageToolSchemaFromActions(
@@ -105,31 +111,10 @@ function buildMessageToolSchemaFromActions(
const props: Record<string, unknown> = { ...MessageToolCommonSchema }; const props: Record<string, unknown> = { ...MessageToolCommonSchema };
if (!options.includeButtons) delete props.buttons; if (!options.includeButtons) delete props.buttons;
const schemas: Array<ReturnType<typeof Type.Object>> = []; return Type.Object({
if (actions.includes("send")) { action: stringEnum(actions),
schemas.push( ...props,
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);
} }
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {

View File

@@ -18,151 +18,73 @@ import {
} from "../../cli/nodes-screen.js"; } from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js"; import { parseDurationMs } from "../../cli/parse-duration.js";
import { imageMimeFromFormat } from "../../media/mime.js"; import { imageMimeFromFormat } from "../../media/mime.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { sanitizeToolResultImages } from "../tool-images.js"; import { sanitizeToolResultImages } from "../tool-images.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js"; import { resolveNodeId } from "./nodes-utils.js";
const NodesToolSchema = Type.Union([ const NODES_TOOL_ACTIONS = [
Type.Object({ "status",
action: Type.Literal("status"), "describe",
gatewayUrl: Type.Optional(Type.String()), "pending",
gatewayToken: Type.Optional(Type.String()), "approve",
timeoutMs: Type.Optional(Type.Number()), "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({ maxWidth: Type.Optional(Type.Number()),
action: Type.Literal("describe"), quality: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()), delayMs: Type.Optional(Type.Number()),
gatewayToken: Type.Optional(Type.String()), deviceId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), duration: Type.Optional(Type.String()),
node: Type.String(), durationMs: Type.Optional(Type.Number()),
}), includeAudio: Type.Optional(Type.Boolean()),
Type.Object({ // screen_record
action: Type.Literal("pending"), fps: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()), screenIndex: Type.Optional(Type.Number()),
gatewayToken: Type.Optional(Type.String()), outPath: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), // location_get
}), maxAgeMs: Type.Optional(Type.Number()),
Type.Object({ locationTimeoutMs: Type.Optional(Type.Number()),
action: Type.Literal("approve"), desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
gatewayUrl: Type.Optional(Type.String()), // run
gatewayToken: Type.Optional(Type.String()), command: Type.Optional(Type.Array(Type.String())),
timeoutMs: Type.Optional(Type.Number()), cwd: Type.Optional(Type.String()),
requestId: Type.String(), env: Type.Optional(Type.Array(Type.String())),
}), commandTimeoutMs: Type.Optional(Type.Number()),
Type.Object({ invokeTimeoutMs: Type.Optional(Type.Number()),
action: Type.Literal("reject"), needsScreenRecording: Type.Optional(Type.Boolean()),
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()),
}),
]);
export function createNodesTool(): AnyAgentTool { export function createNodesTool(): AnyAgentTool {
return { return {

View File

@@ -12,6 +12,7 @@ import {
import type { GatewayMessageProvider } from "../../utils/message-provider.js"; import type { GatewayMessageProvider } from "../../utils/message-provider.js";
import { resolveAgentConfig } from "../agent-scope.js"; import { resolveAgentConfig } from "../agent-scope.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js";
import { registerSubagentRun } from "../subagent-registry.js"; import { registerSubagentRun } from "../subagent-registry.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
@@ -30,9 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat alias. Prefer runTimeoutSeconds. // Back-compat alias. Prefer runTimeoutSeconds.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
cleanup: Type.Optional( cleanup: optionalStringEnum(["delete", "keep"] as const),
Type.Union([Type.Literal("delete"), Type.Literal("keep")]),
),
}); });
function normalizeModelSelection(value: unknown): string | undefined { function normalizeModelSelection(value: unknown): string | undefined {

View File

@@ -13,6 +13,14 @@ import type {
const providerId = "discord"; const providerId = "discord";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export const discordMessageActions: ProviderMessageActionAdapter = { export const discordMessageActions: ProviderMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
const accounts = listEnabledDiscordAccounts(cfg).filter( const accounts = listEnabledDiscordAccounts(cfg).filter(
@@ -462,8 +470,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const guildId = readStringParam(params, "guildId", { required: true }); const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true }); const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true }); const type = readNumberParam(params, "type", { integer: true });
const parentId = const parentId = readParentIdParam(params);
params.parentId === null ? null : readStringParam(params, "parentId");
const topic = readStringParam(params, "topic"); const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true }); const position = readNumberParam(params, "position", { integer: true });
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined; const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
@@ -489,8 +496,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const name = readStringParam(params, "name"); const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic"); const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true }); const position = readNumberParam(params, "position", { integer: true });
const parentId = const parentId = readParentIdParam(params);
params.parentId === null ? null : readStringParam(params, "parentId");
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined; const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", { const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true, integer: true,
@@ -525,8 +531,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const channelId = readStringParam(params, "channelId", { const channelId = readStringParam(params, "channelId", {
required: true, required: true,
}); });
const parentId = const parentId = readParentIdParam(params);
params.parentId === null ? null : readStringParam(params, "parentId");
const position = readNumberParam(params, "position", { integer: true }); const position = readNumberParam(params, "position", { integer: true });
return await handleDiscordAction( return await handleDiscordAction(
{ {