Compare commits

...
37 changed files with 1046 additions and 470 deletions

No files matched your search

+1
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.
+4
View File
@@ -6,9 +6,13 @@
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor. - Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior) - Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow) - Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
- Agents: support per-agent model fallbacks via `agents.list[].model`. (#583 — thanks @mitschabaude-bot)
- Tools: add tool profiles plus group shorthands for tool policy allow/deny (global, per-agent, sandbox).
### Fixes ### Fixes
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon. - Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
- Tools: harden tool input schemas for strict providers (no unions; flattened actions; enum helpers + tests).
- Discord: allow clearing channel parents via message tool without nullable schema inputs.
## 2026.1.12-4 ## 2026.1.12-4
+43 -1
View File
@@ -569,7 +569,9 @@ Inbound messages are routed to an agent via bindings.
- `name`: display name for the agent. - `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`). - `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`. - `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
- `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent. - `model`: per-agent default model, overrides `agents.defaults.model` for that agent.
- string form: `"provider/model"`, overrides only `agents.defaults.model.primary`
- object form: `{ primary, fallbacks }` (fallbacks override `agents.defaults.model.fallbacks`; `[]` disables global fallbacks for that agent)
- `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions). - `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions).
- `groupChat`: per-agent mention-gating (`mentionPatterns`). - `groupChat`: per-agent mention-gating (`mentionPatterns`).
- `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`). - `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`).
@@ -583,6 +585,7 @@ Inbound messages are routed to an agent via bindings.
- `subagents`: per-agent sub-agent defaults. - `subagents`: per-agent sub-agent defaults.
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent) - `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
- `tools`: per-agent tool restrictions (applied before sandbox tool policy). - `tools`: per-agent tool restrictions (applied before sandbox tool policy).
- `profile`: base tool profile (applied before allow/deny)
- `allow`: array of allowed tool names - `allow`: array of allowed tool names
- `deny`: array of denied tool names (deny wins) - `deny`: array of denied tool names (deny wins)
- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.). - `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
@@ -1503,6 +1506,34 @@ Legacy: `tools.bash` is still accepted as an alias.
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Per-agent override: `agents.list[].tools.profile`.
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins). `tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**. This is applied even when the Docker sandbox is **off**.
@@ -1513,6 +1544,17 @@ Example (disable browser/canvas everywhere):
} }
``` ```
Tool groups (shorthands) work in **global** and **per-agent** tool policies:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
`tools.elevated` controls elevated (host) exec access: `tools.elevated` controls elevated (host) exec access:
- `enabled`: allow elevated mode (default true) - `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled) - `allowFrom`: per-provider allowlists (empty = disabled)
@@ -50,6 +50,7 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
## Tool policy: which tools exist/are callable ## Tool policy: which tools exist/are callable
Two layers matter: Two layers matter:
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny` - **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*` - **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
@@ -59,7 +60,7 @@ Rules of thumb:
### Tool groups (shorthands) ### Tool groups (shorthands)
For sandbox tool policy, you can use `group:*` entries that expand to multiple tools: Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
```json5 ```json5
{ {
@@ -78,6 +79,11 @@ Available groups:
- `group:fs`: `read`, `write`, `edit`, `apply_patch` - `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get` - `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
## Elevated: exec-only “run on host” ## Elevated: exec-only “run on host”
+34 -5
View File
@@ -106,6 +106,28 @@ For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevate
--- ---
### Example 2b: Global coding profile + messaging-only agent
```json
{
"tools": { "profile": "coding" },
"agents": {
"list": [
{
"id": "support",
"tools": { "profile": "messaging", "allow": ["slack"] }
}
]
}
}
```
**Result:**
- default agents get coding tools
- `support` agent is messaging-only (+ Slack tool)
---
### Example 3: Different Sandbox Modes per Agent ### Example 3: Different Sandbox Modes per Agent
```json ```json
@@ -165,22 +187,29 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
### Tool Restrictions ### Tool Restrictions
The filtering order is: The filtering order is:
1. **Global tool policy** (`tools.allow` / `tools.deny`) 1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
2. **Agent-specific tool policy** (`agents.list[].tools`) 2. **Global tool policy** (`tools.allow` / `tools.deny`)
3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`) 3. **Agent-specific tool policy** (`agents.list[].tools`)
4. **Subagent tool policy** (`tools.subagents.tools`, if applicable) 4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
Each level can further restrict tools, but cannot grant back denied tools from earlier levels. Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
### Tool groups (shorthands) ### Tool groups (shorthands)
Sandbox tool policy supports `group:*` entries that expand to multiple concrete tools: Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
- `group:runtime`: `exec`, `bash`, `process` - `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch` - `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get` - `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
### Elevated Mode ### Elevated Mode
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow). `tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).
+71
View File
@@ -22,6 +22,77 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
} }
``` ```
## Tool profiles (base allowlist)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
Per-agent override: `agents.list[].tools.profile`.
Profiles:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
Example (global coding profile, messaging-only support agent):
```json5
{
tools: { profile: "coding" },
agents: {
list: [
{
id: "support",
tools: { profile: "messaging", allow: ["slack"] }
}
]
}
}
```
## Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
Use these in `tools.allow` / `tools.deny`.
Available groups:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
Example (allow only file tools + browser):
```json5
{
tools: {
allow: ["group:fs", "browser"]
}
}
```
## Plugins + tools ## Plugins + tools
Plugins can register **additional tools** (and CLI commands) beyond the core set. Plugins can register **additional tools** (and CLI commands) beyond the core set.
+67 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js"; import {
resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
} from "./agent-scope.js";
describe("resolveAgentConfig", () => { describe("resolveAgentConfig", () => {
it("should return undefined when no agents config exists", () => { it("should return undefined when no agents config exists", () => {
@@ -47,6 +51,68 @@ describe("resolveAgentConfig", () => {
}); });
}); });
it("supports per-agent model primary+fallbacks", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4",
fallbacks: ["openai/gpt-4.1"],
},
},
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: ["openai/gpt-5.2"],
},
},
],
},
};
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
"anthropic/claude-opus-4",
);
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
"openai/gpt-5.2",
]);
// If fallbacks isn't present, we don't override the global fallbacks.
const cfgNoOverride: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
undefined,
);
// Explicit empty list disables global fallbacks for that agent.
const cfgDisable: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: [],
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
});
it("should return agent-specific sandbox config", () => { it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
+28 -2
View File
@@ -21,7 +21,7 @@ type ResolvedAgentConfig = {
name?: string; name?: string;
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: string; model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"]; memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"]; humanDelay?: AgentEntry["humanDelay"];
identity?: AgentEntry["identity"]; identity?: AgentEntry["identity"];
@@ -95,7 +95,11 @@ export function resolveAgentConfig(
workspace: workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined, typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined, model:
typeof entry.model === "string" ||
(entry.model && typeof entry.model === "object")
? entry.model
: undefined,
memorySearch: entry.memorySearch, memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay, humanDelay: entry.humanDelay,
identity: entry.identity, identity: entry.identity,
@@ -109,6 +113,28 @@ export function resolveAgentConfig(
}; };
} }
export function resolveAgentModelPrimary(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
const primary = raw.primary?.trim();
return primary || undefined;
}
export function resolveAgentModelFallbacksOverride(
cfg: ClawdbotConfig,
agentId: string,
): string[] | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw || typeof raw === "string") return undefined;
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
if (!Object.hasOwn(raw, "fallbacks")) return undefined;
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
}
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) { export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId); const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
-14
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;
+100
View File
@@ -124,6 +124,106 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
}); });
it("does not append configured primary when fallbacksOverride is set", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
},
},
},
});
const run = vi
.fn()
.mockImplementation(() =>
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
);
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
run,
}),
).rejects.toThrow("All models failed");
expect(run.mock.calls).toEqual([
["anthropic", "claude-opus-4-5"],
["anthropic", "claude-haiku-3-5"],
]);
});
it("uses fallbacksOverride instead of agents.defaults.model.fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
const res = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["openai/gpt-4.1"],
run: async (provider, model) => {
calls.push({ provider, model });
if (provider === "anthropic") {
throw Object.assign(new Error("nope"), { status: 401 });
}
if (provider === "openai" && model === "gpt-4.1") {
return "ok";
}
throw new Error(`unexpected candidate: ${provider}/${model}`);
},
});
expect(res.result).toBe("ok");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
{ provider: "openai", model: "gpt-4.1" },
]);
});
it("treats an empty fallbacksOverride as disabling global fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: [],
run: async (provider, model) => {
calls.push({ provider, model });
throw new Error("primary failed");
},
}),
).rejects.toThrow("primary failed");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
]);
});
it("falls back on missing API key errors", async () => { it("falls back on missing API key errors", async () => {
const cfg = makeCfg(); const cfg = makeCfg();
const run = vi const run = vi
+16 -2
View File
@@ -126,6 +126,8 @@ function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined; cfg: ClawdbotConfig | undefined;
provider: string; provider: string;
model: string; model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] { }): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER; const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL; const model = params.model.trim() || DEFAULT_MODEL;
@@ -159,6 +161,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false); addCandidate({ provider, model }, false);
const modelFallbacks = (() => { const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] } | { fallbacks?: string[] }
| string | string
@@ -177,7 +180,11 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true); addCandidate(resolved.ref, true);
} }
if (primary?.provider && primary.model) { if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
addCandidate({ provider: primary.provider, model: primary.model }, false); addCandidate({ provider: primary.provider, model: primary.model }, false);
} }
@@ -188,6 +195,8 @@ export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined; cfg: ClawdbotConfig | undefined;
provider: string; provider: string;
model: string; model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: (provider: string, model: string) => Promise<T>; run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: { onError?: (attempt: {
provider: string; provider: string;
@@ -202,7 +211,12 @@ export async function runWithModelFallback<T>(params: {
model: string; model: string;
attempts: FallbackAttempt[]; attempts: FallbackAttempt[];
}> { }> {
const candidates = resolveFallbackCandidates(params); const candidates = resolveFallbackCandidates({
cfg: params.cfg,
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const attempts: FallbackAttempt[] = []; const attempts: FallbackAttempt[] = [];
let lastError: unknown; let lastError: unknown;
+142 -1
View File
@@ -5,6 +5,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp"; import sharp from "sharp";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js"; import { createBrowserTool } from "./tools/browser-tool.js";
@@ -16,7 +17,7 @@ describe("createClawdbotCodingTools", () => {
expect(schema.anyOf).toBeUndefined(); expect(schema.anyOf).toBeUndefined();
}); });
it("merges properties for union tool schemas", () => { it("keeps browser tool schema properties after normalization", () => {
const tools = createClawdbotCodingTools(); const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser"); const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined(); expect(browser).toBeDefined();
@@ -266,6 +267,95 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
const tools = createClawdbotCodingTools();
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("keeps raw core tool schemas union-free", () => {
const tools = createClawdbotTools();
const coreTools = new Set([
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
]);
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
if (!coreTools.has(tool.name)) continue;
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("does not expose provider-specific message tools", () => { it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" }); const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name)); const names = new Set(tools.map((tool) => tool.name));
@@ -517,6 +607,57 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "browser")).toBe(false); expect(tools.some((tool) => tool.name === "browser")).toBe(false);
}); });
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => { it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools(); const tools = createClawdbotCodingTools();
+26 -23
View File
@@ -28,6 +28,11 @@ import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js"; import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { sanitizeToolResultImages } from "./tool-images.js"; import { sanitizeToolResultImages } from "./tool-images.js";
import {
expandToolGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper // NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to normalize payloads and sanitize oversized images before they hit providers. // to normalize payloads and sanitize oversized images before they hit providers.
@@ -291,21 +296,6 @@ function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema); return cleanSchemaForGemini(schema);
} }
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
function isOpenAIProvider(provider?: string) { function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase(); const normalized = provider?.trim().toLowerCase();
return normalized === "openai" || normalized === "openai-codex"; return normalized === "openai" || normalized === "openai-codex";
@@ -357,8 +347,8 @@ function isToolAllowedByPolicyName(
policy?: SandboxToolPolicy, policy?: SandboxToolPolicy,
): boolean { ): boolean {
if (!policy) return true; if (!policy) return true;
const deny = new Set(normalizeToolNames(policy.deny)); const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = normalizeToolNames(policy.allow); const allowRaw = expandToolGroups(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null; const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name); const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false; if (deny.has(normalized)) return false;
@@ -391,11 +381,15 @@ function resolveEffectiveToolPolicy(params: {
: undefined; : undefined;
const agentTools = agentConfig?.tools; const agentTools = agentConfig?.tools;
const hasAgentToolPolicy = const hasAgentToolPolicy =
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny); Array.isArray(agentTools?.allow) ||
Array.isArray(agentTools?.deny) ||
typeof agentTools?.profile === "string";
const globalTools = params.config?.tools; const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
return { return {
agentId, agentId,
policy: hasAgentToolPolicy ? agentTools : globalTools, policy: hasAgentToolPolicy ? agentTools : globalTools,
profile,
}; };
} }
@@ -703,10 +697,15 @@ export function createClawdbotCodingTools(options?: {
}): AnyAgentTool[] { }): AnyAgentTool[] {
const execToolName = "exec"; const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const { agentId, policy: effectiveToolsPolicy } = resolveEffectiveToolPolicy({ const {
agentId,
policy: effectiveToolsPolicy,
profile,
} = resolveEffectiveToolPolicy({
config: options?.config, config: options?.config,
sessionKey: options?.sessionKey, sessionKey: options?.sessionKey,
}); });
const profilePolicy = resolveToolProfilePolicy(profile);
const scopeKey = const scopeKey =
options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy = const subagentPolicy =
@@ -714,6 +713,7 @@ export function createClawdbotCodingTools(options?: {
? resolveSubagentToolPolicy(options.config) ? resolveSubagentToolPolicy(options.config)
: undefined; : undefined;
const allowBackground = isToolAllowedByPolicies("process", [ const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
effectiveToolsPolicy, effectiveToolsPolicy,
sandbox?.tools, sandbox?.tools,
subagentPolicy, subagentPolicy,
@@ -829,12 +829,15 @@ export function createClawdbotCodingTools(options?: {
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
}), }),
]; ];
const toolsFiltered = effectiveToolsPolicy const toolsFiltered = profilePolicy
? filterToolsByPolicy(tools, effectiveToolsPolicy) ? filterToolsByPolicy(tools, profilePolicy)
: tools; : tools;
const sandboxed = sandbox const policyFiltered = effectiveToolsPolicy
? filterToolsByPolicy(toolsFiltered, sandbox.tools) ? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
: toolsFiltered; : toolsFiltered;
const sandboxed = sandbox
? filterToolsByPolicy(policyFiltered, sandbox.tools)
: policyFiltered;
const subagentFiltered = subagentPolicy const subagentFiltered = subagentPolicy
? filterToolsByPolicy(sandboxed, subagentPolicy) ? filterToolsByPolicy(sandboxed, subagentPolicy)
: sandboxed; : sandboxed;
+5 -52
View File
@@ -33,6 +33,7 @@ import {
resolveSessionAgentId, resolveSessionAgentId,
} from "./agent-scope.js"; } from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js"; import { syncSkillsToWorkspace } from "./skills.js";
import { expandToolGroups } from "./tool-policy.js";
import { import {
DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
@@ -239,58 +240,10 @@ const BROWSER_BRIDGES = new Map<
{ bridge: BrowserBridge; containerName: string } { bridge: BrowserBridge; containerName: string }
>(); >();
function normalizeToolList(values?: string[]) {
if (!values) return [];
return values
.map((value) => value.trim())
.filter(Boolean)
.map((value) => value.toLowerCase());
}
const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
};
function expandToolGroupEntry(entry: string): string[] {
const raw = entry.trim();
if (!raw) return [];
const lower = raw.toLowerCase();
const group = TOOL_GROUPS[lower];
if (group) return group;
return [raw];
}
function expandToolGroups(values?: string[]): string[] {
if (!values) return [];
const out: string[] = [];
for (const value of values) {
for (const expanded of expandToolGroupEntry(value)) {
const trimmed = expanded.trim();
if (!trimmed) continue;
out.push(trimmed);
}
}
return out;
}
function isToolAllowed(policy: SandboxToolPolicy, name: string) { function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(normalizeToolList(expandToolGroups(policy.deny))); const deny = new Set(expandToolGroups(policy.deny));
if (deny.has(name.toLowerCase())) return false; if (deny.has(name.toLowerCase())) return false;
const allow = normalizeToolList(expandToolGroups(policy.allow)); const allow = expandToolGroups(policy.allow);
if (allow.length === 0) return true; if (allow.length === 0) return true;
return allow.includes(name.toLowerCase()); return allow.includes(name.toLowerCase());
} }
@@ -687,8 +640,8 @@ export function formatSandboxToolPolicyBlockedMessage(params: {
}); });
if (!runtime.sandboxed) return undefined; if (!runtime.sandboxed) return undefined;
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny)); const deny = new Set(expandToolGroups(runtime.toolPolicy.deny));
const allow = normalizeToolList(runtime.toolPolicy.allow); const allow = expandToolGroups(runtime.toolPolicy.allow);
const allowSet = allow.length > 0 ? new Set(allow) : null; const allowSet = allow.length > 0 ? new Set(allow) : null;
const blockedByDeny = deny.has(tool); const blockedByDeny = deny.has(tool);
const blockedByAllow = allowSet ? !allowSet.has(tool) : false; const blockedByAllow = allowSet ? !allowSet.has(tool) : false;
+27
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));
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
expandToolGroups,
resolveToolProfilePolicy,
TOOL_GROUPS,
} from "./tool-policy.js";
describe("tool-policy", () => {
it("expands groups and normalizes aliases", () => {
const expanded = expandToolGroups([
"group:runtime",
"BASH",
"apply-patch",
"group:fs",
]);
const set = new Set(expanded);
expect(set.has("exec")).toBe(true);
expect(set.has("bash")).toBe(true);
expect(set.has("process")).toBe(true);
expect(set.has("apply_patch")).toBe(true);
expect(set.has("read")).toBe(true);
expect(set.has("write")).toBe(true);
expect(set.has("edit")).toBe(true);
});
it("resolves known profiles and ignores unknown ones", () => {
const coding = resolveToolProfilePolicy("coding");
expect(coding?.allow).toContain("group:fs");
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
});
it("includes core tool groups in group:clawdbot", () => {
const group = TOOL_GROUPS["group:clawdbot"];
expect(group).toContain("browser");
expect(group).toContain("message");
expect(group).toContain("session_status");
});
});
+116
View File
@@ -0,0 +1,116 @@
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
type ToolProfilePolicy = {
allow?: string[];
deny?: string[];
};
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
export const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// UI helpers
"group:ui": ["browser", "canvas"],
// Automation + infra
"group:automation": ["cron", "gateway"],
// Messaging surface
"group:messaging": ["message"],
// Nodes + device tools
"group:nodes": ["nodes"],
// All Clawdbot native tools (excludes provider plugins).
"group:clawdbot": [
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
],
};
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
minimal: {
allow: ["session_status"],
},
coding: {
allow: [
"group:fs",
"group:runtime",
"group:sessions",
"group:memory",
"image",
],
},
messaging: {
allow: [
"group:messaging",
"sessions_list",
"sessions_history",
"sessions_send",
"session_status",
],
},
full: {},
};
export function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
export function normalizeToolList(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
export function expandToolGroups(list?: string[]) {
const normalized = normalizeToolList(list);
const expanded: string[] = [];
for (const value of normalized) {
const group = TOOL_GROUPS[value];
if (group) {
expanded.push(...group);
continue;
}
expanded.push(value);
}
return Array.from(new Set(expanded));
}
export function resolveToolProfilePolicy(
profile?: string,
): ToolProfilePolicy | undefined {
if (!profile) return undefined;
const resolved = TOOL_PROFILES[profile as ToolProfileId];
if (!resolved) return undefined;
if (!resolved.allow && !resolved.deny) return undefined;
return {
allow: resolved.allow ? [...resolved.allow] : undefined,
deny: resolved.deny ? [...resolved.deny] : undefined,
};
}
+29 -31
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()),
+39 -70
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 {
+31 -78
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 {
@@ -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");
} }
+38
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",
+2 -6
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()),
+11 -26
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, {
+61 -139
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 {
+2 -3
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 {
+9
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { runCliAgent } from "../../agents/cli-runner.js"; import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js"; import { lookupContextTokens } from "../../agents/context.js";
@@ -394,6 +395,10 @@ export async function runReplyAgent(params: {
cfg: followupRun.run.config, cfg: followupRun.run.config,
provider: followupRun.run.provider, provider: followupRun.run.provider,
model: followupRun.run.model, model: followupRun.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
followupRun.run.config,
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
),
run: (provider, model) => run: (provider, model) =>
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId, sessionId: followupRun.run.sessionId,
@@ -586,6 +591,10 @@ export async function runReplyAgent(params: {
cfg: followupRun.run.config, cfg: followupRun.run.config,
provider: followupRun.run.provider, provider: followupRun.run.provider,
model: followupRun.run.model, model: followupRun.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
followupRun.run.config,
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
),
run: (provider, model) => { run: (provider, model) => {
if (isCliProvider(provider, followupRun.run.config)) { if (isCliProvider(provider, followupRun.run.config)) {
const startedAt = Date.now(); const startedAt = Date.now();
+2 -2
View File
@@ -1,6 +1,6 @@
import { import {
resolveAgentConfig,
resolveAgentDir, resolveAgentDir,
resolveAgentModelPrimary,
resolveDefaultAgentId, resolveDefaultAgentId,
resolveSessionAgentId, resolveSessionAgentId,
} from "../../agents/agent-scope.js"; } from "../../agents/agent-scope.js";
@@ -1593,7 +1593,7 @@ export function resolveDefaultModel(params: {
aliasIndex: ModelAliasIndex; aliasIndex: ModelAliasIndex;
} { } {
const agentModelOverride = params.agentId const agentModelOverride = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.model?.trim() ? resolveAgentModelPrimary(params.cfg, params.agentId)
: undefined; : undefined;
const cfg = const cfg =
agentModelOverride && agentModelOverride.length > 0 agentModelOverride && agentModelOverride.length > 0
+6
View File
@@ -1,10 +1,12 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js"; import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runWithModelFallback } from "../../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js"; import { hasNonzeroUsage } from "../../agents/usage.js";
import { import {
resolveAgentIdFromSessionKey,
type SessionEntry, type SessionEntry,
updateSessionStoreEntry, updateSessionStoreEntry,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
@@ -136,6 +138,10 @@ export function createFollowupRunner(params: {
cfg: queued.run.config, cfg: queued.run.config,
provider: queued.run.provider, provider: queued.run.provider,
model: queued.run.model, model: queued.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
queued.run.config,
resolveAgentIdFromSessionKey(queued.run.sessionKey),
),
run: (provider, model) => run: (provider, model) =>
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId: queued.run.sessionId, sessionId: queued.run.sessionId,
+26 -1
View File
@@ -1,6 +1,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { import {
resolveAgentDir, resolveAgentDir,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js"; } from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
@@ -333,9 +335,28 @@ export async function agentCommand(
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }
const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId);
const cfgForModelSelection = agentModelPrimary
? {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: {
...(typeof cfg.agents?.defaults?.model === "object"
? cfg.agents.defaults.model
: undefined),
primary: agentModelPrimary,
},
},
},
}
: cfg;
const { provider: defaultProvider, model: defaultModel } = const { provider: defaultProvider, model: defaultModel } =
resolveConfiguredModelRef({ resolveConfiguredModelRef({
cfg, cfg: cfgForModelSelection,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
}); });
@@ -442,6 +463,10 @@ export async function agentCommand(
cfg, cfg,
provider, provider,
model, model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
cfg,
sessionAgentId,
),
run: (providerOverride, modelOverride) => { run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfg)) { if (isCliProvider(providerOverride, cfg)) {
const cliSessionId = getCliSessionId(sessionEntry, providerOverride); const cliSessionId = getCliSessionId(sessionEntry, providerOverride);
+9 -1
View File
@@ -142,7 +142,15 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
const entry = listAgentEntries(cfg).find( const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId), (agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
); );
if (entry?.model?.trim()) return entry.model.trim(); if (entry?.model) {
if (typeof entry.model === "string" && entry.model.trim()) {
return entry.model.trim();
}
if (typeof entry.model === "object") {
const primary = entry.model.primary?.trim();
if (primary) return primary;
}
}
const raw = cfg.agents?.defaults?.model; const raw = cfg.agents?.defaults?.model;
if (typeof raw === "string") return raw; if (typeof raw === "string") return raw;
return raw?.primary?.trim() || undefined; return raw?.primary?.trim() || undefined;
+2 -2
View File
@@ -1,5 +1,5 @@
import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai"; import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveAgentConfig } from "../agents/agent-scope.js"; import { resolveAgentModelPrimary } from "../agents/agent-scope.js";
import { import {
CLAUDE_CLI_PROFILE_ID, CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID,
@@ -152,7 +152,7 @@ export async function warnIfModelConfigLooksOff(
options?: { agentId?: string; agentDir?: string }, options?: { agentId?: string; agentDir?: string },
) { ) {
const agentModelOverride = options?.agentId const agentModelOverride = options?.agentId
? resolveAgentConfig(config, options.agentId)?.model?.trim() ? resolveAgentModelPrimary(config, options.agentId)
: undefined; : undefined;
const configWithModel = const configWithModel =
agentModelOverride && agentModelOverride.length > 0 agentModelOverride && agentModelOverride.length > 0
+2
View File
@@ -107,6 +107,8 @@ const FIELD_LABELS: Record<string, string> = {
"tools.audio.transcription.args": "Audio Transcription Args", "tools.audio.transcription.args": "Audio Transcription Args",
"tools.audio.transcription.timeoutSeconds": "tools.audio.transcription.timeoutSeconds":
"Audio Transcription Timeout (sec)", "Audio Transcription Timeout (sec)",
"tools.profile": "Tool Profile",
"agents.list[].tools.profile": "Agent Tool Profile",
"tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.enabled": "Enable apply_patch",
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
"gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.basePath": "Control UI Base Path",
+16 -1
View File
@@ -988,7 +988,11 @@ export type QueueConfig = {
drop?: QueueDropPolicy; drop?: QueueDropPolicy;
}; };
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type AgentToolsConfig = { export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
/** Per-agent elevated exec gate (can only further restrict global tools.elevated). */ /** Per-agent elevated exec gate (can only further restrict global tools.elevated). */
@@ -1053,6 +1057,8 @@ export type MemorySearchConfig = {
}; };
export type ToolsConfig = { export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
audio?: { audio?: {
@@ -1121,13 +1127,22 @@ export type ToolsConfig = {
}; };
}; };
export type AgentModelConfig =
| string
| {
/** Primary model (provider/model). */
primary?: string;
/** Per-agent model fallbacks (provider/model). */
fallbacks?: string[];
};
export type AgentConfig = { export type AgentConfig = {
id: string; id: string;
default?: boolean; default?: boolean;
name?: string; name?: string;
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: string; model?: AgentModelConfig;
memorySearch?: MemorySearchConfig; memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */ /** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig; humanDelay?: HumanDelayConfig;
+19 -2
View File
@@ -837,6 +837,15 @@ const ToolPolicySchema = z
}) })
.optional(); .optional();
const ToolProfileSchema = z
.union([
z.literal("minimal"),
z.literal("coding"),
z.literal("messaging"),
z.literal("full"),
])
.optional();
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers). // Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
const ElevatedAllowFromSchema = z const ElevatedAllowFromSchema = z
.record(z.string(), z.array(z.union([z.string(), z.number()]))) .record(z.string(), z.array(z.union([z.string(), z.number()])))
@@ -866,6 +875,7 @@ const AgentSandboxSchema = z
const AgentToolsSchema = z const AgentToolsSchema = z
.object({ .object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
elevated: z elevated: z
@@ -930,14 +940,20 @@ const MemorySearchSchema = z
.optional(), .optional(),
}) })
.optional(); .optional();
const AgentModelSchema = z.union([
z.string(),
z.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
}),
]);
const AgentEntrySchema = z.object({ const AgentEntrySchema = z.object({
id: z.string(), id: z.string(),
default: z.boolean().optional(), default: z.boolean().optional(),
name: z.string().optional(), name: z.string().optional(),
workspace: z.string().optional(), workspace: z.string().optional(),
agentDir: z.string().optional(), agentDir: z.string().optional(),
model: z.string().optional(), model: AgentModelSchema.optional(),
memorySearch: MemorySearchSchema, memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(), humanDelay: HumanDelaySchema.optional(),
identity: IdentitySchema, identity: IdentitySchema,
@@ -962,6 +978,7 @@ const AgentEntrySchema = z.object({
const ToolsSchema = z const ToolsSchema = z
.object({ .object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
audio: z audio: z
+5
View File
@@ -1,6 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { import {
resolveAgentConfig, resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
resolveDefaultAgentId, resolveDefaultAgentId,
} from "../agents/agent-scope.js"; } from "../agents/agent-scope.js";
@@ -449,6 +450,10 @@ export async function runCronIsolatedAgentTurn(params: {
cfg: cfgWithAgentDefaults, cfg: cfgWithAgentDefaults,
provider, provider,
model, model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.cfg,
agentId,
),
run: (providerOverride, modelOverride) => { run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) { if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
const cliSessionId = getCliSessionId( const cliSessionId = getCliSessionId(
+11 -6
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(
{ {