Files
dingtalk-workspace-cli/internal/helpers/connect_qoder_stream.go

536 lines
13 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"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
const qoderInitializeTimeout = 60 * time.Second
var (
qoderAbsPath = filepath.Abs
qoderExecCommand = exec.Command
qoderUUIDString = uuid.NewString
)
// qoderStreamForwarder keeps one qodercli stream-json subprocess alive for the
// lifetime of `dws dev connect`. DWS sends each DingTalk turn as a JSON user
// message with a per-conversation session_id, so Qoder keeps context without a
// fresh CLI startup on every chat message.
type qoderStreamForwarder struct {
name string
bin string
env []string
timeout time.Duration
workDir string
model string
yolo bool
sessions *convSessions
mu sync.Mutex
cmd *exec.Cmd
stdin io.WriteCloser
lines chan string
done chan error
stderr *lockedStringBuffer
}
func newQoderStreamForwarder(name, bin string, env []string, timeout time.Duration, opts connectAgentOptions, sessions *convSessions) forwarder {
return &qoderStreamForwarder{
name: name,
bin: bin,
env: env,
timeout: timeout,
workDir: opts.WorkDir,
model: opts.Model,
yolo: opts.Yolo,
sessions: sessions,
}
}
func (f *qoderStreamForwarder) canStream() bool { return true }
func (f *qoderStreamForwarder) label() string {
memo := "stateless"
if f.sessions != nil {
memo = "stream-session-memory"
}
return fmt.Sprintf("qoder-stream:%s (%s)", f.bin, memo)
}
func (f *qoderStreamForwarder) forward(ctx context.Context, convID, text string) (string, error) {
return f.forwardStream(ctx, convID, text, nil)
}
// forwardWithAttachments uses qodercli's native --attachment transport for a
// media turn. The persistent stream-json protocol has no documented file-part
// shape, so sending the same session through a one-shot CLI process is the
// only reliable way to provide the original bytes without enabling broad file
// tools for every ordinary chat message.
func (f *qoderStreamForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
if len(attachments) == 0 {
return f.forward(ctx, convID, text)
}
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
f.mu.Lock()
defer f.mu.Unlock()
args := []string{"--print", "--output-format", "text", "--max-turns", "30"}
if f.sessions != nil {
args = append(args, f.sessions.args(convID)...)
}
if f.yolo {
args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions")
} else {
args = append(args, "--system-prompt", "", "--setting-sources", "", "--tools", "")
}
if f.model != "" {
args = append(args, "--model", f.model)
}
for _, attachment := range attachments {
if path := strings.TrimSpace(attachment.LocalPath); path != "" {
args = append(args, "--attachment", path)
}
}
args = append(args, "-p", text)
cmd := exec.CommandContext(ctx, f.bin, args...)
cmd.Dir = f.cwd()
cmd.Env = append(os.Environ(), f.env...)
out, err := cmd.Output()
reply := strings.TrimSpace(string(out))
if reply != "" && !agentReplyIsError(reply) {
return brandReply(f.name, reply), nil
}
if reply != "" {
return agentBackendErrorReply(reply), nil
}
if err != nil {
if f.sessions != nil {
f.sessions.reset(convID)
}
return "", fmt.Errorf("本地 %s agent 附件调用失败:%s", f.name, truncateRunes(execErrorMessage(err), 300))
}
return "(本地 agent 无文本输出)", nil
}
func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
f.mu.Lock()
defer f.mu.Unlock()
if err := f.ensureLocked(ctx); err != nil {
return "", err
}
sessionID := qoderUUIDString()
if f.sessions != nil {
sessionID = f.sessions.id(convID)
}
msg := map[string]any{
"type": "user",
"message": map[string]any{"role": "user", "content": text},
"parent_tool_use_id": nil,
"session_id": sessionID,
}
if err := f.writeJSONLocked(msg); err != nil {
f.closeLocked()
return "", fmt.Errorf("写入 Qoder 请求失败:%w", err)
}
var acc strings.Builder
for {
line, err := f.readLineLocked(ctx)
if err != nil {
f.closeLocked()
if f.sessions != nil {
f.sessions.reset(convID)
}
return "", err
}
if f.handleControlRequestLocked(line) {
continue
}
delta, final, done := parseQoderPersistentLine(line)
if delta != "" {
acc.WriteString(delta)
if onDelta != nil {
onDelta(brandReply(f.name, acc.String()))
}
}
if !done {
continue
}
final = strings.TrimSpace(final)
if final == "" {
final = strings.TrimSpace(acc.String())
}
if agentReplyIsError(final) {
if f.sessions != nil {
f.sessions.reset(convID)
}
return agentBackendErrorReply(final), nil
}
if final == "" {
return "(本地 agent 无文本输出)", nil
}
return brandReply(f.name, final), nil
}
}
func (f *qoderStreamForwarder) resetSession(convID string) {
if f.sessions != nil {
f.sessions.reset(convID)
}
}
func (f *qoderStreamForwarder) close() error {
f.mu.Lock()
defer f.mu.Unlock()
return f.closeLocked()
}
func (f *qoderStreamForwarder) commandArgs() []string {
args := []string{
"--print",
"--output-format", "stream-json",
"--input-format", "stream-json",
}
if f.yolo {
args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions")
} else {
args = append(args,
"--system-prompt", "",
"--setting-sources", "",
"--tools", "",
)
}
if f.model != "" {
args = append(args, "--model", f.model)
}
return args
}
func (f *qoderStreamForwarder) cwd() string {
if f.workDir == "" {
return connectWorkDir()
}
if abs, err := qoderAbsPath(f.workDir); err == nil {
return abs
}
return f.workDir
}
func (f *qoderStreamForwarder) ensureLocked(ctx context.Context) error {
if f.cmd != nil {
select {
case err := <-f.done:
msg := ""
if f.stderr != nil {
msg = strings.TrimSpace(f.stderr.String())
}
f.clearProcessLocked()
if err != nil {
if msg != "" {
fmt.Fprintf(os.Stderr, "[connect][qoder] stream-json 进程已退出,尝试重启:%s\n", truncateRunes(msg, 300))
} else {
fmt.Fprintf(os.Stderr, "[connect][qoder] stream-json 进程已退出,尝试重启:%v\n", err)
}
} else {
fmt.Fprintln(os.Stderr, "[connect][qoder] stream-json 进程已退出,尝试重启")
}
default:
return nil
}
}
cmd := qoderExecCommand(f.bin, f.commandArgs()...)
cmd.Dir = f.cwd()
cmd.Env = append(os.Environ(), f.env...)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr := &lockedStringBuffer{}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 Qoder stream-json 失败:%w", err)
}
f.cmd = cmd
f.stdin = stdin
f.lines = make(chan string, 1024)
f.done = make(chan error, 1)
f.stderr = stderr
go scanQoderLines(stdout, f.lines)
go func() {
f.done <- cmd.Wait()
}()
return f.initializeLocked(ctx)
}
func (f *qoderStreamForwarder) initializeLocked(parent context.Context) error {
ctx, cancel := context.WithTimeout(parent, qoderInitializeTimeout)
defer cancel()
requestID := "dws_init_" + qoderUUIDString()
request := map[string]any{
"type": "control_request",
"request_id": requestID,
"request": map[string]any{
"type": "initialize",
"subtype": "initialize",
"modelPolicyProvider": false,
"initializeTimeoutMs": int(qoderInitializeTimeout / time.Millisecond),
},
}
if err := f.writeJSONLocked(request); err != nil {
f.closeLocked()
return fmt.Errorf("初始化 Qoder stream-json 失败:%w", err)
}
for {
line, err := f.readLineLocked(ctx)
if err != nil {
f.closeLocked()
return err
}
if f.handleControlRequestLocked(line) {
continue
}
matched, err := qoderControlResponse(line, requestID)
if err != nil {
f.closeLocked()
return err
}
if matched {
return nil
}
}
}
func (f *qoderStreamForwarder) writeJSONLocked(v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
_, err = f.stdin.Write(append(data, '\n'))
return err
}
func (f *qoderStreamForwarder) readLineLocked(ctx context.Context) (string, error) {
for {
select {
case line, ok := <-f.lines:
if !ok {
msg := "Qoder stream-json stdout 已关闭"
if f.stderr != nil {
if s := strings.TrimSpace(f.stderr.String()); s != "" {
msg = s
}
}
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
}
line = strings.TrimSpace(line)
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
return line, nil
case err := <-f.done:
msg := ""
if f.stderr != nil {
msg = strings.TrimSpace(f.stderr.String())
}
f.clearProcessLocked()
if err != nil {
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
}
return "", fmt.Errorf("本地 %s agent 已退出", f.name)
case <-ctx.Done():
return "", ctx.Err()
}
}
}
func (f *qoderStreamForwarder) handleControlRequestLocked(line string) bool {
var ev struct {
Type string `json:"type"`
RequestID string `json:"request_id"`
Request map[string]any `json:"request"`
}
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_request" || ev.RequestID == "" {
return false
}
subtype, _ := ev.Request["subtype"].(string)
if subtype == "" {
subtype, _ = ev.Request["type"].(string)
}
fmt.Fprintf(os.Stderr, "[connect][qoder] 自动拒绝/跳过未桥接的 control_request subtype=%q requestId=%s\n", subtype, ev.RequestID)
response := map[string]any{
"type": "control_response",
"response": map[string]any{
"subtype": "error",
"request_id": ev.RequestID,
"error": "DWS qoder connector does not provide host callbacks",
"code": "unsupported_control_request",
},
}
_ = f.writeJSONLocked(response)
return true
}
func (f *qoderStreamForwarder) closeLocked() error {
var killErr error
if f.stdin != nil {
_ = f.stdin.Close()
}
if f.cmd != nil && f.cmd.Process != nil {
killErr = f.cmd.Process.Kill()
}
if f.done != nil {
select {
case <-f.done:
case <-helperAfter(2 * time.Second):
}
}
f.clearProcessLocked()
return killErr
}
func (f *qoderStreamForwarder) clearProcessLocked() {
f.cmd = nil
f.stdin = nil
f.lines = nil
f.done = nil
f.stderr = nil
}
func scanQoderLines(stdout io.Reader, lines chan<- string) {
defer close(lines)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
lines <- scanner.Text()
}
}
func qoderControlResponse(line, requestID string) (bool, error) {
var ev struct {
Type string `json:"type"`
Response struct {
Subtype string `json:"subtype"`
RequestID string `json:"request_id"`
Error string `json:"error"`
} `json:"response"`
}
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_response" {
return false, nil
}
if ev.Response.RequestID != requestID {
return false, nil
}
if ev.Response.Subtype == "error" {
if ev.Response.Error == "" {
ev.Response.Error = "unknown initialize error"
}
return true, fmt.Errorf("初始化 Qoder stream-json 失败:%s", truncateRunes(ev.Response.Error, 300))
}
return true, nil
}
func parseQoderPersistentLine(line string) (delta, final string, done bool) {
var ev struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Error string `json:"error"`
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 "", "", false
}
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 == "assistant":
if t := text(); t != "" {
return t + "\n\n", "", false
}
case ev.Type == "result":
if ev.Subtype != "" && ev.Subtype != "success" {
if ev.Error != "" {
return "", ev.Error, true
}
}
if t := text(); t != "" {
return "", t, true
}
return "", strings.TrimSpace(ev.Result), true
case ev.Type == "error":
if ev.Error != "" {
return "", ev.Error, true
}
}
return "", "", false
}
type lockedStringBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (b *lockedStringBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.Write(p)
}
func (b *lockedStringBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.String()
}