diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 68fe7d75604..56fc4553d8b 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -4,6 +4,7 @@ import { LiveSessionModelSwitchError } from "./live-model-switch.js"; const state = vi.hoisted(() => ({ runWithModelFallbackMock: vi.fn(), runAgentAttemptMock: vi.fn(), + resolveEffectiveModelFallbacksMock: vi.fn().mockReturnValue(undefined), emitAgentEventMock: vi.fn(), registerAgentRunContextMock: vi.fn(), clearAgentRunContextMock: vi.fn(), @@ -15,7 +16,7 @@ vi.mock("./model-fallback.js", () => ({ runWithModelFallback: (params: unknown) => state.runWithModelFallbackMock(params), })); -vi.mock("./command/attempt-execution.js", () => ({ +vi.mock("./command/attempt-execution.runtime.js", () => ({ buildAcpResult: vi.fn(), createAcpVisibleTextAccumulator: vi.fn(), emitAcpAssistantDelta: vi.fn(), @@ -107,7 +108,7 @@ vi.mock("../cli/deps.js", () => ({ createDefaultDeps: () => ({}), })); -vi.mock("../config/config.js", () => ({ +vi.mock("../config/io.js", () => ({ loadConfig: () => ({ agents: { defaults: { @@ -122,6 +123,9 @@ vi.mock("../config/config.js", () => ({ readConfigFileSnapshotForWrite: async () => ({ snapshot: { valid: false }, }), +})); + +vi.mock("../config/runtime-snapshot.js", () => ({ setRuntimeConfigSnapshot: vi.fn(), })); @@ -136,7 +140,7 @@ vi.mock("../config/sessions.js", () => ({ ), })); -vi.mock("../config/sessions/transcript.js", () => ({ +vi.mock("../config/sessions/transcript-resolve.runtime.js", () => ({ resolveSessionTranscriptFile: async () => ({ sessionFile: "/tmp/session.jsonl", sessionEntry: { sessionId: "session-1", updatedAt: Date.now() }, @@ -146,6 +150,7 @@ vi.mock("../config/sessions/transcript.js", () => ({ vi.mock("../infra/agent-events.js", () => ({ clearAgentRunContext: (...args: unknown[]) => state.clearAgentRunContextMock(...args), emitAgentEvent: (...args: unknown[]) => state.emitAgentEventMock(...args), + onAgentEvent: vi.fn(), registerAgentRunContext: (...args: unknown[]) => state.registerAgentRunContextMock(...args), })); @@ -158,12 +163,18 @@ vi.mock("../infra/skills-remote.js", () => ({ })); vi.mock("../logging/subsystem.js", () => ({ - createSubsystemLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), + createSubsystemLogger: () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + raw: vi.fn(), + child: vi.fn(() => logger), + }; + return logger; + }, })); vi.mock("../routing/session-key.js", () => ({ @@ -198,12 +209,11 @@ vi.mock("../utils/message-channel.js", () => ({ resolveMessageChannel: () => "test", })); -const resolveEffectiveModelFallbacksMock = vi.fn().mockReturnValue(undefined); vi.mock("./agent-scope.js", () => ({ listAgentIds: () => ["default"], resolveAgentConfig: () => undefined, resolveAgentDir: () => "/tmp/agent", - resolveEffectiveModelFallbacks: resolveEffectiveModelFallbacksMock, + resolveEffectiveModelFallbacks: state.resolveEffectiveModelFallbacksMock, resolveSessionAgentId: () => "default", resolveAgentSkillsFilter: () => undefined, resolveAgentWorkspaceDir: () => "/tmp/workspace", @@ -468,7 +478,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); - resolveEffectiveModelFallbacksMock.mockClear(); + state.resolveEffectiveModelFallbacksMock.mockClear(); const agentCommand = await getAgentCommand(); await agentCommand({ @@ -477,11 +487,11 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { senderIsOwner: true, }); - expect(resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2); - expect(resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({ + expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2); + expect(state.resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({ hasSessionModelOverride: false, }); - expect(resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({ + expect(state.resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({ hasSessionModelOverride: true, }); }); @@ -508,7 +518,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); - resolveEffectiveModelFallbacksMock.mockClear(); + state.resolveEffectiveModelFallbacksMock.mockClear(); const agentCommand = await getAgentCommand(); await agentCommand({ @@ -517,11 +527,11 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { senderIsOwner: true, }); - expect(resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2); - expect(resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({ + expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2); + expect(state.resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({ hasSessionModelOverride: false, }); - expect(resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({ + expect(state.resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({ hasSessionModelOverride: false, }); }); @@ -546,7 +556,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "claude")); - resolveEffectiveModelFallbacksMock.mockClear(); + state.resolveEffectiveModelFallbacksMock.mockClear(); const agentCommand = await getAgentCommand(); await agentCommand({ @@ -555,11 +565,11 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { senderIsOwner: true, }); - expect(resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2); - expect(resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({ + expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2); + expect(state.resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({ hasSessionModelOverride: false, }); - expect(resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({ + expect(state.resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({ hasSessionModelOverride: true, }); }); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 5ff7e8e1a85..ea792a9f032 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -15,7 +15,6 @@ import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-tar import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; import { loadConfig, readConfigFileSnapshotForWrite } from "../config/io.js"; import { setRuntimeConfigSnapshot } from "../config/runtime-snapshot.js"; -import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isSecretRef } from "../config/types.secrets.js"; @@ -48,18 +47,9 @@ import { import { ensureAuthProfileStore } from "./auth-profiles.js"; import { clearSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; import { - buildAcpResult, - createAcpVisibleTextAccumulator, - emitAcpAssistantDelta, - emitAcpLifecycleEnd, - emitAcpLifecycleError, - emitAcpLifecycleStart, - persistAcpTurnTranscript, persistSessionEntry as persistSessionEntryBase, prependInternalEventContext, - runAgentAttempt, - sessionFileHasContent, -} from "./command/attempt-execution.js"; +} from "./command/attempt-execution.shared.js"; import { deliverAgentCommandResult } from "./command/delivery.js"; import { resolveAgentRunContext } from "./command/run-context.js"; import { updateSessionStoreAfterAgentRun } from "./command/session-store.js"; @@ -88,6 +78,21 @@ import { resolveAgentTimeoutMs } from "./timeout.js"; import { ensureAgentWorkspace } from "./workspace.js"; const log = createSubsystemLogger("agents/agent-command"); +type AttemptExecutionRuntime = typeof import("./command/attempt-execution.runtime.js"); +type TranscriptResolveRuntime = typeof import("../config/sessions/transcript-resolve.runtime.js"); + +let attemptExecutionRuntimePromise: Promise | undefined; +let transcriptResolveRuntimePromise: Promise | undefined; + +function loadAttemptExecutionRuntime(): Promise { + attemptExecutionRuntimePromise ??= import("./command/attempt-execution.runtime.js"); + return attemptExecutionRuntimePromise; +} + +function loadTranscriptResolveRuntime(): Promise { + transcriptResolveRuntimePromise ??= import("../config/sessions/transcript-resolve.runtime.js"); + return transcriptResolveRuntimePromise; +} type PersistSessionEntryParams = { sessionStore: Record; @@ -455,13 +460,14 @@ async function agentCommandInternal( } if (acpResolution?.kind === "ready" && sessionKey) { + const attemptExecutionRuntime = await loadAttemptExecutionRuntime(); const startedAt = Date.now(); registerAgentRunContext(runId, { sessionKey, }); - emitAcpLifecycleStart({ runId, startedAt }); + attemptExecutionRuntime.emitAcpLifecycleStart({ runId, startedAt }); - const visibleTextAccumulator = createAcpVisibleTextAccumulator(); + const visibleTextAccumulator = attemptExecutionRuntime.createAcpVisibleTextAccumulator(); let stopReason: string | undefined; try { const dispatchPolicyError = resolveAcpDispatchPolicyError(cfg); @@ -501,7 +507,7 @@ async function agentCommandInternal( if (!visibleUpdate) { return; } - emitAcpAssistantDelta({ + attemptExecutionRuntime.emitAcpAssistantDelta({ runId, text: visibleUpdate.text, delta: visibleUpdate.delta, @@ -514,19 +520,19 @@ async function agentCommandInternal( fallbackCode: "ACP_TURN_FAILED", fallbackMessage: "ACP turn failed before completion.", }); - emitAcpLifecycleError({ + attemptExecutionRuntime.emitAcpLifecycleError({ runId, message: acpError.message, }); throw acpError; } - emitAcpLifecycleEnd({ runId }); + attemptExecutionRuntime.emitAcpLifecycleEnd({ runId }); const finalTextRaw = visibleTextAccumulator.finalizeRaw(); const finalText = visibleTextAccumulator.finalize(); try { - sessionEntry = await persistAcpTurnTranscript({ + sessionEntry = await attemptExecutionRuntime.persistAcpTurnTranscript({ body, finalText: finalTextRaw, sessionId, @@ -544,7 +550,7 @@ async function agentCommandInternal( ); } - const result = buildAcpResult({ + const result = attemptExecutionRuntime.buildAcpResult({ payloadText: finalText, startedAt, stopReason, @@ -792,6 +798,7 @@ async function agentCommandInternal( }); } } + const { resolveSessionTranscriptFile } = await loadTranscriptResolveRuntime(); let sessionFile: string | undefined; if (sessionStore && sessionKey) { const resolvedSessionFile = await resolveSessionTranscriptFile({ @@ -821,8 +828,9 @@ async function agentCommandInternal( const startedAt = Date.now(); let lifecycleEnded = false; + const attemptExecutionRuntime = await loadAttemptExecutionRuntime(); - let result: Awaited>; + let result: Awaited>; let fallbackProvider = provider; let fallbackModel = model; const MAX_LIVE_SWITCH_RETRIES = 5; @@ -852,7 +860,7 @@ async function agentCommandInternal( run: async (providerOverride, modelOverride, runOptions) => { const isFallbackRetry = fallbackAttemptIndex > 0; fallbackAttemptIndex += 1; - return runAgentAttempt({ + return attemptExecutionRuntime.runAgentAttempt({ providerOverride, modelOverride, cfg, @@ -878,7 +886,8 @@ async function agentCommandInternal( sessionStore, storePath, allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, - sessionHasHistory: !isNewSession || (await sessionFileHasContent(sessionFile)), + sessionHasHistory: + !isNewSession || (await attemptExecutionRuntime.sessionFileHasContent(sessionFile)), onAgentEvent: (evt) => { if ( evt.stream === "lifecycle" && diff --git a/src/agents/command/attempt-execution.runtime.ts b/src/agents/command/attempt-execution.runtime.ts new file mode 100644 index 00000000000..5c9e9d2a44b --- /dev/null +++ b/src/agents/command/attempt-execution.runtime.ts @@ -0,0 +1,11 @@ +export { + buildAcpResult, + createAcpVisibleTextAccumulator, + emitAcpAssistantDelta, + emitAcpLifecycleEnd, + emitAcpLifecycleError, + emitAcpLifecycleStart, + persistAcpTurnTranscript, + runAgentAttempt, + sessionFileHasContent, +} from "./attempt-execution.js"; diff --git a/src/agents/command/attempt-execution.shared.ts b/src/agents/command/attempt-execution.shared.ts new file mode 100644 index 00000000000..3da80e48fa8 --- /dev/null +++ b/src/agents/command/attempt-execution.shared.ts @@ -0,0 +1,41 @@ +import { updateSessionStore } from "../../config/sessions/store.js"; +import { mergeSessionEntry, type SessionEntry } from "../../config/sessions/types.js"; +import { formatAgentInternalEventsForPrompt } from "../internal-events.js"; +import { hasInternalRuntimeContext } from "../internal-runtime-context.js"; +import type { AgentCommandOpts } from "./types.js"; + +export type PersistSessionEntryParams = { + sessionStore: Record; + sessionKey: string; + storePath: string; + entry: SessionEntry; + clearedFields?: string[]; +}; + +export async function persistSessionEntry(params: PersistSessionEntryParams): Promise { + const persisted = await updateSessionStore(params.storePath, (store) => { + const merged = mergeSessionEntry(store[params.sessionKey], params.entry); + for (const field of params.clearedFields ?? []) { + if (!Object.hasOwn(params.entry, field)) { + Reflect.deleteProperty(merged, field); + } + } + store[params.sessionKey] = merged; + return merged; + }); + params.sessionStore[params.sessionKey] = persisted; +} + +export function prependInternalEventContext( + body: string, + events: AgentCommandOpts["internalEvents"], +): string { + if (hasInternalRuntimeContext(body)) { + return body; + } + const renderedEvents = formatAgentInternalEventsForPrompt(events); + if (!renderedEvents) { + return body; + } + return [renderedEvents, body].filter(Boolean).join("\n\n"); +} diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 1549dd4568c..b191fba86e9 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js"; import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js"; -import { mergeSessionEntry, type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { resolveSessionTranscriptFile } from "../../config/sessions/transcript.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -14,13 +14,12 @@ import { resolveBootstrapWarningSignaturesSeen } from "../bootstrap-budget.js"; import { runCliAgent } from "../cli-runner.js"; import { clearCliSession, getCliSessionBinding, setCliSessionBinding } from "../cli-session.js"; import { FailoverError } from "../failover-error.js"; -import { formatAgentInternalEventsForPrompt } from "../internal-events.js"; -import { hasInternalRuntimeContext } from "../internal-runtime-context.js"; import { isCliProvider } from "../model-selection.js"; import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js"; import { runEmbeddedPiAgent } from "../pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../skills.js"; import { resolveFallbackRetryPrompt } from "./attempt-execution.helpers.js"; +import { persistSessionEntry } from "./attempt-execution.shared.js"; import { resolveAgentRunContext } from "./run-context.js"; import type { AgentCommandOpts } from "./types.js"; @@ -32,42 +31,6 @@ export { const log = createSubsystemLogger("agents/agent-command"); -export type PersistSessionEntryParams = { - sessionStore: Record; - sessionKey: string; - storePath: string; - entry: SessionEntry; - clearedFields?: string[]; -}; - -export async function persistSessionEntry(params: PersistSessionEntryParams): Promise { - const persisted = await updateSessionStore(params.storePath, (store) => { - const merged = mergeSessionEntry(store[params.sessionKey], params.entry); - for (const field of params.clearedFields ?? []) { - if (!Object.hasOwn(params.entry, field)) { - Reflect.deleteProperty(merged, field); - } - } - store[params.sessionKey] = merged; - return merged; - }); - params.sessionStore[params.sessionKey] = persisted; -} - -export function prependInternalEventContext( - body: string, - events: AgentCommandOpts["internalEvents"], -): string { - if (hasInternalRuntimeContext(body)) { - return body; - } - const renderedEvents = formatAgentInternalEventsForPrompt(events); - if (!renderedEvents) { - return body; - } - return [renderedEvents, body].filter(Boolean).join("\n\n"); -} - const ACP_TRANSCRIPT_USAGE = { input: 0, output: 0, diff --git a/src/config/sessions/transcript-resolve.runtime.ts b/src/config/sessions/transcript-resolve.runtime.ts new file mode 100644 index 00000000000..fc28ca02de3 --- /dev/null +++ b/src/config/sessions/transcript-resolve.runtime.ts @@ -0,0 +1 @@ +export { resolveSessionTranscriptFile } from "./transcript.js";