pi-native 与 runner NDJSON 把推理和工具调用投影为 detail,任务流始终落库;非 200 的流式尝试回落到一次性 /v1/run,避免改变既有错误分类。
395 lines
12 KiB
Go
395 lines
12 KiB
Go
package agent
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/config"
|
||
"chat-agent-system/internal/workspace"
|
||
)
|
||
|
||
type PiAgentBackend struct {
|
||
cfg *config.Config
|
||
}
|
||
|
||
func (p *PiAgentBackend) Name() string { return "pi-agent" }
|
||
|
||
func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, error) {
|
||
if len(req.Targets) == 0 {
|
||
req.Targets = []string{workspace.DeliverableKey}
|
||
}
|
||
|
||
workDir := req.WorkDir
|
||
if !filepath.IsAbs(workDir) {
|
||
wd, _ := os.Getwd()
|
||
workDir = filepath.Join(wd, workDir)
|
||
}
|
||
|
||
payload, _ := json.Marshal(p.runnerPayload(req, workDir, false, 0))
|
||
|
||
bridgeBin := p.cfg.PiAgentBridgeBin
|
||
if !filepath.IsAbs(bridgeBin) {
|
||
wd, _ := os.Getwd()
|
||
bridgeBin = filepath.Join(wd, bridgeBin)
|
||
}
|
||
|
||
timeout := req.Timeout
|
||
if timeout <= 0 {
|
||
timeout = p.cfg.AgentTimeout
|
||
}
|
||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
|
||
if runnerURL := strings.TrimRight(strings.TrimSpace(p.cfg.PiAgentRunnerURL), "/"); runnerURL != "" {
|
||
return p.executeRemote(runCtx, runnerURL, req, workDir, timeout)
|
||
}
|
||
|
||
cmd := exec.CommandContext(runCtx, bridgeBin)
|
||
cmd.Dir = workDir
|
||
cmd.Stdin = bytes.NewReader(payload)
|
||
cmd.Env = append(os.Environ(),
|
||
"PI_AGENT_BIN="+p.cfg.PiAgentBin,
|
||
"PI_AGENT_MODEL="+p.cfg.PiAgentModel,
|
||
"PI_AGENT_PROVIDER="+p.cfg.PiAgentProvider,
|
||
"PI_AGENT_SYSTEM_PROMPT_FILE=AGENTS.md",
|
||
)
|
||
|
||
var stdout bytes.Buffer
|
||
cmd.Stdout = &stdout
|
||
cmd.Stderr = io.Discard
|
||
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
res := &Result{
|
||
Success: false,
|
||
ErrorReason: "pi execution error",
|
||
}
|
||
res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient
|
||
return res, nil
|
||
}
|
||
|
||
type bridgeResponse struct {
|
||
OK bool `json:"ok"`
|
||
FallbackReason string `json:"fallback_reason"`
|
||
Retryable bool `json:"retryable"`
|
||
}
|
||
var bResp bridgeResponse
|
||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||
if len(lines) > 0 {
|
||
_ = json.Unmarshal([]byte(lines[len(lines)-1]), &bResp)
|
||
}
|
||
logDigest := "stdout_digest=" + DigestBytes(stdout.Bytes())
|
||
|
||
if !bResp.OK {
|
||
errMsg := safeLegacyReason(bResp.FallbackReason)
|
||
if errMsg == "" {
|
||
errMsg = "pi bridge reported task failed"
|
||
}
|
||
res := &Result{
|
||
Success: false,
|
||
ErrorReason: errMsg,
|
||
Logs: logDigest,
|
||
Retryable: bResp.Retryable,
|
||
}
|
||
if !res.Retryable {
|
||
res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
patches, err := MaterializeTargets(workDir, req.Targets, nil)
|
||
if err != nil {
|
||
res := &Result{
|
||
Success: false,
|
||
ErrorReason: err.Error(),
|
||
Patches: patches,
|
||
Logs: logDigest,
|
||
}
|
||
res.Retryable = AssessStepFailure(StepFailureSignals{
|
||
Result: res,
|
||
WorkDir: workDir,
|
||
TargetMissing: true,
|
||
}).Class == FailureTransient
|
||
return res, nil
|
||
}
|
||
|
||
return &Result{
|
||
Success: true,
|
||
Patches: patches,
|
||
Logs: logDigest,
|
||
}, nil
|
||
}
|
||
|
||
func (p *PiAgentBackend) runnerPayload(req Request, workDir string, remote bool, timeout time.Duration) map[string]any {
|
||
profile := strings.TrimSpace(req.ToolProfile)
|
||
if profile == "" {
|
||
profile = ToolProfileWorkspace
|
||
}
|
||
body := map[string]any{
|
||
"workspace": workDir,
|
||
"model": p.cfg.PiAgentModel,
|
||
"provider": p.cfg.PiAgentProvider,
|
||
"prompt": req.Prompt,
|
||
"instructions": req.Instructions,
|
||
"targets": req.Targets,
|
||
"tool_profile": profile,
|
||
}
|
||
if remote {
|
||
runID := filepath.Base(workDir)
|
||
if runID == "" || runID == "." || runID == string(filepath.Separator) {
|
||
runID = "unknown"
|
||
}
|
||
body["timeout_ms"] = timeout.Milliseconds()
|
||
body["run_id"] = runID
|
||
body["typed_result"] = true
|
||
}
|
||
return body
|
||
}
|
||
|
||
// runnerDataRoots caches a successful /health data_dir per runner URL.
|
||
// A failed probe is not cached: the next call may try again, and this call skips the check.
|
||
var runnerDataRoots sync.Map
|
||
|
||
func probeRunnerDataRoot(ctx context.Context, runnerURL string) (string, bool) {
|
||
if v, ok := runnerDataRoots.Load(runnerURL); ok {
|
||
root, _ := v.(string)
|
||
return root, root != ""
|
||
}
|
||
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||
defer cancel()
|
||
httpReq, err := http.NewRequestWithContext(probeCtx, http.MethodGet, runnerURL+"/health", nil)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
client := &http.Client{Timeout: 2 * time.Second}
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", false
|
||
}
|
||
var body struct {
|
||
DataDir string `json:"data_dir"`
|
||
}
|
||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
||
return "", false
|
||
}
|
||
root := filepath.Clean(strings.TrimSpace(body.DataDir))
|
||
if root == "" || root == "." {
|
||
return "", false
|
||
}
|
||
runnerDataRoots.Store(runnerURL, root)
|
||
log.Printf("pi runner data_dir check passed")
|
||
return root, true
|
||
}
|
||
|
||
func workDirWithinRunnerRoot(workDir, root string) bool {
|
||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(workDir))
|
||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, req Request, workDir string, timeout time.Duration) (*Result, error) {
|
||
if root, ok := probeRunnerDataRoot(ctx, runnerURL); ok && !workDirWithinRunnerRoot(workDir, root) {
|
||
// Do not include either path: the caller classifies this phrase, and paths leak host layout.
|
||
log.Printf("pi runner data root mismatch; refusing /v1/run")
|
||
return &Result{Success: false, ErrorReason: "pi runner data root mismatch"}, nil
|
||
}
|
||
|
||
expectedRunID := filepath.Base(workDir)
|
||
if expectedRunID == "" || expectedRunID == "." || expectedRunID == string(filepath.Separator) {
|
||
expectedRunID = "unknown"
|
||
}
|
||
reqBody, _ := json.Marshal(p.runnerPayload(req, workDir, true, timeout))
|
||
|
||
// 过程流式路径(2026-09-28 设计 C.5):调用方带 OnEvent 时优先走 NDJSON 流式
|
||
// 端点,实时转发 proc 帧;旧 runner 404 时回落一次性 /v1/run(无过程事件)。
|
||
if req.OnEvent != nil {
|
||
if res, attempted, err := p.executeRemoteStream(ctx, runnerURL, reqBody, req, expectedRunID, timeout); attempted {
|
||
return res, err
|
||
}
|
||
}
|
||
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, runnerURL+"/v1/run", bytes.NewReader(reqBody))
|
||
if err != nil {
|
||
return &Result{
|
||
Success: false,
|
||
ErrorReason: fmt.Sprintf("failed to build remote request: %v", err),
|
||
}, nil
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
ApplyRunnerAuth(httpReq, p.cfg.PiAgentRunnerAuthToken)
|
||
|
||
client := &http.Client{Timeout: timeout}
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return &Result{
|
||
Success: false,
|
||
ErrorReason: fmt.Sprintf("pi remote runner call failed: %v", err),
|
||
Retryable: true,
|
||
}, nil
|
||
}
|
||
defer resp.Body.Close()
|
||
limited, err := io.ReadAll(io.LimitReader(resp.Body, MaxResultBodyBytes+1))
|
||
if err != nil {
|
||
return &Result{Success: false, ErrorReason: "failed to read remote response", Retryable: true}, nil
|
||
}
|
||
if len(limited) > MaxResultBodyBytes {
|
||
return &Result{Success: false, ErrorReason: "runner result body too large"}, nil
|
||
}
|
||
return decodeRemoteResult(limited, expectedRunID, workDir, req)
|
||
}
|
||
|
||
// executeRemoteStream 调用 runner /v1/run/stream:proc 帧实时转发 OnEvent,
|
||
// result 终帧走与一次性路径完全相同的 DecodeRemoteRun 校验。
|
||
// attempted=false 表示端点不可用(404/405),调用方应回落 /v1/run。
|
||
func (p *PiAgentBackend) executeRemoteStream(ctx context.Context, runnerURL string, reqBody []byte, req Request, expectedRunID string, timeout time.Duration) (*Result, bool, error) {
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, runnerURL+"/v1/run/stream", bytes.NewReader(reqBody))
|
||
if err != nil {
|
||
return nil, false, nil
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
httpReq.Header.Set("Accept", "application/x-ndjson")
|
||
ApplyRunnerAuth(httpReq, p.cfg.PiAgentRunnerAuthToken)
|
||
|
||
// 无总超时的 client:超时由 ctx 控制,逐行流式读取期间保持连接。
|
||
client := &http.Client{}
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return nil, false, nil // 网络错误也回落一次性路径
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
// 任何非 200(含旧 runner 404 与瞬态 5xx)都回落一次性路径,
|
||
// 保证错误分类与既有 /v1/run 语义完全一致(不改变治理边界)。
|
||
resp.Body.Close()
|
||
return nil, false, nil
|
||
}
|
||
|
||
var resultBody []byte
|
||
var sawResult bool
|
||
sc := bufio.NewScanner(resp.Body)
|
||
sc.Buffer(make([]byte, 0, 64*1024), MaxResultBodyBytes)
|
||
for sc.Scan() {
|
||
if ctx.Err() != nil {
|
||
return &Result{Success: false, ErrorReason: "stream context cancelled", Retryable: true}, true, nil
|
||
}
|
||
line := strings.TrimSpace(sc.Text())
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var frame struct {
|
||
Type string `json:"type"`
|
||
Ev *ProcessEvent `json:"ev,omitempty"`
|
||
Body json.RawMessage `json:"body,omitempty"`
|
||
}
|
||
if err := json.Unmarshal([]byte(line), &frame); err != nil {
|
||
continue
|
||
}
|
||
switch frame.Type {
|
||
case "proc":
|
||
if frame.Ev != nil && req.OnEvent != nil {
|
||
req.OnEvent(*frame.Ev)
|
||
}
|
||
case "result":
|
||
resultBody = frame.Body
|
||
sawResult = true
|
||
}
|
||
if sawResult {
|
||
break // result 是终帧
|
||
}
|
||
}
|
||
if !sawResult {
|
||
return &Result{
|
||
Success: false,
|
||
ErrorReason: "pi runner stream ended without result frame",
|
||
Retryable: true,
|
||
}, true, nil
|
||
}
|
||
res, decErr := decodeRemoteResult(resultBody, expectedRunID, req.WorkDir, req)
|
||
if decErr != nil {
|
||
return res, true, decErr
|
||
}
|
||
return res, true, nil
|
||
}
|
||
|
||
// decodeRemoteResult 一次性与流式路径共用的终态解码与落盘。
|
||
func decodeRemoteResult(body []byte, expectedRunID, workDir string, req Request) (*Result, error) {
|
||
decoded, decErr := DecodeRemoteRun(body, expectedRunID, true)
|
||
if decErr != nil {
|
||
reason := "typed result rejected"
|
||
msg := decErr.Error()
|
||
switch {
|
||
case strings.Contains(msg, "schema"):
|
||
reason = "typed result rejected: schema"
|
||
case strings.Contains(msg, "run mismatch"):
|
||
reason = "typed result rejected: run mismatch"
|
||
case strings.Contains(msg, "digest"):
|
||
reason = "typed result rejected: digest"
|
||
case strings.Contains(msg, "missing result_v1"):
|
||
reason = "typed result rejected: missing result_v1"
|
||
case strings.Contains(msg, "member"):
|
||
reason = "typed result rejected: member"
|
||
case strings.Contains(msg, "outcome"), strings.Contains(msg, "error_code"):
|
||
reason = "typed result rejected: outcome"
|
||
case strings.Contains(msg, "empty body"):
|
||
reason = "typed result rejected: empty body"
|
||
case strings.Contains(msg, "failed to decode"):
|
||
reason = "typed result rejected: decode"
|
||
}
|
||
return &Result{Success: false, ErrorReason: reason}, nil
|
||
}
|
||
|
||
if !decoded.OK {
|
||
reason := decoded.Reason
|
||
if reason == "" {
|
||
reason = "remote pi-agent reported task failure"
|
||
}
|
||
res := &Result{
|
||
Success: false,
|
||
ErrorReason: reason,
|
||
Retryable: decoded.Retryable,
|
||
}
|
||
if !res.Retryable {
|
||
res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
patches, matErr := MaterializeTargets(workDir, req.Targets, decoded.Incoming)
|
||
if matErr != nil {
|
||
res := &Result{
|
||
Success: false,
|
||
ErrorReason: matErr.Error(),
|
||
Patches: patches,
|
||
}
|
||
res.Retryable = decoded.Retryable || AssessStepFailure(StepFailureSignals{
|
||
Result: res,
|
||
WorkDir: workDir,
|
||
TargetMissing: true,
|
||
}).Class == FailureTransient
|
||
return res, nil
|
||
}
|
||
|
||
return &Result{
|
||
Success: true,
|
||
Patches: patches,
|
||
}, nil
|
||
}
|