Files
Agent-System/internal/agent/piagent.go
T
weichen 265616e997 feat(stream): 打通小队过程事件到持久化 detail 帧
pi-native 与 runner NDJSON 把推理和工具调用投影为 detail,任务流始终落库;非 200 的流式尝试回落到一次性 /v1/run,避免改变既有错误分类。
2026-09-28 10:30:52 +00:00

395 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}