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,
|
||||
})
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -510,6 +510,9 @@ func DefaultConfig() *Config {
|
|||
Enabled: false,
|
||||
MonitorUSB: true,
|
||||
},
|
||||
Features: FeaturesConfig{
|
||||
Verbose: false,
|
||||
},
|
||||
BuildInfo: BuildInfo{
|
||||
Version: Version,
|
||||
GitCommit: GitCommit,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue