🐛 fix(agent-runtime): resolve S3 image keys when refreshing messages (#13794)

messageModel.query() calls inside RuntimeExecutors were missing a
postProcessUrl callback, so imageList/videoList/fileList entries retained
raw S3 keys (e.g. `files/user_xxx/icon.png`). After the first tool batch,
the refreshed state fed those raw keys straight into the next LLM call,
and providers like Anthropic reject anything that isn't an absolute URL or
data URI ("Invalid image URL"). Wire a lazy FileService-backed
postProcessUrl into all three query sites (topic reference resolution,
compression, and post-batch refresh) so imageLists stay resolved across
multi-step operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-14 10:29:49 +08:00 committed by GitHub
parent 46adf43453
commit 24be35fd84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 31 deletions

View file

@ -39,6 +39,7 @@ import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering'
import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
import { OnboardingService } from '@/server/services/onboarding';
import {
@ -89,6 +90,26 @@ const getToolFailureKind = (result: ToolExecutionResultResponse): ToolFailureKin
const shouldRetryTool = (kind: ToolFailureKind | undefined, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
// Builds a postProcessUrl callback that resolves S3 keys in file-backed fields
// (imageList, videoList, fileList) to absolute URLs. Must be passed to every
// messageModel.query() call whose output is later fed to the LLM — otherwise
// the provider layer receives raw keys like `files/user_xxx/icon.png` and
// rejects them (see anthropic contextBuilder `Invalid image URL`).
//
// FileService is constructed lazily so environments without S3 config (unit
// tests) don't fail at context-build time; failure returns undefined, which
// leaves URLs as raw keys — same behavior as before this helper existed.
const buildPostProcessUrl = (ctx: Pick<RuntimeExecutorContext, 'serverDB' | 'userId'>) => {
if (!ctx.userId || !ctx.serverDB) return undefined;
let fileService: FileService | undefined;
try {
fileService = new FileService(ctx.serverDB, ctx.userId);
} catch {
return undefined;
}
return (path: string | null) => fileService!.getFullFileUrl(path);
};
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
@ -217,7 +238,6 @@ export interface RuntimeExecutorContext {
botPlatformContext?: any;
discordContext?: any;
evalContext?: EvalContext;
fileService?: any;
loadAgentState?: (operationId: string) => Promise<AgentState | null>;
messageModel: MessageModel;
operationId: string;
@ -373,11 +393,14 @@ export const createRuntimeExecutors = (
async (topicId) => topicModel.findById(topicId),
async (topicId) => {
const topic = await topicModel.findById(topicId);
return messageModel.query({
agentId: topic?.agentId ?? undefined,
groupId: topic?.groupId ?? undefined,
topicId,
});
return messageModel.query(
{
agentId: topic?.agentId ?? undefined,
groupId: topic?.groupId ?? undefined,
topicId,
},
{ postProcessUrl: buildPostProcessUrl(ctx) },
);
},
);
}
@ -1060,11 +1083,14 @@ export const createRuntimeExecutors = (
}
try {
const dbMessages = await ctx.messageModel.query({
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId,
});
const dbMessages = await ctx.messageModel.query(
{
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId,
},
{ postProcessUrl: buildPostProcessUrl(ctx) },
);
const messageIds = dbMessages
.filter(
@ -1816,11 +1842,17 @@ export const createRuntimeExecutors = (
// Query latest messages from database
// Must pass agentId to ensure correct query scope, otherwise when topicId is undefined,
// the query will use isNull(topicId) condition which won't find messages with actual topicId
const latestMessages = await ctx.messageModel.query({
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId: state.metadata?.topicId,
});
//
// postProcessUrl resolves S3 keys in imageList/videoList/fileList to absolute URLs;
// without it the next LLM call sees raw keys and providers reject them.
const latestMessages = await ctx.messageModel.query(
{
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId: state.metadata?.topicId,
},
{ postProcessUrl: buildPostProcessUrl(ctx) },
);
// Use conversation-flow parse to resolve branching into linear flat list
// parse() handles assistantGroup, compare, supervisor, etc. virtual message types

View file

@ -1771,11 +1771,14 @@ describe('RuntimeExecutors', () => {
const result = await executors.call_tools_batch!(instruction, state);
// Should query messages from database with agentId, threadId, and topicId
expect(mockMessageModel.query).toHaveBeenCalledWith({
agentId: 'agent-123',
threadId: 'thread-123',
topicId: 'topic-123',
});
expect(mockMessageModel.query).toHaveBeenCalledWith(
{
agentId: 'agent-123',
threadId: 'thread-123',
topicId: 'topic-123',
},
expect.any(Object),
);
// Messages should be refreshed from database (4 messages from mock)
expect(result.newState.messages).toHaveLength(4);
@ -2099,11 +2102,14 @@ describe('RuntimeExecutors', () => {
await executors.call_tools_batch!(instruction, state);
// Should query messages with agentId, threadId, and topicId from state.metadata
expect(mockMessageModel.query).toHaveBeenCalledWith({
agentId: 'agent-abc',
threadId: 'thread-xyz',
topicId: 'topic-abc-123',
});
expect(mockMessageModel.query).toHaveBeenCalledWith(
{
agentId: 'agent-abc',
threadId: 'thread-xyz',
topicId: 'topic-abc-123',
},
expect.any(Object),
);
});
// LOBE-5143: After DB refresh, state.messages stores raw UIChatMessage[]
@ -2235,11 +2241,14 @@ describe('RuntimeExecutors', () => {
const result = await executors.call_tools_batch!(instruction, state);
// Verify agentId is passed in the query
expect(mockMessageModel.query).toHaveBeenCalledWith({
agentId: 'agent-123',
threadId: 'thread-123',
topicId: undefined,
});
expect(mockMessageModel.query).toHaveBeenCalledWith(
{
agentId: 'agent-123',
threadId: 'thread-123',
topicId: undefined,
},
expect.any(Object),
);
// Expected: newState.messages should NOT be empty
// The next call_llm step needs messages to work properly