refactor: process user header in go side

This commit is contained in:
Acbox
2026-02-20 21:40:13 +08:00
parent 4278675799
commit 6b7c3db952
6 changed files with 99 additions and 76 deletions
+3 -35
View File
@@ -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
}
-1
View File
@@ -1,5 +1,4 @@
export * from './system'
export * from './schedule'
export * from './user'
export * from './subagent'
export * from './utils'
-30
View File
@@ -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()
}
+15 -1
View File
@@ -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 {
-6
View File
@@ -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")
}
+81 -3
View File
@@ -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
}