add stop command
This commit is contained in:
parent
b914e85352
commit
771e53e857
7 changed files with 140 additions and 60 deletions
|
|
@ -357,16 +357,32 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use a channel to decouple message consumption from message processing.
|
||||||
|
// This allows the main loop to continuously consume messages (including /stop)
|
||||||
|
// while processing happens in separate goroutines.
|
||||||
|
msgChan := make(chan bus.InboundMessage, 16)
|
||||||
|
|
||||||
|
// Start message consumer goroutine
|
||||||
|
go func() {
|
||||||
for al.running.Load() {
|
for al.running.Load() {
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
msg, ok := al.bus.ConsumeInbound(ctx)
|
msg, ok := al.bus.ConsumeInbound(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
|
case msgChan <- msg:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Main loop: process messages from channel
|
||||||
|
for al.running.Load() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case msg := <-msgChan:
|
||||||
// Check if this is a command - commands are handled asynchronously
|
// Check if this is a command - commands are handled asynchronously
|
||||||
// so they can interrupt long-running tasks (e.g., /stop)
|
// so they can interrupt long-running tasks (e.g., /stop)
|
||||||
if commands.HasCommandPrefix(msg.Content) {
|
if commands.HasCommandPrefix(msg.Content) {
|
||||||
|
|
@ -374,61 +390,9 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process non-command message synchronously
|
// Process non-command message in a goroutine
|
||||||
func() {
|
// This allows the main loop to continue consuming messages
|
||||||
// Create cancellable context for this message
|
go al.processMessageAsync(ctx, msg)
|
||||||
msgCtx, msgCancel := context.WithCancel(ctx)
|
|
||||||
defer msgCancel()
|
|
||||||
|
|
||||||
// Store cancel function for /stop command
|
|
||||||
al.setCurrentCancel(msgCancel)
|
|
||||||
defer al.clearCurrentCancel()
|
|
||||||
|
|
||||||
response, err := al.processMessage(msgCtx, msg)
|
|
||||||
if err != nil {
|
|
||||||
// Check if the error is due to context cancellation (user issued /stop)
|
|
||||||
if errors.Is(err, context.Canceled) {
|
|
||||||
response = "⏹️ Task stopped."
|
|
||||||
} else {
|
|
||||||
response = fmt.Sprintf("Error processing message: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
|
||||||
Channel: msg.Channel,
|
|
||||||
ChatID: msg.ChatID,
|
|
||||||
Content: response,
|
|
||||||
})
|
|
||||||
logger.InfoCF("agent", "Published outbound response",
|
|
||||||
map[string]any{
|
|
||||||
"channel": msg.Channel,
|
|
||||||
"chat_id": msg.ChatID,
|
|
||||||
"content_len": len(response),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
logger.DebugCF(
|
|
||||||
"agent",
|
|
||||||
"Skipped outbound (message tool already sent)",
|
|
||||||
map[string]any{"channel": msg.Channel},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1730,6 +1694,65 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
return totalChars * 2 / 5
|
return totalChars * 2 / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processMessageAsync processes a non-command message in a goroutine.
|
||||||
|
// This allows the main loop to continue consuming messages while processing.
|
||||||
|
func (al *AgentLoop) processMessageAsync(ctx context.Context, msg bus.InboundMessage) {
|
||||||
|
// Create cancellable context for this message
|
||||||
|
msgCtx, msgCancel := context.WithCancel(ctx)
|
||||||
|
defer msgCancel()
|
||||||
|
|
||||||
|
// Store cancel function for /stop command
|
||||||
|
al.setCurrentCancel(msgCancel)
|
||||||
|
defer al.clearCurrentCancel()
|
||||||
|
|
||||||
|
response, err := al.processMessage(msgCtx, msg)
|
||||||
|
if err != nil {
|
||||||
|
// Check if the error is due to context cancellation (user issued /stop)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
response = "⏹️ Task stopped."
|
||||||
|
} else {
|
||||||
|
response = fmt.Sprintf("Error processing message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
Content: response,
|
||||||
|
})
|
||||||
|
logger.InfoCF("agent", "Published outbound response",
|
||||||
|
map[string]any{
|
||||||
|
"channel": msg.Channel,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"content_len": len(response),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Skipped outbound (message tool already sent)",
|
||||||
|
map[string]any{"channel": msg.Channel},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleCommandAsync handles commands in a separate goroutine so they can
|
// handleCommandAsync handles commands in a separate goroutine so they can
|
||||||
// interrupt long-running tasks. Commands like /stop need to be processed
|
// interrupt long-running tasks. Commands like /stop need to be processed
|
||||||
// immediately without waiting for the current message to finish.
|
// immediately without waiting for the current message to finish.
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition {
|
||||||
listCommand(),
|
listCommand(),
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
|
stopCommand(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
pkg/commands/cmd_stop.go
Normal file
23
pkg/commands/cmd_stop.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func stopCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "stop",
|
||||||
|
Description: "Stop the current running task",
|
||||||
|
Usage: "/stop",
|
||||||
|
Strict: true,
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt.CancelCurrentTask == nil {
|
||||||
|
return req.Reply("Stop command is not available in this context.")
|
||||||
|
}
|
||||||
|
if rt.CancelCurrentTask() {
|
||||||
|
// Don't send response here - processMessageAsync will send "Task stopped"
|
||||||
|
// when it detects the context cancellation.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return req.Reply("No task is currently running.")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ type Definition struct {
|
||||||
Aliases []string
|
Aliases []string
|
||||||
SubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers
|
SubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers
|
||||||
Handler Handler // for simple commands without sub-commands
|
Handler Handler // for simple commands without sub-commands
|
||||||
|
Strict bool // if true, command must match exactly (no extra arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveUsage returns the usage string. When SubCommands are present,
|
// EffectiveUsage returns the usage string. When SubCommands are present,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package commands
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Outcome int
|
type Outcome int
|
||||||
|
|
@ -56,6 +57,15 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin
|
||||||
req.Reply = func(string) error { return nil }
|
req.Reply = func(string) error { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if strict command has extra arguments
|
||||||
|
if def.Strict {
|
||||||
|
tokens := strings.Fields(strings.TrimSpace(req.Text))
|
||||||
|
if len(tokens) > 1 {
|
||||||
|
// Strict command with extra arguments - passthrough to agent
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simple command — no sub-commands
|
// Simple command — no sub-commands
|
||||||
if len(def.SubCommands) == 0 {
|
if len(def.SubCommands) == 0 {
|
||||||
if def.Handler == nil {
|
if def.Handler == nil {
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ type Runtime struct {
|
||||||
GetEnabledChannels func() []string
|
GetEnabledChannels func() []string
|
||||||
SwitchModel func(value string) (oldModel string, err error)
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
SwitchChannel func(value string) error
|
SwitchChannel func(value string) error
|
||||||
|
CancelCurrentTask func() bool // Cancel the currently running task, returns true if a task was cancelled
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -663,6 +663,7 @@ type ToolsConfig struct {
|
||||||
MCP MCPConfig `json:"mcp"`
|
MCP MCPConfig `json:"mcp"`
|
||||||
LLMCallLog LLMCallLogConfig `json:"llm_call_log"`
|
LLMCallLog LLMCallLogConfig `json:"llm_call_log"`
|
||||||
ConversationLog ConversationLogConfig `json:"conversation_log"`
|
ConversationLog ConversationLogConfig `json:"conversation_log"`
|
||||||
|
Sanitizer SanitizerConfig `json:"sanitizer"`
|
||||||
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
||||||
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
|
|
@ -693,6 +694,26 @@ type ConversationLogConfig struct {
|
||||||
MaxFiles int `json:"max_files" env:"PICOCLAW_TOOLS_CONVERSATION_LOG_MAX_FILES"`
|
MaxFiles int `json:"max_files" env:"PICOCLAW_TOOLS_CONVERSATION_LOG_MAX_FILES"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SanitizerConfig 配置敏感信息脱敏
|
||||||
|
type SanitizerConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_SANITIZER_ENABLED"`
|
||||||
|
Keywords []SanitizerKeyword `json:"keywords"`
|
||||||
|
CustomPatterns []SanitizerPattern `json:"custom_patterns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizerKeyword 关键词脱敏规则
|
||||||
|
type SanitizerKeyword struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizerPattern 自定义正则脱敏规则
|
||||||
|
type SanitizerPattern struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
type SearchCacheConfig struct {
|
type SearchCacheConfig struct {
|
||||||
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
||||||
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue