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

@@ -27,7 +27,8 @@ const (
// HeartbeatHandler is the function type for handling heartbeat.
// 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
type HeartbeatService struct {
@@ -168,7 +169,11 @@ func (hs *HeartbeatService) executeHeartbeat() {
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 {
hs.logInfo("Heartbeat handler returned nil result")
@@ -287,13 +292,12 @@ func (hs *HeartbeatService) sendResponse(response string) {
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)
platform, userID := hs.parseLastChannel(lastChannel)
// Skip internal channels that can't receive messages
if platform == "" || userID == "" {
return
}
platform, userID := parts[0], parts[1]
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: platform,
@@ -304,6 +308,32 @@ func (hs *HeartbeatService) sendResponse(response string) {
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
func (hs *HeartbeatService) logInfo(format string, args ...any) {
hs.log("INFO", format, args...)

View File

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