Merge remote-tracking branch 'origin/main' into feature/agent-avatar-support

This commit is contained in:
Peter Steinberger
2026-01-22 06:03:56 +00:00
84 changed files with 1323 additions and 381 deletions

View File

@@ -54,6 +54,7 @@ import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
@@ -139,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecElevatedDefaults = {
enabled: boolean;
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
const execSchema = Type.Object({
@@ -659,6 +660,11 @@ export function createExecTool(
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
// Derive agentId only when sessionKey is an agent session key.
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
const agentId =
defaults?.agentId ??
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
return {
name: "exec",
@@ -700,12 +706,23 @@ export function createExecTool(
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: null;
const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn =
elevatedDefaults?.defaultLevel === "on" &&
elevatedDefaults.enabled &&
elevatedDefaults.allowed;
const elevatedRequested =
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
const elevatedDefaultMode =
elevatedDefaults?.defaultLevel === "full"
? "full"
: elevatedDefaults?.defaultLevel === "ask"
? "ask"
: elevatedDefaults?.defaultLevel === "on"
? "ask"
: "off";
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: elevatedDefaultMode;
const elevatedRequested = elevatedMode !== "off";
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
@@ -761,6 +778,10 @@ export function createExecTool(
const configuredAsk = defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
ask = "off";
}
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
@@ -799,7 +820,7 @@ export function createExecTool(
if (host === "node") {
const approvals = resolveExecApprovals(
defaults?.agentId,
agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const hostSecurity = minSecurity(security, approvals.agent.security);
@@ -865,7 +886,7 @@ export function createExecTool(
cwd: workdir,
env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId: defaults?.agentId,
agentId,
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
@@ -895,9 +916,9 @@ export function createExecTool(
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: undefined,
sessionKey: defaults?.sessionKey,
agentId,
resolvedPath: null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
@@ -1025,8 +1046,8 @@ export function createExecTool(
};
}
if (host === "gateway") {
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
if (host === "gateway" && !bypassApprovals) {
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -1060,7 +1081,7 @@ export function createExecTool(
const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command;
const effectiveTimeout =
@@ -1080,9 +1101,9 @@ export function createExecTool(
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
agentId,
resolvedPath,
sessionKey: defaults?.sessionKey,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
@@ -1123,7 +1144,7 @@ export function createExecTool(
for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
addAllowlistEntry(approvals.file, agentId, pattern);
}
}
}
@@ -1152,7 +1173,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
defaults?.agentId,
agentId,
match,
commandText,
resolvedPath ?? undefined,
@@ -1242,7 +1263,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
defaults?.agentId,
agentId,
match,
params.command,
analysis.segments[0]?.resolution?.resolvedPath,

View File

@@ -183,6 +183,8 @@ export function buildSystemPrompt(params: {
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: params.agentId,
workspaceDir: params.workspaceDir,
cwd: process.cwd(),
runtime: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,

View File

@@ -68,6 +68,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
}
function isInstructionsRequiredError(raw: string): boolean {
return /instructions are required/i.test(raw);
}
function toInt(value: string | undefined, fallback: number): number {
const trimmed = value?.trim();
if (!trimmed) return fallback;
@@ -443,6 +447,15 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&
isInstructionsRequiredError(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message });
break;

View File

@@ -44,6 +44,8 @@ describe("resolveOpencodeZenModelApi", () => {
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
});

View File

@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
}
/**
* OpenCode Zen routes models to different APIs based on model family.
* OpenCode Zen routes models to specific API shapes by family.
*/
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase();
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
return "anthropic-messages";
}
if (lower.startsWith("gemini-")) {
return "google-generative-ai";
}
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
return "openai-completions";
}

View File

@@ -279,6 +279,8 @@ export async function runEmbeddedAttempt(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: sessionAgentId,
workspaceDir: effectiveWorkspace,
cwd: process.cwd(),
runtime: {
host: machineName,
os: `${os.type()} ${os.release()}`,

View File

@@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = {
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
};

View File

@@ -0,0 +1,106 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { buildSystemPromptParams } from "./system-prompt-params.js";
async function makeTempDir(label: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`));
}
async function makeRepoRoot(root: string): Promise<void> {
await fs.mkdir(path.join(root, ".git"), { recursive: true });
}
function buildParams(params: { config?: ClawdbotConfig; workspaceDir?: string; cwd?: string }) {
return buildSystemPromptParams({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
runtime: {
host: "host",
os: "os",
arch: "arch",
node: "node",
model: "model",
},
});
}
describe("buildSystemPromptParams repo root", () => {
it("detects repo root from workspaceDir", async () => {
const temp = await makeTempDir("workspace");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "nested", "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("falls back to cwd when workspaceDir has no repo", async () => {
const temp = await makeTempDir("cwd");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir, cwd: repoRoot });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("uses configured repoRoot when valid", async () => {
const temp = await makeTempDir("config");
const repoRoot = path.join(temp, "config-root");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(repoRoot, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(workspaceDir);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot,
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("ignores invalid repoRoot config and auto-detects", async () => {
const temp = await makeTempDir("invalid");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot: path.join(temp, "missing"),
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("returns undefined when no repo is found", async () => {
const workspaceDir = await makeTempDir("norepo");
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBeUndefined();
});
});

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import {
formatUserTime,
@@ -18,6 +21,7 @@ export type RuntimeInfoInput = {
capabilities?: string[];
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
repoRoot?: string;
};
export type SystemPromptRuntimeParams = {
@@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: {
config?: ClawdbotConfig;
agentId?: string;
runtime: Omit<RuntimeInfoInput, "agentId">;
workspaceDir?: string;
cwd?: string;
}): SystemPromptRuntimeParams {
const repoRoot = resolveRepoRoot({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
});
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
@@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: {
runtimeInfo: {
agentId: params.agentId,
...params.runtime,
repoRoot,
},
userTimezone,
userTime,
userTimeFormat,
};
}
function resolveRepoRoot(params: {
config?: ClawdbotConfig;
workspaceDir?: string;
cwd?: string;
}): string | undefined {
const configured = params.config?.agents?.defaults?.repoRoot?.trim();
if (configured) {
try {
const resolved = path.resolve(configured);
const stat = fs.statSync(resolved);
if (stat.isDirectory()) return resolved;
} catch {
// ignore invalid config path
}
}
const candidates = [params.workspaceDir, params.cwd]
.map((value) => value?.trim())
.filter(Boolean) as string[];
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
const root = findGitRoot(resolved);
if (root) return root;
}
return undefined;
}
function findGitRoot(startDir: string): string | null {
let current = path.resolve(startDir);
for (let i = 0; i < 12; i += 1) {
const gitPath = path.join(current, ".git");
try {
const stat = fs.statSync(gitPath);
if (stat.isDirectory() || stat.isFile()) return current;
} catch {
// ignore missing .git at this level
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}

View File

@@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => {
{
agentId: "work",
host: "host",
repoRoot: "/repo",
os: "macOS",
arch: "arm64",
node: "v20",
@@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("agent=work");
expect(line).toContain("host=host");
expect(line).toContain("repo=/repo");
expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude");
@@ -320,7 +322,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("You are running in a sandboxed runtime");
expect(prompt).toContain("Sub-agents stay sandboxed");
expect(prompt).toContain("User can toggle with /elevated on|off.");
expect(prompt).toContain("User can toggle with /elevated on|off|ask|full.");
expect(prompt).toContain("Current elevated level: on");
});

View File

@@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: {
defaultModel?: string;
channel?: string;
capabilities?: string[];
repoRoot?: string;
};
messageToolHints?: string[];
sandboxInfo?: {
@@ -175,7 +176,7 @@ export function buildAgentSystemPrompt(params: {
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
};
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
@@ -200,7 +201,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
agents_list: "List agent ids allowed for sessions_spawn",
@@ -351,7 +352,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
@@ -443,12 +444,14 @@ export function buildAgentSystemPrompt(params: {
params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session."
: "",
params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "",
params.sandboxInfo.elevated?.allowed
? "You may also send /elevated on|off when needed."
? "User can toggle with /elevated on|off|ask|full."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).`
? "You may also send /elevated on|off|ask|full when needed."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
: "",
]
.filter(Boolean)
@@ -570,6 +573,7 @@ export function buildRuntimeLine(
node?: string;
model?: string;
defaultModel?: string;
repoRoot?: string;
},
runtimeChannel?: string,
runtimeCapabilities: string[] = [],
@@ -578,6 +582,7 @@ export function buildRuntimeLine(
return `Runtime: ${[
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch