mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 06:17:41 +00:00
737 lines
24 KiB
Go
737 lines
24 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// HandleAgentEvent routes agent lifecycle events to streaming/reaction channels.
|
|
// Called from the bus event subscriber — must be non-blocking.
|
|
// eventType: "run.started", "chunk", "tool.call", "tool.result", "run.completed", "run.failed", "run.cancelled"
|
|
func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
|
val, ok := m.runs.Load(runID)
|
|
if !ok {
|
|
return
|
|
}
|
|
rc := val.(*RunContext)
|
|
|
|
m.mu.RLock()
|
|
ch, exists := m.channels[rc.ChannelName]
|
|
m.mu.RUnlock()
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
// Use RunContext's TenantID directly (set at RegisterRun time from channel instance)
|
|
// rather than querying the channel interface - more direct and future-proof for
|
|
// channels that might serve multiple tenants.
|
|
if rc.TenantID != uuid.Nil {
|
|
ctx = store.WithTenantID(ctx, rc.TenantID)
|
|
}
|
|
|
|
if eventType == protocol.AgentEventRunStarted {
|
|
m.scheduleQuickAck(rc)
|
|
}
|
|
|
|
// Forward to StreamingChannel (only when streaming is enabled for this run).
|
|
// Without this gate, channels that implement StreamingChannel but have streaming
|
|
// disabled (e.g. group_stream=false) would create stream messages AND emit
|
|
// block.reply outbound messages, causing duplicate delivery.
|
|
if sc, ok := ch.(StreamingChannel); ok && rc.Streaming {
|
|
switch eventType {
|
|
case protocol.AgentEventRunStarted:
|
|
firstStreamIsReasoning := rc.ReasoningDelivery.ShowInChannel && !rc.ReasoningDelivery.BubbleDelivery && sc.ReasoningStreamEnabled()
|
|
stream, err := sc.CreateStream(ctx, rc.ChatID, firstStreamIsReasoning)
|
|
if err != nil {
|
|
slog.Debug("stream start failed", "channel", rc.ChannelName, "error", err)
|
|
} else {
|
|
rc.mu.Lock()
|
|
rc.stream = stream
|
|
rc.mu.Unlock()
|
|
}
|
|
case protocol.ChatEventThinking:
|
|
// Accumulate thinking/reasoning content and route to the current stream.
|
|
// The stream created on run.started becomes the "reasoning lane":
|
|
// - DMs: edits the "Thinking..." placeholder with reasoning text
|
|
// - Groups: edits a fresh message with reasoning text
|
|
// When the first chunk arrives, this stream is stopped (reasoning message stays
|
|
// visible) and a new stream is created for the answer lane.
|
|
// Gated by ReasoningStreamEnabled() — channels can opt out (e.g. Slack).
|
|
content := extractPayloadString(payload, "content")
|
|
if content != "" {
|
|
if rc.ReasoningDelivery.BubbleDelivery {
|
|
m.appendReasoningBubble(runID, rc, content)
|
|
break
|
|
}
|
|
if !rc.ReasoningDelivery.ShowInChannel || !sc.ReasoningStreamEnabled() {
|
|
break
|
|
}
|
|
rc.mu.Lock()
|
|
rc.thinkingBuffer += content
|
|
rc.hasThinking = true
|
|
thinkText := rc.thinkingBuffer
|
|
currentStream := rc.stream
|
|
rc.mu.Unlock()
|
|
if currentStream != nil {
|
|
currentStream.Update(ctx, formatReasoningPreview(thinkText))
|
|
}
|
|
}
|
|
case protocol.AgentEventToolCall:
|
|
if rc.ReasoningDelivery.BubbleDelivery {
|
|
m.flushReasoningBubbles(runID)
|
|
}
|
|
// Agent is executing a tool — mark tool phase so the next chunk
|
|
// (new LLM iteration) resets the stream buffer.
|
|
// Stop the current stream (reasoning or answer) and finalize only
|
|
// the answer stream (reasoning messages stay visible).
|
|
rc.mu.Lock()
|
|
currentStream := rc.stream
|
|
rc.stream = nil
|
|
rc.inToolPhase = true
|
|
rc.thinkingDone = false // allow new thinking in next iteration
|
|
rc.thinkingBuffer = "" // reset thinking buffer for new iteration
|
|
rc.hasThinking = false // new iteration starts fresh
|
|
rc.tagParsePending = "" // discard any incomplete tag from the previous iteration
|
|
rc.mu.Unlock()
|
|
if currentStream != nil {
|
|
if err := currentStream.Stop(ctx); err != nil {
|
|
slog.Debug("stream tool-phase stop failed", "channel", rc.ChannelName, "error", err)
|
|
}
|
|
// Don't finalize mid-run streams — their messageID must NOT go
|
|
// into placeholders. Otherwise tool_status placeholder_update
|
|
// overwrites streamed content, and subsequent FinalizeStream
|
|
// calls overwrite the placeholder key, leaving earlier messages
|
|
// stuck at tool status text. Only run.completed finalizes.
|
|
}
|
|
|
|
toolName := extractPayloadString(payload, "name")
|
|
if toolName != "" && ShouldDeliverGeneratedProgress(rc.ChatBehavior, rc.Streaming) {
|
|
go m.sendIntermediateProgress(rc, toolName)
|
|
}
|
|
case protocol.ChatEventChunk:
|
|
// Accumulate chunk deltas into full text.
|
|
content := extractPayloadString(payload, "content")
|
|
if content != "" {
|
|
if rc.ReasoningDelivery.BubbleDelivery {
|
|
m.flushReasoningBubbles(runID)
|
|
}
|
|
rc.mu.Lock()
|
|
needNewStream := rc.inToolPhase
|
|
if needNewStream {
|
|
rc.streamBuffer = ""
|
|
rc.inToolPhase = false
|
|
}
|
|
|
|
// Fallback <think> tag parsing: for providers that embed thinking
|
|
// in the content stream (DeepSeek-via-OpenRouter, Qwen, some Ollama models).
|
|
// Only activates when no native ChatEventThinking was received.
|
|
if !rc.hasThinking && !rc.thinkingDone {
|
|
previousAnswer := rc.streamBuffer
|
|
candidate := rc.streamBuffer + rc.tagParsePending + content
|
|
split := SplitThinkTags(candidate)
|
|
if split.Found || split.Pending != "" {
|
|
// Found think tags — commit to buffer and route or suppress reasoning
|
|
// before any tagged content can leak into the answer lane.
|
|
displayReasoningInStream := rc.ReasoningDelivery.ShowInChannel && !rc.ReasoningDelivery.BubbleDelivery && sc.ReasoningStreamEnabled()
|
|
previousThinking := rc.thinkingBuffer
|
|
rc.thinkingBuffer = split.Thinking
|
|
thinkText := rc.thinkingBuffer
|
|
currentStream := rc.stream
|
|
if split.Partial || split.Pending != "" {
|
|
rc.streamBuffer = split.Answer
|
|
rc.tagParsePending = split.Pending
|
|
answerText := rc.streamBuffer
|
|
// Still inside <think> — wait for the close tag before streaming
|
|
// answer content. Native thinking uses hasThinking; tag parsing
|
|
// keeps it false until close so later chunks continue parsing.
|
|
rc.mu.Unlock()
|
|
if rc.ReasoningDelivery.BubbleDelivery {
|
|
if delta := reasoningDelta(previousThinking, thinkText); delta != "" {
|
|
m.appendReasoningBubble(runID, rc, delta)
|
|
}
|
|
} else if displayReasoningInStream && currentStream != nil {
|
|
currentStream.Update(ctx, formatReasoningPreview(thinkText))
|
|
} else if currentStream != nil {
|
|
currentStream.Update(ctx, answerText)
|
|
}
|
|
break
|
|
}
|
|
rc.tagParsePending = ""
|
|
// Tag closed — transition to answer, or strip reasoning entirely
|
|
// when Show Reasoning is off.
|
|
answerText := split.Answer
|
|
rc.thinkingDone = true
|
|
rc.hasThinking = displayReasoningInStream
|
|
rc.streamBuffer = answerText
|
|
reasoningStream := currentStream
|
|
rc.mu.Unlock()
|
|
|
|
if rc.ReasoningDelivery.BubbleDelivery {
|
|
if delta := reasoningDelta(previousThinking, thinkText); delta != "" {
|
|
m.appendReasoningBubble(runID, rc, delta)
|
|
}
|
|
m.flushReasoningBubbles(runID)
|
|
}
|
|
|
|
if previousAnswer != "" || !displayReasoningInStream {
|
|
if reasoningStream != nil && answerText != "" {
|
|
reasoningStream.Update(ctx, answerText)
|
|
}
|
|
break
|
|
}
|
|
|
|
// Stop reasoning stream after showing the final extracted thinking.
|
|
if reasoningStream != nil {
|
|
reasoningStream.Update(ctx, formatReasoningPreview(thinkText))
|
|
_ = reasoningStream.Stop(ctx)
|
|
}
|
|
// Create answer stream
|
|
stream, err := sc.CreateStream(ctx, rc.ChatID, false)
|
|
if err != nil {
|
|
slog.Debug("stream restart after think-tag failed", "channel", rc.ChannelName, "error", err)
|
|
} else {
|
|
rc.mu.Lock()
|
|
rc.stream = stream
|
|
rc.mu.Unlock()
|
|
}
|
|
// Update answer stream with extracted answer content
|
|
if answerText != "" {
|
|
rc.mu.Lock()
|
|
currentStream = rc.stream
|
|
rc.mu.Unlock()
|
|
if currentStream != nil {
|
|
currentStream.Update(ctx, answerText)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Reasoning→answer transition: first chunk after native thinking events.
|
|
// Stop the reasoning stream (keep message visible) and create a
|
|
// new stream for the answer lane.
|
|
needTransition := rc.hasThinking && !rc.thinkingDone
|
|
if needTransition {
|
|
rc.thinkingDone = true
|
|
rc.streamBuffer = "" // fresh answer buffer
|
|
}
|
|
reasoningStream := rc.stream
|
|
rc.mu.Unlock()
|
|
|
|
// Finalize reasoning stream (stop editing, keep message)
|
|
if needTransition && reasoningStream != nil {
|
|
_ = reasoningStream.Stop(ctx)
|
|
// Don't call FinalizeStream — reasoning messageID should NOT
|
|
// go into placeholders. Send() must edit the answer message.
|
|
}
|
|
|
|
// Create fresh stream for answer (or new tool iteration)
|
|
if needNewStream || needTransition {
|
|
stream, err := sc.CreateStream(ctx, rc.ChatID, false)
|
|
if err != nil {
|
|
slog.Debug("stream restart failed", "channel", rc.ChannelName, "error", err)
|
|
} else {
|
|
rc.mu.Lock()
|
|
rc.stream = stream
|
|
rc.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
rc.mu.Lock()
|
|
rc.streamBuffer += content
|
|
fullText := rc.streamBuffer
|
|
currentStream := rc.stream
|
|
rc.mu.Unlock()
|
|
if currentStream != nil {
|
|
currentStream.Update(ctx, fullText)
|
|
}
|
|
}
|
|
case protocol.AgentEventRunCompleted:
|
|
rc.mu.Lock()
|
|
currentStream := rc.stream
|
|
rc.stream = nil
|
|
rc.mu.Unlock()
|
|
if currentStream != nil {
|
|
if err := currentStream.Stop(ctx); err != nil {
|
|
slog.Debug("stream end failed", "channel", rc.ChannelName, "error", err)
|
|
}
|
|
sc.FinalizeStream(ctx, rc.ChatID, currentStream)
|
|
}
|
|
case protocol.AgentEventRunFailed:
|
|
// Clean up streaming state on failure
|
|
rc.mu.Lock()
|
|
currentStream := rc.stream
|
|
rc.stream = nil
|
|
rc.mu.Unlock()
|
|
if currentStream != nil {
|
|
_ = currentStream.Stop(ctx)
|
|
}
|
|
// Issue 958: Send user-friendly error message instead of silent "..."
|
|
errStr := extractPayloadString(payload, "error")
|
|
if friendlyMsg := FormatAgentError(errStr); friendlyMsg != "" {
|
|
m.bus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: rc.ChannelName,
|
|
ChatID: rc.ChatID,
|
|
Content: friendlyMsg,
|
|
TenantID: rc.TenantID,
|
|
})
|
|
}
|
|
case protocol.AgentEventRunCancelled:
|
|
// Clean up streaming state on cancellation
|
|
rc.mu.Lock()
|
|
currentStream := rc.stream
|
|
rc.stream = nil
|
|
rc.mu.Unlock()
|
|
if currentStream != nil {
|
|
_ = currentStream.Stop(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
if eventType == protocol.AgentEventToolCall {
|
|
toolName := extractPayloadString(payload, "name")
|
|
if toolName != "" && ShouldDeliverGeneratedProgress(rc.ChatBehavior, rc.Streaming) {
|
|
go m.sendIntermediateProgress(rc, toolName)
|
|
}
|
|
}
|
|
|
|
if !rc.Streaming && rc.ReasoningDelivery.BubbleDelivery {
|
|
switch eventType {
|
|
case protocol.ChatEventThinking:
|
|
content := extractPayloadString(payload, "content")
|
|
if content != "" {
|
|
m.appendReasoningBubble(runID, rc, content)
|
|
}
|
|
return
|
|
case protocol.ChatEventChunk:
|
|
content := extractPayloadString(payload, "content")
|
|
if content != "" {
|
|
m.appendReasoningBubbleFromThinkTagChunk(runID, rc, content)
|
|
}
|
|
m.flushReasoningBubbles(runID)
|
|
return
|
|
case protocol.AgentEventToolCall, protocol.AgentEventRunCompleted, protocol.AgentEventRunFailed, protocol.AgentEventRunCancelled:
|
|
m.flushReasoningBubbles(runID)
|
|
}
|
|
}
|
|
|
|
// Handle block.reply: deliver intermediate assistant text to non-streaming channels.
|
|
// Gated by explicit block_reply or generated-progress chat behavior.
|
|
// Streaming channels already deliver via chunks, so skip to avoid double-delivery.
|
|
if eventType == protocol.AgentEventBlockReply {
|
|
content := extractPayloadString(payload, "content")
|
|
if content == "" {
|
|
return
|
|
}
|
|
source := extractPayloadString(payload, "source")
|
|
rc.mu.Lock()
|
|
streaming := rc.Streaming
|
|
rc.blockReplySeen++
|
|
isInitialBlockReply := rc.blockReplySeen == 1
|
|
blockReplyEnabled := rc.BlockReplyEnabled
|
|
chatBehavior := rc.ChatBehavior
|
|
rc.mu.Unlock()
|
|
|
|
if streaming {
|
|
return // streaming already delivered via chunks
|
|
}
|
|
if !blockReplyEnabled {
|
|
return
|
|
}
|
|
isToolAnnouncement := source == protocol.BlockReplySourceToolAnnouncement
|
|
if isInitialBlockReply && !isToolAnnouncement && blockReplyEnabled && ShouldSuppressInitialBlockReply(chatBehavior, streaming) {
|
|
return
|
|
}
|
|
|
|
m.cancelQuickAck(rc)
|
|
rc.mu.Lock()
|
|
rc.blockReplySent = true
|
|
rc.interimDelivered++
|
|
rc.lastInterimReply = content
|
|
rc.mu.Unlock()
|
|
|
|
// Build outbound metadata: copy routing fields but strip reply_to_message_id
|
|
// (block replies are standalone) and placeholder_key (reserve for final message).
|
|
// feishu_reply_target_id MUST be preserved so intermediate block replies for
|
|
// threaded Lark messages also land inside the same thread.
|
|
var outMeta map[string]string
|
|
if rc.Metadata != nil {
|
|
outMeta = make(map[string]string)
|
|
for _, k := range routingMetaKeys {
|
|
if v := rc.Metadata[k]; v != "" {
|
|
outMeta[k] = v
|
|
}
|
|
}
|
|
if len(outMeta) == 0 {
|
|
outMeta = nil
|
|
}
|
|
}
|
|
|
|
m.bus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: rc.ChannelName,
|
|
ChatID: rc.ChatID,
|
|
Content: content,
|
|
Metadata: outMeta,
|
|
TenantID: rc.TenantID,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Handle LLM retry: update placeholder to notify user
|
|
if eventType == protocol.AgentEventRunRetrying {
|
|
attempt := extractPayloadString(payload, "attempt")
|
|
maxAttempts := extractPayloadString(payload, "maxAttempts")
|
|
retryMsg := fmt.Sprintf("Provider busy, retrying... (%s/%s)", attempt, maxAttempts)
|
|
m.bus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: rc.ChannelName,
|
|
ChatID: rc.ChatID,
|
|
Content: retryMsg,
|
|
TenantID: rc.TenantID,
|
|
Metadata: map[string]string{
|
|
"placeholder_update": "true",
|
|
},
|
|
})
|
|
}
|
|
|
|
// Forward to ReactionChannel
|
|
if reactionCh, ok := ch.(ReactionChannel); ok {
|
|
status := ""
|
|
switch eventType {
|
|
case protocol.AgentEventRunStarted:
|
|
status = "thinking"
|
|
case protocol.AgentEventToolCall:
|
|
// Use tool-specific reaction statuses to activate existing variants
|
|
// (web → ⚡, coding → 👨💻) that are already defined in channel reaction maps.
|
|
toolName := extractPayloadString(payload, "name")
|
|
status = resolveToolReactionStatus(toolName)
|
|
case protocol.AgentEventRunCompleted:
|
|
status = "done"
|
|
case protocol.AgentEventRunFailed:
|
|
// Issue 3: transient provider failures (rate limit, timeout, overload)
|
|
// already surface a friendly retry message via FormatAgentError — a
|
|
// terminal 💔 reaction would be misleading. Clear any interim reaction
|
|
// instead; permanent errors keep the 💔.
|
|
if isTransientFailureMessage(extractPayloadString(payload, "error")) {
|
|
if err := reactionCh.ClearReaction(ctx, rc.ChatID, rc.MessageID); err != nil {
|
|
slog.Debug("reaction clear failed", "channel", rc.ChannelName, "error", err)
|
|
}
|
|
status = ""
|
|
} else {
|
|
status = "error"
|
|
}
|
|
case protocol.AgentEventRunCancelled:
|
|
status = "done"
|
|
}
|
|
if status != "" {
|
|
if err := reactionCh.OnReactionEvent(ctx, rc.ChatID, rc.MessageID, status); err != nil {
|
|
slog.Debug("reaction event failed", "channel", rc.ChannelName, "status", status, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forward to ActivityIndicatorChannel — ephemeral "agent is working" indicator
|
|
// (e.g. Bitrix24 imbot InputAction.notify). Events set the status content; a
|
|
// conditional heartbeat ticker (started here) fills LLM-inference gaps. Best-effort:
|
|
// fireActivity never blocks and drops on error/rate-limit.
|
|
if actCh, ok := ch.(ActivityIndicatorChannel); ok {
|
|
switch eventType {
|
|
case protocol.AgentEventRunStarted:
|
|
m.fireActivity(rc, actCh, ActivityStatusThinking)
|
|
m.startActivityTicker(rc, actCh)
|
|
case protocol.AgentEventToolCall:
|
|
m.fireActivity(rc, actCh, resolveToolActivityStatus(extractPayloadString(payload, "name")))
|
|
case protocol.AgentEventToolResult:
|
|
m.fireActivity(rc, actCh, ActivityStatusAnalyzing)
|
|
}
|
|
}
|
|
|
|
// Clean up on terminal events
|
|
if eventType == protocol.AgentEventRunCompleted || eventType == protocol.AgentEventRunFailed || eventType == protocol.AgentEventRunCancelled {
|
|
m.cancelQuickAck(rc)
|
|
m.stopActivityTicker(rc)
|
|
rc.mu.Lock()
|
|
stopReasoningBubbleTimerLocked(rc)
|
|
rc.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func (m *Manager) scheduleQuickAck(rc *RunContext) {
|
|
if !ShouldSendQuickAck(rc.ChatBehavior, rc.Streaming) {
|
|
return
|
|
}
|
|
delay := time.Duration(rc.ChatBehavior.QuickAck.MinDelayMs) * time.Millisecond
|
|
if delay <= 0 {
|
|
m.sendQuickAck(rc)
|
|
return
|
|
}
|
|
rc.mu.Lock()
|
|
if rc.ackTimer == nil && !rc.ackSent && !rc.blockReplySent {
|
|
rc.ackTimer = time.AfterFunc(delay, func() {
|
|
m.sendQuickAck(rc)
|
|
})
|
|
}
|
|
rc.mu.Unlock()
|
|
}
|
|
|
|
func (m *Manager) cancelQuickAck(rc *RunContext) {
|
|
rc.mu.Lock()
|
|
rc.ackCancelled = true
|
|
if rc.ackTimer != nil {
|
|
rc.ackTimer.Stop()
|
|
rc.ackTimer = nil
|
|
}
|
|
rc.mu.Unlock()
|
|
}
|
|
|
|
func (m *Manager) sendQuickAck(rc *RunContext) {
|
|
rc.mu.Lock()
|
|
if rc.ackCancelled || rc.ackSent || rc.blockReplySent || !ShouldSendQuickAck(rc.ChatBehavior, rc.Streaming) {
|
|
rc.mu.Unlock()
|
|
return
|
|
}
|
|
mode := effectiveQuickAckMode(rc.ChatBehavior.QuickAck.Mode)
|
|
generator := rc.Delivery.QuickAckGenerator
|
|
request := rc.deliveryRequestLocked(DeliveryPurposeQuickAck, "")
|
|
templates := append([]string(nil), rc.ChatBehavior.QuickAck.Templates...)
|
|
rc.ackSent = true
|
|
rc.ackTimer = nil
|
|
rc.mu.Unlock()
|
|
|
|
content := ""
|
|
if mode == QuickAckModeLLMGenerated || mode == QuickAckModeSidecar {
|
|
if generator == nil {
|
|
slog.Warn("channel delivery quick ack generator unavailable",
|
|
"channel", rc.ChannelName, "purpose", request.Purpose)
|
|
return
|
|
}
|
|
generated, err := generator.GenerateDeliveryMessage(context.Background(), request)
|
|
if err != nil {
|
|
slog.Warn("channel delivery quick ack generation failed",
|
|
"channel", rc.ChannelName, "purpose", request.Purpose, "error", err)
|
|
return
|
|
}
|
|
if generated == "" {
|
|
slog.Warn("channel delivery quick ack generation empty",
|
|
"channel", rc.ChannelName, "purpose", request.Purpose)
|
|
return
|
|
}
|
|
content = generated
|
|
} else {
|
|
if len(templates) == 0 {
|
|
return
|
|
}
|
|
content = sanitizeDeliveryMessage(templates[0], request.MaxChars)
|
|
if content == "" {
|
|
return
|
|
}
|
|
}
|
|
|
|
rc.mu.Lock()
|
|
if rc.ackCancelled || rc.blockReplySent {
|
|
rc.mu.Unlock()
|
|
return
|
|
}
|
|
rc.mu.Unlock()
|
|
|
|
m.bus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: rc.ChannelName,
|
|
ChatID: rc.ChatID,
|
|
Content: content,
|
|
Metadata: copyRoutingMeta(rc.Metadata),
|
|
TenantID: rc.TenantID,
|
|
})
|
|
}
|
|
|
|
func (m *Manager) sendIntermediateProgress(rc *RunContext, toolName string) {
|
|
rc.mu.Lock()
|
|
if rc.ackCancelled || rc.blockReplySent || !ShouldDeliverGeneratedProgress(rc.ChatBehavior, rc.Streaming) {
|
|
rc.mu.Unlock()
|
|
return
|
|
}
|
|
generator := rc.Delivery.ProgressGenerator
|
|
request := rc.deliveryRequestLocked(DeliveryPurposeProgress, toolName)
|
|
rc.mu.Unlock()
|
|
|
|
content := ""
|
|
if generator == nil {
|
|
slog.Warn("channel delivery progress generator unavailable",
|
|
"channel", rc.ChannelName, "purpose", request.Purpose)
|
|
return
|
|
} else if generated, err := generator.GenerateDeliveryMessage(context.Background(), request); err != nil {
|
|
slog.Warn("channel delivery progress generation failed",
|
|
"channel", rc.ChannelName, "purpose", request.Purpose, "error", err)
|
|
return
|
|
} else if generated == "" {
|
|
slog.Warn("channel delivery progress generation empty",
|
|
"channel", rc.ChannelName, "purpose", request.Purpose)
|
|
return
|
|
} else {
|
|
content = generated
|
|
}
|
|
|
|
rc.mu.Lock()
|
|
if rc.ackCancelled || rc.blockReplySent {
|
|
rc.mu.Unlock()
|
|
return
|
|
}
|
|
rc.ackCancelled = true
|
|
if rc.ackTimer != nil {
|
|
rc.ackTimer.Stop()
|
|
rc.ackTimer = nil
|
|
}
|
|
rc.blockReplySent = true
|
|
rc.interimDelivered++
|
|
rc.lastInterimReply = content
|
|
rc.mu.Unlock()
|
|
|
|
m.bus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: rc.ChannelName,
|
|
ChatID: rc.ChatID,
|
|
Content: content,
|
|
Metadata: copyRoutingMeta(rc.Metadata),
|
|
TenantID: rc.TenantID,
|
|
})
|
|
}
|
|
|
|
func (rc *RunContext) deliveryRequestLocked(purpose, toolName string) DeliveryMessageRequest {
|
|
maxTokens := rc.ChatBehavior.QuickAck.MaxTokens
|
|
maxChars := rc.ChatBehavior.QuickAck.MaxChars
|
|
timeout := rc.ChatBehavior.QuickAck.Timeout
|
|
if purpose == DeliveryPurposeProgress {
|
|
maxTokens = rc.ChatBehavior.IntermediateReplies.MaxTokens
|
|
maxChars = rc.ChatBehavior.IntermediateReplies.MaxChars
|
|
timeout = rc.ChatBehavior.IntermediateReplies.Timeout
|
|
}
|
|
return DeliveryMessageRequest{
|
|
Purpose: purpose,
|
|
UserMessage: rc.Delivery.Inbound,
|
|
Locale: rc.Delivery.Locale,
|
|
PeerKind: rc.Delivery.PeerKind,
|
|
ChannelType: rc.Delivery.Channel,
|
|
AgentName: rc.Delivery.AgentName,
|
|
ToolName: toolName,
|
|
PersonaBrief: rc.Delivery.PersonaBrief,
|
|
MaxTokens: maxTokens,
|
|
MaxChars: maxChars,
|
|
Timeout: timeout,
|
|
}
|
|
}
|
|
|
|
// extractPayloadString extracts a string field from a payload (map[string]string or map[string]interface{}).
|
|
func extractPayloadString(payload any, key string) string {
|
|
switch p := payload.(type) {
|
|
case map[string]string:
|
|
return p[key]
|
|
case map[string]any:
|
|
if v, ok := p[key].(string); ok {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// toolStatusMap maps builtin tool names to user-friendly status messages.
|
|
var toolStatusMap = map[string]string{
|
|
// Filesystem
|
|
"read_file": "📝 Reading file...",
|
|
"write_file": "📝 Writing file...",
|
|
"list_files": "📝 Listing files...",
|
|
"edit": "📝 Editing file...",
|
|
// Runtime
|
|
"exec": "⚡ Running code...",
|
|
// Web
|
|
"web_search": "🔍 Searching the web...",
|
|
"web_fetch": "🔍 Fetching web content...",
|
|
// Memory
|
|
"memory_search": "🧠 Searching memory...",
|
|
"memory_get": "🧠 Retrieving memory...",
|
|
"knowledge_graph_search": "🧠 Querying knowledge graph...",
|
|
// Media
|
|
"read_image": "👁 Analyzing image...",
|
|
"read_document": "📄 Reading document...",
|
|
"read_audio": "🎧 Processing audio...",
|
|
"read_video": "🎬 Processing video...",
|
|
"create_image": "🎨 Creating image...",
|
|
"create_video": "🎬 Creating video...",
|
|
"create_audio": "🎵 Creating audio...",
|
|
"tts": "🔊 Generating speech...",
|
|
// Browser
|
|
"browser": "🌐 Browsing...",
|
|
// Delegation & teams
|
|
"spawn": "👥 Delegating task...",
|
|
"team_tasks": "📋 Managing team tasks...",
|
|
// Sessions
|
|
"sessions_list": "📋 Listing sessions...",
|
|
"session_status": "📋 Checking session...",
|
|
"sessions_history": "📋 Reading history...",
|
|
"sessions_send": "📤 Sending message...",
|
|
// Other
|
|
"message": "📤 Sending message...",
|
|
"cron": "⏰ Managing schedule...",
|
|
"skill_search": "🔍 Searching skills...",
|
|
"use_skill": "🧩 Using skill...",
|
|
"mcp_tool_search": "🔌 Searching MCP tools...",
|
|
}
|
|
|
|
// toolPrefixStatus maps tool name prefixes to status messages (fallback for dynamic tools).
|
|
var toolPrefixStatus = []struct {
|
|
prefix string
|
|
status string
|
|
}{
|
|
{"mcp_", "🔌 Using external tool..."},
|
|
}
|
|
|
|
// formatToolStatus returns a user-friendly status message for a tool name.
|
|
func formatToolStatus(toolName string) string {
|
|
if s, ok := toolStatusMap[toolName]; ok {
|
|
return s
|
|
}
|
|
for _, p := range toolPrefixStatus {
|
|
if strings.HasPrefix(toolName, p.prefix) {
|
|
return p.status
|
|
}
|
|
}
|
|
return "🔧 Running " + toolName + "..."
|
|
}
|
|
|
|
// formatReasoningPreview formats accumulated thinking text for display as a
|
|
// streaming reasoning message. Uses markdown italic prefix so channels that
|
|
// convert markdown (Telegram, Slack) show "Reasoning:" in italics.
|
|
// Truncated to 4096 runes (Telegram limit, rune-safe for CJK/emoji).
|
|
func formatReasoningPreview(thinking string) string {
|
|
if thinking == "" {
|
|
return ""
|
|
}
|
|
const maxRunes = 4096
|
|
text := "_Reasoning:_\n" + thinking
|
|
runes := []rune(text)
|
|
if len(runes) > maxRunes {
|
|
text = string(runes[:maxRunes-3]) + "..."
|
|
}
|
|
return text
|
|
}
|
|
|
|
// resolveToolReactionStatus maps a tool name to a reaction status string.
|
|
// Returns tool-specific statuses ("web", "coding") that activate existing
|
|
// but previously unused reaction variants in channel implementations.
|
|
func resolveToolReactionStatus(toolName string) string {
|
|
switch {
|
|
case strings.HasPrefix(toolName, "web") || toolName == "browser":
|
|
return "web"
|
|
case toolName == "exec":
|
|
return "coding"
|
|
default:
|
|
return "tool"
|
|
}
|
|
}
|