refactor(heartbeat): add configurable interval and channel-aware routing

feat(config): add heartbeat interval configuration with default 30 minutes

feat(state): migrate state file from workspace root to state directory

feat(channels): skip internal channels in outbound dispatcher

feat(agent): record last active channel for heartbeat context

refactor(subagent): use configurable default model instead of provider default
This commit is contained in:
yinwm
2026-02-13 11:13:32 +08:00
parent 8fbbb67f70
commit 4dfa133cb8
9 changed files with 120 additions and 47 deletions

View File

@@ -654,12 +654,16 @@ func gatewayCmd() {
heartbeatService := heartbeat.NewHeartbeatService( heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(), cfg.WorkspacePath(),
30, cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled, cfg.Heartbeat.Enabled,
) )
heartbeatService.SetBus(msgBus) heartbeatService.SetBus(msgBus)
heartbeatService.SetHandler(func(prompt string) *tools.ToolResult { heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
response, err := agentLoop.ProcessDirect(context.Background(), prompt, "heartbeat") // Use cli:direct as fallback if no valid channel
if channel == "" || chatID == "" {
channel, chatID = "cli", "direct"
}
response, err := agentLoop.ProcessDirectWithChannel(context.Background(), prompt, "heartbeat", channel, chatID)
if err != nil { if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
} }

View File

@@ -82,7 +82,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
toolsRegistry.Register(messageTool) toolsRegistry.Register(messageTool)
// Register spawn tool // Register spawn tool
subagentManager := tools.NewSubagentManager(provider, workspace, msgBus) subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus)
spawnTool := tools.NewSpawnTool(subagentManager) spawnTool := tools.NewSpawnTool(subagentManager)
toolsRegistry.Register(spawnTool) toolsRegistry.Register(spawnTool)
@@ -187,9 +187,14 @@ func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sess
} }
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
// Add message preview to log // Add message preview to log (show full content for error messages)
preview := utils.Truncate(msg.Content, 80) var logContent string
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview), 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),
map[string]interface{}{ map[string]interface{}{
"channel": msg.Channel, "channel": msg.Channel,
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
@@ -255,6 +260,18 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// runAgentLoop is the core message processing logic. // runAgentLoop is the core message processing logic.
// It handles context building, LLM calls, tool execution, and response handling. // It handles context building, LLM calls, tool execution, and response handling.
func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) { func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent)
internalChannels := map[string]bool{"cli": true, "system": true, "subagent": true}
if !internalChannels[opts.Channel] {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
}
}
}
// 1. Update tool contexts // 1. Update tool contexts
al.updateToolContexts(opts.Channel, opts.ChatID) al.updateToolContexts(opts.Channel, opts.ChatID)

View File

@@ -218,6 +218,9 @@ func (m *Manager) StopAll(ctx context.Context) error {
func (m *Manager) dispatchOutbound(ctx context.Context) { func (m *Manager) dispatchOutbound(ctx context.Context) {
logger.InfoC("channels", "Outbound dispatcher started") logger.InfoC("channels", "Outbound dispatcher started")
// Internal channels that don't have actual handlers
internalChannels := map[string]bool{"cli": true, "system": true, "subagent": true}
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -229,6 +232,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
continue continue
} }
// Silently skip internal channels
if internalChannels[msg.Channel] {
continue
}
m.mu.RLock() m.mu.RLock()
channel, exists := m.channels[msg.Channel] channel, exists := m.channels[msg.Channel]
m.mu.RUnlock() m.mu.RUnlock()

View File

@@ -136,6 +136,7 @@ type SlackConfig struct {
type HeartbeatConfig struct { type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
} }
type ProvidersConfig struct { type ProvidersConfig struct {
@@ -262,6 +263,7 @@ func DefaultConfig() *Config {
}, },
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{
Enabled: true, Enabled: true,
Interval: 30, // default 30 minutes
}, },
} }
} }

View File

