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 <namnn.0911@gmail.com>
This commit is contained in:
Nam Nguyen NgocandNam Nguyen Ngoc authored and GitHub committed 2026-03-10 10:12:33 +07:00
1 parent 885814ddd1
commit 5f7f4eb204
3 files changed
+42 -31

No files matched your search

+30 -31
View File
@@ -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"
}
}
}
+1
View File
@@ -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)
}
+11
View File
@@ -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 {