fix: resolve blackboard split-brain in multi-agent tools

Tools were bound to a static board at registration time while the
system prompt injected data from a separate per-session board.
Add BoardAware interface and SetBoard() methods so tools receive
the correct session blackboard before each execution cycle.
This commit is contained in:
Leandro Barbosa 2026-02-18 14:59:16 -03:00
parent c5145f1ade
commit 4def7b497e
4 changed files with 44 additions and 10 deletions

View file

@ -174,14 +174,14 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
if len(registry.ListAgentIDs()) > 1 {
resolver := &registryResolver{registry: registry}
// Blackboard tool: per-agent instance sharing a common blackboard
// The actual blackboard is created per session in getOrCreateBlackboard
// For tool registration, we use a shared "global" blackboard.
sharedBoard := multiagent.NewBlackboard()
agent.Tools.Register(multiagent.NewBlackboardTool(sharedBoard, agentID))
// Blackboard tool: per-agent instance sharing a placeholder blackboard.
// The actual per-session blackboard is wired via SetBoard in updateToolContexts
// before each message processing cycle (fixing the split-brain bug).
placeholderBoard := multiagent.NewBlackboard()
agent.Tools.Register(multiagent.NewBlackboardTool(placeholderBoard, agentID))
// Handoff tool: delegate tasks to other agents
agent.Tools.Register(multiagent.NewHandoffTool(resolver, sharedBoard, agentID))
agent.Tools.Register(multiagent.NewHandoffTool(resolver, placeholderBoard, agentID))
// List agents tool: discover available agents
agent.Tools.Register(multiagent.NewListAgentsTool(resolver))
@ -438,8 +438,8 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
}
}
// 1. Update tool contexts
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
// 1. Update tool contexts (including per-session blackboard wiring)
al.updateToolContexts(agent, opts.Channel, opts.ChatID, opts.SessionKey)
// 2. Build messages (skip history for heartbeat)
var history []providers.Message
@ -742,8 +742,9 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
return finalContent, iteration, nil
}
// updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
// updateToolContexts updates the context for tools that need channel/chatID info
// and wires the per-session blackboard to board-aware tools.
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string, sessionKey string) {
// Use ContextualTool interface instead of type assertions
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
@ -765,6 +766,20 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st
ht.SetContext(channel, chatID)
}
}
// Wire the per-session blackboard to board-aware tools (fixes split-brain bug).
// This ensures BlackboardTool and HandoffTool operate on the same board that
// gets injected into the system prompt via getOrCreateBlackboard.
if sessionKey != "" {
bb := al.getOrCreateBlackboard(sessionKey)
for _, toolName := range []string{"blackboard", "handoff"} {
if tool, ok := agent.Tools.Get(toolName); ok {
if ba, ok := tool.(multiagent.BoardAware); ok {
ba.SetBoard(bb)
}
}
}
}
}
// getOrCreateBlackboard returns the blackboard for a session, creating one if needed.

View file

@ -16,6 +16,13 @@ type BlackboardEntry struct {
Timestamp time.Time `json:"timestamp"`
}
// BoardAware is implemented by tools that need the session blackboard injected
// before each execution. This fixes the split-brain bug where tools were bound
// to a static board at registration time instead of the per-session board.
type BoardAware interface {
SetBoard(board *Blackboard)
}
// Blackboard is a thread-safe shared context pool for multi-agent collaboration.
// Agents read and write string key-value entries, each tagged with authorship
// and scope metadata.

View file

@ -23,6 +23,12 @@ func NewBlackboardTool(board *Blackboard, agentID string) *BlackboardTool {
}
}
// SetBoard replaces the blackboard reference, allowing the tool to be wired
// to the correct per-session board before each execution.
func (t *BlackboardTool) SetBoard(board *Blackboard) {
t.board = board
}
// Name returns the tool name.
func (t *BlackboardTool) Name() string { return "blackboard" }

View file

@ -85,6 +85,12 @@ func (t *HandoffTool) Parameters() map[string]any {
}
}
// SetBoard replaces the blackboard reference, allowing the tool to be wired
// to the correct per-session board before each execution.
func (t *HandoffTool) SetBoard(board *Blackboard) {
t.board = board
}
// SetContext updates the origin channel and chat ID for handoff routing.
func (t *HandoffTool) SetContext(channel, chatID string) {
t.originChannel = channel