536 lines
13 KiB
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()
|
|
}
|