mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 11:31:35 +00:00
* fix: honor reply hooks for user agent turns * fix: preserve reply hook turn context * fix: gate CLI reply hooks by trigger * fix(plugins): run reply hooks once per admitted turn Co-authored-by: Clive (OpenClaw) <clive@openclaw.local> * fix(plugins): reconcile reply hooks after rebase * fix(auto-reply): dispatch hooks for active steers * test: use valid channel context shape in steer hook test --------- Co-authored-by: Clive (OpenClaw) <clive@openclaw.local> Co-authored-by: Peter Steinberger <steipete@gmail.com>
83 lines
3.1 KiB
TypeScript
83 lines
3.1 KiB
TypeScript
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
|
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
|
import { runOncePerAgentRun } from "../infra/agent-events.js";
|
|
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
|
import { getGlobalHookRunner } from "./hook-runner-global.js";
|
|
import type {
|
|
PluginHookAgentContext,
|
|
PluginHookBeforeAgentReplyEvent,
|
|
PluginHookBeforeAgentReplyResult,
|
|
} from "./hook-types.js";
|
|
|
|
const BEFORE_AGENT_REPLY_TRIGGERS = new Set(["user", "heartbeat", "cron"]);
|
|
const BEFORE_AGENT_REPLY_OBSERVER_KEY = Symbol.for("openclaw.beforeAgentReply.observer");
|
|
|
|
type BeforeAgentReplyObserver = {
|
|
beforeDispatch: () => Promise<boolean | void>;
|
|
afterDispatch: (
|
|
result: PluginHookBeforeAgentReplyResult | undefined,
|
|
) => Promise<PluginHookBeforeAgentReplyResult | undefined>;
|
|
};
|
|
|
|
type BeforeAgentReplyObserverScope = BeforeAgentReplyObserver & { runId?: string };
|
|
|
|
const beforeAgentReplyObserver = resolveGlobalSingleton<
|
|
AsyncLocalStorage<BeforeAgentReplyObserverScope>
|
|
>(BEFORE_AGENT_REPLY_OBSERVER_KEY, () => new AsyncLocalStorage());
|
|
|
|
/** Attaches durable admission bookkeeping without moving hook ownership out of the runner. */
|
|
export function withBeforeAgentReplyObserver<T>(
|
|
observer: BeforeAgentReplyObserver,
|
|
run: () => T,
|
|
): T {
|
|
return beforeAgentReplyObserver.run({ ...observer }, run);
|
|
}
|
|
|
|
/** Preserves the full plugin reply contract, including private payload metadata. */
|
|
export function buildHandledBeforeAgentReplyPayloads(reply?: ReplyPayload): ReplyPayload[] {
|
|
return [reply ?? { text: SILENT_REPLY_TOKEN }];
|
|
}
|
|
|
|
/** Runs the reply claim hook once for one admitted turn, across model fallbacks. */
|
|
export function runBeforeAgentReplyForTurn(params: {
|
|
runId: string;
|
|
trigger?: string;
|
|
event: PluginHookBeforeAgentReplyEvent;
|
|
context: PluginHookAgentContext;
|
|
onDispatch?: () => void;
|
|
onDeclined?: () => void;
|
|
}): Promise<PluginHookBeforeAgentReplyResult | undefined> {
|
|
if (!params.trigger || !BEFORE_AGENT_REPLY_TRIGGERS.has(params.trigger)) {
|
|
return Promise.resolve(undefined);
|
|
}
|
|
return runOncePerAgentRun(params.runId, "before_agent_reply", async () => {
|
|
const hookRunner = getGlobalHookRunner();
|
|
if (!hookRunner?.hasHooks("before_agent_reply")) {
|
|
return undefined;
|
|
}
|
|
const observerScope = beforeAgentReplyObserver.getStore();
|
|
// Nested agent runs inherit async context. Bind recovery to the first runner
|
|
// so a hook-spawned child cannot checkpoint its parent's admitted turn.
|
|
const observer =
|
|
observerScope && (!observerScope.runId || observerScope.runId === params.runId)
|
|
? observerScope
|
|
: undefined;
|
|
if (observer && !observer.runId) {
|
|
observer.runId = params.runId;
|
|
}
|
|
if ((await observer?.beforeDispatch()) === false) {
|
|
return undefined;
|
|
}
|
|
params.onDispatch?.();
|
|
let result = await hookRunner.runBeforeAgentReply(params.event, params.context);
|
|
if (!result?.handled) {
|
|
params.onDeclined?.();
|
|
}
|
|
if (observer) {
|
|
result = await observer.afterDispatch(result);
|
|
}
|
|
return result;
|
|
});
|
|
}
|