diff --git a/CHANGELOG.md b/CHANGELOG.md index e7874fd0b16..eaf33fdeba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111. - Nodes/CLI: add `openclaw nodes remove --node ` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files. Thanks @openclaw. - Docker: install the CA certificate bundle in the slim runtime image so HTTPS calls from containerized gateways no longer fail TLS setup after the `bookworm-slim` base switch. Fixes #72787. Thanks @ryuhaneul. - Providers/OpenRouter: remove retired Hunter Alpha and Healer Alpha static catalog rows and disable proxy reasoning injection for stale Hunter Alpha configs, so replies are not hidden when OpenRouter returns answer text in reasoning fields. Fixes #43942. Thanks @EvanDataForge. diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 39a83fd5681..36d017b9371 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -33,23 +33,23 @@ openclaw hooks info session-memory ## Event types -| Event | When it fires | -| ------------------------ | ------------------------------------------------ | -| `command:new` | `/new` command issued | -| `command:reset` | `/reset` command issued | -| `command:stop` | `/stop` command issued | -| `command` | Any command event (general listener) | -| `session:compact:before` | Before compaction summarizes history | -| `session:compact:after` | After compaction completes | -| `session:patch` | When session properties are modified | -| `agent:bootstrap` | Before workspace bootstrap files are injected | -| `gateway:startup` | After channels start and hooks are loaded | -| `gateway:shutdown` | When gateway shutdown begins | -| `gateway:pre-restart` | Before an expected gateway restart | -| `message:received` | Inbound message from any channel | -| `message:transcribed` | After audio transcription completes | -| `message:preprocessed` | After all media and link understanding completes | -| `message:sent` | Outbound message delivered | +| Event | When it fires | +| ------------------------ | ---------------------------------------------------------- | +| `command:new` | `/new` command issued | +| `command:reset` | `/reset` command issued | +| `command:stop` | `/stop` command issued | +| `command` | Any command event (general listener) | +| `session:compact:before` | Before compaction summarizes history | +| `session:compact:after` | After compaction completes | +| `session:patch` | When session properties are modified | +| `agent:bootstrap` | Before workspace bootstrap files are injected | +| `gateway:startup` | After channels start and hooks are loaded | +| `gateway:shutdown` | When gateway shutdown begins | +| `gateway:pre-restart` | Before an expected gateway restart | +| `message:received` | Inbound message from any channel | +| `message:transcribed` | After audio transcription completes | +| `message:preprocessed` | After media and link preprocessing completes or is skipped | +| `message:sent` | Outbound message delivered | ## Writing hooks diff --git a/src/auto-reply/reply/get-reply.message-hooks.test.ts b/src/auto-reply/reply/get-reply.message-hooks.test.ts index 5f6de6745ad..e3961a4cc87 100644 --- a/src/auto-reply/reply/get-reply.message-hooks.test.ts +++ b/src/auto-reply/reply/get-reply.message-hooks.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { logVerbose } from "../../globals.js"; import type { MsgContext } from "../templating.js"; import { withFastReplyConfig } from "./get-reply-fast-path.js"; import { @@ -74,6 +75,7 @@ describe("getReplyFromConfig message hooks", () => { mocks.triggerInternalHook.mockReset(); mocks.resolveReplyDirectives.mockReset(); mocks.initSessionState.mockReset(); + vi.mocked(logVerbose).mockReset(); mocks.applyMediaUnderstanding.mockImplementation(async (...args: unknown[]) => { const { ctx } = args[0] as { ctx: MsgContext }; @@ -198,4 +200,62 @@ describe("getReplyFromConfig message hooks", () => { expect(mocks.applyMediaUnderstanding).not.toHaveBeenCalled(); expect(mocks.applyLinkUnderstanding).not.toHaveBeenCalled(); }); + + it("continues dispatching when media understanding fails before reply routing", async () => { + mocks.applyMediaUnderstanding.mockRejectedValueOnce( + new Error("Cannot find module '/tmp/openclaw/dist/media-understanding/apply.runtime-old.js'"), + ); + + const reply = await getReplyFromConfig(buildCtx(), undefined, withFastReplyConfig({})); + + expect(reply).toEqual({ text: "ok" }); + expect(mocks.applyMediaUnderstanding).toHaveBeenCalledTimes(1); + expect(mocks.initSessionState).toHaveBeenCalledTimes(1); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledTimes(1); + expect(mocks.createInternalHookEvent).toHaveBeenCalledTimes(1); + expect(mocks.createInternalHookEvent).toHaveBeenCalledWith( + "message", + "preprocessed", + "agent:main:telegram:-100123", + expect.any(Object), + ); + expect(logVerbose).toHaveBeenCalledWith( + expect.stringContaining("media understanding failed, proceeding with raw content"), + ); + }); + + it("continues dispatching URL messages when link understanding fails before reply routing", async () => { + mocks.applyLinkUnderstanding.mockRejectedValueOnce( + new Error("Cannot find module '/tmp/openclaw/dist/link-understanding/apply.runtime-old.js'"), + ); + + const reply = await getReplyFromConfig( + buildCtx({ + Body: "read https://example.test/page", + BodyForAgent: "read https://example.test/page", + RawBody: "read https://example.test/page", + CommandBody: "read https://example.test/page", + BodyForCommands: "read https://example.test/page", + MediaPath: undefined, + MediaUrl: undefined, + MediaPaths: undefined, + MediaUrls: undefined, + MediaTypes: undefined, + MediaType: undefined, + Sticker: undefined, + StickerMediaIncluded: undefined, + }), + undefined, + withFastReplyConfig({}), + ); + + expect(reply).toEqual({ text: "ok" }); + expect(mocks.applyMediaUnderstanding).not.toHaveBeenCalled(); + expect(mocks.applyLinkUnderstanding).toHaveBeenCalledTimes(1); + expect(mocks.initSessionState).toHaveBeenCalledTimes(1); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledTimes(1); + expect(logVerbose).toHaveBeenCalledWith( + expect.stringContaining("link understanding failed, proceeding with raw content"), + ); + }); }); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 0ee4fa3dea6..ae1ab3c745a 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -10,6 +10,8 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; 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 { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; @@ -137,9 +139,17 @@ async function applyMediaUnderstandingIfNeeded(params: { if (!hasInboundMedia(params.ctx)) { return false; } - const { applyMediaUnderstanding } = await loadMediaUnderstandingApplyRuntime(); - await applyMediaUnderstanding(params); - return true; + try { + const { applyMediaUnderstanding } = await loadMediaUnderstandingApplyRuntime(); + await applyMediaUnderstanding(params); + return true; + } catch (err) { + mediaUnderstandingApplyRuntimePromise = null; + logVerbose( + `media understanding failed, proceeding with raw content: ${formatErrorMessage(err)}`, + ); + return false; + } } async function applyLinkUnderstandingIfNeeded(params: { @@ -149,9 +159,17 @@ async function applyLinkUnderstandingIfNeeded(params: { if (!hasLinkCandidate(params.ctx)) { return false; } - const { applyLinkUnderstanding } = await loadLinkUnderstandingApplyRuntime(); - await applyLinkUnderstanding(params); - return true; + try { + const { applyLinkUnderstanding } = await loadLinkUnderstandingApplyRuntime(); + await applyLinkUnderstanding(params); + return true; + } catch (err) { + linkUnderstandingApplyRuntimePromise = null; + logVerbose( + `link understanding failed, proceeding with raw content: ${formatErrorMessage(err)}`, + ); + return false; + } } export async function getReplyFromConfig( diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index f834b7ecc0e..508e51330f4 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -68,6 +68,8 @@ describe("tsdown config", () => { "agents/models-config.runtime", "subagent-registry.runtime", "agents/pi-model-discovery-runtime", + "link-understanding/apply.runtime", + "media-understanding/apply.runtime", "index", "commands/status.summary.runtime", "plugins/provider-discovery.runtime", diff --git a/tsdown.config.ts b/tsdown.config.ts index 7327921efd3..5b83c633145 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -214,6 +214,8 @@ function buildCoreDistEntries(): Record { "agents/models-config.runtime": "src/agents/models-config.runtime.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", + "link-understanding/apply.runtime": "src/link-understanding/apply.runtime.ts", + "media-understanding/apply.runtime": "src/media-understanding/apply.runtime.ts", "commands/doctor/shared/plugin-registry-migration": "src/commands/doctor/shared/plugin-registry-migration.ts", "commands/status.summary.runtime": "src/commands/status.summary.runtime.ts",