From 5f7f4eb2045a205c50b369f5dfc9f3e817d05b19 Mon Sep 17 00:00:00 2001 From: Nam Nguyen Ngoc Date: Tue, 10 Mar 2026 10:12:33 +0700 Subject: [PATCH] fix(gateway): scoped group writer prompt for system runs + media forwarding gaps (#104) * fix(gateway): extract media forwarding helper and fix group writer cron/media gaps - Extract appendMediaToOutbound() helper to deduplicate media attachment conversion across all outbound paths - Add media forwarding to cron job handler, handoff announce, and teammate message flows - Fix silent reply suppression to allow media-only responses (no text content) - Skip group writer refusal prompt for system-initiated runs (cron, delegate, subagent) with empty senderID * fix(agent): protect identity files in system-initiated group writer runs - Move writer list lookup before senderID check so cron/delegate/subagent runs still load group writer config - Inject file protection prompt for system-initiated runs instead of skipping entirely, preventing modification of SOUL.md, IDENTITY.md, AGENTS.md, and USER.md - Retain fail-open behavior when writer list is unavailable --------- Co-authored-by: Nam Nguyen Ngoc --- cmd/gateway_consumer.go | 61 +++++++++++++++++----------------- cmd/gateway_cron.go | 1 + internal/agent/loop_history.go | 11 ++++++ 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 1a077501..534f37bc 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -360,19 +360,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents Metadata: meta, } - // Convert media results from agent run to outbound media attachments - for _, mr := range outcome.Result.Media { - outMsg.Media = append(outMsg.Media, bus.MediaAttachment{ - URL: mr.Path, - ContentType: mr.ContentType, - }) - if mr.AsVoice { - if outMsg.Metadata == nil { - outMsg.Metadata = make(map[string]string) - } - outMsg.Metadata["audio_as_voice"] = "true" - } - } + appendMediaToOutbound(&outMsg, outcome.Result.Media) msgBus.PublishOutbound(outMsg) }(msg.Channel, msg.ChatID, sessionKey, runID, outMeta, blockReply) @@ -535,12 +523,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents Content: announceContent, Metadata: meta, } - for _, mr := range outcome.Result.Media { - outMsg.Media = append(outMsg.Media, bus.MediaAttachment{ - URL: mr.Path, - ContentType: mr.ContentType, - }) - } + appendMediaToOutbound(&outMsg, outcome.Result.Media) msgBus.PublishOutbound(outMsg) }(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata["subagent_label"], outMeta, announceReq) continue @@ -664,12 +647,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents Content: announceContent, Metadata: meta, } - for _, mr := range outcome.Result.Media { - outMsg.Media = append(outMsg.Media, bus.MediaAttachment{ - URL: mr.Path, - ContentType: mr.ContentType, - }) - } + appendMediaToOutbound(&outMsg, outcome.Result.Media) msgBus.PublishOutbound(outMsg) }(sessionKey, origChannel, msg.ChatID, msg.SenderID, outMeta, announceReq) continue @@ -730,15 +708,17 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents slog.Error("handoff announce: agent run failed", "error", outcome.Err) return } - if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) { + if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) { return } - msgBus.PublishOutbound(bus.OutboundMessage{ + outMsg := bus.OutboundMessage{ Channel: origCh, ChatID: chatID, Content: outcome.Result.Content, Metadata: meta, - }) + } + appendMediaToOutbound(&outMsg, outcome.Result.Media) + msgBus.PublishOutbound(outMsg) }(origChannel, msg.ChatID, outMeta) continue } @@ -798,18 +778,20 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents slog.Error("teammate message: agent run failed", "error", outcome.Err) return } - if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) { + if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) { slog.Info("teammate message: suppressed silent/empty reply", "from", senderID) return } // Deliver response to origin channel (same as delegate/subagent announce). // This allows the lead to respond to users after receiving teammate updates. - msgBus.PublishOutbound(bus.OutboundMessage{ + outMsg := bus.OutboundMessage{ Channel: origCh, ChatID: chatID, Content: outcome.Result.Content, Metadata: meta, - }) + } + appendMediaToOutbound(&outMsg, outcome.Result.Media) + msgBus.PublishOutbound(outMsg) }(origChannel, msg.ChatID, msg.SenderID, outMeta) continue } @@ -885,3 +867,20 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents debouncer.Push(msg) } } + +// appendMediaToOutbound converts agent MediaResults to outbound MediaAttachments +// on the given OutboundMessage. Handles voice annotation when applicable. +func appendMediaToOutbound(msg *bus.OutboundMessage, media []agent.MediaResult) { + for _, mr := range media { + msg.Media = append(msg.Media, bus.MediaAttachment{ + URL: mr.Path, + ContentType: mr.ContentType, + }) + if mr.AsVoice { + if msg.Metadata == nil { + msg.Metadata = make(map[string]string) + } + msg.Metadata["audio_as_voice"] = "true" + } + } +} diff --git a/cmd/gateway_cron.go b/cmd/gateway_cron.go index 9e157597..071f9973 100644 --- a/cmd/gateway_cron.go +++ b/cmd/gateway_cron.go @@ -72,6 +72,7 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg if peerKind == "group" { outMsg.Metadata = map[string]string{"group_id": job.Payload.To} } + appendMediaToOutbound(&outMsg, result.Media) msgBus.PublishOutbound(outMsg) } diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index c68b20a6..e26438a6 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -459,6 +459,17 @@ func (l *Loop) buildGroupWriterPrompt(ctx context.Context, groupID, senderID str return "", files // fail-open } + // System-initiated runs (cron, delegate, subagent) have no sender ID. + // Allow reading, messaging, and tool use freely, but still protect + // identity files (SOUL.md, IDENTITY.md, etc.) from modification. + if senderID == "" { + var sb strings.Builder + sb.WriteString("## Group File Permissions\n\n") + sb.WriteString("This is a system-initiated run (cron/scheduled task). You may read files, send messages, and use tools freely.\n") + sb.WriteString("However, do NOT modify protected identity files (SOUL.md, IDENTITY.md, AGENTS.md, USER.md) unless explicitly instructed by the task.\n") + return sb.String(), files + } + numericID := strings.SplitN(senderID, "|", 2)[0] isWriter := false for _, w := range writers {