feat(agent): add readMedia tool for model to view the image (#165)

* feat(agent): add readMedia tool for loading local images into model
context

* feat(channel/inbound): include container attachment refs in inbound
query

* fix(agent): preserve ImagePart literal typing in buildNativeImageParts

* chore: rename tool

---------

Co-authored-by: 晨苒 <16112591+chen-ran@users.noreply.github.com>
This commit is contained in:
Ringo.Typowriter
2026-03-04 11:24:01 +08:00
committed by GitHub
parent 64609c2101
commit 0a2a17ecc8
7 changed files with 350 additions and 39 deletions
+42 -5
View File
@@ -142,7 +142,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
if sender == nil {
return fmt.Errorf("reply sender not configured")
}
text := buildInboundQuery(msg.Message)
text := buildInboundQuery(msg.Message, nil)
if p.logger != nil {
p.logger.Debug("inbound handle start",
slog.String("channel", msg.Channel.String()),
@@ -153,7 +153,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
slog.String("conversation_id", strings.TrimSpace(msg.Conversation.ID)),
)
}
if strings.TrimSpace(text) == "" && len(msg.Message.Attachments) == 0 {
if strings.TrimSpace(msg.Message.PlainText()) == "" && len(msg.Message.Attachments) == 0 {
if p.logger != nil {
p.logger.Debug("inbound dropped empty", slog.String("channel", msg.Channel.String()))
}
@@ -185,6 +185,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
identity := state.Identity
resolvedAttachments := p.ingestInboundAttachments(ctx, cfg, msg, strings.TrimSpace(identity.BotID), msg.Message.Attachments)
attachments := mapChannelToChatAttachments(resolvedAttachments)
text = buildInboundQuery(msg.Message, attachments)
// Resolve or create the route via channel_routes.
if p.routeResolver == nil {
@@ -1052,7 +1053,7 @@ func mapStreamChunkToChannelEvents(chunk conversation.StreamChunk) ([]channel.St
}
}
func buildInboundQuery(message channel.Message) string {
func buildInboundQuery(message channel.Message, attachments []conversation.ChatAttachment) string {
text := strings.TrimSpace(message.PlainText())
if text != "" {
return text
@@ -1061,10 +1062,46 @@ func buildInboundQuery(message channel.Message) string {
return ""
}
count := len(message.Attachments)
fallback := fmt.Sprintf("[User sent %d attachments]", count)
if count == 1 {
return "[User sent 1 attachment]"
fallback = "[User sent 1 attachment]"
}
return fmt.Sprintf("[User sent %d attachments]", count)
refs := collectContainerAttachmentRefs(attachments)
if len(refs) == 0 {
return fallback
}
var sb strings.Builder
sb.WriteString(fallback)
sb.WriteString("\n[Attachment refs: container paths]\n")
for _, ref := range refs {
sb.WriteString("- ")
sb.WriteString(ref)
sb.WriteByte('\n')
}
return strings.TrimSpace(sb.String())
}
func collectContainerAttachmentRefs(attachments []conversation.ChatAttachment) []string {
if len(attachments) == 0 {
return nil
}
seen := make(map[string]struct{}, len(attachments))
refs := make([]string, 0, len(attachments))
for _, att := range attachments {
ref := strings.TrimSpace(att.Path)
if ref == "" {
continue
}
if _, exists := seen[ref]; exists {
continue
}
seen[ref] = struct{}{}
refs = append(refs, ref)
}
if len(refs) == 0 {
return nil
}
return refs
}
func normalizeContentPartType(raw string) channel.MessagePartType {
+25 -2
View File
@@ -387,7 +387,7 @@ func TestBuildInboundQueryAttachmentFallback(t *testing.T) {
{Type: channel.AttachmentImage},
},
}
if got := buildInboundQuery(one); got != "[User sent 1 attachment]" {
if got := buildInboundQuery(one, nil); got != "[User sent 1 attachment]" {
t.Fatalf("unexpected single attachment fallback: %q", got)
}
@@ -397,11 +397,34 @@ func TestBuildInboundQueryAttachmentFallback(t *testing.T) {
{Type: channel.AttachmentImage},
},
}
if got := buildInboundQuery(two); got != "[User sent 2 attachments]" {
if got := buildInboundQuery(two, nil); got != "[User sent 2 attachments]" {
t.Fatalf("unexpected multiple attachment fallback: %q", got)
}
}
func TestBuildInboundQueryAttachmentFallbackWithContainerRefs(t *testing.T) {
t.Parallel()
msg := channel.Message{
Attachments: []channel.Attachment{
{Type: channel.AttachmentImage},
{Type: channel.AttachmentImage},
},
}
atts := []conversation.ChatAttachment{
{Path: "/data/media/ab/first.png"},
{Path: "/data/media/cd/second.png"},
{Path: "/data/media/ab/first.png"},
}
want := "[User sent 2 attachments]\n" +
"[Attachment refs: container paths]\n" +
"- /data/media/ab/first.png\n" +
"- /data/media/cd/second.png"
if got := buildInboundQuery(msg, atts); got != want {
t.Fatalf("unexpected attachment refs fallback:\nwant:\n%s\n\ngot:\n%s", want, got)
}
}
func TestChannelInboundProcessorAttachmentOnlyUsesFallbackQuery(t *testing.T) {
channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-fallback"}}
memberSvc := &fakeMemberService{isMember: true}