mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
#6022 stopped qoder from delivering interim narration as Result.Output, but hermes / kimi / kiro / traecli / grok still accumulate every MessageText into one builder and hand the whole thing to Result.Output — the same leak (#6006), since Result.Output becomes the channel reply and the auto-generated issue comment. Extract the boundary rule into acpDeliverableTracker (observe / result) and share it across all six backends instead of copying qoder's block five times: Result.Output keeps only the text after the latest tool call, a turn that ends on a tool call falls back to the latest non-empty text block so the reply is never empty, and provider-error detection keeps reading the full text stream. Covered by tracker unit tests and a cross-backend regression test that pins both scenarios on all six backends, over both tool-use emission paths (emitted at the tool call and deferred to tool completion). Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
69 lines
2.6 KiB
Go
69 lines
2.6 KiB
Go
package agent
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// acpDeliverableTracker splits an ACP turn's text stream into the final
|
|
// user-facing answer and the complete transcript.
|
|
//
|
|
// Result.Output is "final user-facing output selected by the backend" — it
|
|
// becomes the channel reply and the auto-generated issue comment, so interim
|
|
// narration ("Let me check the logs first…") must not reach it (#6006). ACP
|
|
// runtimes emit narration and the final answer as the same AgentMessageChunk
|
|
// type, and a tool call is the only boundary they expose, so the deliverable is
|
|
// the text emitted after the latest tool call. That boundary is a heuristic
|
|
// until the runtimes mark the final answer explicitly.
|
|
//
|
|
// The full transcript stays available because provider-error detection must
|
|
// keep reading every chunk: adapters inject their terminal "API call failed
|
|
// after N retries" give-up message as an ordinary agent turn, which can land
|
|
// before a tool call and would be invisible in the deliverable alone.
|
|
//
|
|
// The zero value is ready to use. A tracker is safe for concurrent use:
|
|
// backends call observe from the ACP stdout reader goroutine while the session
|
|
// goroutine calls result.
|
|
type acpDeliverableTracker struct {
|
|
mu sync.Mutex
|
|
// full is every text chunk of the turn, in arrival order.
|
|
full strings.Builder
|
|
// deliverable is the text accumulated since the latest tool call.
|
|
deliverable strings.Builder
|
|
// lastTextBlock is the most recent non-empty text block a tool call
|
|
// displaced; it is what a tool-terminated turn falls back to.
|
|
lastTextBlock string
|
|
}
|
|
|
|
// observe records one streamed message. Backends pass every message they
|
|
// accepted for the current turn; message types other than text and tool use
|
|
// carry no deliverable signal and are ignored.
|
|
func (t *acpDeliverableTracker) observe(msg Message) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
switch msg.Type {
|
|
case MessageText:
|
|
t.full.WriteString(msg.Content)
|
|
t.deliverable.WriteString(msg.Content)
|
|
case MessageToolUse:
|
|
if block := t.deliverable.String(); strings.TrimSpace(block) != "" {
|
|
t.lastTextBlock = block
|
|
}
|
|
t.deliverable.Reset()
|
|
}
|
|
}
|
|
|
|
// result returns the deliverable for Result.Output and the full text stream to
|
|
// scan for provider errors. A turn that ends on a tool call leaves no trailing
|
|
// text block, so the deliverable falls back to the latest non-empty one rather
|
|
// than delivering an empty reply.
|
|
func (t *acpDeliverableTracker) result() (deliverable, full string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
deliverable = t.deliverable.String()
|
|
if strings.TrimSpace(deliverable) == "" {
|
|
deliverable = t.lastTextBlock
|
|
}
|
|
return deliverable, t.full.String()
|
|
}
|