198 lines
5.8 KiB
Go
198 lines
5.8 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
var connectStreamCommandContext = exec.CommandContext
|
|
|
|
// Incremental forwarding: channels whose CLI can emit stream-json get their
|
|
// reply streamed into the AI card as it is produced (the hermes/openclaw
|
|
// experience), instead of one shot at the end.
|
|
//
|
|
// Verified stdout protocols (run live against each CLI):
|
|
// - "cc" (claude / codebuddy): JSONL; visible text arrives as
|
|
// {"type":"stream_event","event":{"type":"content_block_delta",
|
|
// "delta":{"type":"text_delta","text":"…"}}} (thinking_delta frames are
|
|
// internal reasoning and are NOT shown); the final authoritative text is
|
|
// {"type":"result","result":"…"}.
|
|
// - "qoder" (qodercli): JSONL; per-turn snapshots arrive as
|
|
// {"type":"assistant","message":{"content":[{"type":"text","text":"…"}]}}
|
|
// and the final answer as {"type":"result","subtype":"success",
|
|
// "message":{"content":[…]}}.
|
|
|
|
// canStream reports whether this forwarder has an incremental-output mode.
|
|
func (f *execForwarder) canStream() bool {
|
|
return len(f.streamArgv) > 0 && f.parser != ""
|
|
}
|
|
|
|
// forwardStream runs the agent in incremental-output mode, calling onDelta
|
|
// with the full visible text so far whenever it grows, and returns the final
|
|
// reply. Falls back to the one-shot forward when streaming is unsupported.
|
|
func (f *execForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
|
if !f.canStream() || onDelta == nil {
|
|
return f.forward(ctx, convID, text)
|
|
}
|
|
ctx, cancel := applyTimeout(ctx, f.timeout)
|
|
defer cancel()
|
|
|
|
run := func() (string, string, error) {
|
|
args := f.commandArgs(f.streamArgv, convID, text)
|
|
cmd := connectStreamCommandContext(ctx, f.streamArgv[0], args...)
|
|
f.configureCommand(cmd)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return "", err.Error(), err
|
|
}
|
|
var stderrBuf strings.Builder
|
|
cmd.Stderr = &stderrBuf
|
|
if err := cmd.Start(); err != nil {
|
|
return "", err.Error(), err
|
|
}
|
|
|
|
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
|
|
finalText := ""
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || !strings.HasPrefix(line, "{") {
|
|
continue
|
|
}
|
|
delta, final := parseStreamLine(f.parser, line)
|
|
if delta != "" {
|
|
acc.WriteString(delta)
|
|
onDelta(brandReply(f.name, acc.String()))
|
|
}
|
|
if final != "" {
|
|
finalText = final
|
|
}
|
|
}
|
|
waitErr := cmd.Wait()
|
|
|
|
if finalText == "" {
|
|
finalText = strings.TrimSpace(acc.String())
|
|
}
|
|
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
|
|
// forwarded as the answer (issue #14): return an actionable hint instead.
|
|
if agentReplyIsError(finalText) {
|
|
if f.hasSession(convID) {
|
|
f.sessions.reset(convID)
|
|
}
|
|
return agentBackendErrorReply(finalText), "", nil
|
|
}
|
|
if finalText != "" {
|
|
return brandReply(f.name, finalText), "", nil
|
|
}
|
|
if waitErr != nil {
|
|
msg := waitErr.Error()
|
|
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
|
|
msg = s
|
|
}
|
|
return "", msg, waitErr
|
|
}
|
|
return "(本地 agent 无文本输出)", "", nil
|
|
}
|
|
|
|
reply, msg, err := run()
|
|
if err == nil {
|
|
return reply, nil
|
|
}
|
|
if f.hasSession(convID) && agentSessionMissingError(msg) {
|
|
f.sessions.reset(convID)
|
|
reply, msg, err = run()
|
|
if err == nil {
|
|
return reply, nil
|
|
}
|
|
}
|
|
if f.hasSession(convID) {
|
|
f.sessions.reset(convID)
|
|
}
|
|
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
|
}
|
|
|
|
// parseStreamLine extracts (visible-text delta, final text) from one JSONL
|
|
// line, per parser protocol. Either return value may be empty.
|
|
func parseStreamLine(parser, line string) (delta, final string) {
|
|
switch parser {
|
|
case "cc":
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Event struct {
|
|
Type string `json:"type"`
|
|
Delta struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"delta"`
|
|
} `json:"event"`
|
|
Result string `json:"result"`
|
|
}
|
|
if json.Unmarshal([]byte(line), &ev) != nil {
|
|
return "", ""
|
|
}
|
|
switch ev.Type {
|
|
case "stream_event":
|
|
if ev.Event.Type == "content_block_delta" && ev.Event.Delta.Type == "text_delta" {
|
|
return ev.Event.Delta.Text, ""
|
|
}
|
|
case "result":
|
|
return "", strings.TrimSpace(ev.Result)
|
|
}
|
|
case "qoder":
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Subtype string `json:"subtype"`
|
|
Result string `json:"result"`
|
|
Message struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"message"`
|
|
}
|
|
if json.Unmarshal([]byte(line), &ev) != nil {
|
|
return "", ""
|
|
}
|
|
text := func() string {
|
|
var b strings.Builder
|
|
for _, c := range ev.Message.Content {
|
|
if c.Type == "text" {
|
|
b.WriteString(c.Text)
|
|
}
|
|
}
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
switch {
|
|
case ev.Type == "result" && ev.Subtype == "success":
|
|
if t := text(); t != "" {
|
|
return "", t
|
|
}
|
|
return "", strings.TrimSpace(ev.Result)
|
|
case ev.Type == "assistant":
|
|
if t := text(); t != "" {
|
|
// Turn-level snapshot: append as a paragraph.
|
|
return t + "\n\n", ""
|
|
}
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|