fix(subagent): replace bare count with deterministic roster in announce messages

Subagent announce messages told the parent LLM "There are still X active
runs" — a bare number. The LLM had to reconstruct which subagents were
done vs running from conversation history, causing hallucination when
results arrived out of order.

Replace with a full roster listing each subagent by label and status,
including per-agent config limits. Remove dead CountRunningForParent()
and unused rosterFunc field on AnnounceQueue.
This commit is contained in:
viettranx committed 2026-03-19 16:33:54 +07:00
1 parent 941a9657ca
commit ec24b982f3
4 files changed
+92 -37

No files matched your search

+2 -5
View File
@@ -117,8 +117,8 @@ func runGateway() {
// Wire announce queue for batched subagent result delivery (matching TS debounce pattern)
announceQueue := tools.NewAnnounceQueue(1000, 20,
func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
remainingActive := subagentMgr.CountRunningForParent(meta.ParentAgent)
content := tools.FormatBatchedAnnounce(items, remainingActive)
roster := subagentMgr.RosterForParent(meta.ParentAgent)
content := tools.FormatBatchedAnnounce(items, roster)
senderID := fmt.Sprintf("subagent:batch-%d", len(items))
label := items[0].Label
if len(items) > 1 {
@@ -153,9 +153,6 @@ func runGateway() {
Media: batchMedia,
})
},
func(parentID string) int {
return subagentMgr.CountRunningForParent(parentID)
},
)
subagentMgr.SetAnnounceQueue(announceQueue)
+37 -24
View File
@@ -40,11 +40,7 @@ type AnnounceQueue struct {
queues map[string]*sessionQueue // session key → queue
debounce time.Duration // default 1000ms
cap int // max items per session before immediate drain (default 20)
onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata)
// countActiveFunc returns the number of still-running subagents for a parent.
// Used at drain time for accurate remaining-active count.
countActiveFunc func(parentID string) int
onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata)
}
type sessionQueue struct {
@@ -58,7 +54,6 @@ func NewAnnounceQueue(
debounceMs int,
cap int,
onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata),
countActive func(parentID string) int,
) *AnnounceQueue {
if debounceMs <= 0 {
debounceMs = 1000 // TS default
@@ -67,11 +62,10 @@ func NewAnnounceQueue(
cap = 20 // TS default
}
return &AnnounceQueue{
queues: make(map[string]*sessionQueue),
debounce: time.Duration(debounceMs) * time.Millisecond,
cap: cap,
onDrain: onDrain,
countActiveFunc: countActive,
queues: make(map[string]*sessionQueue),
debounce: time.Duration(debounceMs) * time.Millisecond,
cap: cap,
onDrain: onDrain,
}
}
@@ -131,8 +125,8 @@ func (aq *AnnounceQueue) drain(sessionKey string, items []AnnounceQueueItem, met
}
// FormatBatchedAnnounce builds the announce content for a batch of items.
// remainingActive is the count of still-running subagents at drain time.
func FormatBatchedAnnounce(items []AnnounceQueueItem, remainingActive int) string {
// roster provides deterministic task labels+statuses to prevent LLM hallucination.
func FormatBatchedAnnounce(items []AnnounceQueueItem, roster SubagentRoster) string {
if len(items) == 1 {
// Single item: use the same format as before (no batching overhead)
item := items[0]
@@ -143,7 +137,7 @@ func FormatBatchedAnnounce(items []AnnounceQueueItem, remainingActive int) strin
statusLabel = "was cancelled"
}
replyInstruction := buildReplyInstruction(remainingActive)
replyInstruction := buildReplyInstruction(roster)
return fmt.Sprintf(
"[System Message] A subagent task %q just %s.\n\n"+
@@ -177,30 +171,49 @@ func FormatBatchedAnnounce(items []AnnounceQueueItem, remainingActive int) strin
}
sb.WriteString("---\n\n")
sb.WriteString(buildReplyInstruction(remainingActive))
sb.WriteString(buildReplyInstruction(roster))
return sb.String()
}
func buildReplyInstruction(remainingActive int) string {
if remainingActive > 0 {
runsLabel := "runs"
if remainingActive == 1 {
runsLabel = "run"
// buildReplyInstruction generates the instruction block for the parent LLM,
// including a deterministic roster of all subagent tasks with their statuses.
func buildReplyInstruction(roster SubagentRoster) string {
// Count running tasks from roster
running := 0
for _, e := range roster.Entries {
if e.Status == TaskStatusRunning {
running++
}
}
// Build roster block
var rosterBlock string
if len(roster.Entries) > 0 {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Subagent roster (%d spawned, max %d per agent):\n",
roster.Total, roster.MaxPerAgent))
for _, e := range roster.Entries {
sb.WriteString(fmt.Sprintf(" [%-9s] %s\n", e.Status, e.Label))
}
rosterBlock = sb.String()
}
if running > 0 {
return fmt.Sprintf(
"There are still %d active subagent %s for this session. "+
"%s\n"+
"%d subagent(s) still running. "+
"If they are part of the same workflow, wait for the remaining results "+
"before sending a user update. If they are unrelated, respond normally "+
"using only the result above. "+
"Do NOT copy or echo the [System Message] block verbatim — rewrite in your own voice. "+
"Reply ONLY: NO_REPLY if this result was already delivered to the user.",
remainingActive, runsLabel,
rosterBlock, running,
)
}
return "A completed subagent task is ready for user delivery. " +
"Convert the result above into your normal assistant voice and " +
return rosterBlock +
"\nAll subagent tasks completed. Convert the result above into your normal assistant voice and " +
"send that user-facing update now. Keep this internal context private " +
"(don't mention system/log/stats/session details or announce type), " +
"and do NOT copy the [System Message] block verbatim. " +
+51 -6
View File
@@ -10,9 +10,11 @@
package tools
import (
"cmp"
"context"
"fmt"
"log/slog"
"slices"
"sync"
"time"
@@ -133,17 +135,60 @@ func (sm *SubagentManager) effectiveConfig(ctx context.Context) SubagentConfig {
return cfg
}
// CountRunningForParent returns the number of running tasks for a parent.
func (sm *SubagentManager) CountRunningForParent(parentID string) int {
// SubagentRosterEntry summarizes one subagent task for the roster.
type SubagentRosterEntry struct {
Label string
Status string // "running", "completed", "failed", "cancelled"
}
// SubagentRoster holds the full roster of subagent tasks for a parent,
// including per-agent config limits for deterministic LLM context.
type SubagentRoster struct {
Entries []SubagentRosterEntry
Total int // total tasks for this parent
MaxPerAgent int // from spawnConfig.MaxChildrenPerAgent
}
// RosterForParent returns the full roster of tasks for a parent.
// Sorted: completed/failed/cancelled first, then running (deterministic output).
func (sm *SubagentManager) RosterForParent(parentID string) SubagentRoster {
sm.mu.RLock()
defer sm.mu.RUnlock()
count := 0
var entries []SubagentRosterEntry
maxPerAgent := 0
for _, t := range sm.tasks {
if t.ParentID == parentID && t.Status == TaskStatusRunning {
count++
if t.ParentID != parentID {
continue
}
entries = append(entries, SubagentRosterEntry{
Label: t.Label,
Status: t.Status,
})
if maxPerAgent == 0 {
maxPerAgent = t.spawnConfig.MaxChildrenPerAgent
}
}
return count
// Sort: non-running first (completed/failed/cancelled), then running.
// Within same group, sort alphabetically by label for determinism.
slices.SortFunc(entries, func(a, b SubagentRosterEntry) int {
aRunning := a.Status == TaskStatusRunning
bRunning := b.Status == TaskStatusRunning
if aRunning != bRunning {
if aRunning {
return 1 // running goes last
}
return -1
}
return cmp.Compare(a.Label, b.Label)
})
return SubagentRoster{
Entries: entries,
Total: len(entries),
MaxPerAgent: maxPerAgent,
}
}
// SubagentDenyAlways is the list of tools always denied to subagents.
+2 -2
View File
@@ -51,8 +51,8 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
sm.announceQueue.Enqueue(sessionKey, item, meta)
} else {
// Direct publish (no batching)
remainingActive := sm.CountRunningForParent(task.ParentID)
announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, remainingActive)
roster := sm.RosterForParent(task.ParentID)
announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, roster)
announceMeta := map[string]string{
"origin_channel": task.OriginChannel,