Files
Agent-System/internal/agent/piagent.go
T
weichen 3ba15df875 fix(agent): 记录 PI runner 数据目录核对结果
启动后第一次核对通过时打一条不含路径的日志,便于确认 test 没有再打到 dev sidecar。
2026-09-28 03:17:26 +00:00

309 lines
8.6 KiB
Go

package agent
import (
"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))
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
}
decoded, decErr := DecodeRemoteRun(limited, 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
}