mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
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:
1 parent
941a9657ca
commit
ec24b982f3
4 files changed
+92
-37
No files matched your search
+2
-5
@@ -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)
|
||||
|
||||
|
||||
@@ -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. " +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user