Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
"tool_feedback": {
"enabled": false,
"max_args_length": 300,
"separate_messages": false
"separate_messages": false,
"animation_interval_secs": 3,
"edit_min_interval_seconds": 0
}
}
},
Expand Down
11 changes: 9 additions & 2 deletions docs/operations/debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ Debug logs are server-side only. If you want the agent to send a visible notific
"tool_feedback": {
"enabled": true,
"max_args_length": 300,
"separate_messages": true
"separate_messages": true,
"animation_interval_secs": 3,
"edit_min_interval_seconds": 0
}
}
}
Expand All @@ -88,14 +90,19 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo
| `enabled` | bool | `false` | Send a chat notification for each tool call |
| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message |
| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
| `animation_interval_secs` | int | `3` | Seconds between progress animation edits for channels that support editable tool feedback |
| `edit_min_interval_seconds` | int | `0` | Minimum seconds between edits of the same tracked progress message. `0` preserves legacy behavior with no edit throttle |

### Environment variables

Both fields can also be set via environment variables:
These fields can also be set via environment variables:

```bash
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES=false
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ANIMATION_INTERVAL_SECS=3
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_EDIT_MIN_INTERVAL_SECONDS=10
```

> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag.
6 changes: 6 additions & 0 deletions pkg/channels/discord/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
return nil
}

func (c *DiscordChannel) ConfigureToolFeedbackAnimator(cfg channels.ToolFeedbackAnimatorConfig) {
if c.progress != nil {
c.progress.Configure(cfg)
}
}

func (c *DiscordChannel) Stop(ctx context.Context) error {
logger.InfoC("discord", "Stopping Discord bot")
c.SetRunning(false)
Expand Down
6 changes: 6 additions & 0 deletions pkg/channels/feishu/feishu_64.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
return nil
}

func (c *FeishuChannel) ConfigureToolFeedbackAnimator(cfg channels.ToolFeedbackAnimatorConfig) {
if c.progress != nil {
c.progress.Configure(cfg)
}
}

func (c *FeishuChannel) Stop(ctx context.Context) error {
c.mu.Lock()
if c.cancel != nil {
Expand Down
10 changes: 10 additions & 0 deletions pkg/channels/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ type toolFeedbackMessageContentPreparer interface {
PrepareToolFeedbackMessageContent(content string) string
}

type toolFeedbackAnimatorConfigurer interface {
ConfigureToolFeedbackAnimator(cfg ToolFeedbackAnimatorConfig)
}

type asyncTask struct {
cancel context.CancelFunc
}
Expand Down Expand Up @@ -594,6 +598,12 @@ func (m *Manager) initChannel(typeName, channelName string) {
if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok {
setter.SetOwner(ch)
}
if setter, ok := ch.(toolFeedbackAnimatorConfigurer); ok && m.config != nil {
setter.ConfigureToolFeedbackAnimator(ToolFeedbackAnimatorConfig{
AnimationInterval: m.config.Agents.Defaults.GetToolFeedbackAnimationInterval(),
MinEditInterval: m.config.Agents.Defaults.GetToolFeedbackEditMinInterval(),
})
}
m.channels[channelName] = ch
m.publishChannelEvent(
runtimeevents.KindChannelLifecycleInitialized,
Expand Down
6 changes: 6 additions & 0 deletions pkg/channels/matrix/matrix.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@ func (c *MatrixChannel) Start(ctx context.Context) error {
return nil
}

func (c *MatrixChannel) ConfigureToolFeedbackAnimator(cfg channels.ToolFeedbackAnimatorConfig) {
if c.progress != nil {
c.progress.Configure(cfg)
}
}

func (c *MatrixChannel) Stop(ctx context.Context) error {
logger.InfoC("matrix", "Stopping Matrix channel")
c.SetRunning(false)
Expand Down
6 changes: 6 additions & 0 deletions pkg/channels/pico/pico.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ func (c *PicoChannel) Start(ctx context.Context) error {
return nil
}

func (c *PicoChannel) ConfigureToolFeedbackAnimator(cfg channels.ToolFeedbackAnimatorConfig) {
if c.progress != nil {
c.progress.Configure(cfg)
}
}

// Stop implements Channel.
func (c *PicoChannel) Stop(ctx context.Context) error {
logger.InfoC("pico", "Stopping Pico Protocol channel")
Expand Down
6 changes: 6 additions & 0 deletions pkg/channels/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return nil
}

func (c *TelegramChannel) ConfigureToolFeedbackAnimator(cfg channels.ToolFeedbackAnimatorConfig) {
if c.progress != nil {
c.progress.Configure(cfg)
}
}

func (c *TelegramChannel) Stop(ctx context.Context) error {
logger.InfoC("telegram", "Stopping Telegram bot...")
c.SetRunning(false)
Expand Down
158 changes: 150 additions & 8 deletions pkg/channels/tool_feedback_animator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ package channels

import (
"context"
"errors"
"regexp"
"strconv"
"strings"
"sync"
"time"
)

const toolFeedbackAnimationInterval = 3 * time.Second
const defaultToolFeedbackAnimationInterval = 3 * time.Second

const initialToolFeedbackAnimationFrame = ""

var toolFeedbackAnimationFrames = []string{"..", "."}

var retryAfterPattern = regexp.MustCompile(`retry after:? (\d+)`)

// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length
// so callers can reserve room before sending messages to length-limited APIs.
func MaxToolFeedbackAnimationFrameLength() int {
Expand All @@ -32,18 +37,51 @@ type toolFeedbackAnimationState struct {
done chan struct{}
}

// ToolFeedbackAnimatorConfig controls how often editable progress messages are
// updated. Zero values preserve the legacy behavior: animation edits every
// three seconds and no minimum interval between content edits.
type ToolFeedbackAnimatorConfig struct {
AnimationInterval time.Duration
MinEditInterval time.Duration
}

type ToolFeedbackAnimator struct {
mu sync.Mutex
editFn func(ctx context.Context, chatID, messageID, content string) error
entries map[string]*toolFeedbackAnimationState
mu sync.Mutex
editFn func(ctx context.Context, chatID, messageID, content string) error
entries map[string]*toolFeedbackAnimationState
animationInterval time.Duration
minEditInterval time.Duration
lastEditAt map[string]time.Time
editPausedTil map[string]time.Time
}

func NewToolFeedbackAnimator(
editFn func(ctx context.Context, chatID, messageID, content string) error,
) *ToolFeedbackAnimator {
return &ToolFeedbackAnimator{
editFn: editFn,
entries: make(map[string]*toolFeedbackAnimationState),
editFn: editFn,
entries: make(map[string]*toolFeedbackAnimationState),
animationInterval: defaultToolFeedbackAnimationInterval,
lastEditAt: make(map[string]time.Time),
editPausedTil: make(map[string]time.Time),
}
}

func (a *ToolFeedbackAnimator) Configure(cfg ToolFeedbackAnimatorConfig) {
if a == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
if cfg.AnimationInterval > 0 {
a.animationInterval = cfg.AnimationInterval
} else {
a.animationInterval = defaultToolFeedbackAnimationInterval
}
if cfg.MinEditInterval > 0 {
a.minEditInterval = cfg.MinEditInterval
} else {
a.minEditInterval = 0
}
}

Expand Down Expand Up @@ -122,12 +160,24 @@ func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content strin
return "", false, nil
}

if a.shouldSkipEdit(chatID) {
a.Record(chatID, msgID, content)
return msgID, true, nil
}
animatedContent := InitialAnimatedToolFeedbackContent(content)
if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil {
if isMessageNotModifiedError(err) {
a.Record(chatID, msgID, content)
return msgID, true, nil
}
if delay, ok := a.retryAfterDelay(err); ok {
a.pauseEdits(chatID, delay)
}
a.Record(chatID, msgID, baseContent)
return "", true, err
}

a.markEdit(chatID)
a.Record(chatID, msgID, content)
return msgID, true, nil
}
Expand Down Expand Up @@ -163,7 +213,7 @@ func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState
func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
defer close(entry.done)

ticker := time.NewTicker(toolFeedbackAnimationInterval)
ticker := time.NewTicker(a.getAnimationInterval())
defer ticker.Stop()

frameIdx := 1
Expand All @@ -176,16 +226,108 @@ func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationSt
if a.editFn == nil {
continue
}
if a.shouldSkipEdit(chatID) {
continue
}
frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)]
content := formatAnimatedToolFeedbackContent(entry.baseContent, frame)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = a.editFn(ctx, chatID, entry.messageID, content)
if err := a.editFn(ctx, chatID, entry.messageID, content); err != nil {
if delay, ok := a.retryAfterDelay(err); ok {
a.pauseEdits(chatID, delay)
}
} else {
a.markEdit(chatID)
}
cancel()
frameIdx++
}
}
}

