mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 08:12:43 +00:00
refactor: dedupe agent and reply runtimes
This commit is contained in:
@@ -241,16 +241,9 @@ export async function markAuthProfileUsed(params: {
|
||||
if (!freshStore.profiles[profileId]) {
|
||||
return false;
|
||||
}
|
||||
freshStore.usageStats = freshStore.usageStats ?? {};
|
||||
freshStore.usageStats[profileId] = {
|
||||
...freshStore.usageStats[profileId],
|
||||
lastUsed: Date.now(),
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
updateUsageStatsEntry(freshStore, profileId, (existing) =>
|
||||
resetUsageStats(existing, { lastUsed: Date.now() }),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -262,16 +255,9 @@ export async function markAuthProfileUsed(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
store.usageStats[profileId] = {
|
||||
...store.usageStats[profileId],
|
||||
lastUsed: Date.now(),
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
updateUsageStatsEntry(store, profileId, (existing) =>
|
||||
resetUsageStats(existing, { lastUsed: Date.now() }),
|
||||
);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
@@ -360,6 +346,30 @@ export function resolveProfileUnusableUntilForDisplay(
|
||||
return resolveProfileUnusableUntil(stats);
|
||||
}
|
||||
|
||||
function resetUsageStats(
|
||||
existing: ProfileUsageStats | undefined,
|
||||
overrides?: Partial<ProfileUsageStats>,
|
||||
): ProfileUsageStats {
|
||||
return {
|
||||
...existing,
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function updateUsageStatsEntry(
|
||||
store: AuthProfileStore,
|
||||
profileId: string,
|
||||
updater: (existing: ProfileUsageStats | undefined) => ProfileUsageStats,
|
||||
): void {
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
store.usageStats[profileId] = updater(store.usageStats[profileId]);
|
||||
}
|
||||
|
||||
function keepActiveWindowOrRecompute(params: {
|
||||
existingUntil: number | undefined;
|
||||
now: number;
|
||||
@@ -448,9 +458,6 @@ export async function markAuthProfileFailure(params: {
|
||||
if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) {
|
||||
return false;
|
||||
}
|
||||
freshStore.usageStats = freshStore.usageStats ?? {};
|
||||
const existing = freshStore.usageStats[profileId] ?? {};
|
||||
|
||||
const now = Date.now();
|
||||
const providerKey = normalizeProviderId(profile.provider);
|
||||
const cfgResolved = resolveAuthCooldownConfig({
|
||||
@@ -458,12 +465,14 @@ export async function markAuthProfileFailure(params: {
|
||||
providerId: providerKey,
|
||||
});
|
||||
|
||||
freshStore.usageStats[profileId] = computeNextProfileUsageStats({
|
||||
existing,
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
});
|
||||
updateUsageStatsEntry(freshStore, profileId, (existing) =>
|
||||
computeNextProfileUsageStats({
|
||||
existing: existing ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -475,8 +484,6 @@ export async function markAuthProfileFailure(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
const existing = store.usageStats[profileId] ?? {};
|
||||
const now = Date.now();
|
||||
const providerKey = normalizeProviderId(store.profiles[profileId]?.provider ?? "");
|
||||
const cfgResolved = resolveAuthCooldownConfig({
|
||||
@@ -484,12 +491,14 @@ export async function markAuthProfileFailure(params: {
|
||||
providerId: providerKey,
|
||||
});
|
||||
|
||||
store.usageStats[profileId] = computeNextProfileUsageStats({
|
||||
existing,
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
});
|
||||
updateUsageStatsEntry(store, profileId, (existing) =>
|
||||
computeNextProfileUsageStats({
|
||||
existing: existing ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
}),
|
||||
);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
@@ -528,14 +537,7 @@ export async function clearAuthProfileCooldown(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
freshStore.usageStats[profileId] = {
|
||||
...freshStore.usageStats[profileId],
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
updateUsageStatsEntry(freshStore, profileId, (existing) => resetUsageStats(existing));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -547,13 +549,6 @@ export async function clearAuthProfileCooldown(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
store.usageStats[profileId] = {
|
||||
...store.usageStats[profileId],
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
updateUsageStatsEntry(store, profileId, (existing) => resetUsageStats(existing));
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@ import {
|
||||
type ExecSecurity,
|
||||
buildEnforcedShellCommand,
|
||||
evaluateShellAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
recordAllowlistUse,
|
||||
requiresExecApproval,
|
||||
resolveAllowAlwaysPatterns,
|
||||
resolveExecApprovals,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
||||
@@ -19,10 +16,13 @@ import { logInfo } from "../logger.js";
|
||||
import { markBackgrounded, tail } from "./bash-process-registry.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
resolveRegisteredExecApprovalDecision,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||
@@ -67,16 +67,12 @@ export type ProcessGatewayAllowlistResult = {
|
||||
export async function processGatewayAllowlist(
|
||||
params: ProcessGatewayAllowlistParams,
|
||||
): Promise<ProcessGatewayAllowlistResult> {
|
||||
const approvals = resolveExecApprovals(params.agentId, {
|
||||
const { approvals, hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({
|
||||
agentId: params.agentId,
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
host: "gateway",
|
||||
});
|
||||
const hostSecurity = minSecurity(params.security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=gateway security=deny");
|
||||
}
|
||||
const allowlistEval = evaluateShellAllowlist({
|
||||
command: params.command,
|
||||
allowlist: approvals.allowlist,
|
||||
@@ -172,20 +168,19 @@ export async function processGatewayAllowlist(
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
void (async () => {
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
decision = await resolveRegisteredExecApprovalDecision({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
evaluateShellAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
requiresExecApproval,
|
||||
resolveExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
@@ -17,10 +14,13 @@ import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-cont
|
||||
import { logInfo } from "../logger.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
resolveRegisteredExecApprovalDecision,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
createApprovalSlug,
|
||||
@@ -56,16 +56,12 @@ export type ExecuteNodeHostCommandParams = {
|
||||
export async function executeNodeHostCommand(
|
||||
params: ExecuteNodeHostCommandParams,
|
||||
): Promise<AgentToolResult<ExecToolDetails>> {
|
||||
const approvals = resolveExecApprovals(params.agentId, {
|
||||
const { hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({
|
||||
agentId: params.agentId,
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
host: "node",
|
||||
});
|
||||
const hostSecurity = minSecurity(params.security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=node security=deny");
|
||||
}
|
||||
if (params.boundNode && params.requestedNode && params.boundNode !== params.requestedNode) {
|
||||
throw new Error(`exec node not allowed (bound to ${params.boundNode})`);
|
||||
}
|
||||
@@ -243,17 +239,16 @@ export async function executeNodeHostCommand(
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
void (async () => {
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
decision = await resolveRegisteredExecApprovalDecision({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
);
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
52
src/agents/bash-tools.exec-host-shared.ts
Normal file
52
src/agents/bash-tools.exec-host-shared.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
resolveExecApprovals,
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
|
||||
|
||||
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
|
||||
|
||||
export type ExecHostApprovalContext = {
|
||||
approvals: ResolvedExecApprovals;
|
||||
hostSecurity: ExecSecurity;
|
||||
hostAsk: ExecAsk;
|
||||
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
|
||||
};
|
||||
|
||||
export function resolveExecHostApprovalContext(params: {
|
||||
agentId?: string;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
host: "gateway" | "node";
|
||||
}): ExecHostApprovalContext {
|
||||
const approvals = resolveExecApprovals(params.agentId, {
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
});
|
||||
const hostSecurity = minSecurity(params.security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error(`exec denied: host=${params.host} security=deny`);
|
||||
}
|
||||
return { approvals, hostSecurity, hostAsk, askFallback };
|
||||
}
|
||||
|
||||
export async function resolveApprovalDecisionOrUndefined(params: {
|
||||
approvalId: string;
|
||||
preResolvedDecision: string | null | undefined;
|
||||
onFailure: () => void;
|
||||
}): Promise<string | null | undefined> {
|
||||
try {
|
||||
return await resolveRegisteredExecApprovalDecision({
|
||||
approvalId: params.approvalId,
|
||||
preResolvedDecision: params.preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
params.onFailure();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readErrorName } from "../infra/errors.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
isAuthPermanentErrorMessage,
|
||||
@@ -82,13 +83,6 @@ function getStatusCode(err: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getErrorName(err: unknown): string {
|
||||
if (!err || typeof err !== "object") {
|
||||
return "";
|
||||
}
|
||||
return "name" in err ? String(err.name) : "";
|
||||
}
|
||||
|
||||
function getErrorCode(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
@@ -127,7 +121,7 @@ function hasTimeoutHint(err: unknown): boolean {
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
if (getErrorName(err) === "TimeoutError") {
|
||||
if (readErrorName(err) === "TimeoutError") {
|
||||
return true;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
@@ -141,7 +135,7 @@ export function isTimeoutError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (getErrorName(err) !== "AbortError") {
|
||||
if (readErrorName(err) !== "AbortError") {
|
||||
return false;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { completeSimple, getModel } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { makeZeroUsageSnapshot } from "./usage.js";
|
||||
|
||||
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
|
||||
const LIVE = isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
@@ -39,20 +40,7 @@ describeLive("gemini live switch", () => {
|
||||
api: "google-gemini-cli",
|
||||
provider: "google-antigravity",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
usage: makeZeroUsageSnapshot(),
|
||||
stopReason: "stop",
|
||||
timestamp: now,
|
||||
},
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
|
||||
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
session: createPerSenderSessionConfig(),
|
||||
};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
@@ -24,10 +22,7 @@ describe("agents_list", () => {
|
||||
|
||||
function setConfigWithAgentList(agentList: AgentConfig[]) {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
session: createPerSenderSessionConfig(),
|
||||
agents: {
|
||||
list: agentList,
|
||||
},
|
||||
@@ -51,10 +46,7 @@ describe("agents_list", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
session: createPerSenderSessionConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
@@ -13,10 +14,7 @@ vi.mock("../gateway/call.js", () => ({
|
||||
|
||||
let storeTemplatePath = "";
|
||||
let configOverride: Record<string, unknown> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
session: createPerSenderSessionConfig(),
|
||||
};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
@@ -35,11 +33,7 @@ function writeStore(agentId: string, store: Record<string, unknown>) {
|
||||
|
||||
function setSubagentLimits(subagents: Record<string, unknown>) {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents,
|
||||
@@ -75,11 +69,7 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
`openclaw-subagent-depth-${Date.now()}-${Math.random().toString(16).slice(2)}-{agentId}.json`,
|
||||
);
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
};
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
@@ -177,11 +167,7 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
|
||||
it("rejects when active children for requester session reached maxChildrenPerAgent", async () => {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
@@ -214,11 +200,7 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
|
||||
it("does not use subagent maxConcurrent as a per-parent spawn gate", async () => {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
|
||||
@@ -55,6 +55,40 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
return tool.execute(callId, { task: "do thing", agentId, sandbox });
|
||||
}
|
||||
|
||||
function setResearchUnsandboxedConfig(params?: { includeSandboxedDefault?: boolean }) {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
...(params?.includeSandboxedDefault
|
||||
? {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAllowedSpawn(params: {
|
||||
allowAgents: string[];
|
||||
agentId: string;
|
||||
@@ -156,33 +190,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
});
|
||||
|
||||
it("forbids sandboxed cross-agent spawns that would unsandbox the child", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
setResearchUnsandboxedConfig({ includeSandboxedDefault: true });
|
||||
|
||||
const result = await executeSpawn("call11", "research");
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
@@ -193,28 +201,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
});
|
||||
|
||||
it('forbids sandbox="require" when target runtime is unsandboxed', async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
setResearchUnsandboxedConfig();
|
||||
|
||||
const result = await executeSpawn("call12", "research", "require");
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitSdkTools } from "./pi-embedded-runner.js";
|
||||
|
||||
function createStubTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: "",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({}) as AgentToolResult<unknown>,
|
||||
};
|
||||
}
|
||||
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
|
||||
|
||||
describe("splitSdkTools", () => {
|
||||
const tools = [
|
||||
|
||||
@@ -182,6 +182,16 @@ export function emitAssistantLifecycleErrorAndEnd(params: {
|
||||
params.emit({ type: "agent_end" });
|
||||
}
|
||||
|
||||
export function createReasoningFinalAnswerMessage(): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Because it helps" },
|
||||
{ type: "text", text: "Final answer" },
|
||||
],
|
||||
} as AssistantMessage;
|
||||
}
|
||||
|
||||
type LifecycleErrorAgentEvent = {
|
||||
stream?: unknown;
|
||||
data?: {
|
||||
|
||||
@@ -346,6 +346,33 @@ export function handleMessageEnd(
|
||||
maybeEmitReasoning();
|
||||
}
|
||||
|
||||
const emitSplitResultAsBlockReply = (
|
||||
splitResult: ReturnType<typeof ctx.consumeReplyDirectives> | null | undefined,
|
||||
) => {
|
||||
if (!splitResult || !onBlockReply) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
} = splitResult;
|
||||
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
|
||||
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
|
||||
void onBlockReply({
|
||||
text: cleanedText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
(ctx.state.blockReplyBreak === "message_end" ||
|
||||
(ctx.blockChunker ? ctx.blockChunker.hasBuffered() : ctx.state.blockBuffer.length > 0)) &&
|
||||
@@ -369,28 +396,7 @@ export function handleMessageEnd(
|
||||
);
|
||||
} else {
|
||||
ctx.state.lastBlockReplyText = text;
|
||||
const splitResult = ctx.consumeReplyDirectives(text, { final: true });
|
||||
if (splitResult) {
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
} = splitResult;
|
||||
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
|
||||
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
|
||||
void onBlockReply({
|
||||
text: cleanedText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
});
|
||||
}
|
||||
}
|
||||
emitSplitResultAsBlockReply(ctx.consumeReplyDirectives(text, { final: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,27 +409,7 @@ export function handleMessageEnd(
|
||||
}
|
||||
|
||||
if (ctx.state.blockReplyBreak === "text_end" && onBlockReply) {
|
||||
const tailResult = ctx.consumeReplyDirectives("", { final: true });
|
||||
if (tailResult) {
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
} = tailResult;
|
||||
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
|
||||
void onBlockReply({
|
||||
text: cleanedText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
});
|
||||
}
|
||||
}
|
||||
emitSplitResultAsBlockReply(ctx.consumeReplyDirectives("", { final: true }));
|
||||
}
|
||||
|
||||
ctx.state.deltaBuffer = "";
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
THINKING_TAG_CASES,
|
||||
createReasoningFinalAnswerMessage,
|
||||
createStubSessionHarness,
|
||||
} from "./pi-embedded-subscribe.e2e-harness.js";
|
||||
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
|
||||
@@ -31,13 +32,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
it("emits reasoning as a separate message when enabled", () => {
|
||||
const { emit, onBlockReply } = createReasoningBlockReplyHarness();
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Because it helps" },
|
||||
{ type: "text", text: "Final answer" },
|
||||
],
|
||||
} as AssistantMessage;
|
||||
const assistantMessage = createReasoningFinalAnswerMessage();
|
||||
|
||||
emit({ type: "message_end", message: assistantMessage });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createReasoningFinalAnswerMessage,
|
||||
createStubSessionHarness,
|
||||
emitAssistantTextDelta,
|
||||
emitAssistantTextEnd,
|
||||
@@ -22,13 +22,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
emitAssistantTextDelta({ emit, delta: "answer" });
|
||||
emitAssistantTextEnd({ emit });
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Because it helps" },
|
||||
{ type: "text", text: "Final answer" },
|
||||
],
|
||||
} as AssistantMessage;
|
||||
const assistantMessage = createReasoningFinalAnswerMessage();
|
||||
|
||||
emit({ type: "message_end", message: assistantMessage });
|
||||
|
||||
@@ -52,13 +46,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Because it helps" },
|
||||
{ type: "text", text: "Final answer" },
|
||||
],
|
||||
} as AssistantMessage;
|
||||
const assistantMessage = createReasoningFinalAnswerMessage();
|
||||
|
||||
emit({ type: "message_end", message: assistantMessage });
|
||||
emitAssistantTextEnd({ emit, content: "Draft reply" });
|
||||
|
||||
@@ -9,6 +9,15 @@ async function createAgentDir(): Promise<string> {
|
||||
return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-auth-storage-"));
|
||||
}
|
||||
|
||||
async function withAgentDir(run: (agentDir: string) => Promise<void>): Promise<void> {
|
||||
const agentDir = await createAgentDir();
|
||||
try {
|
||||
await run(agentDir);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(pathname: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(pathname);
|
||||
@@ -18,10 +27,25 @@ async function pathExists(pathname: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
function writeRuntimeOpenRouterProfile(agentDir: string): void {
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openrouter:default": {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key: "sk-or-v1-runtime",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
}
|
||||
|
||||
describe("discoverAuthStorage", () => {
|
||||
it("loads runtime credentials from auth-profiles without writing auth.json", async () => {
|
||||
const agentDir = await createAgentDir();
|
||||
try {
|
||||
await withAgentDir(async (agentDir) => {
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
@@ -61,27 +85,12 @@ describe("discoverAuthStorage", () => {
|
||||
});
|
||||
|
||||
expect(await pathExists(path.join(agentDir, "auth.json"))).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("scrubs static api_key entries from legacy auth.json and keeps oauth entries", async () => {
|
||||
const agentDir = await createAgentDir();
|
||||
try {
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openrouter:default": {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key: "sk-or-v1-runtime",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
await withAgentDir(async (agentDir) => {
|
||||
writeRuntimeOpenRouterProfile(agentDir);
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth.json"),
|
||||
JSON.stringify(
|
||||
@@ -109,53 +118,39 @@ describe("discoverAuthStorage", () => {
|
||||
type: "oauth",
|
||||
access: "oauth-access",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy auth.json when auth store is forced read-only", async () => {
|
||||
const agentDir = await createAgentDir();
|
||||
const previous = process.env.OPENCLAW_AUTH_STORE_READONLY;
|
||||
process.env.OPENCLAW_AUTH_STORE_READONLY = "1";
|
||||
try {
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openrouter:default": {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key: "sk-or-v1-runtime",
|
||||
await withAgentDir(async (agentDir) => {
|
||||
const previous = process.env.OPENCLAW_AUTH_STORE_READONLY;
|
||||
process.env.OPENCLAW_AUTH_STORE_READONLY = "1";
|
||||
try {
|
||||
writeRuntimeOpenRouterProfile(agentDir);
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
openrouter: { type: "api_key", key: "legacy-static-key" },
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
openrouter: { type: "api_key", key: "legacy-static-key" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
discoverAuthStorage(agentDir);
|
||||
discoverAuthStorage(agentDir);
|
||||
|
||||
const parsed = JSON.parse(await fs.readFile(path.join(agentDir, "auth.json"), "utf8")) as {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
expect(parsed.openrouter).toMatchObject({ type: "api_key", key: "legacy-static-key" });
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_AUTH_STORE_READONLY;
|
||||
} else {
|
||||
process.env.OPENCLAW_AUTH_STORE_READONLY = previous;
|
||||
const parsed = JSON.parse(await fs.readFile(path.join(agentDir, "auth.json"), "utf8")) as {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
expect(parsed.openrouter).toMatchObject({ type: "api_key", key: "legacy-static-key" });
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_AUTH_STORE_READONLY;
|
||||
} else {
|
||||
process.env.OPENCLAW_AUTH_STORE_READONLY = previous;
|
||||
}
|
||||
}
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
@@ -7,16 +5,7 @@ import {
|
||||
isToolAllowedByPolicyName,
|
||||
resolveSubagentToolPolicy,
|
||||
} from "./pi-tools.policy.js";
|
||||
|
||||
function createStubTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: "",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({}) as AgentToolResult<unknown>,
|
||||
};
|
||||
}
|
||||
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
|
||||
|
||||
describe("pi-tools.policy", () => {
|
||||
it("treats * in allow as allow-all", () => {
|
||||
|
||||
@@ -18,6 +18,30 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => {
|
||||
type ToolWithExecute = {
|
||||
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<unknown>;
|
||||
};
|
||||
type CodingToolsInput = NonNullable<Parameters<typeof createOpenClawCodingTools>[0]>;
|
||||
|
||||
const APPLY_PATCH_PAYLOAD = `*** Begin Patch
|
||||
*** Add File: /agent/pwned.txt
|
||||
+owned-by-apply-patch
|
||||
*** End Patch`;
|
||||
|
||||
function resolveApplyPatchTool(
|
||||
params: Pick<CodingToolsInput, "sandbox" | "workspaceDir"> & { config: OpenClawConfig },
|
||||
): ToolWithExecute {
|
||||
const tools = createOpenClawCodingTools({
|
||||
sandbox: params.sandbox,
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.config,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-5.2",
|
||||
});
|
||||
const applyPatchTool = tools.find((t) => t.name === "apply_patch") as ToolWithExecute | undefined;
|
||||
if (!applyPatchTool) {
|
||||
throw new Error("apply_patch tool missing");
|
||||
}
|
||||
return applyPatchTool;
|
||||
}
|
||||
|
||||
describe("tools.fs.workspaceOnly", () => {
|
||||
it("defaults to allowing sandbox mounts outside the workspace root", async () => {
|
||||
await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => {
|
||||
@@ -62,32 +86,18 @@ describe("tools.fs.workspaceOnly", () => {
|
||||
|
||||
it("enforces apply_patch workspace-only in sandbox mounts by default", async () => {
|
||||
await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
allow: ["read", "exec"],
|
||||
exec: { applyPatch: { enabled: true } },
|
||||
},
|
||||
};
|
||||
const tools = createOpenClawCodingTools({
|
||||
const applyPatchTool = resolveApplyPatchTool({
|
||||
sandbox,
|
||||
workspaceDir: sandboxRoot,
|
||||
config: cfg,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-5.2",
|
||||
config: {
|
||||
tools: {
|
||||
allow: ["read", "exec"],
|
||||
exec: { applyPatch: { enabled: true } },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
const applyPatchTool = tools.find((t) => t.name === "apply_patch") as
|
||||
| ToolWithExecute
|
||||
| undefined;
|
||||
if (!applyPatchTool) {
|
||||
throw new Error("apply_patch tool missing");
|
||||
}
|
||||
|
||||
const patch = `*** Begin Patch
|
||||
*** Add File: /agent/pwned.txt
|
||||
+owned-by-apply-patch
|
||||
*** End Patch`;
|
||||
|
||||
await expect(applyPatchTool.execute("t1", { input: patch })).rejects.toThrow(
|
||||
await expect(applyPatchTool.execute("t1", { input: APPLY_PATCH_PAYLOAD })).rejects.toThrow(
|
||||
/Path escapes sandbox root/i,
|
||||
);
|
||||
await expect(fs.stat(path.join(agentRoot, "pwned.txt"))).rejects.toMatchObject({
|
||||
@@ -98,32 +108,18 @@ describe("tools.fs.workspaceOnly", () => {
|
||||
|
||||
it("allows apply_patch outside workspace root when explicitly disabled", async () => {
|
||||
await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
allow: ["read", "exec"],
|
||||
exec: { applyPatch: { enabled: true, workspaceOnly: false } },
|
||||
},
|
||||
};
|
||||
const tools = createOpenClawCodingTools({
|
||||
const applyPatchTool = resolveApplyPatchTool({
|
||||
sandbox,
|
||||
workspaceDir: sandboxRoot,
|
||||
config: cfg,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-5.2",
|
||||
config: {
|
||||
tools: {
|
||||
allow: ["read", "exec"],
|
||||
exec: { applyPatch: { enabled: true, workspaceOnly: false } },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
const applyPatchTool = tools.find((t) => t.name === "apply_patch") as
|
||||
| ToolWithExecute
|
||||
| undefined;
|
||||
if (!applyPatchTool) {
|
||||
throw new Error("apply_patch tool missing");
|
||||
}
|
||||
|
||||
const patch = `*** Begin Patch
|
||||
*** Add File: /agent/pwned.txt
|
||||
+owned-by-apply-patch
|
||||
*** End Patch`;
|
||||
|
||||
await applyPatchTool.execute("t2", { input: patch });
|
||||
await applyPatchTool.execute("t2", { input: APPLY_PATCH_PAYLOAD });
|
||||
expect(await fs.readFile(path.join(agentRoot, "pwned.txt"), "utf8")).toBe(
|
||||
"owned-by-apply-patch\n",
|
||||
);
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
|
||||
import { resolveDockerSpawnInvocation } from "./docker.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-docker-spawn-test-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
const createTempDir = () => tempDirs.make("openclaw-docker-spawn-test-");
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
describe("resolveDockerSpawnInvocation", () => {
|
||||
|
||||
@@ -79,53 +79,17 @@ export function countActiveRunsForSessionFromRuns(
|
||||
return count;
|
||||
}
|
||||
|
||||
export function countActiveDescendantRunsFromRuns(
|
||||
function forEachDescendantRun(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
): number {
|
||||
visitor: (runId: string, entry: SubagentRunRecord) => void,
|
||||
): boolean {
|
||||
const root = rootSessionKey.trim();
|
||||
if (!root) {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
const pending = [root];
|
||||
const visited = new Set<string>([root]);
|
||||
let count = 0;
|
||||
while (pending.length > 0) {
|
||||
const requester = pending.shift();
|
||||
if (!requester) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of runs.values()) {
|
||||
if (entry.requesterSessionKey !== requester) {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.endedAt !== "number") {
|
||||
count += 1;
|
||||
}
|
||||
const childKey = entry.childSessionKey.trim();
|
||||
if (!childKey || visited.has(childKey)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(childKey);
|
||||
pending.push(childKey);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function countPendingDescendantRunsInternal(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
excludeRunId?: string,
|
||||
): number {
|
||||
const root = rootSessionKey.trim();
|
||||
if (!root) {
|
||||
return 0;
|
||||
}
|
||||
const excludedRunId = excludeRunId?.trim();
|
||||
const pending = [root];
|
||||
const visited = new Set<string>([root]);
|
||||
let count = 0;
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
const requester = pending[index];
|
||||
if (!requester) {
|
||||
@@ -135,11 +99,7 @@ function countPendingDescendantRunsInternal(
|
||||
if (entry.requesterSessionKey !== requester) {
|
||||
continue;
|
||||
}
|
||||
const runEnded = typeof entry.endedAt === "number";
|
||||
const cleanupCompleted = typeof entry.cleanupCompletedAt === "number";
|
||||
if ((!runEnded || !cleanupCompleted) && runId !== excludedRunId) {
|
||||
count += 1;
|
||||
}
|
||||
visitor(runId, entry);
|
||||
const childKey = entry.childSessionKey.trim();
|
||||
if (!childKey || visited.has(childKey)) {
|
||||
continue;
|
||||
@@ -148,6 +108,44 @@ function countPendingDescendantRunsInternal(
|
||||
pending.push(childKey);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function countActiveDescendantRunsFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
): number {
|
||||
let count = 0;
|
||||
if (
|
||||
!forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
|
||||
if (typeof entry.endedAt !== "number") {
|
||||
count += 1;
|
||||
}
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function countPendingDescendantRunsInternal(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
excludeRunId?: string,
|
||||
): number {
|
||||
const excludedRunId = excludeRunId?.trim();
|
||||
let count = 0;
|
||||
if (
|
||||
!forEachDescendantRun(runs, rootSessionKey, (runId, entry) => {
|
||||
const runEnded = typeof entry.endedAt === "number";
|
||||
const cleanupCompleted = typeof entry.cleanupCompletedAt === "number";
|
||||
if ((!runEnded || !cleanupCompleted) && runId !== excludedRunId) {
|
||||
count += 1;
|
||||
}
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -170,30 +168,13 @@ export function listDescendantRunsForRequesterFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
): SubagentRunRecord[] {
|
||||
const root = rootSessionKey.trim();
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
const pending = [root];
|
||||
const visited = new Set<string>([root]);
|
||||
const descendants: SubagentRunRecord[] = [];
|
||||
while (pending.length > 0) {
|
||||
const requester = pending.shift();
|
||||
if (!requester) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of runs.values()) {
|
||||
if (entry.requesterSessionKey !== requester) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
|
||||
descendants.push(entry);
|
||||
const childKey = entry.childSessionKey.trim();
|
||||
if (!childKey || visited.has(childKey)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(childKey);
|
||||
pending.push(childKey);
|
||||
}
|
||||
})
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
12
src/agents/test-helpers/pi-tool-stubs.ts
Normal file
12
src/agents/test-helpers/pi-tool-stubs.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export function createStubTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: "",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({}) as AgentToolResult<unknown>,
|
||||
};
|
||||
}
|
||||
11
src/agents/test-helpers/session-config.ts
Normal file
11
src/agents/test-helpers/session-config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
export function createPerSenderSessionConfig(
|
||||
overrides: Partial<NonNullable<OpenClawConfig["session"]>> = {},
|
||||
): NonNullable<OpenClawConfig["session"]> {
|
||||
return {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,31 @@ export function resolveActionArg(args: unknown): string | undefined {
|
||||
return action || undefined;
|
||||
}
|
||||
|
||||
export function resolveToolVerbAndDetailForArgs(params: {
|
||||
toolKey: string;
|
||||
args?: unknown;
|
||||
meta?: string;
|
||||
spec?: ToolDisplaySpec;
|
||||
fallbackDetailKeys?: string[];
|
||||
detailMode: "first" | "summary";
|
||||
detailCoerce?: CoerceDisplayValueOptions;
|
||||
detailMaxEntries?: number;
|
||||
detailFormatKey?: (raw: string) => string;
|
||||
}): { verb?: string; detail?: string } {
|
||||
return resolveToolVerbAndDetail({
|
||||
toolKey: params.toolKey,
|
||||
args: params.args,
|
||||
meta: params.meta,
|
||||
action: resolveActionArg(params.args),
|
||||
spec: params.spec,
|
||||
fallbackDetailKeys: params.fallbackDetailKeys,
|
||||
detailMode: params.detailMode,
|
||||
detailCoerce: params.detailCoerce,
|
||||
detailMaxEntries: params.detailMaxEntries,
|
||||
detailFormatKey: params.detailFormatKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function coerceDisplayValue(
|
||||
value: unknown,
|
||||
opts: CoerceDisplayValueOptions = {},
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
formatToolDetailText,
|
||||
formatDetailKey,
|
||||
normalizeToolName,
|
||||
resolveActionArg,
|
||||
resolveToolVerbAndDetail,
|
||||
resolveToolVerbAndDetailForArgs,
|
||||
type ToolDisplaySpec as ToolDisplaySpecBase,
|
||||
} from "./tool-display-common.js";
|
||||
import TOOL_DISPLAY_OVERRIDES_JSON from "./tool-display-overrides.json" with { type: "json" };
|
||||
@@ -67,12 +66,10 @@ export function resolveToolDisplay(params: {
|
||||
const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩";
|
||||
const title = spec?.title ?? defaultTitle(name);
|
||||
const label = spec?.label ?? title;
|
||||
const action = resolveActionArg(params.args);
|
||||
let { verb, detail } = resolveToolVerbAndDetail({
|
||||
let { verb, detail } = resolveToolVerbAndDetailForArgs({
|
||||
toolKey: key,
|
||||
args: params.args,
|
||||
meta: params.meta,
|
||||
action,
|
||||
spec,
|
||||
fallbackDetailKeys: FALLBACK.detailKeys,
|
||||
detailMode: "summary",
|
||||
@@ -96,7 +93,7 @@ export function resolveToolDisplay(params: {
|
||||
|
||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||
const detailRaw = display.detail ? redactToolDetail(display.detail) : undefined;
|
||||
return formatToolDetailText(detailRaw, { prefixWithWith: true });
|
||||
return formatToolDetailText(detailRaw);
|
||||
}
|
||||
|
||||
export function formatToolSummary(display: ToolDisplay): string {
|
||||
|
||||
@@ -29,16 +29,7 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
|
||||
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");
|
||||
}
|
||||
import { readDiscordParentIdParam } from "./discord-actions-shared.js";
|
||||
|
||||
type DiscordRoleMutation = (params: {
|
||||
guildId: string;
|
||||
@@ -287,7 +278,7 @@ export async function handleDiscordGuildAction(
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const type = readNumberParam(params, "type", { integer: true });
|
||||
const parentId = readParentIdParam(params);
|
||||
const parentId = readDiscordParentIdParam(params);
|
||||
const topic = readStringParam(params, "topic");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const nsfw = params.nsfw as boolean | undefined;
|
||||
@@ -325,7 +316,7 @@ export async function handleDiscordGuildAction(
|
||||
const name = readStringParam(params, "name");
|
||||
const topic = readStringParam(params, "topic");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const parentId = readParentIdParam(params);
|
||||
const parentId = readDiscordParentIdParam(params);
|
||||
const nsfw = params.nsfw as boolean | undefined;
|
||||
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
||||
integer: true,
|
||||
@@ -388,7 +379,7 @@ export async function handleDiscordGuildAction(
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const parentId = readParentIdParam(params);
|
||||
const parentId = readDiscordParentIdParam(params);
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
if (accountId) {
|
||||
await moveChannelDiscord(
|
||||
|
||||
13
src/agents/tools/discord-actions-shared.ts
Normal file
13
src/agents/tools/discord-actions-shared.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readStringParam } from "./common.js";
|
||||
|
||||
export function readDiscordParentIdParam(
|
||||
params: Record<string, unknown>,
|
||||
): string | null | undefined {
|
||||
if (params.clearParent === true) {
|
||||
return null;
|
||||
}
|
||||
if (params.parentId === null) {
|
||||
return null;
|
||||
}
|
||||
return readStringParam(params, "parentId");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
|
||||
import { createOpenClawCodingTools } from "../pi-tools.js";
|
||||
import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
|
||||
import { createUnsafeMountedSandbox } from "../test-helpers/unsafe-mounted-sandbox.js";
|
||||
import { makeZeroUsageSnapshot } from "../usage.js";
|
||||
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
|
||||
|
||||
async function writeAuthProfiles(agentDir: string, profiles: unknown) {
|
||||
@@ -766,23 +767,6 @@ describe("image tool MiniMax VLM routing", () => {
|
||||
});
|
||||
|
||||
describe("image tool response validation", () => {
|
||||
function zeroUsage() {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(
|
||||
overrides: Partial<{
|
||||
api: string;
|
||||
@@ -800,7 +784,7 @@ describe("image tool response validation", () => {
|
||||
model: "gpt-5-mini",
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
usage: zeroUsage(),
|
||||
usage: makeZeroUsageSnapshot(),
|
||||
content: [] as unknown[],
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type Api, type Context, complete, type Model } from "@mariozechner/pi-ai";
|
||||
import { type Context, complete } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { getDefaultLocalRoots, loadWebMedia } from "../../web/media.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { minimaxUnderstandImage } from "../minimax-vlm.js";
|
||||
import {
|
||||
coerceImageAssistantText,
|
||||
@@ -11,15 +11,20 @@ import {
|
||||
type ImageModelConfig,
|
||||
resolveProviderVisionModelFromConfig,
|
||||
} from "./image-tool.helpers.js";
|
||||
import {
|
||||
applyImageModelConfigDefaults,
|
||||
buildTextToolResult,
|
||||
resolveMediaToolLocalRoots,
|
||||
resolveModelFromRegistry,
|
||||
resolveModelRuntimeApiKey,
|
||||
resolvePromptAndModelOverride,
|
||||
} from "./media-tool-shared.js";
|
||||
import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js";
|
||||
import {
|
||||
createSandboxBridgeReadFile,
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
ensureOpenClawModelsJson,
|
||||
getApiKeyForModel,
|
||||
normalizeWorkspaceDir,
|
||||
requireApiKey,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
runWithImageModelFallback,
|
||||
type AnyAgentTool,
|
||||
@@ -202,18 +207,7 @@ async function runImagePrompt(params: {
|
||||
model: string;
|
||||
attempts: Array<{ provider: string; model: string; error: string }>;
|
||||
}> {
|
||||
const effectiveCfg: OpenClawConfig | undefined = params.cfg
|
||||
? {
|
||||
...params.cfg,
|
||||
agents: {
|
||||
...params.cfg.agents,
|
||||
defaults: {
|
||||
...params.cfg.agents?.defaults,
|
||||
imageModel: params.imageModelConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.imageModelConfig);
|
||||
|
||||
await ensureOpenClawModelsJson(effectiveCfg, params.agentDir);
|
||||
const authStorage = discoverAuthStorage(params.agentDir);
|
||||
@@ -223,20 +217,16 @@ async function runImagePrompt(params: {
|
||||
cfg: effectiveCfg,
|
||||
modelOverride: params.modelOverride,
|
||||
run: async (provider, modelId) => {
|
||||
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
|
||||
if (!model) {
|
||||
throw new Error(`Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
const model = resolveModelFromRegistry({ modelRegistry, provider, modelId });
|
||||
if (!model.input?.includes("image")) {
|
||||
throw new Error(`Model does not support images: ${provider}/${modelId}`);
|
||||
}
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
const apiKey = await resolveModelRuntimeApiKey({
|
||||
model,
|
||||
cfg: effectiveCfg,
|
||||
agentDir: params.agentDir,
|
||||
authStorage,
|
||||
});
|
||||
const apiKey = requireApiKey(apiKeyInfo, model.provider);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
|
||||
// MiniMax VLM only supports a single image; use the first one.
|
||||
if (model.provider === "minimax") {
|
||||
@@ -308,6 +298,7 @@ export function createImageTool(options?: {
|
||||
? "Analyze one or more images with a vision model. Use image for a single path/URL, or images for multiple (up to 20). Only use this tool when images were NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you."
|
||||
: "Analyze one or more images with the configured image model (agents.defaults.imageModel). Use image for a single path/URL, or images for multiple (up to 20). Provide a prompt describing what to analyze.";
|
||||
|
||||
<<<<<<< HEAD
|
||||
const localRoots = (() => {
|
||||
const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
|
||||
if (options?.fsPolicy?.workspaceOnly) {
|
||||
@@ -319,6 +310,18 @@ export function createImageTool(options?: {
|
||||
}
|
||||
return Array.from(new Set([...roots, workspaceDir]));
|
||||
})();
|
||||
||||||| parent of 4a741746c (refactor: dedupe agent and reply runtimes)
|
||||
const localRoots = (() => {
|
||||
const roots = getDefaultLocalRoots();
|
||||
const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
|
||||
if (!workspaceDir) {
|
||||
return roots;
|
||||
}
|
||||
return Array.from(new Set([...roots, workspaceDir]));
|
||||
})();
|
||||
=======
|
||||
const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir);
|
||||
>>>>>>> 4a741746c (refactor: dedupe agent and reply runtimes)
|
||||
|
||||
return {
|
||||
label: "Image",
|
||||
@@ -383,12 +386,10 @@ export function createImageTool(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
const promptRaw =
|
||||
typeof record.prompt === "string" && record.prompt.trim()
|
||||
? record.prompt.trim()
|
||||
: DEFAULT_PROMPT;
|
||||
const modelOverride =
|
||||
typeof record.model === "string" && record.model.trim() ? record.model.trim() : undefined;
|
||||
const { prompt: promptRaw, modelOverride } = resolvePromptAndModelOverride(
|
||||
record,
|
||||
DEFAULT_PROMPT,
|
||||
);
|
||||
const maxBytesMb = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
|
||||
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);
|
||||
|
||||
@@ -525,14 +526,7 @@ export function createImageTool(options?: {
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: result.text }],
|
||||
details: {
|
||||
model: `${result.provider}/${result.model}`,
|
||||
...imageDetails,
|
||||
attempts: result.attempts,
|
||||
},
|
||||
};
|
||||
return buildTextToolResult(result, imageDetails);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
107
src/agents/tools/media-tool-shared.ts
Normal file
107
src/agents/tools/media-tool-shared.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { type Api, type Model } from "@mariozechner/pi-ai";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getDefaultLocalRoots } from "../../web/media.js";
|
||||
import type { ImageModelConfig } from "./image-tool.helpers.js";
|
||||
import { getApiKeyForModel, normalizeWorkspaceDir, requireApiKey } from "./tool-runtime.helpers.js";
|
||||
|
||||
type TextToolAttempt = {
|
||||
provider: string;
|
||||
model: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type TextToolResult = {
|
||||
text: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
attempts: TextToolAttempt[];
|
||||
};
|
||||
|
||||
export function applyImageModelConfigDefaults(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
imageModelConfig: ImageModelConfig,
|
||||
): OpenClawConfig | undefined {
|
||||
if (!cfg) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
imageModel: imageModelConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMediaToolLocalRoots(workspaceDirRaw: string | undefined): string[] {
|
||||
const roots = getDefaultLocalRoots();
|
||||
const workspaceDir = normalizeWorkspaceDir(workspaceDirRaw);
|
||||
if (!workspaceDir) {
|
||||
return [...roots];
|
||||
}
|
||||
return Array.from(new Set([...roots, workspaceDir]));
|
||||
}
|
||||
|
||||
export function resolvePromptAndModelOverride(
|
||||
args: Record<string, unknown>,
|
||||
defaultPrompt: string,
|
||||
): {
|
||||
prompt: string;
|
||||
modelOverride?: string;
|
||||
} {
|
||||
const prompt =
|
||||
typeof args.prompt === "string" && args.prompt.trim() ? args.prompt.trim() : defaultPrompt;
|
||||
const modelOverride =
|
||||
typeof args.model === "string" && args.model.trim() ? args.model.trim() : undefined;
|
||||
return { prompt, modelOverride };
|
||||
}
|
||||
|
||||
export function buildTextToolResult(
|
||||
result: TextToolResult,
|
||||
extraDetails: Record<string, unknown>,
|
||||
): {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: Record<string, unknown>;
|
||||
} {
|
||||
return {
|
||||
content: [{ type: "text", text: result.text }],
|
||||
details: {
|
||||
model: `${result.provider}/${result.model}`,
|
||||
...extraDetails,
|
||||
attempts: result.attempts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModelFromRegistry(params: {
|
||||
modelRegistry: { find: (provider: string, modelId: string) => unknown };
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): Model<Api> {
|
||||
const model = params.modelRegistry.find(params.provider, params.modelId) as Model<Api> | null;
|
||||
if (!model) {
|
||||
throw new Error(`Unknown model: ${params.provider}/${params.modelId}`);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function resolveModelRuntimeApiKey(params: {
|
||||
model: Model<Api>;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
agentDir: string;
|
||||
authStorage: {
|
||||
setRuntimeApiKey: (provider: string, apiKey: string) => void;
|
||||
};
|
||||
}): Promise<string> {
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model: params.model,
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
const apiKey = requireApiKey(apiKeyInfo, params.model.provider);
|
||||
params.authStorage.setRuntimeApiKey(params.model.provider, apiKey);
|
||||
return apiKey;
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { type Api, type Context, complete, type Model } from "@mariozechner/pi-ai";
|
||||
import { type Context, complete } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-extract.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { getDefaultLocalRoots, loadWebMediaRaw } from "../../web/media.js";
|
||||
import { loadWebMediaRaw } from "../../web/media.js";
|
||||
import {
|
||||
coerceImageModelConfig,
|
||||
type ImageModelConfig,
|
||||
resolveProviderVisionModelFromConfig,
|
||||
} from "./image-tool.helpers.js";
|
||||
import {
|
||||
applyImageModelConfigDefaults,
|
||||
buildTextToolResult,
|
||||
resolveMediaToolLocalRoots,
|
||||
resolveModelFromRegistry,
|
||||
resolveModelRuntimeApiKey,
|
||||
resolvePromptAndModelOverride,
|
||||
} from "./media-tool-shared.js";
|
||||
import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js";
|
||||
import { anthropicAnalyzePdf, geminiAnalyzePdf } from "./pdf-native-providers.js";
|
||||
import {
|
||||
@@ -23,9 +31,6 @@ import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
ensureOpenClawModelsJson,
|
||||
getApiKeyForModel,
|
||||
normalizeWorkspaceDir,
|
||||
requireApiKey,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
runWithImageModelFallback,
|
||||
type AnyAgentTool,
|
||||
@@ -176,18 +181,7 @@ async function runPdfPrompt(params: {
|
||||
native: boolean;
|
||||
attempts: Array<{ provider: string; model: string; error: string }>;
|
||||
}> {
|
||||
const effectiveCfg: OpenClawConfig | undefined = params.cfg
|
||||
? {
|
||||
...params.cfg,
|
||||
agents: {
|
||||
...params.cfg.agents,
|
||||
defaults: {
|
||||
...params.cfg.agents?.defaults,
|
||||
imageModel: params.pdfModelConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.pdfModelConfig);
|
||||
|
||||
await ensureOpenClawModelsJson(effectiveCfg, params.agentDir);
|
||||
const authStorage = discoverAuthStorage(params.agentDir);
|
||||
@@ -205,18 +199,13 @@ async function runPdfPrompt(params: {
|
||||
cfg: effectiveCfg,
|
||||
modelOverride: params.modelOverride,
|
||||
run: async (provider, modelId) => {
|
||||
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
|
||||
if (!model) {
|
||||
throw new Error(`Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
const model = resolveModelFromRegistry({ modelRegistry, provider, modelId });
|
||||
const apiKey = await resolveModelRuntimeApiKey({
|
||||
model,
|
||||
cfg: effectiveCfg,
|
||||
agentDir: params.agentDir,
|
||||
authStorage,
|
||||
});
|
||||
const apiKey = requireApiKey(apiKeyInfo, model.provider);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
|
||||
if (providerSupportsNativePdf(provider)) {
|
||||
if (params.pageNumbers && params.pageNumbers.length > 0) {
|
||||
@@ -338,6 +327,7 @@ export function createPdfTool(options?: {
|
||||
? Math.floor(maxPagesDefault)
|
||||
: DEFAULT_MAX_PAGES;
|
||||
|
||||
<<<<<<< HEAD
|
||||
const localRoots = (() => {
|
||||
const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
|
||||
if (options?.fsPolicy?.workspaceOnly) {
|
||||
@@ -349,6 +339,18 @@ export function createPdfTool(options?: {
|
||||
}
|
||||
return Array.from(new Set([...roots, workspaceDir]));
|
||||
})();
|
||||
||||||| parent of 4a741746c (refactor: dedupe agent and reply runtimes)
|
||||
const localRoots = (() => {
|
||||
const roots = getDefaultLocalRoots();
|
||||
const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
|
||||
if (!workspaceDir) {
|
||||
return roots;
|
||||
}
|
||||
return Array.from(new Set([...roots, workspaceDir]));
|
||||
})();
|
||||
=======
|
||||
const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir);
|
||||
>>>>>>> 4a741746c (refactor: dedupe agent and reply runtimes)
|
||||
|
||||
const description =
|
||||
"Analyze one or more PDF documents with a model. Supports native PDF analysis for Anthropic and Google models, with text/image extraction fallback for other providers. Use pdf for a single path/URL, or pdfs for multiple (up to 10). Provide a prompt describing what to analyze.";
|
||||
@@ -412,12 +414,10 @@ export function createPdfTool(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
const promptRaw =
|
||||
typeof record.prompt === "string" && record.prompt.trim()
|
||||
? record.prompt.trim()
|
||||
: DEFAULT_PROMPT;
|
||||
const modelOverride =
|
||||
typeof record.model === "string" && record.model.trim() ? record.model.trim() : undefined;
|
||||
const { prompt: promptRaw, modelOverride } = resolvePromptAndModelOverride(
|
||||
record,
|
||||
DEFAULT_PROMPT,
|
||||
);
|
||||
const maxBytesMbRaw = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
|
||||
const maxBytesMb =
|
||||
typeof maxBytesMbRaw === "number" && Number.isFinite(maxBytesMbRaw) && maxBytesMbRaw > 0
|
||||
@@ -573,15 +573,7 @@ export function createPdfTool(options?: {
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: result.text }],
|
||||
details: {
|
||||
model: `${result.provider}/${result.model}`,
|
||||
native: result.native,
|
||||
...pdfDetails,
|
||||
attempts: result.attempts,
|
||||
},
|
||||
};
|
||||
return buildTextToolResult(result, { native: result.native, ...pdfDetails });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
resolveSessionReference,
|
||||
resolveVisibleSessionReference,
|
||||
shouldResolveSessionIdInput,
|
||||
shouldVerifyRequesterSpawnedSessionVisibility,
|
||||
} from "./sessions-resolution.js";
|
||||
|
||||
@@ -10,10 +10,10 @@ import { jsonResult, readStringParam } from "./common.js";
|
||||
import {
|
||||
createSessionVisibilityGuard,
|
||||
createAgentToAgentPolicy,
|
||||
isResolvedSessionVisibleToRequester,
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
resolveSessionReference,
|
||||
resolveSandboxedSessionToolContext,
|
||||
resolveVisibleSessionReference,
|
||||
stripToolMessages,
|
||||
} from "./sessions-helpers.js";
|
||||
|
||||
@@ -197,23 +197,21 @@ export function createSessionsHistoryTool(opts?: {
|
||||
if (!resolvedSession.ok) {
|
||||
return jsonResult({ status: resolvedSession.status, error: resolvedSession.error });
|
||||
}
|
||||
// From here on, use the canonical key (sessionId inputs already resolved).
|
||||
const resolvedKey = resolvedSession.key;
|
||||
const displayKey = resolvedSession.displayKey;
|
||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||
|
||||
const visible = await isResolvedSessionVisibleToRequester({
|
||||
const visibleSession = await resolveVisibleSessionReference({
|
||||
resolvedSession,
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
targetSessionKey: resolvedKey,
|
||||
restrictToSpawned,
|
||||
resolvedViaSessionId,
|
||||
visibilitySessionKey: sessionKeyParam,
|
||||
});
|
||||
if (!visible) {
|
||||
if (!visibleSession.ok) {
|
||||
return jsonResult({
|
||||
status: "forbidden",
|
||||
error: `Session not visible from this sandboxed agent session: ${sessionKeyParam}`,
|
||||
status: visibleSession.status,
|
||||
error: visibleSession.error,
|
||||
});
|
||||
}
|
||||
// From here on, use the canonical key (sessionId inputs already resolved).
|
||||
const resolvedKey = visibleSession.key;
|
||||
const displayKey = visibleSession.displayKey;
|
||||
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const visibility = resolveEffectiveSessionToolsVisibility({
|
||||
|
||||
@@ -159,6 +159,19 @@ export type SessionReferenceResolution =
|
||||
}
|
||||
| { ok: false; status: "error" | "forbidden"; error: string };
|
||||
|
||||
export type VisibleSessionReferenceResolution =
|
||||
| {
|
||||
ok: true;
|
||||
key: string;
|
||||
displayKey: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
status: "forbidden";
|
||||
error: string;
|
||||
displayKey: string;
|
||||
};
|
||||
|
||||
async function resolveSessionKeyFromSessionId(params: {
|
||||
sessionId: string;
|
||||
alias: string;
|
||||
@@ -289,6 +302,31 @@ export async function resolveSessionReference(params: {
|
||||
return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false };
|
||||
}
|
||||
|
||||
export async function resolveVisibleSessionReference(params: {
|
||||
resolvedSession: Extract<SessionReferenceResolution, { ok: true }>;
|
||||
requesterSessionKey: string;
|
||||
restrictToSpawned: boolean;
|
||||
visibilitySessionKey: string;
|
||||
}): Promise<VisibleSessionReferenceResolution> {
|
||||
const resolvedKey = params.resolvedSession.key;
|
||||
const displayKey = params.resolvedSession.displayKey;
|
||||
const visible = await isResolvedSessionVisibleToRequester({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
targetSessionKey: resolvedKey,
|
||||
restrictToSpawned: params.restrictToSpawned,
|
||||
resolvedViaSessionId: params.resolvedSession.resolvedViaSessionId,
|
||||
});
|
||||
if (!visible) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "forbidden",
|
||||
error: `Session not visible from this sandboxed agent session: ${params.visibilitySessionKey}`,
|
||||
displayKey,
|
||||
};
|
||||
}
|
||||
return { ok: true, key: resolvedKey, displayKey };
|
||||
}
|
||||
|
||||
export function normalizeOptionalKey(value?: string) {
|
||||
return normalizeKey(value);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
createSessionVisibilityGuard,
|
||||
createAgentToAgentPolicy,
|
||||
extractAssistantText,
|
||||
isResolvedSessionVisibleToRequester,
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
resolveSessionReference,
|
||||
resolveSandboxedSessionToolContext,
|
||||
resolveVisibleSessionReference,
|
||||
stripToolMessages,
|
||||
} from "./sessions-helpers.js";
|
||||
import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./sessions-send-helpers.js";
|
||||
@@ -171,25 +171,23 @@ export function createSessionsSendTool(opts?: {
|
||||
error: resolvedSession.error,
|
||||
});
|
||||
}
|
||||
// Normalize sessionKey/sessionId input into a canonical session key.
|
||||
const resolvedKey = resolvedSession.key;
|
||||
const displayKey = resolvedSession.displayKey;
|
||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||
|
||||
const visible = await isResolvedSessionVisibleToRequester({
|
||||
const visibleSession = await resolveVisibleSessionReference({
|
||||
resolvedSession,
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
targetSessionKey: resolvedKey,
|
||||
restrictToSpawned,
|
||||
resolvedViaSessionId,
|
||||
visibilitySessionKey: sessionKey,
|
||||
});
|
||||
if (!visible) {
|
||||
if (!visibleSession.ok) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "forbidden",
|
||||
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
|
||||
sessionKey: displayKey,
|
||||
status: visibleSession.status,
|
||||
error: visibleSession.error,
|
||||
sessionKey: visibleSession.displayKey,
|
||||
});
|
||||
}
|
||||
// Normalize sessionKey/sessionId input into a canonical session key.
|
||||
const resolvedKey = visibleSession.key;
|
||||
const displayKey = visibleSession.displayKey;
|
||||
const timeoutSeconds =
|
||||
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)
|
||||
? Math.max(0, Math.floor(params.timeoutSeconds))
|
||||
|
||||
Reference in New Issue
Block a user