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.
This commit is contained in:
Vincent Koc
2026-04-24 11:37:34 -07:00
committed by GitHub
parent a43c1f8807
commit 3bd2ee78b6
9 changed files with 292 additions and 11 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<FinalizedMsgContext> = {}): 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({

View File

@@ -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(

View File

@@ -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[];
};

View File

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

View File

@@ -217,6 +217,7 @@ export type PluginHookLlmOutputEvent = {
};
export type PluginHookAgentEndEvent = {
runId?: string;
messages: unknown[];
success: boolean;
error?: string;

View File

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

View File

@@ -510,6 +510,16 @@ export function createHookRunner(
// Agent Hooks
// =========================================================================
function withAgentRunId<TEvent extends { runId?: string }>(
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<PluginHookBeforeAgentStartResult | undefined> {
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<void> {
return runVoidHook("agent_end", event, ctx);
return runVoidHook("agent_end", withAgentRunId(event, ctx), ctx);
}
/**