feat(feishu): add tool progress feedback with verbose toggle
- Send "Thinking..." placeholder card on message receive - Report each tool call and result as progress updates on the card - Accumulate progress lines in a single evolving card (EditMessage) - Add IsProgress flag to OutboundMessage for progress vs final distinction - Add Bus() accessor to BaseChannel for progress support in channel code - Add FeaturesConfig with Verbose flag to gate progress updates - When verbose=false (default), discard progress silently (old behavior) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9cd2d21800
commit
f8598f50bd
8 changed files with 176 additions and 8 deletions
|
|
@ -1199,6 +1199,16 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Send progress update to user
|
||||||
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Content: formatToolProgress(tc.Name, tc.Arguments),
|
||||||
|
IsProgress: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Create async callback for tools that implement AsyncExecutor.
|
// Create async callback for tools that implement AsyncExecutor.
|
||||||
// When the background work completes, this publishes the result
|
// When the background work completes, this publishes the result
|
||||||
// as an inbound system message so processSystemMessage routes it
|
// as an inbound system message so processSystemMessage routes it
|
||||||
|
|
@ -1271,6 +1281,18 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report tool result to user via progress update
|
||||||
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
|
if resultMsg := formatToolResult(r.tc.Name, r.tc.Arguments, r.result); resultMsg != "" {
|
||||||
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Content: resultMsg,
|
||||||
|
IsProgress: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If tool returned media refs, publish them as outbound media
|
// If tool returned media refs, publish them as outbound media
|
||||||
if len(r.result.Media) > 0 {
|
if len(r.result.Media) > 0 {
|
||||||
parts := make([]bus.MediaPart, 0, len(r.result.Media))
|
parts := make([]bus.MediaPart, 0, len(r.result.Media))
|
||||||
|
|
@ -1872,3 +1894,74 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
}
|
}
|
||||||
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatToolProgress formats a human-readable progress string for a tool call.
|
||||||
|
func formatToolProgress(toolName string, args map[string]any) string {
|
||||||
|
argStr := func(key string) string {
|
||||||
|
if v, ok := args[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch toolName {
|
||||||
|
case "write_file":
|
||||||
|
if path := argStr("path"); path != "" {
|
||||||
|
return fmt.Sprintf("Writing %s", path)
|
||||||
|
}
|
||||||
|
case "read_file":
|
||||||
|
if path := argStr("path"); path != "" {
|
||||||
|
return fmt.Sprintf("Reading %s", path)
|
||||||
|
}
|
||||||
|
case "edit_file":
|
||||||
|
if path := argStr("path"); path != "" {
|
||||||
|
return fmt.Sprintf("Editing %s", path)
|
||||||
|
}
|
||||||
|
case "exec":
|
||||||
|
if cmd := argStr("command"); cmd != "" {
|
||||||
|
return fmt.Sprintf("Executing %s", utils.Truncate(cmd, 80))
|
||||||
|
}
|
||||||
|
case "web_search":
|
||||||
|
if query := argStr("query"); query != "" {
|
||||||
|
return fmt.Sprintf("Searching %s", utils.Truncate(query, 60))
|
||||||
|
}
|
||||||
|
case "web_fetch":
|
||||||
|
if u := argStr("url"); u != "" {
|
||||||
|
return fmt.Sprintf("Fetching %s", utils.Truncate(u, 80))
|
||||||
|
}
|
||||||
|
case "spawn":
|
||||||
|
if label := argStr("label"); label != "" {
|
||||||
|
return fmt.Sprintf("Subtask %s", label)
|
||||||
|
}
|
||||||
|
case "message":
|
||||||
|
return "Sending message"
|
||||||
|
case "cron":
|
||||||
|
if action := argStr("action"); action != "" {
|
||||||
|
return fmt.Sprintf("Cron %s", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolName
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatToolResult formats a result summary for a completed tool call.
|
||||||
|
// Returns empty string if no result message is needed.
|
||||||
|
func formatToolResult(toolName string, args map[string]any, result *tools.ToolResult) string {
|
||||||
|
desc := formatToolProgress(toolName, args)
|
||||||
|
|
||||||
|
// Error: always report
|
||||||
|
if result.Err != nil {
|
||||||
|
errPreview := utils.Truncate(result.Err.Error(), 100)
|
||||||
|
return fmt.Sprintf("[%s] Failed: %s", desc, errPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec: show output summary
|
||||||
|
if toolName == "exec" && result.ForLLM != "" {
|
||||||
|
output := utils.Truncate(result.ForLLM, 120)
|
||||||
|
return fmt.Sprintf("[%s] Done:\n%s", desc, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ type OutboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
IsProgress bool `json:"is_progress,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaPart describes a single media attachment to send.
|
// MediaPart describes a single media attachment to send.
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,9 @@ func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }
|
||||||
// GetMediaStore returns the injected MediaStore (may be nil).
|
// GetMediaStore returns the injected MediaStore (may be nil).
|
||||||
func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }
|
func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }
|
||||||
|
|
||||||
|
// Bus returns the underlying MessageBus.
|
||||||
|
func (c *BaseChannel) Bus() *bus.MessageBus { return c.bus }
|
||||||
|
|
||||||
// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel.
|
// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel.
|
||||||
func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {
|
func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {
|
||||||
c.placeholderRecorder = r
|
c.placeholderRecorder = r
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ type FeishuChannel struct {
|
||||||
var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures")
|
var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures")
|
||||||
|
|
||||||
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
||||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
func NewFeishuChannel(cfg config.FeishuConfig, _ config.FeaturesConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||||
return nil, errors.New(
|
return nil, errors.New(
|
||||||
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
|
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
@ -32,6 +34,7 @@ import (
|
||||||
type FeishuChannel struct {
|
type FeishuChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.FeishuConfig
|
config config.FeishuConfig
|
||||||
|
features config.FeaturesConfig
|
||||||
client *lark.Client
|
client *lark.Client
|
||||||
wsClient *larkws.Client
|
wsClient *larkws.Client
|
||||||
|
|
||||||
|
|
@ -39,9 +42,18 @@ type FeishuChannel struct {
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
placeholders sync.Map // chatID -> *progressState
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
// progressState tracks the placeholder card and accumulated progress lines for a chat.
|
||||||
|
type progressState struct {
|
||||||
|
messageID string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeishuChannel(
|
||||||
|
cfg config.FeishuConfig, features config.FeaturesConfig, bus *bus.MessageBus,
|
||||||
|
) (*FeishuChannel, error) {
|
||||||
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
|
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
|
||||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
|
@ -50,6 +62,7 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
|
||||||
ch := &FeishuChannel{
|
ch := &FeishuChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
features: features,
|
||||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
||||||
}
|
}
|
||||||
ch.SetOwner(ch)
|
ch.SetOwner(ch)
|
||||||
|
|
@ -121,6 +134,31 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Progress update: append to the existing placeholder card instead of sending a new message.
|
||||||
|
if msg.IsProgress {
|
||||||
|
if !c.features.Verbose {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
val, ok := c.placeholders.Load(msg.ChatID)
|
||||||
|
if !ok {
|
||||||
|
// Placeholder already consumed (e.g. by final response); silently discard.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state := val.(*progressState)
|
||||||
|
state.lines = append(state.lines, "─ "+msg.Content)
|
||||||
|
combined := strings.Join(state.lines, "\n")
|
||||||
|
if err := c.EditMessage(ctx, msg.ChatID, state.messageID, combined); err != nil {
|
||||||
|
logger.DebugCF("feishu", "Failed to update progress card", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final response: consume placeholder (stop progress updates), then send as new message.
|
||||||
|
c.placeholders.Delete(msg.ChatID)
|
||||||
|
|
||||||
// Build interactive card with markdown content
|
// Build interactive card with markdown content
|
||||||
cardContent, err := buildMarkdownCard(msg.Content)
|
cardContent, err := buildMarkdownCard(msg.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -424,6 +462,30 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
||||||
"preview": utils.Truncate(content, 80),
|
"preview": utils.Truncate(content, 80),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Send thinking placeholder as interactive card before dispatching to agent.
|
||||||
|
// Using a card (not text) so that subsequent Message.Patch calls can update it.
|
||||||
|
placeholderCtx, placeholderCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer placeholderCancel()
|
||||||
|
|
||||||
|
initialText := "Thinking..."
|
||||||
|
cardContent, _ := buildMarkdownCard(initialText)
|
||||||
|
placeholderReq := larkim.NewCreateMessageReqBuilder().
|
||||||
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||||
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
|
ReceiveId(chatID).
|
||||||
|
MsgType(larkim.MsgTypeInteractive).
|
||||||
|
Content(cardContent).
|
||||||
|
Build()).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
placeholderResp, placeholderErr := c.client.Im.V1.Message.Create(placeholderCtx, placeholderReq)
|
||||||
|
if placeholderErr == nil && placeholderResp.Success() && placeholderResp.Data.MessageId != nil {
|
||||||
|
c.placeholders.Store(chatID, &progressState{
|
||||||
|
messageID: *placeholderResp.Data.MessageId,
|
||||||
|
lines: []string{initialText},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
|
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ import (
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
return NewFeishuChannel(cfg.Channels.Feishu, b)
|
return NewFeishuChannel(cfg.Channels.Feishu, cfg.Features, b)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,16 @@ type Config struct {
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
|
Features FeaturesConfig `json:"features"`
|
||||||
// BuildInfo contains build-time version information
|
// BuildInfo contains build-time version information
|
||||||
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeaturesConfig holds feature flags that control optional behavior across the system.
|
||||||
|
type FeaturesConfig struct {
|
||||||
|
Verbose bool `json:"verbose" env:"PICOCLAW_FEATURES_VERBOSE"`
|
||||||
|
}
|
||||||
|
|
||||||
// BuildInfo contains build-time version information
|
// BuildInfo contains build-time version information
|
||||||
type BuildInfo struct {
|
type BuildInfo struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,9 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MonitorUSB: true,
|
MonitorUSB: true,
|
||||||
},
|
},
|
||||||
|
Features: FeaturesConfig{
|
||||||
|
Verbose: false,
|
||||||
|
},
|
||||||
BuildInfo: BuildInfo{
|
BuildInfo: BuildInfo{
|
||||||
Version: Version,
|
Version: Version,
|
||||||
GitCommit: GitCommit,
|
GitCommit: GitCommit,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue