2026-02-04 11:06:13 +00:00
|
|
|
// PicoClaw - Ultra-lightweight personal AI agent
|
|
|
|
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
|
|
|
|
// License: MIT
|
|
|
|
|
//
|
|
|
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
|
|
|
|
|
|
package agent
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-02-22 22:03:23 +00:00
|
|
|
"path/filepath"
|
2026-02-10 08:05:23 +00:00
|
|
|
"strings"
|
2026-02-11 11:27:36 +00:00
|
|
|
"sync"
|
2026-02-11 17:26:22 +00:00
|
|
|
"sync/atomic"
|
2026-02-11 11:27:36 +00:00
|
|
|
"time"
|
2026-02-14 16:28:36 +00:00
|
|
|
"unicode/utf8"
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
2026-02-16 08:30:54 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-13 07:05:16 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/constants"
|
2026-02-10 05:18:23 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/routing"
|
2026-02-20 10:55:04 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
2026-02-12 11:49:36 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/state"
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/tools"
|
2026-02-11 12:22:41 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
2026-02-04 11:06:13 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type AgentLoop struct {
|
|
|
|
|
bus *bus.MessageBus
|
2026-02-16 13:34:55 +00:00
|
|
|
cfg *config.Config
|
|
|
|
|
registry *AgentRegistry
|
2026-02-12 11:49:36 +00:00
|
|
|
state *state.Manager
|
2026-02-11 17:26:22 +00:00
|
|
|
running atomic.Bool
|
2026-02-16 13:34:55 +00:00
|
|
|
summarizing sync.Map
|
|
|
|
|
fallback *providers.FallbackChain
|
2026-02-16 08:30:54 +00:00
|
|
|
channelManager *channels.Manager
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaStore media.MediaStore
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// processOptions configures how a message is processed
|
|
|
|
|
type processOptions struct {
|
|
|
|
|
SessionKey string // Session identifier for history/context
|
|
|
|
|
Channel string // Target channel for tool execution
|
|
|
|
|
ChatID string // Target chat ID for tool execution
|
|
|
|
|
UserMessage string // User message content (may include prefix)
|
|
|
|
|
DefaultResponse string // Response when LLM returns empty
|
|
|
|
|
EnableSummary bool // Whether to trigger summarization
|
|
|
|
|
SendResponse bool // Whether to send response via bus
|
2026-02-13 06:39:39 +00:00
|
|
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-13 06:39:39 +00:00
|
|
|
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
registry := NewAgentRegistry(cfg, provider)
|
2026-02-10 08:05:23 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Register shared tools to all agents
|
|
|
|
|
registerSharedTools(cfg, msgBus, registry, provider)
|
2026-02-13 06:39:39 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Set up shared fallback chain
|
|
|
|
|
cooldown := providers.NewCooldownTracker()
|
|
|
|
|
fallbackChain := providers.NewFallbackChain(cooldown)
|
2026-02-13 06:39:39 +00:00
|
|
|
|
2026-02-13 15:24:26 +00:00
|
|
|
// Create state manager using default agent's workspace for channel recording
|
|
|
|
|
defaultAgent := registry.GetDefaultAgent()
|
|
|
|
|
var stateManager *state.Manager
|
|
|
|
|
if defaultAgent != nil {
|
|
|
|
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
|
|
|
|
}
|
2026-02-10 08:05:23 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
return &AgentLoop{
|
|
|
|
|
bus: msgBus,
|
|
|
|
|
cfg: cfg,
|
|
|
|
|
registry: registry,
|
2026-02-13 15:24:26 +00:00
|
|
|
state: stateManager,
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
summarizing: sync.Map{},
|
|
|
|
|
fallback: fallbackChain,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
2026-02-20 18:03:11 +00:00
|
|
|
func registerSharedTools(
|
|
|
|
|
cfg *config.Config,
|
|
|
|
|
msgBus *bus.MessageBus,
|
|
|
|
|
registry *AgentRegistry,
|
|
|
|
|
provider providers.LLMProvider,
|
|
|
|
|
) {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
for _, agentID := range registry.ListAgentIDs() {
|
|
|
|
|
agent, ok := registry.GetAgent(agentID)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-12 11:49:36 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Web tools
|
2026-02-14 13:38:04 +00:00
|
|
|
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
|
|
|
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
|
|
|
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
|
|
|
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
2026-02-23 04:12:34 +00:00
|
|
|
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
|
|
|
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
|
|
|
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
|
|
|
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
2026-02-14 13:38:04 +00:00
|
|
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
|
|
|
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
2026-02-18 14:39:14 +00:00
|
|
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
|
|
|
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
|
|
|
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
2026-02-24 09:16:16 +00:00
|
|
|
Proxy: cfg.Tools.Web.Proxy,
|
2026-02-14 13:38:04 +00:00
|
|
|
}); searchTool != nil {
|
|
|
|
|
agent.Tools.Register(searchTool)
|
|
|
|
|
}
|
2026-02-24 09:16:16 +00:00
|
|
|
agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
2026-02-14 13:38:04 +00:00
|
|
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
|
|
|
|
agent.Tools.Register(tools.NewI2CTool())
|
|
|
|
|
agent.Tools.Register(tools.NewSPITool())
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Message tool
|
|
|
|
|
messageTool := tools.NewMessageTool()
|
|
|
|
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
Channel: channel,
|
|
|
|
|
ChatID: chatID,
|
|
|
|
|
Content: content,
|
|
|
|
|
})
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
agent.Tools.Register(messageTool)
|
|
|
|
|
|
2026-02-20 10:55:04 +00:00
|
|
|
// Skill discovery and installation tools
|
|
|
|
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
|
|
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
|
|
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
|
|
|
|
})
|
2026-02-20 18:03:11 +00:00
|
|
|
searchCache := skills.NewSearchCache(
|
|
|
|
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
|
|
|
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
|
|
|
|
)
|
2026-02-20 10:55:04 +00:00
|
|
|
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
|
|
|
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Spawn tool with allowlist checker
|
2026-02-13 15:24:26 +00:00
|
|
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
2026-02-19 18:16:37 +00:00
|
|
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
|
|
|
|
currentAgentID := agentID
|
|
|
|
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
|
|
|
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
|
|
|
|
})
|
|
|
|
|
agent.Tools.Register(spawnTool)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (al *AgentLoop) Run(ctx context.Context) error {
|
2026-02-11 17:26:22 +00:00
|
|
|
al.running.Store(true)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-11 17:26:22 +00:00
|
|
|
for al.running.Load() {
|
2026-02-04 11:06:13 +00:00
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return nil
|
|
|
|
|
default:
|
|
|
|
|
msg, ok := al.bus.ConsumeInbound(ctx)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:27:55 +00:00
|
|
|
// Process message and ensure media is released afterward
|
|
|
|
|
func() {
|
|
|
|
|
defer func() {
|
|
|
|
|
if al.mediaStore != nil && msg.MediaScope != "" {
|
|
|
|
|
if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
|
|
|
|
|
logger.WarnCF("agent", "Failed to release media", map[string]any{
|
|
|
|
|
"scope": msg.MediaScope,
|
|
|
|
|
"error": releaseErr.Error(),
|
|
|
|
|
})
|
2026-02-13 15:24:26 +00:00
|
|
|
}
|
2026-02-13 06:41:21 +00:00
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
response, err := al.processMessage(ctx, msg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
response = fmt.Sprintf("Error processing message: %v", err)
|
2026-02-13 06:41:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:27:55 +00:00
|
|
|
if response != "" {
|
|
|
|
|
// Check if the message tool already sent a response during this round.
|
|
|
|
|
// If so, skip publishing to avoid duplicate messages to the user.
|
|
|
|
|
// Use default agent's tools to check (message tool is shared).
|
|
|
|
|
alreadySent := false
|
|
|
|
|
defaultAgent := al.registry.GetDefaultAgent()
|
|
|
|
|
if defaultAgent != nil {
|
|
|
|
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
|
|
|
|
if mt, ok := tool.(*tools.MessageTool); ok {
|
|
|
|
|
alreadySent = mt.HasSentInRound()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !alreadySent {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
2026-02-22 15:27:55 +00:00
|
|
|
Channel: msg.Channel,
|
|
|
|
|
ChatID: msg.ChatID,
|
|
|
|
|
Content: response,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-13 06:41:21 +00:00
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
}()
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (al *AgentLoop) Stop() {
|
2026-02-11 17:26:22 +00:00
|
|
|
al.running.Store(false)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 04:28:37 +00:00
|
|
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
for _, agentID := range al.registry.ListAgentIDs() {
|
|
|
|
|
if agent, ok := al.registry.GetAgent(agentID); ok {
|
|
|
|
|
agent.Tools.Register(tool)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-16 08:30:54 +00:00
|
|
|
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
|
|
|
|
al.channelManager = cm
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:27:55 +00:00
|
|
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
|
|
|
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
|
|
|
|
al.mediaStore = s
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:03:23 +00:00
|
|
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
|
|
|
|
// from a filename and MIME content type.
|
|
|
|
|
func inferMediaType(filename, contentType string) string {
|
|
|
|
|
ct := strings.ToLower(contentType)
|
|
|
|
|
fn := strings.ToLower(filename)
|
|
|
|
|
|
|
|
|
|
if strings.HasPrefix(ct, "image/") {
|
|
|
|
|
return "image"
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
|
|
|
|
|
return "audio"
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(ct, "video/") {
|
|
|
|
|
return "video"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: infer from extension
|
|
|
|
|
ext := filepath.Ext(fn)
|
|
|
|
|
switch ext {
|
|
|
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
|
|
|
|
return "image"
|
|
|
|
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
|
|
|
|
return "audio"
|
|
|
|
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
|
|
|
|
return "video"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:49:36 +00:00
|
|
|
// RecordLastChannel records the last active channel for this workspace.
|
|
|
|
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
|
|
|
|
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
2026-02-13 15:24:26 +00:00
|
|
|
if al.state == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-12 15:51:52 +00:00
|
|
|
return al.state.SetLastChannel(channel)
|
2026-02-12 11:49:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RecordLastChatID records the last active chat ID for this workspace.
|
|
|
|
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
|
|
|
|
func (al *AgentLoop) RecordLastChatID(chatID string) error {
|
2026-02-13 15:24:26 +00:00
|
|
|
if al.state == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-12 15:51:52 +00:00
|
|
|
return al.state.SetLastChatID(chatID)
|
2026-02-12 11:49:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) {
|
2026-02-11 04:28:37 +00:00
|
|
|
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
func (al *AgentLoop) ProcessDirectWithChannel(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
content, sessionKey, channel, chatID string,
|
|
|
|
|
) (string, error) {
|
2026-02-04 11:06:13 +00:00
|
|
|
msg := bus.InboundMessage{
|
2026-02-11 04:28:37 +00:00
|
|
|
Channel: channel,
|
|
|
|
|
SenderID: "cron",
|
|
|
|
|
ChatID: chatID,
|
2026-02-04 11:06:13 +00:00
|
|
|
Content: content,
|
|
|
|
|
SessionKey: sessionKey,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return al.processMessage(ctx, msg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 06:39:39 +00:00
|
|
|
// ProcessHeartbeat processes a heartbeat request without session history.
|
|
|
|
|
// Each heartbeat is independent and doesn't accumulate context.
|
|
|
|
|
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
2026-02-13 15:24:26 +00:00
|
|
|
agent := al.registry.GetDefaultAgent()
|
|
|
|
|
return al.runAgentLoop(ctx, agent, processOptions{
|
2026-02-13 06:39:39 +00:00
|
|
|
SessionKey: "heartbeat",
|
|
|
|
|
Channel: channel,
|
|
|
|
|
ChatID: chatID,
|
|
|
|
|
UserMessage: content,
|
|
|
|
|
DefaultResponse: "I've completed processing but have no response to give.",
|
|
|
|
|
EnableSummary: false,
|
|
|
|
|
SendResponse: false,
|
|
|
|
|
NoHistory: true, // Don't load session history for heartbeat
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
2026-02-13 03:13:32 +00:00
|
|
|
// Add message preview to log (show full content for error messages)
|
|
|
|
|
var logContent string
|
|
|
|
|
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
|
|
|
|
|
logContent = msg.Content // Full content for errors
|
|
|
|
|
} else {
|
|
|
|
|
logContent = utils.Truncate(msg.Content, 80)
|
|
|
|
|
}
|
|
|
|
|
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-10 05:18:23 +00:00
|
|
|
"channel": msg.Channel,
|
|
|
|
|
"chat_id": msg.ChatID,
|
|
|
|
|
"sender_id": msg.SenderID,
|
|
|
|
|
"session_key": msg.SessionKey,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-10 08:05:23 +00:00
|
|
|
// Route system messages to processSystemMessage
|
|
|
|
|
if msg.Channel == "system" {
|
|
|
|
|
return al.processSystemMessage(ctx, msg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 08:30:54 +00:00
|
|
|
// Check for commands
|
|
|
|
|
if response, handled := al.handleCommand(ctx, msg); handled {
|
|
|
|
|
return response, nil
|
|
|
|
|
}
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Route to determine agent and session key
|
|
|
|
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
|
|
|
|
Channel: msg.Channel,
|
|
|
|
|
AccountID: msg.Metadata["account_id"],
|
|
|
|
|
Peer: extractPeer(msg),
|
|
|
|
|
ParentPeer: extractParentPeer(msg),
|
|
|
|
|
GuildID: msg.Metadata["guild_id"],
|
|
|
|
|
TeamID: msg.Metadata["team_id"],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
agent, ok := al.registry.GetAgent(route.AgentID)
|
|
|
|
|
if !ok {
|
|
|
|
|
agent = al.registry.GetDefaultAgent()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
|
|
|
|
sessionKey := route.SessionKey
|
|
|
|
|
if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
|
|
|
|
|
sessionKey = msg.SessionKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.InfoCF("agent", "Routed message",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
|
|
|
|
"session_key": sessionKey,
|
|
|
|
|
"matched_by": route.MatchedBy,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return al.runAgentLoop(ctx, agent, processOptions{
|
|
|
|
|
SessionKey: sessionKey,
|
2026-02-11 12:22:41 +00:00
|
|
|
Channel: msg.Channel,
|
|
|
|
|
ChatID: msg.ChatID,
|
|
|
|
|
UserMessage: msg.Content,
|
|
|
|
|
DefaultResponse: "I've completed processing but have no response to give.",
|
|
|
|
|
EnableSummary: true,
|
|
|
|
|
SendResponse: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
|
|
|
|
if msg.Channel != "system" {
|
|
|
|
|
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
|
2026-02-10 08:05:23 +00:00
|
|
|
}
|
2026-02-11 12:22:41 +00:00
|
|
|
|
|
|
|
|
logger.InfoCF("agent", "Processing system message",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-11 12:22:41 +00:00
|
|
|
"sender_id": msg.SenderID,
|
|
|
|
|
"chat_id": msg.ChatID,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-13 06:39:39 +00:00
|
|
|
// Parse origin channel from chat_id (format: "channel:chat_id")
|
2026-02-11 12:22:41 +00:00
|
|
|
var originChannel, originChatID string
|
|
|
|
|
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
|
|
|
|
|
originChannel = msg.ChatID[:idx]
|
|
|
|
|
originChatID = msg.ChatID[idx+1:]
|
|
|
|
|
} else {
|
|
|
|
|
originChannel = "cli"
|
|
|
|
|
originChatID = msg.ChatID
|
2026-02-10 08:05:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-13 06:39:39 +00:00
|
|
|
// Extract subagent result from message content
|
|
|
|
|
// Format: "Task 'label' completed.\n\nResult:\n<actual content>"
|
|
|
|
|
content := msg.Content
|
|
|
|
|
if idx := strings.Index(content, "Result:\n"); idx >= 0 {
|
|
|
|
|
content = content[idx+8:] // Extract just the result part
|
|
|
|
|
}
|
2026-02-11 12:22:41 +00:00
|
|
|
|
2026-02-13 06:39:39 +00:00
|
|
|
// Skip internal channels - only log, don't send to user
|
2026-02-13 07:05:16 +00:00
|
|
|
if constants.IsInternalChannel(originChannel) {
|
2026-02-13 06:39:39 +00:00
|
|
|
logger.InfoCF("agent", "Subagent completed (internal channel)",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-13 09:51:47 +00:00
|
|
|
"sender_id": msg.SenderID,
|
|
|
|
|
"content_len": len(content),
|
|
|
|
|
"channel": originChannel,
|
2026-02-13 06:39:39 +00:00
|
|
|
})
|
|
|
|
|
return "", nil
|
|
|
|
|
}
|
2026-02-10 16:30:38 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Use default agent for system messages
|
|
|
|
|
agent := al.registry.GetDefaultAgent()
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// Use the origin session for context
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
2026-02-13 06:39:39 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
return al.runAgentLoop(ctx, agent, processOptions{
|
2026-02-11 12:22:41 +00:00
|
|
|
SessionKey: sessionKey,
|
|
|
|
|
Channel: originChannel,
|
|
|
|
|
ChatID: originChatID,
|
|
|
|
|
UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
|
|
|
|
|
DefaultResponse: "Background task completed.",
|
|
|
|
|
EnableSummary: false,
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
SendResponse: true,
|
2026-02-11 12:22:41 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// runAgentLoop is the core message processing logic.
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) {
|
2026-02-13 03:13:32 +00:00
|
|
|
// 0. Record last channel for heartbeat notifications (skip internal channels)
|
|
|
|
|
if opts.Channel != "" && opts.ChatID != "" {
|
|
|
|
|
// Don't record internal channels (cli, system, subagent)
|
2026-02-13 07:05:16 +00:00
|
|
|
if !constants.IsInternalChannel(opts.Channel) {
|
2026-02-13 03:13:32 +00:00
|
|
|
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
|
|
|
|
if err := al.RecordLastChannel(channelKey); err != nil {
|
2026-02-20 18:03:11 +00:00
|
|
|
logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
|
2026-02-13 03:13:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// 1. Update tool contexts
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
2026-02-10 16:30:38 +00:00
|
|
|
|
2026-02-13 06:39:39 +00:00
|
|
|
// 2. Build messages (skip history for heartbeat)
|
|
|
|
|
var history []providers.Message
|
|
|
|
|
var summary string
|
|
|
|
|
if !opts.NoHistory {
|
2026-02-13 15:24:26 +00:00
|
|
|
history = agent.Sessions.GetHistory(opts.SessionKey)
|
|
|
|
|
summary = agent.Sessions.GetSummary(opts.SessionKey)
|
2026-02-13 06:39:39 +00:00
|
|
|
}
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
messages := agent.ContextBuilder.BuildMessages(
|
2026-02-10 16:30:38 +00:00
|
|
|
history,
|
|
|
|
|
summary,
|
2026-02-11 12:22:41 +00:00
|
|
|
opts.UserMessage,
|
2026-02-04 11:06:13 +00:00
|
|
|
nil,
|
2026-02-11 12:22:41 +00:00
|
|
|
opts.Channel,
|
|
|
|
|
opts.ChatID,
|
2026-02-04 11:06:13 +00:00
|
|
|
)
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// 3. Save user message to session
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
2026-02-11 12:22:41 +00:00
|
|
|
|
|
|
|
|
// 4. Run LLM iteration loop
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
2026-02-11 12:22:41 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:34:32 +00:00
|
|
|
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
|
|
|
|
// This is controlled by the tool's Silent flag and ForUser content
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// 5. Handle empty response
|
|
|
|
|
if finalContent == "" {
|
|
|
|
|
finalContent = opts.DefaultResponse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6. Save final assistant message to session
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
2026-02-13 15:24:26 +00:00
|
|
|
agent.Sessions.Save(opts.SessionKey)
|
2026-02-11 12:22:41 +00:00
|
|
|
|
|
|
|
|
// 7. Optional: summarization
|
|
|
|
|
if opts.EnableSummary {
|
2026-02-16 13:34:55 +00:00
|
|
|
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
2026-02-11 12:22:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 8. Optional: send response via bus
|
|
|
|
|
if opts.SendResponse {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
2026-02-11 12:22:41 +00:00
|
|
|
Channel: opts.Channel,
|
|
|
|
|
ChatID: opts.ChatID,
|
|
|
|
|
Content: finalContent,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 9. Log response
|
|
|
|
|
responsePreview := utils.Truncate(finalContent, 120)
|
|
|
|
|
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
2026-02-11 12:22:41 +00:00
|
|
|
"session_key": opts.SessionKey,
|
|
|
|
|
"iterations": iteration,
|
|
|
|
|
"final_length": len(finalContent),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return finalContent, nil
|
|
|
|
|
}
|
2026-02-11 10:43:21 +00:00
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// runLLMIteration executes the LLM call loop with tool handling.
|
2026-02-20 18:03:11 +00:00
|
|
|
func (al *AgentLoop) runLLMIteration(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
agent *AgentInstance,
|
|
|
|
|
messages []providers.Message,
|
|
|
|
|
opts processOptions,
|
|
|
|
|
) (string, int, error) {
|
2026-02-04 11:06:13 +00:00
|
|
|
iteration := 0
|
|
|
|
|
var finalContent string
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
for iteration < agent.MaxIterations {
|
2026-02-04 11:06:13 +00:00
|
|
|
iteration++
|
|
|
|
|
|
2026-02-10 05:18:23 +00:00
|
|
|
logger.DebugCF("agent", "LLM iteration",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
2026-02-10 05:18:23 +00:00
|
|
|
"iteration": iteration,
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"max": agent.MaxIterations,
|
2026-02-10 05:18:23 +00:00
|
|
|
})
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// Build tool definitions
|
2026-02-13 15:24:26 +00:00
|
|
|
providerToolDefs := agent.Tools.ToProviderDefs()
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-10 15:33:28 +00:00
|
|
|
// Log LLM request details
|
|
|
|
|
logger.DebugCF("agent", "LLM request",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
2026-02-11 12:22:41 +00:00
|
|
|
"iteration": iteration,
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"model": agent.Model,
|
2026-02-11 12:22:41 +00:00
|
|
|
"messages_count": len(messages),
|
|
|
|
|
"tools_count": len(providerToolDefs),
|
2026-02-19 18:16:37 +00:00
|
|
|
"max_tokens": agent.MaxTokens,
|
|
|
|
|
"temperature": agent.Temperature,
|
2026-02-10 15:33:28 +00:00
|
|
|
"system_prompt_len": len(messages[0].Content),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Log full messages (detailed)
|
|
|
|
|
logger.DebugCF("agent", "Full LLM request",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-10 15:33:28 +00:00
|
|
|
"iteration": iteration,
|
|
|
|
|
"messages_json": formatMessagesForLog(messages),
|
|
|
|
|
"tools_json": formatToolsForLog(providerToolDefs),
|
|
|
|
|
})
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
// Call LLM with fallback chain if candidates are configured.
|
2026-02-16 08:30:54 +00:00
|
|
|
var response *providers.LLMResponse
|
|
|
|
|
var err error
|
|
|
|
|
|
2026-02-16 13:34:55 +00:00
|
|
|
callLLM := func() (*providers.LLMResponse, error) {
|
|
|
|
|
if len(agent.Candidates) > 1 && al.fallback != nil {
|
|
|
|
|
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
|
|
|
|
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
2026-02-20 18:03:11 +00:00
|
|
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
"max_tokens": agent.MaxTokens,
|
|
|
|
|
"temperature": agent.Temperature,
|
|
|
|
|
"prompt_cache_key": agent.ID,
|
2026-02-16 13:34:55 +00:00
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if fbErr != nil {
|
|
|
|
|
return nil, fbErr
|
|
|
|
|
}
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
|
|
|
|
|
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
|
|
|
|
|
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{"agent_id": agent.ID, "iteration": iteration})
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
}
|
2026-02-16 13:34:55 +00:00
|
|
|
return fbResult.Response, nil
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
}
|
2026-02-20 18:03:11 +00:00
|
|
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
"max_tokens": agent.MaxTokens,
|
|
|
|
|
"temperature": agent.Temperature,
|
|
|
|
|
"prompt_cache_key": agent.ID,
|
2026-02-16 08:30:54 +00:00
|
|
|
})
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
}
|
2026-02-16 08:30:54 +00:00
|
|
|
|
2026-02-16 13:34:55 +00:00
|
|
|
// Retry loop for context/token errors
|
|
|
|
|
maxRetries := 2
|
|
|
|
|
for retry := 0; retry <= maxRetries; retry++ {
|
|
|
|
|
response, err = callLLM()
|
2026-02-16 08:30:54 +00:00
|
|
|
if err == nil {
|
2026-02-16 13:34:55 +00:00
|
|
|
break
|
2026-02-16 08:30:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
errMsg := strings.ToLower(err.Error())
|
|
|
|
|
isContextError := strings.Contains(errMsg, "token") ||
|
|
|
|
|
strings.Contains(errMsg, "context") ||
|
|
|
|
|
strings.Contains(errMsg, "invalidparameter") ||
|
|
|
|
|
strings.Contains(errMsg, "length")
|
|
|
|
|
|
|
|
|
|
if isContextError && retry < maxRetries {
|
2026-02-20 18:03:11 +00:00
|
|
|
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
|
2026-02-16 08:30:54 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
"retry": retry,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-16 13:34:55 +00:00
|
|
|
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
2026-02-16 08:30:54 +00:00
|
|
|
Channel: opts.Channel,
|
|
|
|
|
ChatID: opts.ChatID,
|
2026-02-16 13:34:55 +00:00
|
|
|
Content: "Context window exceeded. Compressing history and retrying...",
|
2026-02-16 08:30:54 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 13:34:55 +00:00
|
|
|
al.forceCompression(agent, opts.SessionKey)
|
|
|
|
|
newHistory := agent.Sessions.GetHistory(opts.SessionKey)
|
|
|
|
|
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
|
|
|
|
messages = agent.ContextBuilder.BuildMessages(
|
|
|
|
|
newHistory, newSummary, "",
|
|
|
|
|
nil, opts.Channel, opts.ChatID,
|
2026-02-16 08:30:54 +00:00
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
if err != nil {
|
2026-02-10 05:18:23 +00:00
|
|
|
logger.ErrorCF("agent", "LLM call failed",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
2026-02-10 05:18:23 +00:00
|
|
|
"iteration": iteration,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
2026-02-16 08:30:54 +00:00
|
|
|
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// Check if no tool calls - we're done
|
2026-02-04 11:06:13 +00:00
|
|
|
if len(response.ToolCalls) == 0 {
|
|
|
|
|
finalContent = response.Content
|
2026-02-10 05:18:23 +00:00
|
|
|
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
2026-02-10 05:18:23 +00:00
|
|
|
"iteration": iteration,
|
|
|
|
|
"content_chars": len(finalContent),
|
|
|
|
|
})
|
2026-02-04 11:06:13 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 05:55:44 +00:00
|
|
|
normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
|
2026-02-10 05:18:23 +00:00
|
|
|
for _, tc := range response.ToolCalls {
|
2026-02-19 01:22:39 +00:00
|
|
|
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
|
2026-02-17 05:55:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Log tool calls
|
|
|
|
|
toolNames := make([]string, 0, len(normalizedToolCalls))
|
|
|
|
|
for _, tc := range normalizedToolCalls {
|
2026-02-10 05:18:23 +00:00
|
|
|
toolNames = append(toolNames, tc.Name)
|
|
|
|
|
}
|
|
|
|
|
logger.InfoCF("agent", "LLM requested tool calls",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
2026-02-10 05:18:23 +00:00
|
|
|
"tools": toolNames,
|
2026-02-17 05:55:44 +00:00
|
|
|
"count": len(normalizedToolCalls),
|
2026-02-10 05:18:23 +00:00
|
|
|
"iteration": iteration,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// Build assistant message with tool calls
|
2026-02-04 11:06:13 +00:00
|
|
|
assistantMsg := providers.Message{
|
2026-02-21 15:29:40 +00:00
|
|
|
Role: "assistant",
|
|
|
|
|
Content: response.Content,
|
|
|
|
|
ReasoningContent: response.ReasoningContent,
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-02-17 05:55:44 +00:00
|
|
|
for _, tc := range normalizedToolCalls {
|
2026-02-04 11:06:13 +00:00
|
|
|
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
2026-02-19 16:36:31 +00:00
|
|
|
// Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
|
|
|
|
|
extraContent := tc.ExtraContent
|
2026-02-16 12:10:23 +00:00
|
|
|
thoughtSignature := ""
|
|
|
|
|
if tc.Function != nil {
|
|
|
|
|
thoughtSignature = tc.Function.ThoughtSignature
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
|
2026-02-19 16:36:31 +00:00
|
|
|
ID: tc.ID,
|
|
|
|
|
Type: "function",
|
|
|
|
|
Name: tc.Name,
|
2026-02-04 11:06:13 +00:00
|
|
|
Function: &providers.FunctionCall{
|
2026-02-16 12:10:23 +00:00
|
|
|
Name: tc.Name,
|
|
|
|
|
Arguments: string(argumentsJSON),
|
|
|
|
|
ThoughtSignature: thoughtSignature,
|
2026-02-04 11:06:13 +00:00
|
|
|
},
|
2026-02-19 16:36:31 +00:00
|
|
|
ExtraContent: extraContent,
|
|
|
|
|
ThoughtSignature: thoughtSignature,
|
2026-02-04 11:06:13 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
messages = append(messages, assistantMsg)
|
|
|
|
|
|
2026-02-11 10:43:21 +00:00
|
|
|
// Save assistant message with tool calls to session
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
2026-02-11 10:43:21 +00:00
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// Execute tool calls
|
2026-02-17 05:55:44 +00:00
|
|
|
for _, tc := range normalizedToolCalls {
|
2026-02-10 08:05:23 +00:00
|
|
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
2026-02-11 12:22:41 +00:00
|
|
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
2026-02-10 08:05:23 +00:00
|
|
|
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"agent_id": agent.ID,
|
2026-02-11 12:22:41 +00:00
|
|
|
"tool": tc.Name,
|
|
|
|
|
"iteration": iteration,
|
2026-02-10 08:05:23 +00:00
|
|
|
})
|
|
|
|
|
|
2026-02-12 11:42:24 +00:00
|
|
|
// Create async callback for tools that implement AsyncTool
|
2026-02-13 06:39:39 +00:00
|
|
|
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
|
|
|
|
|
// Instead, they notify the agent via PublishInbound, and the agent decides
|
|
|
|
|
// whether to forward the result to the user (in processSystemMessage).
|
2026-02-12 11:42:24 +00:00
|
|
|
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
|
2026-02-13 06:39:39 +00:00
|
|
|
// Log the async completion but don't send directly to user
|
|
|
|
|
// The agent will handle user notification via processSystemMessage
|
2026-02-12 11:42:24 +00:00
|
|
|
if !result.Silent && result.ForUser != "" {
|
2026-02-13 06:39:39 +00:00
|
|
|
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-12 11:42:24 +00:00
|
|
|
"tool": tc.Name,
|
|
|
|
|
"content_len": len(result.ForUser),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
toolResult := agent.Tools.ExecuteWithContext(
|
|
|
|
|
ctx,
|
|
|
|
|
tc.Name,
|
|
|
|
|
tc.Arguments,
|
|
|
|
|
opts.Channel,
|
|
|
|
|
opts.ChatID,
|
|
|
|
|
asyncCallback,
|
|
|
|
|
)
|
2026-02-12 11:34:32 +00:00
|
|
|
|
|
|
|
|
// Send ForUser content to user immediately if not Silent
|
|
|
|
|
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
2026-02-12 11:34:32 +00:00
|
|
|
Channel: opts.Channel,
|
|
|
|
|
ChatID: opts.ChatID,
|
|
|
|
|
Content: toolResult.ForUser,
|
|
|
|
|
})
|
|
|
|
|
logger.DebugCF("agent", "Sent tool result to user",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-12 11:34:32 +00:00
|
|
|
"tool": tc.Name,
|
|
|
|
|
"content_len": len(toolResult.ForUser),
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-12 11:28:56 +00:00
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
// If tool returned media refs, publish them as outbound media
|
|
|
|
|
if len(toolResult.Media) > 0 && opts.SendResponse {
|
|
|
|
|
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
|
|
|
|
for _, ref := range toolResult.Media {
|
2026-02-22 22:03:23 +00:00
|
|
|
part := bus.MediaPart{Ref: ref}
|
|
|
|
|
// Populate metadata from MediaStore when available
|
|
|
|
|
if al.mediaStore != nil {
|
|
|
|
|
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
|
|
|
|
part.Filename = meta.Filename
|
|
|
|
|
part.ContentType = meta.ContentType
|
|
|
|
|
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
parts = append(parts, part)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
|
|
|
|
|
Channel: opts.Channel,
|
|
|
|
|
ChatID: opts.ChatID,
|
|
|
|
|
Parts: parts,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:28:56 +00:00
|
|
|
// Determine content for LLM based on tool result
|
|
|
|
|
contentForLLM := toolResult.ForLLM
|
|
|
|
|
if contentForLLM == "" && toolResult.Err != nil {
|
|
|
|
|
contentForLLM = toolResult.Err.Error()
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toolResultMsg := providers.Message{
|
|
|
|
|
Role: "tool",
|
2026-02-12 11:28:56 +00:00
|
|
|
Content: contentForLLM,
|
2026-02-04 11:06:13 +00:00
|
|
|
ToolCallID: tc.ID,
|
|
|
|
|
}
|
|
|
|
|
messages = append(messages, toolResultMsg)
|
|
|
|
|
|
2026-02-11 10:43:21 +00:00
|
|
|
// Save tool result message to session
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
2026-02-11 11:27:36 +00:00
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
return finalContent, iteration, nil
|
2026-02-10 08:05:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// updateToolContexts updates the context for tools that need channel/chatID info.
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
2026-02-12 11:28:56 +00:00
|
|
|
// Use ContextualTool interface instead of type assertions
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
if tool, ok := agent.Tools.Get("message"); ok {
|
2026-02-12 11:28:56 +00:00
|
|
|
if mt, ok := tool.(tools.ContextualTool); ok {
|
2026-02-11 12:22:41 +00:00
|
|
|
mt.SetContext(channel, chatID)
|
2026-02-10 08:05:23 +00:00
|
|
|
}
|
|
|
|
|
}
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
if tool, ok := agent.Tools.Get("spawn"); ok {
|
2026-02-12 11:28:56 +00:00
|
|
|
if st, ok := tool.(tools.ContextualTool); ok {
|
2026-02-11 12:22:41 +00:00
|
|
|
st.SetContext(channel, chatID)
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-13 15:24:26 +00:00
|
|
|
if tool, ok := agent.Tools.Get("subagent"); ok {
|
2026-02-12 12:14:21 +00:00
|
|
|
if st, ok := tool.(tools.ContextualTool); ok {
|
2026-02-11 12:22:41 +00:00
|
|
|
st.SetContext(channel, chatID)
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-02-09 18:25:46 +00:00
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
2026-02-16 13:34:55 +00:00
|
|
|
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
2026-02-11 12:22:41 +00:00
|
|
|
tokenEstimate := al.estimateTokens(newHistory)
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
threshold := agent.ContextWindow * 75 / 100
|
2026-02-11 10:43:21 +00:00
|
|
|
|
2026-02-11 12:22:41 +00:00
|
|
|
if len(newHistory) > 20 || tokenEstimate > threshold {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
summarizeKey := agent.ID + ":" + sessionKey
|
|
|
|
|
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
2026-02-11 12:22:41 +00:00
|
|
|
go func() {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
defer al.summarizing.Delete(summarizeKey)
|
2026-02-26 04:36:19 +00:00
|
|
|
logger.Debug("Memory threshold reached. Optimizing conversation history...")
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
al.summarizeSession(agent, sessionKey)
|
2026-02-11 12:22:41 +00:00
|
|
|
}()
|
2026-02-10 08:05:23 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 08:30:54 +00:00
|
|
|
// forceCompression aggressively reduces context when the limit is hit.
|
|
|
|
|
// It drops the oldest 50% of messages (keeping system prompt and last user message).
|
2026-02-16 13:34:55 +00:00
|
|
|
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
|
|
|
|
history := agent.Sessions.GetHistory(sessionKey)
|
2026-02-16 08:30:54 +00:00
|
|
|
if len(history) <= 4 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keep system prompt (usually [0]) and the very last message (user's trigger)
|
|
|
|
|
// We want to drop the oldest half of the *conversation*
|
|
|
|
|
// Assuming [0] is system, [1:] is conversation
|
|
|
|
|
conversation := history[1 : len(history)-1]
|
|
|
|
|
if len(conversation) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper to find the mid-point of the conversation
|
|
|
|
|
mid := len(conversation) / 2
|
|
|
|
|
|
|
|
|
|
// New history structure:
|
2026-02-19 14:47:03 +00:00
|
|
|
// 1. System Prompt (with compression note appended)
|
|
|
|
|
// 2. Second half of conversation
|
|
|
|
|
// 3. Last message
|
2026-02-16 08:30:54 +00:00
|
|
|
|
|
|
|
|
droppedCount := mid
|
|
|
|
|
keptConversation := conversation[mid:]
|
|
|
|
|
|
2026-02-25 10:07:28 +00:00
|
|
|
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
|
2026-02-19 14:47:03 +00:00
|
|
|
|
|
|
|
|
// Append compression note to the original system prompt instead of adding a new system message
|
|
|
|
|
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
|
2026-02-20 18:03:11 +00:00
|
|
|
compressionNote := fmt.Sprintf(
|
|
|
|
|
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
|
|
|
|
|
droppedCount,
|
|
|
|
|
)
|
2026-02-19 14:47:03 +00:00
|
|
|
enhancedSystemPrompt := history[0]
|
|
|
|
|
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
|
|
|
|
|
newHistory = append(newHistory, enhancedSystemPrompt)
|
2026-02-16 08:30:54 +00:00
|
|
|
|
|
|
|
|
newHistory = append(newHistory, keptConversation...)
|
|
|
|
|
newHistory = append(newHistory, history[len(history)-1]) // Last message
|
|
|
|
|
|
|
|
|
|
// Update session
|
2026-02-16 13:34:55 +00:00
|
|
|
agent.Sessions.SetHistory(sessionKey, newHistory)
|
|
|
|
|
agent.Sessions.Save(sessionKey)
|
2026-02-16 08:30:54 +00:00
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
logger.WarnCF("agent", "Forced compression executed", map[string]any{
|
2026-02-16 08:30:54 +00:00
|
|
|
"session_key": sessionKey,
|
|
|
|
|
"dropped_msgs": droppedCount,
|
|
|
|
|
"new_count": len(newHistory),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 08:05:23 +00:00
|
|
|
// GetStartupInfo returns information about loaded tools and skills for logging.
|
2026-02-20 18:03:11 +00:00
|
|
|
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
|
|
|
|
info := make(map[string]any)
|
2026-02-10 08:05:23 +00:00
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
agent := al.registry.GetDefaultAgent()
|
|
|
|
|
if agent == nil {
|
|
|
|
|
return info
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 08:05:23 +00:00
|
|
|
// Tools info
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
toolsList := agent.Tools.List()
|
2026-02-20 18:03:11 +00:00
|
|
|
info["tools"] = map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"count": len(toolsList),
|
|
|
|
|
"names": toolsList,
|
2026-02-10 08:05:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skills info
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
|
|
|
|
|
|
|
|
|
// Agents info
|
2026-02-20 18:03:11 +00:00
|
|
|
info["agents"] = map[string]any{
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
"count": len(al.registry.ListAgentIDs()),
|
|
|
|
|
"ids": al.registry.ListAgentIDs(),
|
|
|
|
|
}
|
2026-02-10 08:05:23 +00:00
|
|
|
|
|
|
|
|
return info
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 15:33:28 +00:00
|
|
|
// formatMessagesForLog formats messages for logging
|
|
|
|
|
func formatMessagesForLog(messages []providers.Message) string {
|
|
|
|
|
if len(messages) == 0 {
|
|
|
|
|
return "[]"
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 07:06:33 +00:00
|
|
|
var sb strings.Builder
|
|
|
|
|
sb.WriteString("[\n")
|
2026-02-10 15:33:28 +00:00
|
|
|
for i, msg := range messages {
|
2026-02-20 07:06:33 +00:00
|
|
|
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
|
2026-02-16 08:30:54 +00:00
|
|
|
if len(msg.ToolCalls) > 0 {
|
2026-02-20 07:06:33 +00:00
|
|
|
sb.WriteString(" ToolCalls:\n")
|
2026-02-10 15:33:28 +00:00
|
|
|
for _, tc := range msg.ToolCalls {
|
2026-02-20 07:06:33 +00:00
|
|
|
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
2026-02-10 15:33:28 +00:00
|
|
|
if tc.Function != nil {
|
2026-02-20 07:06:33 +00:00
|
|
|
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200))
|
2026-02-10 15:33:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 19:00:57 +00:00
|
|
|
}
|
2026-02-10 15:33:28 +00:00
|
|
|
if msg.Content != "" {
|
2026-02-11 12:22:41 +00:00
|
|
|
content := utils.Truncate(msg.Content, 200)
|
2026-02-20 07:06:33 +00:00
|
|
|
fmt.Fprintf(&sb, " Content: %s\n", content)
|
2026-02-09 19:00:57 +00:00
|
|
|
}
|
2026-02-10 15:33:28 +00:00
|
|
|
if msg.ToolCallID != "" {
|
2026-02-20 07:06:33 +00:00
|
|
|
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
2026-02-20 07:06:33 +00:00
|
|
|
sb.WriteString("\n")
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
2026-02-20 07:06:33 +00:00
|
|
|
sb.WriteString("]")
|
|
|
|
|
return sb.String()
|
2026-02-09 19:00:57 +00:00
|
|
|
}
|
2026-02-09 18:25:46 +00:00
|
|
|
|
2026-02-10 15:33:28 +00:00
|
|
|
// formatToolsForLog formats tool definitions for logging
|
2026-02-20 07:06:33 +00:00
|
|
|
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
|
|
|
|
|
if len(toolDefs) == 0 {
|
2026-02-10 15:33:28 +00:00
|
|
|
return "[]"
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 07:06:33 +00:00
|
|
|
var sb strings.Builder
|
|
|
|
|
sb.WriteString("[\n")
|
|
|
|
|
for i, tool := range toolDefs {
|
|
|
|
|
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
|
|
|
|
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
|
2026-02-10 15:33:28 +00:00
|
|
|
if len(tool.Function.Parameters) > 0 {
|
2026-02-20 07:06:33 +00:00
|
|
|
fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
|
2026-02-10 15:33:28 +00:00
|
|
|
}
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
2026-02-20 07:06:33 +00:00
|
|
|
sb.WriteString("]")
|
|
|
|
|
return sb.String()
|
2026-02-09 19:00:57 +00:00
|
|
|
}
|
2026-02-09 18:25:46 +00:00
|
|
|
|
2026-02-11 11:27:36 +00:00
|
|
|
// summarizeSession summarizes the conversation history for a session.
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
2026-02-11 11:27:36 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
history := agent.Sessions.GetHistory(sessionKey)
|
|
|
|
|
summary := agent.Sessions.GetSummary(sessionKey)
|
2026-02-11 11:27:36 +00:00
|
|
|
|
|
|
|
|
// Keep last 4 messages for continuity
|
|
|
|
|
if len(history) <= 4 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toSummarize := history[:len(history)-4]
|
|
|
|
|
|
|
|
|
|
// Oversized Message Guard
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
maxMessageTokens := agent.ContextWindow / 2
|
2026-02-11 11:27:36 +00:00
|
|
|
validMessages := make([]providers.Message, 0)
|
|
|
|
|
omitted := false
|
|
|
|
|
|
|
|
|
|
for _, m := range toSummarize {
|
|
|
|
|
if m.Role != "user" && m.Role != "assistant" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-16 13:34:55 +00:00
|
|
|
msgTokens := len(m.Content) / 2
|
2026-02-11 11:27:36 +00:00
|
|
|
if msgTokens > maxMessageTokens {
|
|
|
|
|
omitted = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
validMessages = append(validMessages, m)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(validMessages) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Multi-Part Summarization
|
|
|
|
|
var finalSummary string
|
|
|
|
|
if len(validMessages) > 10 {
|
|
|
|
|
mid := len(validMessages) / 2
|
|
|
|
|
part1 := validMessages[:mid]
|
|
|
|
|
part2 := validMessages[mid:]
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
|
|
|
|
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
2026-02-11 11:27:36 +00:00
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
mergePrompt := fmt.Sprintf(
|
|
|
|
|
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
|
|
|
|
s1,
|
|
|
|
|
s2,
|
|
|
|
|
)
|
|
|
|
|
resp, err := agent.Provider.Chat(
|
|
|
|
|
ctx,
|
|
|
|
|
[]providers.Message{{Role: "user", Content: mergePrompt}},
|
|
|
|
|
nil,
|
|
|
|
|
agent.Model,
|
|
|
|
|
map[string]any{
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
"max_tokens": 1024,
|
|
|
|
|
"temperature": 0.3,
|
|
|
|
|
"prompt_cache_key": agent.ID,
|
2026-02-20 18:03:11 +00:00
|
|
|
},
|
|
|
|
|
)
|
2026-02-11 11:27:36 +00:00
|
|
|
if err == nil {
|
|
|
|
|
finalSummary = resp.Content
|
|
|
|
|
} else {
|
|
|
|
|
finalSummary = s1 + " " + s2
|
|
|
|
|
}
|
|
|
|
|
} else {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
|
2026-02-11 11:27:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if omitted && finalSummary != "" {
|
|
|
|
|
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if finalSummary != "" {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
|
|
|
|
agent.Sessions.TruncateHistory(sessionKey, 4)
|
2026-02-13 15:24:26 +00:00
|
|
|
agent.Sessions.Save(sessionKey)
|
2026-02-11 11:27:36 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// summarizeBatch summarizes a batch of messages.
|
2026-02-20 18:03:11 +00:00
|
|
|
func (al *AgentLoop) summarizeBatch(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
agent *AgentInstance,
|
|
|
|
|
batch []providers.Message,
|
|
|
|
|
existingSummary string,
|
|
|
|
|
) (string, error) {
|
2026-02-20 07:06:33 +00:00
|
|
|
var sb strings.Builder
|
|
|
|
|
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
|
2026-02-11 11:27:36 +00:00
|
|
|
if existingSummary != "" {
|
2026-02-20 07:06:33 +00:00
|
|
|
sb.WriteString("Existing context: ")
|
|
|
|
|
sb.WriteString(existingSummary)
|
|
|
|
|
sb.WriteString("\n")
|
2026-02-11 11:27:36 +00:00
|
|
|
}
|
2026-02-20 07:06:33 +00:00
|
|
|
sb.WriteString("\nCONVERSATION:\n")
|
2026-02-11 11:27:36 +00:00
|
|
|
for _, m := range batch {
|
2026-02-20 07:06:33 +00:00
|
|
|
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
2026-02-20 07:06:33 +00:00
|
|
|
prompt := sb.String()
|
2026-02-11 11:27:36 +00:00
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
response, err := agent.Provider.Chat(
|
|
|
|
|
ctx,
|
|
|
|
|
[]providers.Message{{Role: "user", Content: prompt}},
|
|
|
|
|
nil,
|
|
|
|
|
agent.Model,
|
|
|
|
|
map[string]any{
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
"max_tokens": 1024,
|
|
|
|
|
"temperature": 0.3,
|
|
|
|
|
"prompt_cache_key": agent.ID,
|
2026-02-20 18:03:11 +00:00
|
|
|
},
|
|
|
|
|
)
|
2026-02-11 11:27:36 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return response.Content, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// estimateTokens estimates the number of tokens in a message list.
|
2026-02-16 08:30:54 +00:00
|
|
|
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
|
|
|
|
|
// overheads better than the previous 3 chars/token.
|
2026-02-11 11:27:36 +00:00
|
|
|
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
2026-02-16 08:30:54 +00:00
|
|
|
totalChars := 0
|
2026-02-11 11:27:36 +00:00
|
|
|
for _, m := range messages {
|
2026-02-16 08:30:54 +00:00
|
|
|
totalChars += utf8.RuneCountInString(m.Content)
|
2026-02-10 15:33:28 +00:00
|
|
|
}
|
2026-02-16 08:30:54 +00:00
|
|
|
// 2.5 chars per token = totalChars * 2 / 5
|
|
|
|
|
return totalChars * 2 / 5
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
|
|
|
|
|
content := strings.TrimSpace(msg.Content)
|
|
|
|
|
if !strings.HasPrefix(content, "/") {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts := strings.Fields(content)
|
|
|
|
|
if len(parts) == 0 {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd := parts[0]
|
|
|
|
|
args := parts[1:]
|
|
|
|
|
|
|
|
|
|
switch cmd {
|
|
|
|
|
case "/show":
|
|
|
|
|
if len(args) < 1 {
|
2026-02-16 13:34:55 +00:00
|
|
|
return "Usage: /show [model|channel|agents]", true
|
2026-02-16 08:30:54 +00:00
|
|
|
}
|
|
|
|
|
switch args[0] {
|
|
|
|
|
case "model":
|
2026-02-16 13:34:55 +00:00
|
|
|
defaultAgent := al.registry.GetDefaultAgent()
|
|
|
|
|
if defaultAgent == nil {
|
|
|
|
|
return "No default agent configured", true
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
|
2026-02-16 08:30:54 +00:00
|
|
|
case "channel":
|
|
|
|
|
return fmt.Sprintf("Current channel: %s", msg.Channel), true
|
2026-02-16 13:34:55 +00:00
|
|
|
case "agents":
|
|
|
|
|
agentIDs := al.registry.ListAgentIDs()
|
|
|
|
|
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
2026-02-16 08:30:54 +00:00
|
|
|
default:
|
|
|
|
|
return fmt.Sprintf("Unknown show target: %s", args[0]), true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "/list":
|
|
|
|
|
if len(args) < 1 {
|
2026-02-16 13:34:55 +00:00
|
|
|
return "Usage: /list [models|channels|agents]", true
|
2026-02-16 08:30:54 +00:00
|
|
|
}
|
|
|
|
|
switch args[0] {
|
|
|
|
|
case "models":
|
2026-02-16 13:34:55 +00:00
|
|
|
return "Available models: configured in config.json per agent", true
|
2026-02-16 08:30:54 +00:00
|
|
|
case "channels":
|
|
|
|
|
if al.channelManager == nil {
|
|
|
|
|
return "Channel manager not initialized", true
|
|
|
|
|
}
|
|
|
|
|
channels := al.channelManager.GetEnabledChannels()
|
|
|
|
|
if len(channels) == 0 {
|
|
|
|
|
return "No channels enabled", true
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
|
2026-02-16 13:34:55 +00:00
|
|
|
case "agents":
|
|
|
|
|
agentIDs := al.registry.ListAgentIDs()
|
|
|
|
|
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
2026-02-16 08:30:54 +00:00
|
|
|
default:
|
|
|
|
|
return fmt.Sprintf("Unknown list target: %s", args[0]), true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "/switch":
|
|
|
|
|
if len(args) < 3 || args[1] != "to" {
|
|
|
|
|
return "Usage: /switch [model|channel] to <name>", true
|
|
|
|
|
}
|
|
|
|
|
target := args[0]
|
|
|
|
|
value := args[2]
|
|
|
|
|
|
|
|
|
|
switch target {
|
|
|
|
|
case "model":
|
2026-02-16 13:34:55 +00:00
|
|
|
defaultAgent := al.registry.GetDefaultAgent()
|
|
|
|
|
if defaultAgent == nil {
|
|
|
|
|
return "No default agent configured", true
|
|
|
|
|
}
|
|
|
|
|
oldModel := defaultAgent.Model
|
|
|
|
|
defaultAgent.Model = value
|
2026-02-16 08:30:54 +00:00
|
|
|
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
|
|
|
|
case "channel":
|
|
|
|
|
if al.channelManager == nil {
|
|
|
|
|
return "Channel manager not initialized", true
|
|
|
|
|
}
|
|
|
|
|
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
|
|
|
|
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
|
|
|
|
|
}
|
2026-02-16 13:34:55 +00:00
|
|
|
return fmt.Sprintf("Switched target channel to %s", value), true
|
2026-02-16 08:30:54 +00:00
|
|
|
default:
|
|
|
|
|
return fmt.Sprintf("Unknown switch target: %s", target), true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "", false
|
2026-02-09 18:25:46 +00:00
|
|
|
}
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
2026-02-22 13:57:12 +00:00
|
|
|
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
2026-02-22 13:57:12 +00:00
|
|
|
if msg.Peer.Kind == "" {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-22 13:57:12 +00:00
|
|
|
peerID := msg.Peer.ID
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
if peerID == "" {
|
2026-02-22 13:57:12 +00:00
|
|
|
if msg.Peer.Kind == "direct" {
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
peerID = msg.SenderID
|
|
|
|
|
} else {
|
|
|
|
|
peerID = msg.ChatID
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 13:57:12 +00:00
|
|
|
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
|
|
|
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
|
|
|
|
parentKind := msg.Metadata["parent_peer_kind"]
|
|
|
|
|
parentID := msg.Metadata["parent_peer_id"]
|
|
|
|
|
if parentKind == "" || parentID == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
|
|
|
|
}
|