refactor: route agent end side effects through harness

This commit is contained in:
Shakker
2026-05-30 14:53:26 +01:00
committed by Shakker
parent 3037646d22
commit c5af09e378
5 changed files with 179 additions and 32 deletions

View File

@@ -15,6 +15,10 @@ import { claudeCliSessionTranscriptHasContent as claudeCliSessionTranscriptHasCo
import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js";
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js";
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
import {
awaitAgentEndSideEffects,
runAgentEndSideEffects,
} from "./harness/agent-end-side-effects.js";
import {
bootstrapHarnessContextEngine,
finalizeHarnessContextEngineTurn,
@@ -23,8 +27,6 @@ import {
import { buildAgentHookContext } from "./harness/hook-context.js";
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";
import {
awaitAgentHarnessAgentEndHook,
runAgentHarnessAgentEndHook,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
} from "./harness/lifecycle-hook-helpers.js";
@@ -149,7 +151,7 @@ function buildCliContextEngineAssistantMessage(params: {
return buildCliHookAssistantMessage(params) as AgentMessage;
}
type CliAgentEndHookParams = Parameters<typeof runAgentHarnessAgentEndHook>[0];
type CliAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
function shouldAwaitCliAgentEndHook(params: RunCliAgentParams): boolean {
return !params.messageChannel && !params.messageProvider;
@@ -160,10 +162,10 @@ async function runCliAgentEndHook(
hookParams: CliAgentEndHookParams,
): Promise<void> {
if (shouldAwaitCliAgentEndHook(params)) {
await awaitAgentHarnessAgentEndHook(hookParams);
await awaitAgentEndSideEffects(hookParams);
return;
}
runAgentHarnessAgentEndHook(hookParams);
runAgentEndSideEffects(hookParams);
}
async function persistApprovedCliUserTurnTranscript(params: RunCliAgentParams): Promise<void> {
@@ -358,6 +360,7 @@ export async function runPreparedCliAgent(
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
trigger: params.trigger,
...(params.config ? { config: params.config } : {}),
...(context.contextWindowInfo?.tokens
? { contextTokenBudget: context.contextWindowInfo.tokens }
: {}),

View File

@@ -146,6 +146,7 @@ import {
import { countActiveToolExecutions } from "../../embedded-agent-subscribe.handlers.tools.js";
import { subscribeEmbeddedAgentSession } from "../../embedded-agent-subscribe.js";
import { isTimeoutError } from "../../failover-error.js";
import { runAgentEndSideEffects } from "../../harness/agent-end-side-effects.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js";
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
import { filterLocalModelLeanTools, isLocalModelLeanEnabled } from "../../local-model-lean.js";
@@ -4393,33 +4394,26 @@ export async function runEmbeddedAttempt(
});
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
// Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await
// Run even on compaction timeout so plugins can log/cleanup
if (hookRunner?.hasHooks("agent_end")) {
hookRunner
.runAgentEnd(
{
messages: messagesSnapshot,
success: !aborted && !promptError,
error: promptError ? formatErrorMessage(promptError) : undefined,
durationMs: Date.now() - promptStartedAt,
},
{
runId: params.runId,
trace: freezeDiagnosticTraceContext(diagnosticTrace),
agentId: hookAgentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
trigger: params.trigger,
...buildAgentHookContextChannelFields(params),
},
)
.catch((err) => {
log.warn(`agent_end hook failed: ${err}`);
});
}
runAgentEndSideEffects({
event: {
messages: messagesSnapshot,
success: !aborted && !promptError,
error: promptError ? formatErrorMessage(promptError) : undefined,
durationMs: Date.now() - promptStartedAt,
},
ctx: {
runId: params.runId,
trace: freezeDiagnosticTraceContext(diagnosticTrace),
agentId: hookAgentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
trigger: params.trigger,
...(params.config ? { config: params.config } : {}),
...buildAgentHookContextChannelFields(params),
},
hookRunner,
});
} finally {
clearTimeout(abortTimer);
if (abortWarnTimer) {

View File

@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runSkillWorkshopAutoCapture } from "../../skills/workshop/autocapture.js";
import { awaitAgentEndSideEffects, runAgentEndSideEffects } from "./agent-end-side-effects.js";
import {
awaitAgentHarnessAgentEndHook,
runAgentHarnessAgentEndHook,
} from "./lifecycle-hook-helpers.js";
vi.mock("../../skills/workshop/autocapture.js", () => ({
runSkillWorkshopAutoCapture: vi.fn(),
}));
vi.mock("./lifecycle-hook-helpers.js", () => ({
awaitAgentHarnessAgentEndHook: vi.fn(),
runAgentHarnessAgentEndHook: vi.fn(),
}));
const mockAutoCapture = vi.mocked(runSkillWorkshopAutoCapture);
const mockAwaitAgentEndHook = vi.mocked(awaitAgentHarnessAgentEndHook);
const mockRunAgentEndHook = vi.mocked(runAgentHarnessAgentEndHook);
describe("agent end side effects", () => {
beforeEach(() => {
mockAutoCapture.mockReset();
mockAwaitAgentEndHook.mockReset();
mockRunAgentEndHook.mockReset();
});
it("fires plugin agent_end hooks without waiting for Skill Workshop auto-capture", async () => {
let resolveCapture: (() => void) | undefined;
mockAutoCapture.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveCapture = resolve;
}),
);
runAgentEndSideEffects({
event: {
messages: [],
success: true,
},
ctx: {
runId: "run-1",
workspaceDir: "/workspace",
config: {
skills: {
workshop: {
autonomous: {
enabled: true,
},
},
},
},
},
});
expect(mockRunAgentEndHook).toHaveBeenCalledTimes(1);
expect(mockAutoCapture).toHaveBeenCalledWith({
event: {
messages: [],
success: true,
},
ctx: {
runId: "run-1",
workspaceDir: "/workspace",
config: {
skills: {
workshop: {
autonomous: {
enabled: true,
},
},
},
},
},
config: {
skills: {
workshop: {
autonomous: {
enabled: true,
},
},
},
},
});
resolveCapture?.();
});
it("still runs agent_end hooks when Skill Workshop auto-capture fails", async () => {
mockAutoCapture.mockRejectedValueOnce(new Error("capture failed"));
await awaitAgentEndSideEffects({
event: {
messages: [],
success: true,
},
ctx: {
runId: "run-1",
workspaceDir: "/workspace",
},
});
expect(mockAutoCapture).toHaveBeenCalledWith({
event: {
messages: [],
success: true,
},
ctx: {
runId: "run-1",
workspaceDir: "/workspace",
},
});
expect(mockAwaitAgentEndHook).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,32 @@
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { runSkillWorkshopAutoCapture } from "../../skills/workshop/autocapture.js";
import {
awaitAgentHarnessAgentEndHook,
runAgentHarnessAgentEndHook,
} from "./lifecycle-hook-helpers.js";
const log = createSubsystemLogger("agents/harness");
type AgentEndSideEffectsParams = Parameters<typeof runAgentHarnessAgentEndHook>[0];
async function runCoreAgentEndSideEffects(params: AgentEndSideEffectsParams): Promise<void> {
try {
await runSkillWorkshopAutoCapture({
event: params.event,
ctx: params.ctx,
...(params.ctx.config ? { config: params.ctx.config } : {}),
});
} catch (error) {
log.warn(`skill workshop auto-capture failed: ${String(error)}`);
}
}
export function runAgentEndSideEffects(params: AgentEndSideEffectsParams): void {
void runCoreAgentEndSideEffects(params);
runAgentHarnessAgentEndHook(params);
}
export async function awaitAgentEndSideEffects(params: AgentEndSideEffectsParams): Promise<void> {
await runCoreAgentEndSideEffects(params);
await awaitAgentHarnessAgentEndHook(params);
}

View File

@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type {
PluginHookAgentContext,
PluginHookContextWindowSource,
@@ -18,6 +19,7 @@ export type AgentHarnessHookContext = {
contextTokenBudget?: number;
contextWindowSource?: PluginHookContextWindowSource;
contextWindowReferenceTokens?: number;
config?: OpenClawConfig;
};
export function buildAgentHookContext(params: AgentHarnessHookContext): PluginHookAgentContext {