diff --git a/agent/src/agent.ts b/agent/src/agent.ts index c793e775..6977e54c 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -17,7 +17,7 @@ import { Schedule, } from './types' import { ModelInput, hasInputModality } from './types/model' -import { system, schedule, user, subagentSystem } from './prompts' +import { system, schedule, subagentSystem } from './prompts' import { AuthFetcher } from './index' import { createModel } from './model' import { AgentAction } from './types/action' @@ -27,7 +27,7 @@ import { dedupeAttachments, AttachmentsStreamExtractor, } from './utils/attachments' -import type { ContainerFileAttachment, GatewayInputAttachment } from './types/attachment' +import type { GatewayInputAttachment } from './types/attachment' import { getMCPTools } from './tools/mcp' import { getTools } from './tools' import { buildIdentityHeaders } from './utils/headers' @@ -42,28 +42,6 @@ export const buildNativeImageParts = (attachments: GatewayInputAttachment[]): Im .map((attachment) => ({ type: 'image', image: attachment.payload } as ImagePart)) } -const buildFileRefs = ( - attachments: GatewayInputAttachment[], - supportsImage: boolean, -): ContainerFileAttachment[] => { - return attachments - .filter((attachment) => { - if (attachment.transport !== 'tool_file_ref' || !attachment.payload) { - return false - } - if (attachment.type === 'file') { - return true - } - // When image native modality is unavailable, keep image refs as tool files. - return !supportsImage && attachment.type === 'image' - }) - .map((attachment) => ({ - type: 'file' as const, - path: attachment.payload, - metadata: attachment.metadata, - })) -} - export const createAgent = ( { model: modelConfig, @@ -210,21 +188,11 @@ export const createAgent = ( const generateUserPrompt = (input: AgentInput) => { const supportsImage = hasInputModality(modelConfig, ModelInput.Image) - - const allFiles = buildFileRefs(input.attachments, supportsImage) const imageParts = supportsImage ? buildNativeImageParts(input.attachments) : [] - const text = user(input.query, { - channelIdentityId: identity.channelIdentityId || '', - displayName: identity.displayName || 'User', - channel: currentChannel, - conversationType: identity.conversationType || 'direct', - date: new Date(), - attachments: allFiles, - }) const userMessage: UserModelMessage = { role: 'user', - content: [{ type: 'text', text }, ...imageParts], + content: [{ type: 'text', text: input.query }, ...imageParts], } return userMessage } diff --git a/agent/src/prompts/index.ts b/agent/src/prompts/index.ts index feef399b..f43b579a 100644 --- a/agent/src/prompts/index.ts +++ b/agent/src/prompts/index.ts @@ -1,5 +1,4 @@ export * from './system' export * from './schedule' -export * from './user' export * from './subagent' export * from './utils' \ No newline at end of file diff --git a/agent/src/prompts/user.ts b/agent/src/prompts/user.ts deleted file mode 100644 index 3601c3c0..00000000 --- a/agent/src/prompts/user.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ContainerFileAttachment } from '../types' - -export interface UserParams { - channelIdentityId: string - displayName: string - channel: string - conversationType: string - date: Date - attachments: ContainerFileAttachment[] -} - -export const user = ( - query: string, - { channelIdentityId, displayName, channel, conversationType, date, attachments }: UserParams -) => { - const headers = { - 'channel-identity-id': channelIdentityId, - 'display-name': displayName, - 'channel': channel, - 'conversation-type': conversationType, - 'time': date.toISOString(), - 'attachments': attachments.map(attachment => attachment.path), - } - return ` ---- -${Bun.YAML.stringify(headers)} ---- -${query} - `.trim() -} \ No newline at end of file diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index 14c1efdb..dce61128 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -641,9 +641,23 @@ func (p *ChannelInboundProcessor) persistInboundUser( if botID == "" { return false } + var attachmentPaths []string + for _, att := range attachments { + if ap := strings.TrimSpace(att.Path); ap != "" { + attachmentPaths = append(attachmentPaths, ap) + } + } + headerifiedQuery := flow.FormatUserHeader( + strings.TrimSpace(identity.ChannelIdentityID), + strings.TrimSpace(identity.DisplayName), + msg.Channel.String(), + strings.TrimSpace(msg.Conversation.Type), + attachmentPaths, + query, + ) payload, err := json.Marshal(conversation.ModelMessage{ Role: "user", - Content: conversation.NewTextContent(query), + Content: conversation.NewTextContent(headerifiedQuery), }) if err != nil { if p.logger != nil { diff --git a/internal/channel/inbound/channel_test.go b/internal/channel/inbound/channel_test.go index d5a78476..3961b1f4 100644 --- a/internal/channel/inbound/channel_test.go +++ b/internal/channel/inbound/channel_test.go @@ -565,12 +565,6 @@ func TestChannelInboundProcessorGroupMentionTriggersReply(t *testing.T) { if len(sender.sent) != 1 { t.Fatalf("expected one outbound reply, got %d", len(sender.sent)) } - if len(chatSvc.persisted) != 1 { - t.Fatalf("triggered group message should persist inbound user once, got: %d", len(chatSvc.persisted)) - } - if got := chatSvc.persisted[0].Metadata["trigger_mode"]; got != "active_chat" { - t.Fatalf("expected trigger_mode active_chat, got: %v", got) - } if !gateway.gotReq.UserMessagePersisted { t.Fatalf("expected UserMessagePersisted=true for pre-persisted inbound message") } diff --git a/internal/conversation/flow/resolver.go b/internal/conversation/flow/resolver.go index d6eaa6ab..4ac441de 100644 --- a/internal/conversation/flow/resolver.go +++ b/internal/conversation/flow/resolver.go @@ -308,6 +308,18 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r usableSkills = []gatewaySkill{} } + attachments := r.routeAndMergeAttachments(ctx, chatModel, req) + displayName := r.resolveDisplayName(ctx, req) + + headerifiedQuery := FormatUserHeader( + strings.TrimSpace(req.SourceChannelIdentityID), + displayName, + req.CurrentChannel, + strings.TrimSpace(req.ConversationType), + extractFileRefPaths(attachments), + req.Query, + ) + payload := gatewayRequest{ Model: gatewayModelConfig{ ModelID: chatModel.ModelID, @@ -323,17 +335,17 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r Messages: nonNilModelMessages(messages), Skills: nonNilStrings(skills), UsableSkills: usableSkills, - Query: req.Query, + Query: headerifiedQuery, Identity: gatewayIdentity{ BotID: req.BotID, ContainerID: containerID, ChannelIdentityID: strings.TrimSpace(req.SourceChannelIdentityID), - DisplayName: r.resolveDisplayName(ctx, req), + DisplayName: displayName, CurrentPlatform: req.CurrentChannel, ConversationType: strings.TrimSpace(req.ConversationType), SessionToken: req.ChatToken, }, - Attachments: r.routeAndMergeAttachments(ctx, chatModel, req), + Attachments: attachments, } return resolvedContext{payload: payload, model: chatModel, provider: provider}, nil @@ -347,6 +359,7 @@ func (r *Resolver) Chat(ctx context.Context, req conversation.ChatRequest) (conv if err != nil { return conversation.ChatResponse{}, err } + req.Query = rc.payload.Query resp, err := r.postChat(ctx, rc.payload, req.Token) if err != nil { return conversation.ChatResponse{}, err @@ -434,6 +447,7 @@ func (r *Resolver) StreamChat(ctx context.Context, req conversation.ChatRequest) errCh <- err return } + streamReq.Query = rc.payload.Query if !streamReq.UserMessagePersisted { if err := r.persistUserMessage(ctx, streamReq); err != nil { r.logger.Error("gateway stream persist user message failed", @@ -1654,3 +1668,67 @@ func parseResolverUUID(id string) (pgtype.UUID, error) { } return db.ParseUUID(id) } + +// FormatUserHeader wraps a user query with YAML front-matter metadata so +// the LLM sees structured context (sender, channel, time, attachments) +// alongside the raw message. This must be the single source of truth for +// user-message formatting — the agent gateway must NOT add its own header. +func FormatUserHeader(channelIdentityID, displayName, channel, conversationType string, attachmentPaths []string, query string) string { + var sb strings.Builder + sb.WriteString("---\n") + writeYAMLString(&sb, "channel-identity-id", channelIdentityID) + writeYAMLString(&sb, "display-name", displayName) + writeYAMLString(&sb, "channel", channel) + writeYAMLString(&sb, "conversation-type", conversationType) + writeYAMLString(&sb, "time", time.Now().UTC().Format(time.RFC3339)) + if len(attachmentPaths) > 0 { + sb.WriteString("attachments:\n") + for _, p := range attachmentPaths { + sb.WriteString(" - ") + sb.WriteString(p) + sb.WriteByte('\n') + } + } else { + sb.WriteString("attachments: []\n") + } + sb.WriteString("---\n") + sb.WriteString(query) + return sb.String() +} + +func writeYAMLString(sb *strings.Builder, key, value string) { + sb.WriteString(key) + sb.WriteString(": ") + if value == "" || needsYAMLQuote(value) { + sb.WriteByte('"') + sb.WriteString(strings.ReplaceAll(value, `"`, `\"`)) + sb.WriteByte('"') + } else { + sb.WriteString(value) + } + sb.WriteByte('\n') +} + +func needsYAMLQuote(s string) bool { + if s == "" { + return true + } + for _, c := range s { + if c == ':' || c == '#' || c == '"' || c == '\'' || c == '{' || c == '}' || c == '[' || c == ']' || c == ',' || c == '\n' { + return true + } + } + return false +} + +// extractFileRefPaths collects container file paths from gateway attachments +// that use the tool_file_ref transport (files already written to the bot container). +func extractFileRefPaths(attachments []any) []string { + var paths []string + for _, att := range attachments { + if ga, ok := att.(gatewayAttachment); ok && ga.Transport == gatewayTransportToolFileRef && strings.TrimSpace(ga.Payload) != "" { + paths = append(paths, ga.Payload) + } + } + return paths +}