From 1d5b58ac18f7e5c613f9bd8d6a18ff49319cd5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AB=B9=E7=94=B0=E8=B3=A2=E5=8F=B2?= <47170408+estack-takeda-yorichika@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:14:50 +0900 Subject: [PATCH] feat(plugins): pass attachment metadata to before_model_resolve hook (#67322) Merged via squash. Prepared head SHA: 8af0ba970301727c905151c7e5ed991e1afdffc4 Co-authored-by: estack-takeda-yorichika <47170408+estack-takeda-yorichika@users.noreply.github.com> Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com> Reviewed-by: @sallyom --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- src/agents/pi-embedded-runner/run.ts | 7 +- .../pi-embedded-runner/run/setup.test.ts | 75 +++++++++++++++++++ src/agents/pi-embedded-runner/run/setup.ts | 29 +++++-- src/plugins/hook-before-agent-start.types.ts | 7 ++ src/plugins/hook-types.ts | 1 + 6 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run/setup.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 7765bfd0d2b..615f4670385 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -20db3f5afb93db334ad7456d26303c81b2a3eeaa5c1f8846a459eec72be20b96 plugin-sdk-api-baseline.json -d02926e9facb3321a1018804d4c0370d9627963bee5e478942dda469e529c20b plugin-sdk-api-baseline.jsonl +40d6f3ba88037ba0ef7d51743f28cc996b9951137fbe65553473e71b054c6510 plugin-sdk-api-baseline.json +869e0b705e48001a98d85c574ad1e6ec8aef11393cc5f13b936f6004f58213dd plugin-sdk-api-baseline.jsonl diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 24e05cc1fd2..927ce7f3998 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -113,7 +113,11 @@ import { import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; import { handleRetryLimitExhaustion } from "./run/retry-limit.js"; -import { resolveEffectiveRuntimeModel, resolveHookModelSelection } from "./run/setup.js"; +import { + buildBeforeModelResolveAttachments, + resolveEffectiveRuntimeModel, + resolveHookModelSelection, +} from "./run/setup.js"; import { mergeAttemptToolMediaPayloads } from "./run/tool-media-payloads.js"; import { resolveLiveToolResultMaxChars, @@ -302,6 +306,7 @@ export async function runEmbeddedPiAgent( const hookSelection = await resolveHookModelSelection({ prompt: params.prompt, + attachments: buildBeforeModelResolveAttachments(params.images), provider, modelId, hookRunner, diff --git a/src/agents/pi-embedded-runner/run/setup.test.ts b/src/agents/pi-embedded-runner/run/setup.test.ts new file mode 100644 index 00000000000..17ca0c80673 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/setup.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildBeforeModelResolveAttachments, resolveHookModelSelection } from "./setup.js"; + +const hookContext = { + sessionId: "session-1", + workspaceDir: "/tmp/workspace", +}; + +describe("buildBeforeModelResolveAttachments", () => { + it("maps prompt image metadata to before_model_resolve attachments", () => { + expect( + buildBeforeModelResolveAttachments([{ mimeType: "image/png" }, { mimeType: "image/jpeg" }]), + ).toEqual([ + { kind: "image", mimeType: "image/png" }, + { kind: "image", mimeType: "image/jpeg" }, + ]); + }); + + it("omits attachments when there are no images", () => { + expect(buildBeforeModelResolveAttachments(undefined)).toBeUndefined(); + expect(buildBeforeModelResolveAttachments([])).toBeUndefined(); + }); +}); + +describe("resolveHookModelSelection", () => { + it("passes attachment metadata to before_model_resolve hooks", async () => { + const attachments = [{ kind: "image" as const, mimeType: "image/png" }]; + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_model_resolve"), + runBeforeModelResolve: vi.fn(async () => ({ + providerOverride: "vision-provider", + modelOverride: "vision-model", + })), + runBeforeAgentStart: vi.fn(), + }; + + const result = await resolveHookModelSelection({ + prompt: "describe this image", + attachments, + provider: "default-provider", + modelId: "default-model", + hookRunner, + hookContext, + }); + + expect(hookRunner.runBeforeModelResolve).toHaveBeenCalledWith( + { prompt: "describe this image", attachments }, + hookContext, + ); + expect(hookRunner.runBeforeAgentStart).not.toHaveBeenCalled(); + expect(result.provider).toBe("vision-provider"); + expect(result.modelId).toBe("vision-model"); + }); + + it("omits the attachments key for text-only before_model_resolve hooks", async () => { + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_model_resolve"), + runBeforeModelResolve: vi.fn(async () => undefined), + runBeforeAgentStart: vi.fn(), + }; + + await resolveHookModelSelection({ + prompt: "text only", + provider: "default-provider", + modelId: "default-model", + hookRunner, + hookContext, + }); + + expect(hookRunner.runBeforeModelResolve).toHaveBeenCalledWith( + { prompt: "text only" }, + hookContext, + ); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/setup.ts b/src/agents/pi-embedded-runner/run/setup.ts index 6a52bbf7cb8..aa9e099c3be 100644 --- a/src/agents/pi-embedded-runner/run/setup.ts +++ b/src/agents/pi-embedded-runner/run/setup.ts @@ -1,6 +1,10 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { ProviderRuntimeModel } from "../../../plugins/provider-runtime-model.types.js"; -import type { PluginHookBeforeAgentStartResult } from "../../../plugins/types.js"; +import type { + PluginHookBeforeAgentStartResult, + PluginHookBeforeModelResolveAttachment, + PluginHookBeforeModelResolveEvent, +} from "../../../plugins/types.js"; import { CONTEXT_WINDOW_HARD_MIN_TOKENS, CONTEXT_WINDOW_WARN_BELOW_TOKENS, @@ -28,7 +32,7 @@ type HookContext = { type HookRunnerLike = { hasHooks(hookName: string): boolean; runBeforeModelResolve( - input: { prompt: string }, + input: PluginHookBeforeModelResolveEvent, context: HookContext, ): Promise<{ providerOverride?: string; modelOverride?: string } | undefined>; runBeforeAgentStart( @@ -39,6 +43,7 @@ type HookRunnerLike = { export async function resolveHookModelSelection(params: { prompt: string; + attachments?: PluginHookBeforeModelResolveAttachment[]; provider: string; modelId: string; hookRunner?: HookRunnerLike | null; @@ -57,10 +62,10 @@ export async function resolveHookModelSelection(params: { // fields if present. New hook takes precedence when both are set. if (hookRunner?.hasHooks("before_model_resolve")) { try { - modelResolveOverride = await hookRunner.runBeforeModelResolve( - { prompt: params.prompt }, - params.hookContext, - ); + const event: PluginHookBeforeModelResolveEvent = params.attachments + ? { prompt: params.prompt, attachments: params.attachments } + : { prompt: params.prompt }; + modelResolveOverride = await hookRunner.runBeforeModelResolve(event, params.hookContext); } catch (hookErr) { log.warn(`before_model_resolve hook failed: ${String(hookErr)}`); } @@ -99,6 +104,18 @@ export async function resolveHookModelSelection(params: { }; } +export function buildBeforeModelResolveAttachments( + images: readonly { mimeType?: string }[] | undefined, +): PluginHookBeforeModelResolveAttachment[] | undefined { + if (!images?.length) { + return undefined; + } + return images.map((img) => ({ + kind: "image", + mimeType: img.mimeType, + })); +} + export function resolveEffectiveRuntimeModel(params: { cfg: OpenClawConfig | undefined; provider: string; diff --git a/src/plugins/hook-before-agent-start.types.ts b/src/plugins/hook-before-agent-start.types.ts index 00b2dc6a900..928107f17d4 100644 --- a/src/plugins/hook-before-agent-start.types.ts +++ b/src/plugins/hook-before-agent-start.types.ts @@ -1,7 +1,14 @@ // before_model_resolve hook +export type PluginHookBeforeModelResolveAttachment = { + kind: "image" | "video" | "audio" | "document" | "other"; + mimeType?: string; +}; + export type PluginHookBeforeModelResolveEvent = { /** User prompt for this run. No session messages are available yet in this phase. */ prompt: string; + /** Attachment metadata for file-aware model routing. */ + attachments?: PluginHookBeforeModelResolveAttachment[]; }; export type PluginHookBeforeModelResolveResult = { diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 765c1f3a1f2..64667ee68cf 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -33,6 +33,7 @@ export type { PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartOverrideResult, PluginHookBeforeAgentStartResult, + PluginHookBeforeModelResolveAttachment, PluginHookBeforeModelResolveEvent, PluginHookBeforeModelResolveResult, PluginHookBeforePromptBuildEvent,