mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 03:18:26 +00:00
Merge branch 'openclaw:main' into qianfan
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { CliBackendConfig } from "../config/types.js";
|
||||
import { runCliAgent } from "./cli-runner.js";
|
||||
import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";
|
||||
@@ -58,6 +62,85 @@ describe("runCliAgent resume cleanup", () => {
|
||||
expect(pkillArgs[1]).toContain("resume");
|
||||
expect(pkillArgs[1]).toContain("thread-123");
|
||||
});
|
||||
|
||||
it("falls back to per-agent workspace when workspaceDir is missing", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-runner-"));
|
||||
const fallbackWorkspace = path.join(tempDir, "workspace-main");
|
||||
await fs.mkdir(fallbackWorkspace, { recursive: true });
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: fallbackWorkspace,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await runCliAgent({
|
||||
sessionId: "s1",
|
||||
sessionKey: "agent:main:subagent:missing-workspace",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: undefined as unknown as string,
|
||||
config: cfg,
|
||||
prompt: "hi",
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.2-codex",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-1",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const options = runCommandWithTimeoutMock.mock.calls[0]?.[1] as { cwd?: string };
|
||||
expect(options.cwd).toBe(path.resolve(fallbackWorkspace));
|
||||
});
|
||||
|
||||
it("throws when sessionKey is malformed", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-runner-"));
|
||||
const mainWorkspace = path.join(tempDir, "workspace-main");
|
||||
const researchWorkspace = path.join(tempDir, "workspace-research");
|
||||
await fs.mkdir(mainWorkspace, { recursive: true });
|
||||
await fs.mkdir(researchWorkspace, { recursive: true });
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: mainWorkspace,
|
||||
},
|
||||
list: [{ id: "research", workspace: researchWorkspace }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runCliAgent({
|
||||
sessionId: "s1",
|
||||
sessionKey: "agent::broken",
|
||||
agentId: "research",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: undefined as unknown as string,
|
||||
config: cfg,
|
||||
prompt: "hi",
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.2-codex",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-2",
|
||||
}),
|
||||
).rejects.toThrow("Malformed agent session key");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupSuspendedCliProcesses", () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { shouldLogVerbose } from "../globals.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||
@@ -29,12 +28,14 @@ import {
|
||||
import { resolveOpenClawDocsPath } from "./docs-path.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
|
||||
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
|
||||
import { redactRunIdentifier, resolveRunWorkspaceDir } from "./workspace-run.js";
|
||||
|
||||
const log = createSubsystemLogger("agent/claude-cli");
|
||||
|
||||
export async function runCliAgent(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
@@ -51,7 +52,21 @@ export async function runCliAgent(params: {
|
||||
images?: ImageContent[];
|
||||
}): Promise<EmbeddedPiRunResult> {
|
||||
const started = Date.now();
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
const workspaceResolution = resolveRunWorkspaceDir({
|
||||
workspaceDir: params.workspaceDir,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
});
|
||||
const resolvedWorkspace = workspaceResolution.workspaceDir;
|
||||
const redactedSessionId = redactRunIdentifier(params.sessionId);
|
||||
const redactedSessionKey = redactRunIdentifier(params.sessionKey);
|
||||
const redactedWorkspace = redactRunIdentifier(resolvedWorkspace);
|
||||
if (workspaceResolution.usedFallback) {
|
||||
log.warn(
|
||||
`[workspace-fallback] caller=runCliAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`,
|
||||
);
|
||||
}
|
||||
const workspaceDir = resolvedWorkspace;
|
||||
|
||||
const backendResolved = resolveCliBackendConfig(params.provider, params.config);
|
||||
@@ -311,6 +326,7 @@ export async function runCliAgent(params: {
|
||||
export async function runClaudeCliAgent(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
@@ -328,6 +344,7 @@ export async function runClaudeCliAgent(params: {
|
||||
return runCliAgent({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.config,
|
||||
|
||||
@@ -9,7 +9,7 @@ export type ResolvedMemorySearchConfig = {
|
||||
enabled: boolean;
|
||||
sources: Array<"memory" | "sessions">;
|
||||
extraPaths: string[];
|
||||
provider: "openai" | "local" | "gemini" | "auto";
|
||||
provider: "openai" | "local" | "gemini" | "voyage" | "auto";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
|
||||
experimental: {
|
||||
sessionMemory: boolean;
|
||||
};
|
||||
fallback: "openai" | "gemini" | "local" | "none";
|
||||
fallback: "openai" | "gemini" | "local" | "voyage" | "none";
|
||||
model: string;
|
||||
local: {
|
||||
modelPath?: string;
|
||||
@@ -72,6 +72,7 @@ export type ResolvedMemorySearchConfig = {
|
||||
|
||||
const DEFAULT_OPENAI_MODEL = "text-embedding-3-small";
|
||||
const DEFAULT_GEMINI_MODEL = "gemini-embedding-001";
|
||||
const DEFAULT_VOYAGE_MODEL = "voyage-4-large";
|
||||
const DEFAULT_CHUNK_TOKENS = 400;
|
||||
const DEFAULT_CHUNK_OVERLAP = 80;
|
||||
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
|
||||
@@ -136,7 +137,11 @@ function mergeConfig(
|
||||
defaultRemote?.headers,
|
||||
);
|
||||
const includeRemote =
|
||||
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||
hasRemoteConfig ||
|
||||
provider === "openai" ||
|
||||
provider === "gemini" ||
|
||||
provider === "voyage" ||
|
||||
provider === "auto";
|
||||
const batch = {
|
||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
||||
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
||||
@@ -163,7 +168,9 @@ function mergeConfig(
|
||||
? DEFAULT_GEMINI_MODEL
|
||||
: provider === "openai"
|
||||
? DEFAULT_OPENAI_MODEL
|
||||
: undefined;
|
||||
: provider === "voyage"
|
||||
? DEFAULT_VOYAGE_MODEL
|
||||
: undefined;
|
||||
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
|
||||
const local = {
|
||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||
|
||||
@@ -463,4 +463,28 @@ describe("getApiKeyForModel", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts VOYAGE_API_KEY for voyage", async () => {
|
||||
const previous = process.env.VOYAGE_API_KEY;
|
||||
|
||||
try {
|
||||
process.env.VOYAGE_API_KEY = "voyage-test-key";
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveApiKeyForProvider } = await import("./model-auth.js");
|
||||
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "voyage",
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
expect(resolved.apiKey).toBe("voyage-test-key");
|
||||
expect(resolved.source).toContain("VOYAGE_API_KEY");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.VOYAGE_API_KEY;
|
||||
} else {
|
||||
process.env.VOYAGE_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +287,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
const envMap: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
voyage: "VOYAGE_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
deepgram: "DEEPGRAM_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
|
||||
@@ -45,8 +45,12 @@ describe("sessions_spawn thinking defaults", () => {
|
||||
const agentCall = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "agent");
|
||||
const thinkingPatch = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
|
||||
|
||||
expect(agentCall?.params?.thinking).toBe("high");
|
||||
expect(thinkingPatch?.params?.thinkingLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("prefers explicit sessions_spawn.thinking over config default", async () => {
|
||||
@@ -60,7 +64,11 @@ describe("sessions_spawn thinking defaults", () => {
|
||||
const agentCall = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "agent");
|
||||
const thinkingPatch = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
|
||||
|
||||
expect(agentCall?.params?.thinking).toBe("low");
|
||||
expect(thinkingPatch?.params?.thinkingLevel).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +219,75 @@ describe("runEmbeddedPiAgent", () => {
|
||||
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to per-agent workspace when runtime workspaceDir is missing", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const fallbackWorkspace = path.join(tempRoot ?? os.tmpdir(), "workspace-fallback-main");
|
||||
const cfg = {
|
||||
...makeOpenAiConfig(["mock-1"]),
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: fallbackWorkspace,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
await ensureModels(cfg);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test-fallback",
|
||||
sessionKey: "agent:main:subagent:fallback-workspace",
|
||||
sessionFile,
|
||||
workspaceDir: undefined as unknown as string,
|
||||
config: cfg,
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
runId: "run-fallback-workspace",
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
expect(result.payloads?.[0]?.text).toBe("ok");
|
||||
await expect(fs.stat(fallbackWorkspace)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws when sessionKey is malformed", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = {
|
||||
...makeOpenAiConfig(["mock-1"]),
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: path.join(tempRoot ?? os.tmpdir(), "workspace-fallback-main"),
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "research",
|
||||
workspace: path.join(tempRoot ?? os.tmpdir(), "workspace-fallback-research"),
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
await ensureModels(cfg);
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test-fallback-malformed",
|
||||
sessionKey: "agent::broken",
|
||||
agentId: "research",
|
||||
sessionFile,
|
||||
workspaceDir: undefined as unknown as string,
|
||||
config: cfg,
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
runId: "run-fallback-workspace-malformed",
|
||||
enqueue: immediateEnqueue,
|
||||
}),
|
||||
).rejects.toThrow("Malformed agent session key");
|
||||
});
|
||||
|
||||
itIfNotWin32(
|
||||
"persists the first user message before assistant output",
|
||||
{ timeout: 120_000 },
|
||||
|
||||
@@ -172,6 +172,41 @@ describe("resolveModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => {
|
||||
const templateModel = {
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"] as const,
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
};
|
||||
|
||||
vi.mocked(discoverModels).mockReturnValue({
|
||||
find: vi.fn((provider: string, modelId: string) => {
|
||||
if (provider === "anthropic" && modelId === "claude-opus-4-5") {
|
||||
return templateModel;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
} as unknown as ReturnType<typeof discoverModels>);
|
||||
|
||||
const result = resolveModel("anthropic", "claude-opus-4-6", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
|
||||
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
|
||||
expect(result.model).toBeUndefined();
|
||||
|
||||
@@ -23,6 +23,12 @@ const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
|
||||
|
||||
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
|
||||
|
||||
// pi-ai's built-in Anthropic catalog can lag behind OpenClaw's defaults/docs.
|
||||
// Add forward-compat fallbacks for known-new IDs by cloning an older template model.
|
||||
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
|
||||
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
|
||||
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
|
||||
|
||||
function resolveOpenAICodexGpt53FallbackModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
@@ -63,6 +69,51 @@ function resolveOpenAICodexGpt53FallbackModel(
|
||||
} as Model<Api>);
|
||||
}
|
||||
|
||||
function resolveAnthropicOpus46ForwardCompatModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
modelRegistry: ModelRegistry,
|
||||
): Model<Api> | undefined {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
if (normalizedProvider !== "anthropic") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmedModelId = modelId.trim();
|
||||
const lower = trimmedModelId.toLowerCase();
|
||||
const isOpus46 =
|
||||
lower === ANTHROPIC_OPUS_46_MODEL_ID ||
|
||||
lower === ANTHROPIC_OPUS_46_DOT_MODEL_ID ||
|
||||
lower.startsWith(`${ANTHROPIC_OPUS_46_MODEL_ID}-`) ||
|
||||
lower.startsWith(`${ANTHROPIC_OPUS_46_DOT_MODEL_ID}-`);
|
||||
if (!isOpus46) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const templateIds: string[] = [];
|
||||
if (lower.startsWith(ANTHROPIC_OPUS_46_MODEL_ID)) {
|
||||
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_MODEL_ID, "claude-opus-4-5"));
|
||||
}
|
||||
if (lower.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID)) {
|
||||
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5"));
|
||||
}
|
||||
templateIds.push(...ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS);
|
||||
|
||||
for (const templateId of [...new Set(templateIds)].filter(Boolean)) {
|
||||
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
return normalizeModelCompat({
|
||||
...template,
|
||||
id: trimmedModelId,
|
||||
name: trimmedModelId,
|
||||
} as Model<Api>);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildInlineProviderModels(
|
||||
providers: Record<string, InlineProviderConfig>,
|
||||
): InlineModelEntry[] {
|
||||
@@ -140,6 +191,14 @@ export function resolveModel(
|
||||
if (codexForwardCompat) {
|
||||
return { model: codexForwardCompat, authStorage, modelRegistry };
|
||||
}
|
||||
const anthropicForwardCompat = resolveAnthropicOpus46ForwardCompatModel(
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
);
|
||||
if (anthropicForwardCompat) {
|
||||
return { model: anthropicForwardCompat, authStorage, modelRegistry };
|
||||
}
|
||||
const providerCfg = providers[provider];
|
||||
if (providerCfg || modelId.startsWith("mock-")) {
|
||||
const fallbackModel: Model<Api> = normalizeModelCompat({
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
|
||||
import type { EmbeddedPiAgentMeta, EmbeddedPiRunResult } from "./types.js";
|
||||
import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||
import {
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
type FailoverReason,
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { normalizeUsage, type UsageLike } from "../usage.js";
|
||||
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
|
||||
import { compactEmbeddedPiSessionDirect } from "./compact.js";
|
||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||
import { log } from "./logger.js";
|
||||
@@ -92,7 +92,21 @@ export async function runEmbeddedPiAgent(
|
||||
return enqueueSession(() =>
|
||||
enqueueGlobal(async () => {
|
||||
const started = Date.now();
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
const workspaceResolution = resolveRunWorkspaceDir({
|
||||
workspaceDir: params.workspaceDir,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
});
|
||||
const resolvedWorkspace = workspaceResolution.workspaceDir;
|
||||
const redactedSessionId = redactRunIdentifier(params.sessionId);
|
||||
const redactedSessionKey = redactRunIdentifier(params.sessionKey);
|
||||
const redactedWorkspace = redactRunIdentifier(resolvedWorkspace);
|
||||
if (workspaceResolution.usedFallback) {
|
||||
log.warn(
|
||||
`[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`,
|
||||
);
|
||||
}
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
@@ -333,7 +347,7 @@ export async function runEmbeddedPiAgent(
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
@@ -345,6 +359,7 @@ export async function runEmbeddedPiAgent(
|
||||
model,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
agentId: workspaceResolution.agentId,
|
||||
thinkLevel,
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
@@ -401,7 +416,7 @@ export async function runEmbeddedPiAgent(
|
||||
agentAccountId: params.agentAccountId,
|
||||
authProfileId: lastProfileId,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
|
||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { isSubagentSessionKey, normalizeAgentId } from "../../../routing/session-key.js";
|
||||
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
|
||||
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
||||
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
||||
@@ -705,6 +705,13 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
// Get hook runner once for both before_agent_start and agent_end hooks
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const hookAgentId =
|
||||
typeof params.agentId === "string" && params.agentId.trim()
|
||||
? normalizeAgentId(params.agentId)
|
||||
: resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
}).sessionAgentId;
|
||||
|
||||
let promptError: unknown = null;
|
||||
try {
|
||||
@@ -720,7 +727,7 @@ export async function runEmbeddedAttempt(
|
||||
messages: activeSession.messages,
|
||||
},
|
||||
{
|
||||
agentId: params.sessionKey?.split(":")[0] ?? "main",
|
||||
agentId: hookAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
@@ -850,7 +857,7 @@ export async function runEmbeddedAttempt(
|
||||
durationMs: Date.now() - promptStartedAt,
|
||||
},
|
||||
{
|
||||
agentId: params.sessionKey?.split(":")[0] ?? "main",
|
||||
agentId: hookAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
|
||||
@@ -20,6 +20,7 @@ export type ClientToolDefinition = {
|
||||
export type RunEmbeddedPiAgentParams = {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ClientToolDefinition } from "./params.js";
|
||||
export type EmbeddedRunAttemptParams = {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
workspaceDir: "/tmp/test-provider",
|
||||
agentDir: "/tmp/agent-provider",
|
||||
modelProvider: "google-antigravity",
|
||||
modelId: "claude-opus-4-5-thinking",
|
||||
modelId: "claude-opus-4-6-thinking",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
@@ -176,7 +176,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
workspaceDir: "/tmp/test-provider-profile",
|
||||
agentDir: "/tmp/agent-provider-profile",
|
||||
modelProvider: "google-antigravity",
|
||||
modelId: "claude-opus-4-5-thinking",
|
||||
modelId: "claude-opus-4-6-thinking",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
|
||||
@@ -30,8 +30,8 @@ describe("cron tool", () => {
|
||||
],
|
||||
["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }],
|
||||
["remove", { action: "remove", id: "job-2" }, { id: "job-2" }],
|
||||
["run", { action: "run", jobId: "job-1" }, { id: "job-1" }],
|
||||
["run", { action: "run", id: "job-2" }, { id: "job-2" }],
|
||||
["run", { action: "run", jobId: "job-1" }, { id: "job-1", mode: "force" }],
|
||||
["run", { action: "run", id: "job-2" }, { id: "job-2", mode: "force" }],
|
||||
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
|
||||
["runs", { action: "runs", id: "job-2" }, { id: "job-2" }],
|
||||
])("%s sends id to gateway", async (action, args, expectedParams) => {
|
||||
@@ -58,7 +58,21 @@ describe("cron tool", () => {
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call?.params).toEqual({ id: "job-primary" });
|
||||
expect(call?.params).toEqual({ id: "job-primary", mode: "force" });
|
||||
});
|
||||
|
||||
it("supports due-only run mode", async () => {
|
||||
const tool = createCronTool();
|
||||
await tool.execute("call-due", {
|
||||
action: "run",
|
||||
jobId: "job-due",
|
||||
runMode: "due",
|
||||
});
|
||||
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call?.params).toEqual({ id: "job-due", mode: "due" });
|
||||
});
|
||||
|
||||
it("normalizes cron.add job payloads", async () => {
|
||||
@@ -86,7 +100,7 @@ describe("cron tool", () => {
|
||||
deleteAfterRun: true,
|
||||
schedule: { kind: "at", at: new Date(123).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-h
|
||||
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const;
|
||||
|
||||
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
|
||||
const CRON_RUN_MODES = ["due", "force"] as const;
|
||||
|
||||
const REMINDER_CONTEXT_MESSAGES_MAX = 10;
|
||||
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
|
||||
@@ -37,6 +38,7 @@ const CronToolSchema = Type.Object({
|
||||
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
||||
text: Type.Optional(Type.String()),
|
||||
mode: optionalStringEnum(CRON_WAKE_MODES),
|
||||
runMode: optionalStringEnum(CRON_RUN_MODES),
|
||||
contextMessages: Type.Optional(
|
||||
Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }),
|
||||
),
|
||||
@@ -312,7 +314,6 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
||||
}
|
||||
}
|
||||
|
||||
// [Fix Issue 3] Infer delivery target from session key for isolated jobs if not provided
|
||||
if (
|
||||
opts?.agentSessionKey &&
|
||||
job &&
|
||||
@@ -393,7 +394,9 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id }));
|
||||
const runMode =
|
||||
params.runMode === "due" || params.runMode === "force" ? params.runMode : "force";
|
||||
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id, mode: runMode }));
|
||||
}
|
||||
case "runs": {
|
||||
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||
|
||||
@@ -214,6 +214,26 @@ export function createSessionsSpawnTool(opts?: {
|
||||
modelWarning = messageText;
|
||||
}
|
||||
}
|
||||
if (thinkingOverride !== undefined) {
|
||||
try {
|
||||
await callGateway({
|
||||
method: "sessions.patch",
|
||||
params: {
|
||||
key: childSessionKey,
|
||||
thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
} catch (err) {
|
||||
const messageText =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
error: messageText,
|
||||
childSessionKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
const childSystemPrompt = buildSubagentSystemPrompt({
|
||||
requesterSessionKey,
|
||||
requesterOrigin,
|
||||
|
||||
139
src/agents/workspace-run.test.ts
Normal file
139
src/agents/workspace-run.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveRunWorkspaceDir } from "./workspace-run.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
|
||||
|
||||
describe("resolveRunWorkspaceDir", () => {
|
||||
it("resolves explicit workspace values without fallback", () => {
|
||||
const explicit = path.join(process.cwd(), "tmp", "workspace-run-explicit");
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: explicit,
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
});
|
||||
|
||||
expect(result.usedFallback).toBe(false);
|
||||
expect(result.agentId).toBe("main");
|
||||
expect(result.workspaceDir).toBe(path.resolve(explicit));
|
||||
});
|
||||
|
||||
it("falls back to configured per-agent workspace when input is missing", () => {
|
||||
const defaultWorkspace = path.join(process.cwd(), "tmp", "workspace-default-main");
|
||||
const researchWorkspace = path.join(process.cwd(), "tmp", "workspace-research");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: defaultWorkspace },
|
||||
list: [{ id: "research", workspace: researchWorkspace }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
sessionKey: "agent:research:subagent:test",
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
expect(result.usedFallback).toBe(true);
|
||||
expect(result.fallbackReason).toBe("missing");
|
||||
expect(result.agentId).toBe("research");
|
||||
expect(result.workspaceDir).toBe(path.resolve(researchWorkspace));
|
||||
});
|
||||
|
||||
it("falls back to default workspace for blank strings", () => {
|
||||
const defaultWorkspace = path.join(process.cwd(), "tmp", "workspace-default-main");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: defaultWorkspace },
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: " ",
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
expect(result.usedFallback).toBe(true);
|
||||
expect(result.fallbackReason).toBe("blank");
|
||||
expect(result.agentId).toBe("main");
|
||||
expect(result.workspaceDir).toBe(path.resolve(defaultWorkspace));
|
||||
});
|
||||
|
||||
it("falls back to built-in main workspace when config is unavailable", () => {
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: null,
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(result.usedFallback).toBe(true);
|
||||
expect(result.fallbackReason).toBe("missing");
|
||||
expect(result.agentId).toBe("main");
|
||||
expect(result.workspaceDir).toBe(path.resolve(DEFAULT_AGENT_WORKSPACE_DIR));
|
||||
});
|
||||
|
||||
it("throws for malformed agent session keys", () => {
|
||||
expect(() =>
|
||||
resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
sessionKey: "agent::broken",
|
||||
config: undefined,
|
||||
}),
|
||||
).toThrow("Malformed agent session key");
|
||||
});
|
||||
|
||||
it("uses explicit agent id for per-agent fallback when config is unavailable", () => {
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
sessionKey: "definitely-not-a-valid-session-key",
|
||||
agentId: "research",
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(result.agentId).toBe("research");
|
||||
expect(result.agentIdSource).toBe("explicit");
|
||||
expect(result.workspaceDir).toBe(path.resolve(os.homedir(), ".openclaw", "workspace-research"));
|
||||
});
|
||||
|
||||
it("throws for malformed agent session keys even when config has a default agent", () => {
|
||||
const mainWorkspace = path.join(process.cwd(), "tmp", "workspace-main-default");
|
||||
const researchWorkspace = path.join(process.cwd(), "tmp", "workspace-research-default");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: mainWorkspace },
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspace },
|
||||
{ id: "research", workspace: researchWorkspace, default: true },
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
sessionKey: "agent::broken",
|
||||
config: cfg,
|
||||
}),
|
||||
).toThrow("Malformed agent session key");
|
||||
});
|
||||
|
||||
it("treats non-agent legacy keys as default, not malformed", () => {
|
||||
const fallbackWorkspace = path.join(process.cwd(), "tmp", "workspace-default-legacy");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: fallbackWorkspace },
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
sessionKey: "custom-main-key",
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
expect(result.agentId).toBe("main");
|
||||
expect(result.agentIdSource).toBe("default");
|
||||
expect(result.workspaceDir).toBe(path.resolve(fallbackWorkspace));
|
||||
});
|
||||
});
|
||||
106
src/agents/workspace-run.ts
Normal file
106
src/agents/workspace-run.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { redactIdentifier } from "../logging/redact-identifier.js";
|
||||
import {
|
||||
classifySessionKeyShape,
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
|
||||
export type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type";
|
||||
type AgentIdSource = "explicit" | "session_key" | "default";
|
||||
|
||||
export type ResolveRunWorkspaceResult = {
|
||||
workspaceDir: string;
|
||||
usedFallback: boolean;
|
||||
fallbackReason?: WorkspaceFallbackReason;
|
||||
agentId: string;
|
||||
agentIdSource: AgentIdSource;
|
||||
};
|
||||
|
||||
function resolveRunAgentId(params: {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): {
|
||||
agentId: string;
|
||||
agentIdSource: AgentIdSource;
|
||||
} {
|
||||
const rawSessionKey = params.sessionKey?.trim() ?? "";
|
||||
const shape = classifySessionKeyShape(rawSessionKey);
|
||||
if (shape === "malformed_agent") {
|
||||
throw new Error("Malformed agent session key; refusing workspace resolution.");
|
||||
}
|
||||
|
||||
const explicit =
|
||||
typeof params.agentId === "string" && params.agentId.trim()
|
||||
? normalizeAgentId(params.agentId)
|
||||
: undefined;
|
||||
if (explicit) {
|
||||
return { agentId: explicit, agentIdSource: "explicit" };
|
||||
}
|
||||
|
||||
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
||||
if (shape === "missing" || shape === "legacy_or_alias") {
|
||||
return {
|
||||
agentId: defaultAgentId || DEFAULT_AGENT_ID,
|
||||
agentIdSource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseAgentSessionKey(rawSessionKey);
|
||||
if (parsed?.agentId) {
|
||||
return {
|
||||
agentId: normalizeAgentId(parsed.agentId),
|
||||
agentIdSource: "session_key",
|
||||
};
|
||||
}
|
||||
|
||||
// Defensive fallback, should be unreachable for non-malformed shapes.
|
||||
return {
|
||||
agentId: defaultAgentId || DEFAULT_AGENT_ID,
|
||||
agentIdSource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
export function redactRunIdentifier(value: string | undefined): string {
|
||||
return redactIdentifier(value, { len: 12 });
|
||||
}
|
||||
|
||||
export function resolveRunWorkspaceDir(params: {
|
||||
workspaceDir: unknown;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): ResolveRunWorkspaceResult {
|
||||
const requested = params.workspaceDir;
|
||||
const { agentId, agentIdSource } = resolveRunAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
});
|
||||
if (typeof requested === "string") {
|
||||
const trimmed = requested.trim();
|
||||
if (trimmed) {
|
||||
return {
|
||||
workspaceDir: resolveUserPath(trimmed),
|
||||
usedFallback: false,
|
||||
agentId,
|
||||
agentIdSource,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackReason: WorkspaceFallbackReason =
|
||||
requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type";
|
||||
const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId);
|
||||
return {
|
||||
workspaceDir: resolveUserPath(fallbackWorkspace),
|
||||
usedFallback: true,
|
||||
fallbackReason,
|
||||
agentId,
|
||||
agentIdSource,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user