mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🐛 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:
parent
46adf43453
commit
24be35fd84
2 changed files with 72 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue