diff --git a/src/agents/cli-runner.bundle-mcp.e2e.test.ts b/src/agents/cli-runner.bundle-mcp.e2e.test.ts index 70b1ac06bb4c..008f2d047f33 100644 --- a/src/agents/cli-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/cli-runner.bundle-mcp.e2e.test.ts @@ -14,7 +14,7 @@ import { import type { CliPreparedBackend, PreparedCliRunContext, - RunCliAgentParams, + PreparedRunCliAgentParams, } from "./cli-runner/types.js"; // This e2e spins a real stdio MCP server plus a spawned CLI process. Keep the @@ -151,7 +151,7 @@ async function prepareBundleMcpExecutionContext(params: { workspaceDir: params.workspaceDir, config: params.config, })) as CliPreparedBackend; - const runParams: RunCliAgentParams = { + const runParams: PreparedRunCliAgentParams = { sessionId: params.sessionId, sessionFile: params.sessionFile, workspaceDir: params.workspaceDir, diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index a107d86dc72c..5897715e64a7 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -202,7 +202,9 @@ async function runCliAgentEndHook( runAgentEndSideEffects(hookParams); } -async function persistApprovedCliUserTurnTranscript(params: RunCliAgentParams): Promise { +async function persistApprovedCliUserTurnTranscript( + params: RunCliAgentParamsWithSessionFile, +): Promise { if (params.suppressNextUserMessagePersistence === true || !params.userTurnTranscriptRecorder) { return; } diff --git a/src/agents/embedded-agent-runner/compact.runtime.types.ts b/src/agents/embedded-agent-runner/compact.runtime.types.ts index 07824d6863a5..96bedddd13d5 100644 --- a/src/agents/embedded-agent-runner/compact.runtime.types.ts +++ b/src/agents/embedded-agent-runner/compact.runtime.types.ts @@ -1,6 +1,6 @@ -import type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; +import type { CompactEmbeddedAgentSessionRuntimeParams } from "./compact.types.js"; import type { EmbeddedAgentCompactResult } from "./types.js"; export type CompactEmbeddedAgentSessionDirect = ( - params: CompactEmbeddedAgentSessionParams, + params: CompactEmbeddedAgentSessionRuntimeParams, ) => Promise; diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 4e79f2aad9f3..a43d715f3eab 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -95,6 +95,10 @@ import { ensureOpenClawModelsJson } from "../models-config.js"; import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../prompt-surface.js"; import { registerProviderStreamForModel } from "../provider-stream.js"; +import { + applyAgentRunSessionTargetIdentity, + resolveAgentRunSessionTarget, +} from "../run-session-target.js"; import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; import { buildAgentRuntimePlan } from "../runtime-plan/build.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; @@ -123,6 +127,7 @@ import { } from "./compact-reasons.js"; import type { CompactEmbeddedAgentSessionParams, + CompactEmbeddedAgentSessionRuntimeParams, CompactionMessageMetrics, } from "./compact.types.js"; import { dedupeDuplicateUserMessagesForCompaction } from "./compaction-duplicate-user-messages.js"; @@ -175,6 +180,10 @@ import { mapThinkingLevel, normalizeContextTokenBudget } from "./utils.js"; import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js"; export type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; +type CompactEmbeddedAgentSessionParamsWithSessionFile = CompactEmbeddedAgentSessionRuntimeParams & { + sessionFile: string; +}; + function hasRealConversationContent( msg: AgentMessage, messages: AgentMessage[], @@ -417,8 +426,17 @@ function fallbackFailureToCompactionResult(err: unknown): EmbeddedAgentCompactRe * Use this when already inside a session/global lane to avoid deadlocks. */ export async function compactEmbeddedAgentSessionDirect( - params: CompactEmbeddedAgentSessionParams, + paramsInput: CompactEmbeddedAgentSessionRuntimeParams, ): Promise { + const paramsBase = applyAgentRunSessionTargetIdentity(paramsInput); + const runSessionTarget = await resolveAgentRunSessionTarget(paramsBase); + const params: CompactEmbeddedAgentSessionParamsWithSessionFile = { + ...paramsBase, + agentId: paramsBase.agentId ?? runSessionTarget.agentId, + sessionId: runSessionTarget.sessionId, + sessionKey: paramsBase.sessionKey ?? runSessionTarget.sessionKey, + sessionFile: runSessionTarget.sessionFile, + }; if (hasExplicitCompactionModel(params) || !hasCompactionModelFallbackCandidates(params)) { return await compactEmbeddedAgentSessionDirectOnce(params); } @@ -483,7 +501,7 @@ export async function compactEmbeddedAgentSessionDirect( } async function compactEmbeddedAgentSessionDirectOnce( - params: CompactEmbeddedAgentSessionParams, + params: CompactEmbeddedAgentSessionParamsWithSessionFile, ): Promise { const startedAt = Date.now(); const diagId = params.diagId?.trim() || createCompactionDiagId(); diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index f161cd8ad908..a3f8ef3ddd40 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -5,6 +5,7 @@ import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-e import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js"; import type { SkillSnapshot } from "../../skills/types.js"; import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.exec-types.js"; +import type { AgentRunSessionTarget } from "../run-session-target.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; export type CompactEmbeddedAgentSessionParams = { @@ -35,6 +36,9 @@ export type CompactEmbeddedAgentSessionParams = { groupSpace?: string | null; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; + /** Storage-neutral transcript/session target. Defaults to sessionId/sessionKey/agentId. */ + sessionTarget?: AgentRunSessionTarget; + /** Active file-backed artifact for current compaction internals. */ sessionFile: string; /** Optional caller-observed live prompt tokens used for compaction diagnostics. */ currentTokenCount?: number; @@ -97,6 +101,14 @@ export type CompactEmbeddedAgentSessionParams = { allowGatewaySubagentBinding?: boolean; }; +export type CompactEmbeddedAgentSessionRuntimeParams = Omit< + CompactEmbeddedAgentSessionParams, + "sessionFile" +> & { + /** @deprecated Use sessionTarget plus sessionId/sessionKey/agentId for runtime identity. */ + sessionFile?: string; +}; + export type CompactionMessageMetrics = { messages: number; historyTextChars: number; diff --git a/src/agents/run-session-target.test.ts b/src/agents/run-session-target.test.ts new file mode 100644 index 000000000000..1d378bdb3cae --- /dev/null +++ b/src/agents/run-session-target.test.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadSessionStore } from "../config/sessions/store.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveAgentRunSessionTarget } from "./run-session-target.js"; + +describe("agent run session target", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-run-session-target-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("resolves runtime identity through the run config store", async () => { + const storePath = path.join(tempDir, "custom-sessions", "sessions.json"); + const sessionKey = "agent:helper:commitments:test-run"; + + const target = await resolveAgentRunSessionTarget({ + agentId: "helper", + config: { session: { store: storePath } } as OpenClawConfig, + sessionId: "test-run", + sessionKey, + }); + + expect(target).toMatchObject({ + agentId: "helper", + sessionId: "test-run", + sessionKey, + targetKind: "runtime-session", + }); + expect(path.dirname(target.sessionFile)).toBe(path.dirname(storePath)); + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]?.sessionFile).toBe( + target.sessionFile, + ); + }); + + it("uses the agent from an agent-scoped session key when agentId is omitted", async () => { + const storeRoot = path.join(tempDir, "agents", "{agentId}", "sessions.json"); + const sessionKey = "agent:helper:main"; + + const target = await resolveAgentRunSessionTarget({ + config: { session: { store: storeRoot } } as OpenClawConfig, + sessionId: "helper-session", + sessionKey, + }); + + const helperStorePath = path.join(tempDir, "agents", "helper", "sessions.json"); + expect(target.agentId).toBe("helper"); + expect(path.dirname(target.sessionFile)).toBe(path.dirname(helperStorePath)); + expect(loadSessionStore(helperStorePath, { skipCache: true })[sessionKey]?.sessionFile).toBe( + target.sessionFile, + ); + }); +}); diff --git a/src/agents/run-session-target.ts b/src/agents/run-session-target.ts index b64cb0da0595..969398cda27f 100644 --- a/src/agents/run-session-target.ts +++ b/src/agents/run-session-target.ts @@ -1,9 +1,11 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveStorePath } from "../config/sessions/paths.js"; import { resolveSessionTranscriptRuntimeTarget, type SessionTranscriptRuntimeTarget, } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; /** Identifies a run transcript target without naming the current storage artifact. */ export type AgentRunSessionTarget = { @@ -30,10 +32,11 @@ export async function resolveAgentRunSessionTarget(params: { const agentId = normalizeOptionalString(sessionTarget?.agentId) ?? params.agentId; const sessionId = normalizeOptionalString(sessionTarget?.sessionId) ?? params.sessionId; const sessionKey = normalizeOptionalString(sessionTarget?.sessionKey) ?? params.sessionKey; + const effectiveAgentId = agentId ?? resolveAgentIdFromSessionKey(sessionKey); const sessionFile = normalizeOptionalString(params.sessionFile); if (sessionFile) { return { - agentId: agentId ?? "", + agentId: effectiveAgentId ?? "", sessionFile, sessionId, sessionKey: sessionKey ?? "", @@ -43,11 +46,14 @@ export async function resolveAgentRunSessionTarget(params: { if (!sessionKey) { throw new Error(`Cannot resolve run session target without a session key: ${sessionId}`); } + const storePath = + normalizeOptionalString(sessionTarget?.storePath) ?? + resolveStorePath(params.config?.session?.store, { agentId: effectiveAgentId }); return await resolveSessionTranscriptRuntimeTarget({ - ...(agentId ? { agentId } : {}), + ...(effectiveAgentId ? { agentId: effectiveAgentId } : {}), sessionId, sessionKey, - ...(sessionTarget?.storePath ? { storePath: sessionTarget.storePath } : {}), + storePath, ...(sessionTarget?.threadId !== undefined ? { threadId: sessionTarget.threadId } : {}), }); } diff --git a/src/auto-reply/reply/session-fork.ts b/src/auto-reply/reply/session-fork.ts index 47bd07ec3acd..e8c1ade3a9c6 100644 --- a/src/auto-reply/reply/session-fork.ts +++ b/src/auto-reply/reply/session-fork.ts @@ -1,4 +1,7 @@ +import path from "node:path"; +import { resolveStorePath } from "../../config/sessions/paths.js"; import type { SessionEntry } from "../../config/sessions/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; /** @@ -23,6 +26,20 @@ export type ParentForkDecision = message: string; }; +type ParentForkDecisionParams = { + parentEntry: SessionEntry; + agentId?: string; + config?: OpenClawConfig; + storePath?: string; +}; + +type ForkSessionFromParentParams = { + parentEntry: SessionEntry; + agentId: string; + config?: OpenClawConfig; + sessionsDir?: string; +}; + function loadSessionForkRuntime(): Promise { return sessionForkRuntimeLoader.load(); } @@ -37,14 +54,31 @@ function formatParentForkTooLargeMessage(params: { ); } -export async function resolveParentForkDecision(params: { - parentEntry: SessionEntry; - storePath: string; -}): Promise { +function resolveParentForkStorePath(params: { + agentId?: string; + config?: OpenClawConfig; + storePath?: string; +}): string { + return ( + params.storePath ?? resolveStorePath(params.config?.session?.store, { agentId: params.agentId }) + ); +} + +function resolveParentForkSessionsDir(params: { + agentId: string; + config?: OpenClawConfig; + sessionsDir?: string; +}): string { + return params.sessionsDir ?? path.dirname(resolveParentForkStorePath(params)); +} + +export async function resolveParentForkDecision( + params: ParentForkDecisionParams, +): Promise { const maxTokens = DEFAULT_PARENT_FORK_MAX_TOKENS; const parentTokens = await resolveParentForkTokenCount({ parentEntry: params.parentEntry, - storePath: params.storePath, + storePath: resolveParentForkStorePath(params), }); if (typeof parentTokens === "number" && parentTokens > maxTokens) { return { @@ -62,13 +96,14 @@ export async function resolveParentForkDecision(params: { }; } -export async function forkSessionFromParent(params: { - parentEntry: SessionEntry; - agentId: string; - sessionsDir: string; -}): Promise<{ sessionId: string; sessionFile: string } | null> { +export async function forkSessionFromParent( + params: ForkSessionFromParentParams, +): Promise<{ sessionId: string; sessionFile: string } | null> { const runtime = await loadSessionForkRuntime(); - return runtime.forkSessionFromParentRuntime(params); + return runtime.forkSessionFromParentRuntime({ + ...params, + sessionsDir: resolveParentForkSessionsDir(params), + }); } async function resolveParentForkTokenCount(params: { diff --git a/src/commitments/runtime.test.ts b/src/commitments/runtime.test.ts index 2e853779f14d..c20159ee323c 100644 --- a/src/commitments/runtime.test.ts +++ b/src/commitments/runtime.test.ts @@ -25,6 +25,7 @@ vi.mock("./model-selection.runtime.js", () => ({ })); function requireFirstEmbeddedAgentRequest(): { + config?: OpenClawConfig; provider?: string; model?: string; disableTools?: boolean; @@ -224,6 +225,9 @@ describe("commitment extraction runtime", () => { expect(request.provider).toBe("openai"); expect(request.model).toBe("gpt-5.5"); expect(request.disableTools).toBe(true); + expect(request.config?.session?.store).toMatch( + /commitments\/extractor-sessions\/main\/sessions\.json$/, + ); }); it("backs off hidden extraction after terminal model or auth failures", async () => { diff --git a/src/commitments/runtime.ts b/src/commitments/runtime.ts index 0ea8f429fa2a..46e7979859c7 100644 --- a/src/commitments/runtime.ts +++ b/src/commitments/runtime.ts @@ -191,16 +191,6 @@ function openTerminalFailureCooldown( }); } -function resolveExtractionSessionFile(agentId: string, runId: string): string { - return path.join( - resolveStateDir(), - "commitments", - "extractor-sessions", - agentId, - `${runId}.jsonl`, - ); -} - function joinPayloadText(result: EmbeddedAgentPayloadResult): string { return ( result.payloads @@ -211,6 +201,16 @@ function joinPayloadText(result: EmbeddedAgentPayloadResult): string { ); } +function resolveExtractionSessionStore(agentId: string): string { + return path.join( + resolveStateDir(), + "commitments", + "extractor-sessions", + agentId, + "sessions.json", + ); +} + async function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: string; @@ -234,15 +234,21 @@ async function defaultExtractBatch(params: { const resolved = resolveCommitmentsConfig(cfg); const runId = `commitments-${randomUUID()}`; const modelRef = await resolveDefaultModel({ cfg, agentId: first.agentId }); + const helperConfig = { + ...cfg, + session: { + ...cfg.session, + store: resolveExtractionSessionStore(first.agentId), + }, + } as OpenClawConfig; const { runEmbeddedAgent } = await import("../agents/embedded-agent.js"); const result = await runEmbeddedAgent({ sessionId: runId, sessionKey: `agent:${first.agentId}:commitments:${runId}`, agentId: first.agentId, trigger: "manual", - sessionFile: resolveExtractionSessionFile(first.agentId, runId), workspaceDir: resolveAgentWorkspaceDir(cfg, first.agentId), - config: cfg, + config: helperConfig, provider: modelRef.provider, model: modelRef.model, prompt: buildCommitmentExtractionPrompt({ cfg, items: params.items }), diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 6e337877ad5e..1853a971faa1 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -14,6 +14,7 @@ import { import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { getFileStatSnapshot } from "../cache-utils.js"; import { getRuntimeConfig } from "../io.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; import { deriveSessionMetaPatch } from "./metadata.js"; import { resolveStorePath } from "./paths.js"; @@ -184,6 +185,7 @@ type SingleEntryPersistencePatch = { type SessionEntryWorkflowOptions = { agentId?: string; + config?: OpenClawConfig; env?: NodeJS.ProcessEnv; hydrateSkillPromptRefs?: boolean; storePath?: string; @@ -200,7 +202,8 @@ function resolveSessionWorkflowStorePath( return options.storePath; } const agentId = options.agentId ?? resolveAgentIdFromSessionKey(options.sessionKey); - return resolveStorePath(getRuntimeConfig().session?.store, { + const storeConfig = options.config?.session?.store ?? getRuntimeConfig().session?.store; + return resolveStorePath(storeConfig, { agentId, env: options.env, }); diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 95c00bede0e6..de0a69a53bf4 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -447,6 +447,27 @@ describe("Engine contract tests", () => { }); }); + it("delegateCompactionToRuntime uses runtime session identity when available", async () => { + const compactRuntimeSpy = installCompactRuntimeSpy(); + + await delegateCompactionToRuntime({ + sessionId: "s3", + sessionFile: "/tmp/session.json", + runtimeContext: { + agentId: "main", + sessionKey: "agent:main:main", + sessionFile: "/tmp/runtime-context-session.json", + workspaceDir: "/tmp/workspace", + }, + }); + + expect(compactRuntimeSpy).toHaveBeenCalledTimes(1); + const compactRuntimeParams = requireCompactRuntimeParams(0); + expect(compactRuntimeParams.agentId).toBe("main"); + expect(compactRuntimeParams.sessionKey).toBe("agent:main:main"); + expect(compactRuntimeParams.sessionFile).toBeUndefined(); + }); + it("builds a normalized memory system prompt addition from the active memory prompt path", () => { registerMemoryPromptSection(({ citationsMode }) => [ "## Memory Recall", diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index 66514b78329f..3189fb4896bc 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -41,8 +41,9 @@ export async function delegateCompactionToRuntime( // runtimeContext carries the full CompactEmbeddedAgentSessionParams fields set // by runtime callers. We spread them and override the fields that come from // the public ContextEngine compact() signature directly. - const runtimeContext = (params.runtimeContext ?? {}) as ContextEngineRuntimeContext & + const runtimeContextWithFile = (params.runtimeContext ?? {}) as ContextEngineRuntimeContext & Partial; + const { sessionFile: _runtimeSessionFile, ...runtimeContext } = runtimeContextWithFile; const currentTokenCount = params.currentTokenCount ?? (typeof runtimeContext.currentTokenCount === "number" && @@ -50,11 +51,21 @@ export async function delegateCompactionToRuntime( runtimeContext.currentTokenCount > 0 ? Math.floor(runtimeContext.currentTokenCount) : undefined); + const runtimeSessionKey = + typeof runtimeContext.sessionKey === "string" && runtimeContext.sessionKey.trim() + ? runtimeContext.sessionKey.trim() + : undefined; + const sessionKey = params.sessionKey ?? runtimeSessionKey; + const agentId = + typeof runtimeContext.agentId === "string" && runtimeContext.agentId.trim() + ? runtimeContext.agentId.trim() + : undefined; const result = await compactEmbeddedAgentSessionDirect({ ...runtimeContext, sessionId: params.sessionId, - sessionFile: params.sessionFile, + ...(sessionKey ? { sessionKey } : { sessionFile: params.sessionFile }), + ...(agentId ? { agentId } : {}), tokenBudget: params.tokenBudget, ...(currentTokenCount !== undefined ? { currentTokenCount } : {}), force: params.force, diff --git a/src/crestodian/assistant.test.ts b/src/crestodian/assistant.test.ts index a78fc76f7e5c..c6e85b22f8a0 100644 --- a/src/crestodian/assistant.test.ts +++ b/src/crestodian/assistant.test.ts @@ -149,8 +149,10 @@ describe("Crestodian assistant", () => { expect(firstCliCall.model).toBe("claude-opus-4-8"); expect(firstCliCall.cleanupCliLiveSessionOnRunEnd).toBe(true); const firstCliConfig = requireRecord(firstCliCall.config); + const firstCliSession = requireRecord(firstCliConfig.session); const firstCliAgents = requireRecord(firstCliConfig.agents); const firstCliDefaults = requireRecord(firstCliAgents.defaults); + expect(firstCliSession.store).toBe("/tmp/crestodian-planner/sessions.json"); expect(firstCliDefaults.cliBackends).toBeUndefined(); expect(firstCliCall.extraSystemPrompt).toBeTypeOf("string"); expect(firstCliCall.extraSystemPrompt).toContain("Do not use tools, shell commands"); @@ -225,12 +227,14 @@ describe("Crestodian assistant", () => { expect(firstEmbeddedCall.disableTools).toBe(true); expect(firstEmbeddedCall.toolsAllow).toEqual([]); const embeddedConfig = requireRecord(firstEmbeddedCall.config); + const embeddedSession = requireRecord(embeddedConfig.session); const embeddedAgents = requireRecord(embeddedConfig.agents); const embeddedDefaults = requireRecord(embeddedAgents.defaults); const embeddedModel = requireRecord(embeddedDefaults.model); const embeddedPlugins = requireRecord(embeddedConfig.plugins); const embeddedEntries = requireRecord(embeddedPlugins.entries); const embeddedCodexEntry = requireRecord(embeddedEntries.codex); + expect(embeddedSession.store).toBe("/tmp/crestodian-planner/sessions.json"); expect(embeddedModel.primary).toBe("openai/gpt-5.5"); expect(embeddedCodexEntry.enabled).toBe(true); }); diff --git a/src/crestodian/assistant.ts b/src/crestodian/assistant.ts index 72eaaf389549..5a04329cd769 100644 --- a/src/crestodian/assistant.ts +++ b/src/crestodian/assistant.ts @@ -181,9 +181,16 @@ async function runLocalRuntimePlanner( const tempDir = await (params.deps?.createTempDir ?? createTempPlannerDir)(); try { const runId = `crestodian-planner-${randomUUID()}`; - const sessionFile = path.join(tempDir, "session.jsonl"); const sessionId = `${runId}-session`; const sessionKey = `temp:crestodian-planner:${runId}`; + const backendConfig = backend.buildConfig(tempDir); + const helperConfig = { + ...backendConfig, + session: { + ...backendConfig.session, + store: path.join(tempDir, "sessions.json"), + }, + }; switch (backend.runner) { case "cli": { const runCli = params.deps?.runCliAgent ?? (await loadRunCliAgent()); @@ -192,9 +199,8 @@ async function runLocalRuntimePlanner( sessionKey, agentId: "crestodian", trigger: "manual", - sessionFile, workspaceDir: tempDir, - config: backend.buildConfig(tempDir), + config: helperConfig, prompt: params.prompt, provider: backend.provider, model: backend.model, @@ -215,9 +221,8 @@ async function runLocalRuntimePlanner( sessionKey, agentId: "crestodian", trigger: "manual", - sessionFile, workspaceDir: tempDir, - config: backend.buildConfig(tempDir), + config: helperConfig, prompt: params.prompt, provider: backend.provider, model: backend.model, diff --git a/src/hooks/llm-slug-generator.test.ts b/src/hooks/llm-slug-generator.test.ts index b8cd931a5aa0..2ce3db15d4e1 100644 --- a/src/hooks/llm-slug-generator.test.ts +++ b/src/hooks/llm-slug-generator.test.ts @@ -52,6 +52,10 @@ describe("generateSlugViaLLM", () => { const options = requireFirstRunOptions(); expect(options.timeoutMs).toBe(15_000); expect(options.cleanupBundleMcpOnRunEnd).toBe(true); + expect(options.sessionKey).toBe("temp:slug-generator"); + expect(options.sessionId).toMatch(/^slug-generator-/); + expect(options.sessionFile).toBeUndefined(); + expect((options.config as OpenClawConfig).session?.store).toContain("openclaw-slug-"); }); it("marks the run lane-local so internal-helper failures do not poison shared profile health (#71709)", async () => { diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index 54845c6fb9c0..324ea005ccab 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -2,6 +2,7 @@ * LLM-based slug generator for session memory filenames */ +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -35,16 +36,20 @@ export async function generateSlugViaLLM(params: { sessionContent: string; cfg: OpenClawConfig; }): Promise { - let tempSessionFile: string | null = null; - + let tempDir: string | undefined; try { const agentId = resolveDefaultAgentId(params.cfg); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); const agentDir = resolveAgentDir(params.cfg, agentId); - - // Create a temporary session file for this one-off LLM call - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-slug-")); - tempSessionFile = path.join(tempDir, "session.jsonl"); + const sessionId = `slug-generator-${randomUUID()}`; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-slug-")); + const helperConfig = { + ...params.cfg, + session: { + ...params.cfg.session, + store: path.join(tempDir, "sessions.json"), + }, + } as OpenClawConfig; const prompt = `Based on this conversation, generate a short 1-2 word filename slug (lowercase, hyphen-separated, no file extension). @@ -60,13 +65,12 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", const timeoutMs = resolveSlugGeneratorTimeoutMs(params.cfg); const result = await runEmbeddedAgent({ - sessionId: `slug-generator-${Date.now()}`, + sessionId, sessionKey: "temp:slug-generator", agentId, - sessionFile: tempSessionFile, workspaceDir, agentDir, - config: params.cfg, + config: helperConfig, prompt, provider, model, @@ -99,12 +103,11 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", log.error(`Failed to generate slug: ${message}`); return null; } finally { - // Clean up temporary session file - if (tempSessionFile) { + if (tempDir) { try { - await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true }); + await fs.rm(tempDir, { recursive: true, force: true }); } catch { - // Ignore cleanup errors + // Ignore cleanup errors for one-off helper storage. } } } diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts index 1b6bca4a63f3..4cb7df1f1b17 100644 --- a/src/talk/agent-consult-runtime.test.ts +++ b/src/talk/agent-consult-runtime.test.ts @@ -294,13 +294,21 @@ describe("realtime voice agent consult runtime", () => { expect(resolveParentForkDecision).toHaveBeenCalledWith({ parentEntry: sessionStore["agent:main:main"], - storePath: "/tmp/sessions.json", + agentId: "main", + config: {}, }); expect(forkSessionFromParent).toHaveBeenCalledWith({ parentEntry: sessionStore["agent:main:main"], agentId: "main", - sessionsDir: "/tmp", + config: {}, }); + expect(runtime.session.patchSessionEntry).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "main", + config: {}, + sessionKey: "agent:main:subagent:google-meet:meet-1", + }), + ); const forkedEntry = sessionStore["agent:main:subagent:google-meet:meet-1"]; if (!forkedEntry) { throw new Error("Expected forked consult session entry"); @@ -315,7 +323,7 @@ describe("realtime voice agent consult runtime", () => { expectPositiveTimestamp(forkedEntry.updatedAt); const call = requireEmbeddedAgentCall(runEmbeddedAgent); expect(call.sessionId).toBe("forked-session"); - expect(call.sessionFile).toBe("/tmp/forked.jsonl"); + expect(call.sessionFile).toBeUndefined(); expect(call.spawnedBy).toBe("agent:main:main"); }); diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts index 83d2d810cdb5..0aa15d81833c 100644 --- a/src/talk/agent-consult-runtime.ts +++ b/src/talk/agent-consult-runtime.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import path from "node:path"; import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js"; import { forkSessionFromParent, @@ -81,7 +80,8 @@ function resolveDeliverySessionFields(context?: DeliveryContext): Partial; }): Promise { @@ -132,7 +134,8 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: { let forkDecisionWarning: string | undefined; const patched = await params.agentRuntime.session.patchSessionEntry({ - storePath: params.storePath, + agentId: params.agentId, + config: params.cfg, sessionKey: params.sessionKey, fallbackEntry: { sessionId: "", @@ -144,24 +147,28 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: { } if (shouldFork) { const parentEntry = params.agentRuntime.session.getSessionEntry({ - storePath: params.storePath, + agentId: requesterAgentId ?? params.agentId, + config: params.cfg, sessionKey: requesterSessionKey, }); if (parentEntry?.sessionId?.trim()) { const decision = await realtimeVoiceAgentConsultDeps.resolveParentForkDecision({ parentEntry, - storePath: params.storePath, + agentId: params.agentId, + config: params.cfg, }); if (decision.status === "fork") { const fork = await realtimeVoiceAgentConsultDeps.forkSessionFromParent({ parentEntry, agentId: params.agentId, - sessionsDir: path.dirname(params.storePath), + config: params.cfg, }); if (fork) { return { ...deliveryFields, sessionId: fork.sessionId, + // Current fork storage is file-backed; persist the artifact on + // the entry so the run target resolver reuses the forked branch. sessionFile: fork.sessionFile, spawnedBy: requesterSessionKey, forkedFromParent: true, @@ -221,22 +228,20 @@ export async function consultRealtimeVoiceAgent(params: { const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(params.cfg, agentId); await params.agentRuntime.ensureAgentWorkspace({ dir: workspaceDir }); - const storePath = params.agentRuntime.session.resolveStorePath(params.cfg.session?.store, { - agentId, - }); const resolvedDeliveryContext = resolveRealtimeVoiceAgentDeliveryContext({ agentRuntime: params.agentRuntime, - storePath, + agentId, + cfg: params.cfg, sessionKey: params.sessionKey, spawnedBy: params.spawnedBy, }); const sessionEntry = await resolveRealtimeVoiceAgentConsultSessionEntry({ agentId, + cfg: params.cfg, sessionKey: params.sessionKey, spawnedBy: params.spawnedBy, contextMode: params.contextMode, deliveryContext: resolvedDeliveryContext, - storePath, agentRuntime: params.agentRuntime, logger: params.logger, }); @@ -244,9 +249,6 @@ export async function consultRealtimeVoiceAgent(params: { resolvedDeliveryContext ?? deliveryContextFromSession(sessionEntry); const sessionId = sessionEntry.sessionId; - const sessionFile = params.agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, { - agentId, - }); const result = await params.agentRuntime.runEmbeddedAgent({ sessionId, sessionKey: params.sessionKey, @@ -262,7 +264,6 @@ export async function consultRealtimeVoiceAgent(params: { consultDeliveryContext?.threadId != null ? String(consultDeliveryContext.threadId) : undefined, - sessionFile, workspaceDir, config: params.cfg, prompt: buildRealtimeVoiceAgentConsultPrompt({