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:
shikihane 2026-03-10 12:30:50 +08:00
parent 9cd2d21800
commit f8598f50bd
8 changed files with 176 additions and 8 deletions

View file

@ -1199,6 +1199,16 @@ func (al *AgentLoop) runLLMIteration(
"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.
// When the background work completes, this publishes the result
// 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 len(r.result.Media) > 0 {
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}
}
// 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 ""
}

View file

@ -30,9 +30,10 @@ type InboundMessage struct {
}
type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
IsProgress bool `json:"is_progress,omitempty"`
}
// MediaPart describes a single media attachment to send.

View file

@ -311,6 +311,9 @@ func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }
// GetMediaStore returns the injected MediaStore (may be nil).
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.
func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {
c.placeholderRecorder = r

View file

@ -19,7 +19,7 @@ type FeishuChannel struct {
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
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(
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
)

View file

@ -11,8 +11,10 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@ -32,16 +34,26 @@ import (
type FeishuChannel struct {
*channels.BaseChannel
config config.FeishuConfig
features config.FeaturesConfig
client *lark.Client
wsClient *larkws.Client
botOpenID atomic.Value // stores string; populated lazily for @mention detection
mu sync.Mutex
cancel context.CancelFunc
mu sync.Mutex
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,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
@ -50,6 +62,7 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
ch := &FeishuChannel{
BaseChannel: base,
config: cfg,
features: features,
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
}
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)
}
// 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
cardContent, err := buildMarkdownCard(msg.Content)
if err != nil {
@ -424,6 +462,30 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
"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)
return nil
}

View file

@ -8,6 +8,6 @@ import (
func init() {
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)
})
}

View file

@ -59,10 +59,16 @@ type Config struct {
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
Features FeaturesConfig `json:"features"`
// BuildInfo contains build-time version information
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
type BuildInfo struct {
Version string `json:"version"`

View file

@ -510,6 +510,9 @@ func DefaultConfig() *Config {
Enabled: false,
MonitorUSB: true,
},
Features: FeaturesConfig{
Verbose: false,
},
BuildInfo: BuildInfo{
Version: Version,
GitCommit: GitCommit,