@@ -27,7 +27,8 @@ const (
// HeartbeatHandler is the function type for handling heartbeat. // HeartbeatHandler is the function type for handling heartbeat.
// It returns a ToolResult that can indicate async operations. // It returns a ToolResult that can indicate async operations.
type HeartbeatHandler func(prompt string) *tools.ToolResult // channel and chatID are derived from the last active user channel.
type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult
// HeartbeatService manages periodic heartbeat checks // HeartbeatService manages periodic heartbeat checks
type HeartbeatService struct { type HeartbeatService struct {
@@ -168,7 +169,11 @@ func (hs *HeartbeatService) executeHeartbeat() {
return return
} }
result := handler(prompt) // Get last channel info for context
lastChannel := hs.state.GetLastChannel()
channel, chatID := hs.parseLastChannel(lastChannel)
result := handler(prompt, channel, chatID)
if result == nil { if result == nil {
hs.logInfo("Heartbeat handler returned nil result") hs.logInfo("Heartbeat handler returned nil result")
@@ -287,13 +292,12 @@ func (hs *HeartbeatService) sendResponse(response string) {
return return
} }
// Parse channel format: "platform:user_id" (e.g., "telegram:123456") platform, userID := hs.parseLastChannel(lastChannel)
parts := strings.SplitN(lastChannel, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" { // Skip internal channels that can't receive messages
hs.logError("Invalid last channel format: %s", lastChannel) if platform == "" || userID == "" {
return return
} }
platform, userID := parts[0], parts[1]
msgBus.PublishOutbound(bus.OutboundMessage{ msgBus.PublishOutbound(bus.OutboundMessage{
Channel: platform, Channel: platform,
@@ -304,6 +308,32 @@ func (hs *HeartbeatService) sendResponse(response string) {
hs.logInfo("Heartbeat result sent to %s", platform) hs.logInfo("Heartbeat result sent to %s", platform)
} }
// parseLastChannel parses the last channel string into platform and userID.
// Returns empty strings for invalid or internal channels.
func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) {
if lastChannel == "" {
return "", ""
}
// Parse channel format: "platform:user_id" (e.g., "telegram:123456")
parts := strings.SplitN(lastChannel, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
hs.logError("Invalid last channel format: %s", lastChannel)
return "", ""
}
platform, userID = parts[0], parts[1]
// Skip internal channels
internalChannels := map[string]bool{"cli": true, "system": true, "subagent": true}
if internalChannels[platform] {
hs.logInfo("Skipping internal channel: %s", platform)
return "", ""
}
return platform, userID
}
// logInfo logs an informational message to the heartbeat log // logInfo logs an informational message to the heartbeat log
func (hs *HeartbeatService) logInfo(format string, args ...any) { func (hs *HeartbeatService) logInfo(format string, args ...any) {
hs.log("INFO", format, args...) hs.log("INFO", format, args...)

View File

@@ -28,7 +28,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
Async: true, Async: true,
} }
hs.SetHandler(func(prompt string) *tools.ToolResult { hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
asyncCalled = true asyncCalled = true
if prompt == "" { if prompt == "" {
t.Error("Expected non-empty prompt") t.Error("Expected non-empty prompt")
@@ -57,7 +57,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
hs := NewHeartbeatService(tmpDir, 30, true) hs := NewHeartbeatService(tmpDir, 30, true)
hs.started = true // Enable for testing hs.started = true // Enable for testing
hs.SetHandler(func(prompt string) *tools.ToolResult { hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
return &tools.ToolResult{ return &tools.ToolResult{
ForLLM: "Heartbeat failed: connection error", ForLLM: "Heartbeat failed: connection error",
ForUser: "", ForUser: "",
@@ -95,7 +95,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
hs := NewHeartbeatService(tmpDir, 30, true) hs := NewHeartbeatService(tmpDir, 30, true)
hs.started = true // Enable for testing hs.started = true // Enable for testing
hs.SetHandler(func(prompt string) *tools.ToolResult { hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
return &tools.ToolResult{ return &tools.ToolResult{
ForLLM: "Heartbeat completed successfully", ForLLM: "Heartbeat completed successfully",
ForUser: "", ForUser: "",
@@ -169,7 +169,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
hs := NewHeartbeatService(tmpDir, 30, true) hs := NewHeartbeatService(tmpDir, 30, true)
hs.started = true // Enable for testing hs.started = true // Enable for testing
hs.SetHandler(func(prompt string) *tools.ToolResult { hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
return nil return nil
}) })

View File

@@ -35,6 +35,7 @@ type Manager struct {
func NewManager(workspace string) *Manager { func NewManager(workspace string) *Manager {
stateDir := filepath.Join(workspace, "state") stateDir := filepath.Join(workspace, "state")
stateFile := filepath.Join(stateDir, "state.json") stateFile := filepath.Join(stateDir, "state.json")
oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist // Create state directory if it doesn't exist
os.MkdirAll(stateDir, 0755) os.MkdirAll(stateDir, 0755)
@@ -45,10 +46,19 @@ func NewManager(workspace string) *Manager {
state: &State{}, state: &State{},
} }
// Load existing state if available // Try to load from new location first
if err := sm.load(); err != nil { if _, err := os.Stat(stateFile); os.IsNotExist(err) {
// Log warning but continue with empty state // New file doesn't exist, try migrating from old location
log.Printf("[WARN] state: failed to load state file, starting fresh: %v", err) if data, err := os.ReadFile(oldStateFile); err == nil {
if err := json.Unmarshal(data, sm.state); err == nil {
// Migrate to new location
sm.saveAtomic()
log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile)
}
}
} else {
// Load from new location
sm.load()
} }
return sm return sm

View File

@@ -25,15 +25,17 @@ type SubagentManager struct {
tasks map[string]*SubagentTask tasks map[string]*SubagentTask
mu sync.RWMutex mu sync.RWMutex
provider providers.LLMProvider provider providers.LLMProvider
defaultModel string
bus *bus.MessageBus bus *bus.MessageBus
workspace string workspace string
nextID int nextID int
} }
func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus) *SubagentManager { func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager {
return &SubagentManager{ return &SubagentManager{
tasks: make(map[string]*SubagentTask), tasks: make(map[string]*SubagentTask),
provider: provider, provider: provider,
defaultModel: defaultModel,
bus: bus, bus: bus,
workspace: workspace, workspace: workspace,
nextID: 1, nextID: 1,
@@ -93,7 +95,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
default: default:
} }
response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{ response, err := sm.provider.Chat(ctx, messages, nil, sm.defaultModel, map[string]interface{}{
"max_tokens": 4096, "max_tokens": 4096,
}) })
@@ -237,7 +239,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
}, },
} }
response, err := t.manager.provider.Chat(ctx, messages, nil, t.manager.provider.GetDefaultModel(), map[string]interface{}{ response, err := t.manager.provider.Chat(ctx, messages, nil, t.manager.defaultModel, map[string]interface{}{
"max_tokens": 4096, "max_tokens": 4096,
}) })

View File

@@ -39,7 +39,7 @@ func (m *MockLLMProvider) GetContextWindow() int {
// TestSubagentTool_Name verifies tool name // TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) { func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "/tmp/test", nil) manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
if tool.Name() != "subagent" { if tool.Name() != "subagent" {
@@ -50,7 +50,7 @@ func TestSubagentTool_Name(t *testing.T) {
// TestSubagentTool_Description verifies tool description // TestSubagentTool_Description verifies tool description
func TestSubagentTool_Description(t *testing.T) { func TestSubagentTool_Description(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "/tmp/test", nil) manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
desc := tool.Description() desc := tool.Description()
@@ -65,7 +65,7 @@ func TestSubagentTool_Description(t *testing.T) {
// TestSubagentTool_Parameters verifies tool parameters schema // TestSubagentTool_Parameters verifies tool parameters schema
func TestSubagentTool_Parameters(t *testing.T) { func TestSubagentTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "/tmp/test", nil) manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
params := tool.Parameters() params := tool.Parameters()
@@ -115,7 +115,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
// TestSubagentTool_SetContext verifies context setting // TestSubagentTool_SetContext verifies context setting
func TestSubagentTool_SetContext(t *testing.T) { func TestSubagentTool_SetContext(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "/tmp/test", nil) manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("test-channel", "test-chat") tool.SetContext("test-channel", "test-chat")
@@ -129,7 +129,7 @@ func TestSubagentTool_SetContext(t *testing.T) {
func TestSubagentTool_Execute_Success(t *testing.T) { func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "/tmp/test", msgBus) manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("telegram", "chat-123") tool.SetContext("telegram", "chat-123")
@@ -185,7 +185,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
func TestSubagentTool_Execute_NoLabel(t *testing.T) { func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "/tmp/test", msgBus) manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
@@ -208,7 +208,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
// TestSubagentTool_Execute_MissingTask tests error handling for missing task // TestSubagentTool_Execute_MissingTask tests error handling for missing task
func TestSubagentTool_Execute_MissingTask(t *testing.T) { func TestSubagentTool_Execute_MissingTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "/tmp/test", nil) manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
@@ -259,7 +259,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
func TestSubagentTool_Execute_ContextPassing(t *testing.T) { func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "/tmp/test", msgBus) manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
// Set context // Set context
@@ -288,7 +288,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content // Create a mock provider that returns very long content
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "/tmp/test", msgBus) manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()