From 3bd2ee78b6acf105c23656bb2d61416d6ccb2fa7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 11:37:34 -0700 Subject: [PATCH] feat(plugins): expose hook correlation fields Expose first-class hook correlation fields for plugin message and run lifecycle hooks, including frozen diagnostic trace copies for plugin-facing events. --- CHANGELOG.md | 1 + docs/plugins/hooks.md | 11 ++- src/hooks/message-hook-mappers.test.ts | 98 +++++++++++++++++++- src/hooks/message-hook-mappers.ts | 91 +++++++++++++++++- src/plugins/hook-before-agent-start.types.ts | 1 + src/plugins/hook-message.types.ts | 31 +++++++ src/plugins/hook-types.ts | 1 + src/plugins/hooks.correlation.test.ts | 55 +++++++++++ src/plugins/hooks.ts | 14 ++- 9 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 src/plugins/hooks.correlation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7ba6bf826..42a951406de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare. - Plugins/setup: honor explicit `setup.requiresRuntime: false` as a descriptor-only setup contract while keeping omitted values on the legacy setup-api fallback path. Thanks @vincentkoc. - Plugins/setup: report descriptor/runtime drift when setup-api registrations disagree with `setup.providers` or `setup.cliBackends`, without rejecting legacy setup plugins. Thanks @vincentkoc. +- Plugin hooks: expose first-class run, message, sender, session, and trace correlation fields on message hook contexts and run lifecycle events. Thanks @vincentkoc. - TUI/dependencies: remove direct `cli-highlight` usage from the OpenClaw TUI code-block renderer, keeping themed code coloring without the extra root dependency. Thanks @vincentkoc. - Diagnostics/OTEL: export run, model-call, and tool-execution diagnostic lifecycle events as OTEL spans without retaining live span state. Thanks @vincentkoc. - Plugins/activation: expose activation plan reasons and a richer plan API so callers can inspect why a plugin was selected while preserving existing id-list activation behavior. (#70943) Thanks @vincentkoc. diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 843b6771990..a8cfe86e0b5 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -159,6 +159,9 @@ Use the phase-specific hooks for new plugins: `before_agent_start` remains for compatibility. Prefer the explicit hooks above so your plugin does not depend on a legacy combined phase. +`before_agent_start` and `agent_end` include `event.runId` when OpenClaw can +identify the active run. The same value is also available on `ctx.runId`. + Non-bundled plugins that need `llm_input`, `llm_output`, or `agent_end` must set: ```json @@ -182,10 +185,16 @@ Prompt-mutating hooks can be disabled per plugin with Use message hooks for channel-level routing and delivery policy: -- `message_received`: observe inbound content, sender, `threadId`, and metadata. +- `message_received`: observe inbound content, sender, `threadId`, `messageId`, + `senderId`, optional run/session correlation, and metadata. - `message_sending`: rewrite `content` or return `{ cancel: true }`. - `message_sent`: observe final success or failure. +Message hook contexts expose stable correlation fields when available: +`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`, +`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Prefer +these first-class fields before reading legacy metadata. + Prefer typed `threadId` and `replyToId` fields before using channel-specific metadata. diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index f54101a1f5d..ac7cd7618f0 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { @@ -31,6 +32,7 @@ function makeInboundCtx(overrides: Partial = {}): Finalized Surface: "demo-chat", OriginatingChannel: "demo-chat", OriginatingTo: "demo-chat:chat:456", + SessionKey: "session-1", AccountId: "acc-1", MessageSid: "msg-1", SenderId: "sender-1", @@ -141,18 +143,50 @@ describe("message hook mappers", () => { }); it("maps canonical inbound context to plugin/internal received payloads", () => { - const canonical = deriveInboundMessageHookContext(makeInboundCtx({ TopicName: "Deployments" })); + const trace: DiagnosticTraceContext = { + traceId: "11111111111111111111111111111111", + spanId: "2222222222222222", + parentSpanId: "3333333333333333", + }; + const canonical = { + ...deriveInboundMessageHookContext(makeInboundCtx({ TopicName: "Deployments" })), + runId: "run-1", + trace, + callDepth: 2, + }; - expect(toPluginMessageContext(canonical)).toEqual({ + const pluginContext = toPluginMessageContext(canonical); + const receivedEvent = toPluginMessageReceivedEvent(canonical); + expect(pluginContext).toEqual({ channelId: "demo-chat", accountId: "acc-1", conversationId: "demo-chat:chat:456", + sessionKey: "session-1", + runId: "run-1", + messageId: "msg-1", + senderId: "sender-1", + trace, + traceId: "11111111111111111111111111111111", + spanId: "2222222222222222", + parentSpanId: "3333333333333333", + callDepth: 2, }); - expect(toPluginMessageReceivedEvent(canonical)).toEqual({ + expect(pluginContext.trace).not.toBe(trace); + expect(pluginContext.trace).toEqual(trace); + expect(Object.isFrozen(pluginContext.trace)).toBe(true); + expect(receivedEvent).toEqual({ from: "demo-chat:user:123", content: "commands-body", timestamp: 1710000000, threadId: 42, + messageId: "msg-1", + senderId: "sender-1", + sessionKey: "session-1", + runId: "run-1", + trace, + traceId: "11111111111111111111111111111111", + spanId: "2222222222222222", + parentSpanId: "3333333333333333", metadata: expect.objectContaining({ messageId: "msg-1", senderName: "User One", @@ -160,6 +194,9 @@ describe("message hook mappers", () => { topicName: "Deployments", }), }); + expect(receivedEvent.trace).not.toBe(trace); + expect(receivedEvent.trace).toEqual(trace); + expect(Object.isFrozen(receivedEvent.trace)).toBe(true); expect(toInternalMessageReceivedContext(canonical)).toEqual({ from: "demo-chat:user:123", content: "commands-body", @@ -176,6 +213,39 @@ describe("message hook mappers", () => { }); }); + it("passes frozen trace copies to inbound claim and sent plugin hooks", () => { + const trace: DiagnosticTraceContext = { + traceId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + spanId: "bbbbbbbbbbbbbbbb", + parentSpanId: "cccccccccccccccc", + traceFlags: "01", + }; + const inbound = { + ...deriveInboundMessageHookContext(makeInboundCtx()), + trace, + }; + const inboundContext = toPluginInboundClaimContext(inbound); + const inboundEvent = toPluginInboundClaimEvent(inbound); + expect(inboundContext.trace).not.toBe(trace); + expect(inboundContext.trace).toEqual(trace); + expect(Object.isFrozen(inboundContext.trace)).toBe(true); + expect(inboundEvent.trace).not.toBe(trace); + expect(inboundEvent.trace).toEqual(trace); + expect(Object.isFrozen(inboundEvent.trace)).toBe(true); + + const sent = buildCanonicalSentMessageHookContext({ + to: "demo-chat:chat:456", + content: "reply", + success: true, + channelId: "demo-chat", + trace, + }); + const sentEvent = toPluginMessageSentEvent(sent); + expect(sentEvent.trace).not.toBe(trace); + expect(sentEvent.trace).toEqual(trace); + expect(Object.isFrozen(sentEvent.trace)).toBe(true); + }); + it("uses channel plugin claim resolvers for grouped conversations", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ @@ -193,9 +263,16 @@ describe("message hook mappers", () => { channelId: "claim-chat", accountId: "acc-1", conversationId: "channel:123456789012345678", + sessionKey: "session-1", parentConversationId: undefined, senderId: "sender-1", messageId: "msg-1", + runId: undefined, + trace: undefined, + traceId: undefined, + spanId: undefined, + parentSpanId: undefined, + callDepth: undefined, }); }); @@ -217,9 +294,16 @@ describe("message hook mappers", () => { channelId: "claim-chat", accountId: "acc-1", conversationId: "user:1177378744822943744", + sessionKey: "session-1", parentConversationId: undefined, senderId: "sender-1", messageId: "msg-1", + runId: undefined, + trace: undefined, + traceId: undefined, + spanId: undefined, + parentSpanId: undefined, + callDepth: undefined, }); }); @@ -246,7 +330,9 @@ describe("message hook mappers", () => { error: "network error", channelId: "demo-chat", accountId: "acc-1", + sessionKey: "session-1", messageId: "out-1", + runId: "run-out-1", isGroup: true, groupId: "demo-chat:chat:456", }); @@ -255,11 +341,17 @@ describe("message hook mappers", () => { channelId: "demo-chat", accountId: "acc-1", conversationId: "demo-chat:chat:456", + sessionKey: "session-1", + runId: "run-out-1", + messageId: "out-1", }); expect(toPluginMessageSentEvent(canonical)).toEqual({ to: "demo-chat:chat:456", content: "reply", success: false, + messageId: "out-1", + sessionKey: "session-1", + runId: "run-out-1", error: "network error", }); expect(toInternalMessageSentContext(canonical)).toEqual({ diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 25b73cd60af..7352765819a 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -1,6 +1,10 @@ import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + freezeDiagnosticTraceContext, + type DiagnosticTraceContext, +} from "../infra/diagnostic-trace-context.js"; import type { PluginHookInboundClaimContext, PluginHookInboundClaimEvent, @@ -30,6 +34,8 @@ export type CanonicalInboundMessageHookContext = { channelId: string; accountId?: string; conversationId?: string; + sessionKey?: string; + runId?: string; messageId?: string; senderId?: string; senderName?: string; @@ -53,6 +59,8 @@ export type CanonicalInboundMessageHookContext = { isGroup: boolean; groupId?: string; topicName?: string; + trace?: DiagnosticTraceContext; + callDepth?: number; }; export type CanonicalSentMessageHookContext = { @@ -63,7 +71,11 @@ export type CanonicalSentMessageHookContext = { channelId: string; accountId?: string; conversationId?: string; + sessionKey?: string; + runId?: string; messageId?: string; + trace?: DiagnosticTraceContext; + callDepth?: number; isGroup?: boolean; groupId?: string; }; @@ -118,6 +130,7 @@ export function deriveInboundMessageHookContext( channelId, accountId: ctx.AccountId, conversationId, + sessionKey: ctx.SessionKey, messageId: overrides?.messageId ?? ctx.MessageSidFull ?? @@ -155,7 +168,11 @@ export function buildCanonicalSentMessageHookContext(params: { channelId: string; accountId?: string; conversationId?: string; + sessionKey?: string; + runId?: string; messageId?: string; + trace?: DiagnosticTraceContext; + callDepth?: number; isGroup?: boolean; groupId?: string; }): CanonicalSentMessageHookContext { @@ -167,20 +184,64 @@ export function buildCanonicalSentMessageHookContext(params: { channelId: params.channelId, accountId: params.accountId, conversationId: params.conversationId ?? params.to, + sessionKey: params.sessionKey, + runId: params.runId, messageId: params.messageId, + trace: params.trace, + callDepth: params.callDepth, isGroup: params.isGroup, groupId: params.groupId, }; } +type DiagnosticTraceHookFields = Pick< + PluginHookMessageContext, + "trace" | "traceId" | "spanId" | "parentSpanId" +>; + +function assignTraceFields( + target: DiagnosticTraceHookFields, + trace?: DiagnosticTraceContext, +): void { + if (!trace) { + return; + } + const safeTrace = freezeDiagnosticTraceContext(trace); + target.trace = safeTrace; + target.traceId = safeTrace.traceId; + if (safeTrace.spanId) { + target.spanId = safeTrace.spanId; + } + if (safeTrace.parentSpanId) { + target.parentSpanId = safeTrace.parentSpanId; + } +} + export function toPluginMessageContext( canonical: CanonicalInboundMessageHookContext | CanonicalSentMessageHookContext, ): PluginHookMessageContext { - return { + const context: PluginHookMessageContext = { channelId: canonical.channelId, accountId: canonical.accountId, conversationId: canonical.conversationId, }; + if (canonical.sessionKey) { + context.sessionKey = canonical.sessionKey; + } + if (canonical.runId) { + context.runId = canonical.runId; + } + if (canonical.messageId) { + context.messageId = canonical.messageId; + } + if ("senderId" in canonical && canonical.senderId) { + context.senderId = canonical.senderId; + } + assignTraceFields(context, canonical.trace); + if (canonical.callDepth != null) { + context.callDepth = canonical.callDepth; + } + return context; } function stripChannelPrefix(value: string | undefined, channelId: string): string | undefined { @@ -228,14 +289,19 @@ export function toPluginInboundClaimContext( canonical: CanonicalInboundMessageHookContext, ): PluginHookInboundClaimContext { const conversation = resolveInboundConversation(canonical); - return { + const context: PluginHookInboundClaimContext = { channelId: canonical.channelId, accountId: canonical.accountId, conversationId: conversation.conversationId, + sessionKey: canonical.sessionKey, parentConversationId: conversation.parentConversationId, senderId: canonical.senderId, messageId: canonical.messageId, + runId: canonical.runId, + callDepth: canonical.callDepth, }; + assignTraceFields(context, canonical.trace); + return context; } export function toPluginInboundClaimEvent( @@ -246,7 +312,7 @@ export function toPluginInboundClaimEvent( }, ): PluginHookInboundClaimEvent { const context = toPluginInboundClaimContext(canonical); - return { + const event: PluginHookInboundClaimEvent = { content: canonical.content, body: canonical.body, bodyForAgent: canonical.bodyForAgent, @@ -261,6 +327,8 @@ export function toPluginInboundClaimEvent( senderUsername: canonical.senderUsername, threadId: canonical.threadId, messageId: canonical.messageId, + sessionKey: canonical.sessionKey, + runId: canonical.runId, isGroup: canonical.isGroup, commandAuthorized: extras?.commandAuthorized, wasMentioned: extras?.wasMentioned, @@ -284,16 +352,22 @@ export function toPluginInboundClaimEvent( topicName: canonical.topicName, }, }; + assignTraceFields(event, canonical.trace); + return event; } export function toPluginMessageReceivedEvent( canonical: CanonicalInboundMessageHookContext, ): PluginHookMessageReceivedEvent { - return { + const event: PluginHookMessageReceivedEvent = { from: canonical.from, content: canonical.content, timestamp: canonical.timestamp, threadId: canonical.threadId, + messageId: canonical.messageId, + senderId: canonical.senderId, + sessionKey: canonical.sessionKey, + runId: canonical.runId, metadata: { to: canonical.to, provider: canonical.provider, @@ -311,17 +385,24 @@ export function toPluginMessageReceivedEvent( topicName: canonical.topicName, }, }; + assignTraceFields(event, canonical.trace); + return event; } export function toPluginMessageSentEvent( canonical: CanonicalSentMessageHookContext, ): PluginHookMessageSentEvent { - return { + const event: PluginHookMessageSentEvent = { to: canonical.to, content: canonical.content, success: canonical.success, + ...(canonical.messageId ? { messageId: canonical.messageId } : {}), + ...(canonical.sessionKey ? { sessionKey: canonical.sessionKey } : {}), + ...(canonical.runId ? { runId: canonical.runId } : {}), ...(canonical.error ? { error: canonical.error } : {}), }; + assignTraceFields(event, canonical.trace); + return event; } export function toInternalMessageReceivedContext( diff --git a/src/plugins/hook-before-agent-start.types.ts b/src/plugins/hook-before-agent-start.types.ts index 928107f17d4..2acd7539367 100644 --- a/src/plugins/hook-before-agent-start.types.ts +++ b/src/plugins/hook-before-agent-start.types.ts @@ -59,6 +59,7 @@ void assertAllPluginPromptMutationResultFieldsListed; // before_agent_start hook (legacy compatibility: combines both phases) export type PluginHookBeforeAgentStartEvent = { prompt: string; + runId?: string; /** Optional because legacy hook can run in pre-session phase. */ messages?: unknown[]; }; diff --git a/src/plugins/hook-message.types.ts b/src/plugins/hook-message.types.ts index 0d7ec454683..f48734f8dd8 100644 --- a/src/plugins/hook-message.types.ts +++ b/src/plugins/hook-message.types.ts @@ -1,9 +1,19 @@ +import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; import type { PluginConversationBinding } from "./conversation-binding.types.js"; export type PluginHookMessageContext = { channelId: string; accountId?: string; conversationId?: string; + sessionKey?: string; + runId?: string; + messageId?: string; + senderId?: string; + trace?: DiagnosticTraceContext; + traceId?: string; + spanId?: string; + parentSpanId?: string; + callDepth?: number; }; export type PluginHookInboundClaimContext = PluginHookMessageContext & { @@ -28,6 +38,12 @@ export type PluginHookInboundClaimEvent = { senderUsername?: string; threadId?: string | number; messageId?: string; + sessionKey?: string; + runId?: string; + trace?: DiagnosticTraceContext; + traceId?: string; + spanId?: string; + parentSpanId?: string; isGroup: boolean; commandAuthorized?: boolean; wasMentioned?: boolean; @@ -39,6 +55,14 @@ export type PluginHookMessageReceivedEvent = { content: string; timestamp?: number; threadId?: string | number; + messageId?: string; + senderId?: string; + sessionKey?: string; + runId?: string; + trace?: DiagnosticTraceContext; + traceId?: string; + spanId?: string; + parentSpanId?: string; metadata?: Record; }; @@ -59,5 +83,12 @@ export type PluginHookMessageSentEvent = { to: string; content: string; success: boolean; + messageId?: string; + sessionKey?: string; + runId?: string; + trace?: DiagnosticTraceContext; + traceId?: string; + spanId?: string; + parentSpanId?: string; error?: string; }; diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index b52d608ab34..de0cf3ce592 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -217,6 +217,7 @@ export type PluginHookLlmOutputEvent = { }; export type PluginHookAgentEndEvent = { + runId?: string; messages: unknown[]; success: boolean; error?: string; diff --git a/src/plugins/hooks.correlation.test.ts b/src/plugins/hooks.correlation.test.ts new file mode 100644 index 00000000000..58754858bf1 --- /dev/null +++ b/src/plugins/hooks.correlation.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createHookRunner } from "./hooks.js"; +import { addTestHook, TEST_PLUGIN_AGENT_CTX } from "./hooks.test-helpers.js"; +import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; +import type { PluginHookRegistration } from "./types.js"; + +describe("hook correlation fields", () => { + let registry: PluginRegistry; + + beforeEach(() => { + registry = createEmptyPluginRegistry(); + }); + + it("adds runId to legacy before_agent_start events from hook context", async () => { + const handler = vi.fn(() => undefined); + addTestHook({ + registry, + pluginId: "plugin-a", + hookName: "before_agent_start", + handler: handler as PluginHookRegistration["handler"], + }); + + const runner = createHookRunner(registry); + await runner.runBeforeAgentStart({ prompt: "hello" }, TEST_PLUGIN_AGENT_CTX); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ prompt: "hello", runId: "test-run-id" }), + TEST_PLUGIN_AGENT_CTX, + ); + }); + + it("adds runId to agent_end events from hook context", async () => { + const handler = vi.fn(() => undefined); + addTestHook({ + registry, + pluginId: "plugin-a", + hookName: "agent_end", + handler: handler as PluginHookRegistration["handler"], + }); + + const runner = createHookRunner(registry); + await runner.runAgentEnd( + { + messages: [], + success: true, + }, + TEST_PLUGIN_AGENT_CTX, + ); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ messages: [], success: true, runId: "test-run-id" }), + TEST_PLUGIN_AGENT_CTX, + ); + }); +}); diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 3bb27a21d2d..7894ce343a6 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -510,6 +510,16 @@ export function createHookRunner( // Agent Hooks // ========================================================================= + function withAgentRunId( + event: TEvent, + ctx: PluginHookAgentContext, + ): TEvent { + if (event.runId || !ctx.runId) { + return event; + } + return { ...event, runId: ctx.runId }; + } + /** * Run before_model_resolve hook. * Allows plugins to override provider/model before model resolution. @@ -552,7 +562,7 @@ export function createHookRunner( ): Promise { return runModifyingHook<"before_agent_start", PluginHookBeforeAgentStartResult>( "before_agent_start", - event, + withAgentRunId(event, ctx), ctx, { mergeResults: (acc, next) => ({ @@ -588,7 +598,7 @@ export function createHookRunner( event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext, ): Promise { - return runVoidHook("agent_end", event, ctx); + return runVoidHook("agent_end", withAgentRunId(event, ctx), ctx); } /**