perf(agents): keep attempt execution runtime cold

This commit is contained in:
Vincent Koc
2026-04-13 21:03:45 +01:00
parent 305a80ce32
commit f126088761
6 changed files with 118 additions and 83 deletions

View File

@@ -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,
});
});

View File

@@ -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<AttemptExecutionRuntime> | undefined;
let transcriptResolveRuntimePromise: Promise<TranscriptResolveRuntime> | undefined;
function loadAttemptExecutionRuntime(): Promise<AttemptExecutionRuntime> {
attemptExecutionRuntimePromise ??= import("./command/attempt-execution.runtime.js");
return attemptExecutionRuntimePromise;
}
function loadTranscriptResolveRuntime(): Promise<TranscriptResolveRuntime> {
transcriptResolveRuntimePromise ??= import("../config/sessions/transcript-resolve.runtime.js");
return transcriptResolveRuntimePromise;
}
type PersistSessionEntryParams = {
sessionStore: Record<string, SessionEntry>;
@@ -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<ReturnType<typeof runAgentAttempt>>;
let result: Awaited<ReturnType<AttemptExecutionRuntime["runAgentAttempt"]>>;
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" &&

View File

@@ -0,0 +1,11 @@
export {
buildAcpResult,
createAcpVisibleTextAccumulator,
emitAcpAssistantDelta,
emitAcpLifecycleEnd,
emitAcpLifecycleError,
emitAcpLifecycleStart,
persistAcpTurnTranscript,
runAgentAttempt,
sessionFileHasContent,
} from "./attempt-execution.js";

View File

@@ -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<string, SessionEntry>;
sessionKey: string;
storePath: string;
entry: SessionEntry;
clearedFields?: string[];
};
export async function persistSessionEntry(params: PersistSessionEntryParams): Promise<void> {
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");
}

View File

@@ -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<string, SessionEntry>;
sessionKey: string;
storePath: string;
entry: SessionEntry;
clearedFields?: string[];
};
export async function persistSessionEntry(params: PersistSessionEntryParams): Promise<void> {
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,

View File

@@ -0,0 +1 @@
export { resolveSessionTranscriptFile } from "./transcript.js";