func (a *ToolFeedbackAnimator) getAnimationInterval() time.Duration {
if a == nil {
return defaultToolFeedbackAnimationInterval
}
a.mu.Lock()
defer a.mu.Unlock()
if a.animationInterval > 0 {
return a.animationInterval
}
return defaultToolFeedbackAnimationInterval
}

func (a *ToolFeedbackAnimator) shouldSkipEdit(chatID string) bool {
if a == nil {
return true
}
now := time.Now()
a.mu.Lock()
defer a.mu.Unlock()
if until := a.editPausedTil[chatID]; until.After(now) {
return true
}
if a.minEditInterval <= 0 {
return false
}
if last := a.lastEditAt[chatID]; !last.IsZero() && now.Sub(last) < a.minEditInterval {
return true
}
return false
}

func (a *ToolFeedbackAnimator) markEdit(chatID string) {
if a == nil {
return
}
a.mu.Lock()
a.lastEditAt[chatID] = time.Now()
a.mu.Unlock()
}

func (a *ToolFeedbackAnimator) pauseEdits(chatID string, delay time.Duration) {
if a == nil || delay <= 0 {
return
}
a.mu.Lock()
a.editPausedTil[chatID] = time.Now().Add(delay)
a.mu.Unlock()
}

func (a *ToolFeedbackAnimator) retryAfterDelay(err error) (time.Duration, bool) {
if err == nil || a == nil {
return 0, false
}
a.mu.Lock()
minInterval := a.minEditInterval
a.mu.Unlock()
if minInterval <= 0 {
return 0, false
}
errText := strings.ToLower(err.Error())
if !errors.Is(err, ErrRateLimit) &&
!strings.Contains(errText, "too many requests") &&
!strings.Contains(errText, "429") {
return 0, false
}
match := retryAfterPattern.FindStringSubmatch(errText)
if len(match) != 2 {
return minInterval, true
}
seconds, parseErr := strconv.Atoi(match[1])
if parseErr != nil || seconds <= 0 {
return minInterval, true
}
return time.Duration(seconds) * time.Second, true
}

func isMessageNotModifiedError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "message is not modified")
}

func InitialAnimatedToolFeedbackContent(baseContent string) string {
return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame)
}
Expand Down
Loading