mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(plugins): derive hook channel ids from targets
This commit is contained in:
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos.
|
||||
- Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93.
|
||||
- Discord: add configured outbound mention aliases so known `@Name` references can be rewritten to real Discord user mentions instead of relying only on the transient directory cache. Fixes #67587. Thanks @McoreD.
|
||||
- Plugins/hooks: derive hook `ctx.channelId` from the conversation target instead of the provider name, so Discord and other channel plugins can keep per-channel state isolated. Fixes #59881. Thanks @bradfreels.
|
||||
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
|
||||
- Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu.
|
||||
|
||||
|
||||
@@ -212,6 +212,11 @@ Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
|
||||
plugin hooks can scope metrics, side effects, or state to a specific scheduled
|
||||
job.
|
||||
|
||||
For channel-originated runs, `ctx.messageProvider` is the provider surface such
|
||||
as `discord` or `telegram`, while `ctx.channelId` is the conversation target
|
||||
identifier when OpenClaw can derive one from the session key or delivery
|
||||
metadata.
|
||||
|
||||
`agent_end` is an observation hook and runs fire-and-forget after the turn. The
|
||||
hook runner applies a 30 second timeout so a wedged plugin or embedding
|
||||
endpoint cannot leave the hook promise pending forever. A timeout is logged and
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
|
||||
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
|
||||
@@ -76,9 +77,8 @@ export async function runCliAgent(params: RunCliAgentParams): Promise<EmbeddedPi
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
} as const;
|
||||
const hookResult = await hookRunner.runBeforeAgentReply(
|
||||
{ cleanedBody: params.prompt },
|
||||
@@ -154,9 +154,8 @@ export async function runPreparedCliAgent(
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
} as const;
|
||||
|
||||
const buildAgentEndMessages = (lastAssistant?: unknown): unknown[] => [
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CliBackendAuthEpochMode,
|
||||
CliBackendPreparedExecution,
|
||||
} from "../../plugins/cli-backend.types.js";
|
||||
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
|
||||
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||
@@ -349,9 +350,8 @@ export async function prepareCliRunContext(
|
||||
workspaceDir,
|
||||
modelProviderId: params.provider,
|
||||
modelId,
|
||||
messageProvider: params.messageProvider,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
},
|
||||
hookRunner,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { emitAgentPlanEvent } from "../../infra/agent-events.js";
|
||||
import { sleepWithAbort } from "../../infra/backoff.js";
|
||||
import { freezeDiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { resolveProviderAuthProfileId } from "../../plugins/provider-runtime.js";
|
||||
import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
@@ -423,9 +424,8 @@ export async function runEmbeddedPiAgent(
|
||||
workspaceDir: resolvedWorkspace,
|
||||
modelProviderId: provider,
|
||||
modelId,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
};
|
||||
if (params.trigger === "cron" && hookRunner?.hasHooks("before_agent_reply")) {
|
||||
const hookResult = await hookRunner.runBeforeAgentReply(
|
||||
|
||||
@@ -23,6 +23,7 @@ import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summar
|
||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
||||
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
|
||||
import { buildAgentHookContextChannelFields } from "../../../plugins/hook-agent-context.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import {
|
||||
extractModelCompat,
|
||||
@@ -2442,9 +2443,8 @@ export async function runEmbeddedAttempt(
|
||||
workspaceDir: params.workspaceDir,
|
||||
modelProviderId: params.model.provider,
|
||||
modelId: params.model.id,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
};
|
||||
const promptBuildMessages =
|
||||
pruneProcessedHistoryImages(activeSession.messages) ?? activeSession.messages;
|
||||
@@ -2761,9 +2761,8 @@ export async function runEmbeddedAttempt(
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
@@ -3228,9 +3227,8 @@ export async function runEmbeddedAttempt(
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
@@ -3336,9 +3334,8 @@ export async function runEmbeddedAttempt(
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("getReplyFromConfig before_agent_reply wiring", () => {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
messageProvider: "telegram",
|
||||
trigger: "user",
|
||||
channelId: "telegram",
|
||||
channelId: "-100123",
|
||||
}),
|
||||
);
|
||||
expect(mocks.handleInlineActions.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
|
||||
import { type OpenClawConfig, getRuntimeConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import { normalizeStringEntries } from "../../shared/string-normalization.js";
|
||||
@@ -604,9 +605,13 @@ export async function getReplyFromConfig(
|
||||
sessionKey: agentSessionKey,
|
||||
sessionId,
|
||||
workspaceDir,
|
||||
messageProvider: hookMessageProvider,
|
||||
trigger: opts?.isHeartbeat ? "heartbeat" : "user",
|
||||
channelId: hookMessageProvider,
|
||||
...buildAgentHookContextChannelFields({
|
||||
sessionKey: agentSessionKey,
|
||||
messageProvider: hookMessageProvider,
|
||||
currentChannelId: sessionCtx.OriginatingTo ?? ctx.OriginatingTo ?? ctx.To,
|
||||
messageTo: sessionCtx.OriginatingTo ?? ctx.OriginatingTo ?? ctx.To,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (hookResult?.handled) {
|
||||
|
||||
61
src/plugins/hook-agent-context.test.ts
Normal file
61
src/plugins/hook-agent-context.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
resolveAgentHookChannelId,
|
||||
} from "./hook-agent-context.js";
|
||||
|
||||
describe("resolveAgentHookChannelId", () => {
|
||||
it("derives the conversation id from channel session keys", () => {
|
||||
expect(
|
||||
resolveAgentHookChannelId({
|
||||
sessionKey: "agent:main:discord:channel:1472750640760623226",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
currentChannelId: "channel:1472750640760623226",
|
||||
}),
|
||||
).toBe("1472750640760623226");
|
||||
});
|
||||
|
||||
it("uses target metadata when the session key is not a channel conversation", () => {
|
||||
expect(
|
||||
resolveAgentHookChannelId({
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "telegram",
|
||||
currentChannelId: "telegram:-1003841603622",
|
||||
}),
|
||||
).toBe("-1003841603622");
|
||||
});
|
||||
|
||||
it("uses prefixed message targets before falling back to the provider", () => {
|
||||
expect(
|
||||
resolveAgentHookChannelId({
|
||||
messageChannel: "channel:1472750640760623226",
|
||||
messageProvider: "discord",
|
||||
}),
|
||||
).toBe("1472750640760623226");
|
||||
});
|
||||
|
||||
it("falls back to legacy channel/provider values when no conversation id is available", () => {
|
||||
expect(
|
||||
resolveAgentHookChannelId({
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
}),
|
||||
).toBe("discord");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentHookContextChannelFields", () => {
|
||||
it("keeps provider and conversation id separate", () => {
|
||||
expect(
|
||||
buildAgentHookContextChannelFields({
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
}),
|
||||
).toEqual({
|
||||
messageProvider: "discord",
|
||||
channelId: "c1",
|
||||
});
|
||||
});
|
||||
});
|
||||
74
src/plugins/hook-agent-context.ts
Normal file
74
src/plugins/hook-agent-context.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { parseRawSessionConversationRef } from "../sessions/session-key-utils.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import type { PluginHookAgentContext } from "./hook-types.js";
|
||||
|
||||
const TARGET_PREFIXES = new Set(["channel", "chat", "direct", "dm", "group", "thread", "user"]);
|
||||
|
||||
function normalizeKey(value: string | undefined): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripConversationPrefix(
|
||||
value: string | undefined,
|
||||
provider: string | undefined,
|
||||
): string | undefined {
|
||||
const text = normalizeOptionalString(value);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const separatorIndex = text.indexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const prefix = normalizeKey(text.slice(0, separatorIndex));
|
||||
const suffix = normalizeOptionalString(text.slice(separatorIndex + 1));
|
||||
if (!suffix) {
|
||||
return text;
|
||||
}
|
||||
if (TARGET_PREFIXES.has(prefix) || (provider && prefix === normalizeKey(provider))) {
|
||||
return suffix;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function resolveAgentHookChannelId(params: {
|
||||
sessionKey?: string | null;
|
||||
messageChannel?: string | null;
|
||||
messageProvider?: string | null;
|
||||
currentChannelId?: string | null;
|
||||
messageTo?: string | null;
|
||||
}): string | undefined {
|
||||
const provider = normalizeOptionalString(params.messageProvider);
|
||||
const parsed = parseRawSessionConversationRef(params.sessionKey);
|
||||
if (parsed?.rawId) {
|
||||
return parsed.rawId;
|
||||
}
|
||||
|
||||
const metadataChannel =
|
||||
stripConversationPrefix(params.currentChannelId ?? undefined, provider) ??
|
||||
stripConversationPrefix(params.messageTo ?? undefined, provider);
|
||||
if (metadataChannel && normalizeKey(metadataChannel) !== normalizeKey(provider)) {
|
||||
return metadataChannel;
|
||||
}
|
||||
|
||||
const messageChannel = stripConversationPrefix(params.messageChannel ?? undefined, provider);
|
||||
if (messageChannel && normalizeKey(messageChannel) !== normalizeKey(provider)) {
|
||||
return messageChannel;
|
||||
}
|
||||
return normalizeOptionalString(params.messageChannel) ?? provider;
|
||||
}
|
||||
|
||||
export function buildAgentHookContextChannelFields(params: {
|
||||
sessionKey?: string | null;
|
||||
messageChannel?: string | null;
|
||||
messageProvider?: string | null;
|
||||
currentChannelId?: string | null;
|
||||
messageTo?: string | null;
|
||||
}): Pick<PluginHookAgentContext, "channelId" | "messageProvider"> {
|
||||
return {
|
||||
messageProvider: normalizeOptionalString(params.messageProvider),
|
||||
channelId: resolveAgentHookChannelId(params),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user