picoclaw/pkg/agent/prompt_turn.go
maxiaoyang 7129c97dd2 feat(agent): plumb sender ID through user message persistence
Connects the inbound SenderID, which was already carried end-to-end via
InboundContext to PromptBuildRequest, into the per-turn user message so
historical messages remember who said what.

- userPromptMessage gains a senderID parameter. Real human IDs are
  sanitized via SanitizeMessageName; synthetic triggers (cron,
  heartbeat, async:*) are filtered out via IsSystemSenderID so they
  do not surface as distinct users.

- pipeline_setup.go now passes ts.opts.Dispatch.SenderID() and uses
  AddFullMessage unconditionally. The previous AddMessage(role,
  content) shortcut for media-less turns silently dropped the new
  Name field, breaking history disambiguation.

- context.go (BuildMessagesFromPrompt) propagates req.SenderID onto
  the current-turn user message that goes to the LLM.

After this commit, sessions persist sender attribution and provider
adapters receive it on each user message. Adapters that consume Name
(OpenAI native field, Anthropic-style content prefix) land in
follow-up commits.

The existing "## Current Sender" block in buildDynamicContext is
unchanged; it continues to describe the latest speaker in the system
prompt while Name covers the per-message channel for history.

Refs #2702.
2026-04-29 21:02:59 +08:00

139 lines
3.8 KiB
Go

package agent
import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
)
func promptBuildRequestForTurn(
ts *turnState,
history []providers.Message,
summary string,
currentMessage string,
media []string,
) PromptBuildRequest {
return PromptBuildRequest{
History: history,
Summary: summary,
CurrentMessage: currentMessage,
Media: append([]string(nil), media...),
Channel: ts.channel,
ChatID: ts.chatID,
SenderID: ts.opts.Dispatch.SenderID(),
SenderDisplayName: ts.opts.SenderDisplayName,
ActiveSkills: activeSkillNames(ts.agent, ts.opts),
Overlays: promptOverlaysForOptions(ts.opts),
}
}
func promptOverlaysForOptions(opts processOptions) []PromptPart {
systemPrompt := strings.TrimSpace(opts.SystemPromptOverride)
if systemPrompt == "" {
return nil
}
return []PromptPart{
{
ID: "instruction.subturn_profile",
Layer: PromptLayerInstruction,
Slot: PromptSlotWorkspace,
Source: PromptSource{ID: PromptSourceSubTurnProfile, Name: "subturn.profile"},
Title: "SubTurn System Instructions",
Content: systemPrompt,
Stable: false,
Cache: PromptCacheNone,
},
}
}
func promptContentBlock(part PromptPart, cache *providers.CacheControl) providers.ContentBlock {
if cache == nil {
cache = cacheControlForPromptPart(part)
}
return providers.ContentBlock{
Type: "text",
Text: part.Content,
CacheControl: cache,
PromptLayer: string(part.Layer),
PromptSlot: string(part.Slot),
PromptSource: string(part.Source.ID),
}
}
func cacheControlForPromptPart(part PromptPart) *providers.CacheControl {
switch part.Cache {
case PromptCacheEphemeral:
return &providers.CacheControl{Type: "ephemeral"}
default:
return nil
}
}
func promptMessageWithMetadata(
msg providers.Message,
layer PromptLayer,
slot PromptSlot,
source PromptSourceID,
) providers.Message {
msg.PromptLayer = string(layer)
msg.PromptSlot = string(slot)
msg.PromptSource = string(source)
return msg
}
func promptMessageWithDefaultMetadata(
msg providers.Message,
layer PromptLayer,
slot PromptSlot,
source PromptSourceID,
) providers.Message {
if strings.TrimSpace(msg.PromptSource) != "" {
return msg
}
return promptMessageWithMetadata(msg, layer, slot, source)
}
// userPromptMessage builds the per-turn user message that gets persisted
// to session history and sent to the LLM. senderID is the raw inbound
// sender ID; it is sanitized into the OpenAI-compatible Name field unless
// it identifies a system trigger (cron, heartbeat, async callback), in
// which case Name is left empty so synthetic events do not appear as
// distinct human users in the conversation.
func userPromptMessage(content string, media []string, senderID string) providers.Message {
msg := providers.Message{
Role: "user",
Content: content,
}
if !messageutil.IsSystemSenderID(senderID) {
msg.Name = messageutil.SanitizeMessageName(senderID)
}
if len(media) > 0 {
msg.Media = append([]string(nil), media...)
}
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
}
func steeringPromptMessage(msg providers.Message) providers.Message {
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
}
func subTurnResultPromptMessage(content string) providers.Message {
return promptMessageWithMetadata(
providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)},
PromptLayerTurn,
PromptSlotSubTurn,
PromptSourceSubTurnResult,
)
}
func interruptPromptMessage(content string) providers.Message {
return promptMessageWithMetadata(
providers.Message{Role: "user", Content: content},
PromptLayerTurn,
PromptSlotInterrupt,
PromptSourceInterrupt,
)
}