Files
Agent-Native/adapters/native/backend.go
T
weichen fe5a973f15 Add run_command and run_python exec tools with process-group kill and env whitelist
Why: the kernel had no system-tool capability, so deliverable generation
via CLI/Python (the settled product direction) could not run inside
agent-native executions; squad's BashPermission gate had nothing to
guard.

Evidence: go vet ./... clean; go test ./... all ok. Cases cover echo
and non-zero exit reporting, env whitelist isolation (host secrets do
not leak into children), timeout and context-cancel killing the whole
process group promptly, blocklist rejection (sudo/destructive
patterns), 64 KiB head+tail output truncation, python script
persistence under .agent/scripts/, and fail-closed registration (no
interpreter configured = no tool). Adapter tests verify Options.Exec
wiring, per-request Env merge, allowlist stripping, and absence
without Options.Exec.

Impact: native.Options gains Exec *tools.ExecConfig; exec.Request.Env
now flows into child processes; workspace profile gains up to 2 tools
(only when configured). NOT an OS sandbox - isolation stays a host
responsibility, documented in tool descriptions.

Refs: parity-matrix #3/#4; settled product decision (deliverables via
CLI/Python + skills, see archived card feature-exec-interpreters).
2026-10-01 05:31:43 +00:00

325 lines
10 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 native 把 agent-native 自研执行内核(runtime)适配为 exec.Backend,
// 规范后端名 "agent-native"。来源:主仓 internal/agent/pinative.go 的通用化
// 副本(去除产品 config/workspace 依赖);主仓文件仍是产品路径权威,
// 本文件为模块原生新增,语义变更需两侧同步评审。
package native
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"time"
"agent-native/exec"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
"logging-system/logsys"
)
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
type Options struct {
APIBase string
APIKey string
Model string
MaxTurns int
Timeout time.Duration
// Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。
Stream llm.StreamFunc
// Exec 可选:提供时注册 run_command / run_python(解释器留空项不注册)。
// nil = 不注册执行类工具(fail-closed,与 BashPermission 默认 deny 对齐)。
Exec *tools.ExecConfig
// Log 可选。nil 时丢弃日志。
Log *logsys.Logger
// ExecutionID 写入日志,不参与执行控制。
ExecutionID string
}
// Backend 以自研内核实现 exec.Backend。
type Backend struct {
opts Options
// lastFailure 记录各工作目录上次失败原因,用于会话毒化检测
// (context_overflow 等错误续跑同一会话只会复现失败,须换新会话)。
mu sync.Mutex
lastFailure map[string]string
}
// New 创建 native 后端。
func New(opts Options) *Backend {
return &Backend{opts: opts, lastFailure: map[string]string{}}
}
// Name 返回规范后端名。
func (b *Backend) Name() string { return "agent-native" }
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
if len(req.Targets) == 0 {
req.Targets = []string{"output.md"}
}
profile := strings.TrimSpace(req.ToolProfile)
if profile == "" {
profile = exec.ToolProfileWorkspace
}
if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning {
return &exec.Result{Success: false, ErrorReason: "unknown tool profile: " + profile}, nil
}
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
}
model := strings.TrimSpace(b.opts.Model)
if strings.TrimSpace(req.Model) != "" {
model = strings.TrimSpace(req.Model)
}
if model == "" {
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil
}
workDir := req.WorkDir
if !filepath.IsAbs(workDir) {
wd, _ := os.Getwd()
workDir = filepath.Join(wd, workDir)
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
if strings.TrimSpace(req.Instructions) != "" {
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644)
_ = os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(req.Instructions), 0o644)
}
if strings.TrimSpace(req.Prompt) != "" {
_ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(req.Prompt), 0o644)
}
var reg *tools.Registry
if profile == exec.ToolProfileWorkspace {
reg = tools.WorkspaceTools(workDir)
if b.opts.Exec != nil {
execCfg := *b.opts.Exec
for k, v := range req.Env {
execCfg.Env = append(execCfg.Env, k+"="+v)
}
tools.RegisterExecTools(reg, workDir, execCfg)
}
if err := reg.Restrict(req.ToolAllowlist); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
}
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
b.rotateIfPoisoned(workDir)
store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl"))
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
stream := b.opts.Stream
if stream == nil {
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
stream = client.Stream
}
timeout := req.Timeout
if timeout <= 0 {
timeout = b.opts.Timeout
}
var runBudget *runtime.Budget
if req.Budget != nil {
runBudget = &runtime.Budget{
MaxPromptTokens: req.Budget.MaxPromptTokens,
MaxCompletionTokens: req.Budget.MaxCompletionTokens,
MaxToolCalls: req.Budget.MaxToolCalls,
Deadline: req.Budget.Deadline,
}
if !req.Budget.Deadline.IsZero() {
// Deadline 与 Timeout 先到者生效:把更短者作为 ctx 超时。
if until := time.Until(req.Budget.Deadline); timeout <= 0 || until < timeout {
timeout = until
}
}
}
runCtx := ctx
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
}
defer cancel()
execID := req.ExecutionID
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
var logs strings.Builder
if strings.TrimSpace(execID) != "" || strings.TrimSpace(req.SessionScope) != "" {
logs.WriteString("execution_id=" + execID + " session=" + req.SessionScope + "\n")
}
onProcess := req.OnEvent
runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, &logs, onProcess)
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
}
_ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644)
// reasoning 档无工具:把最终文本写入仍为空的目标文件。
if profile == exec.ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" {
for _, target := range req.Targets {
path := filepath.Join(workDir, filepath.FromSlash(target))
if b2, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b2))) > 0 {
continue
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(runRes.Text), 0o644)
}
}
if runRes.Err != nil {
reason := runRes.Err.Error()
b.mu.Lock()
b.lastFailure[workDir] = reason
b.mu.Unlock()
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
Err: runRes.Err,
WorkDir: workDir,
Result: &exec.Result{ErrorReason: reason, Retryable: runRes.Retryable, Logs: logs.String()},
})
b.logger().Error("native step failure classified", runRes.Err,
"failure_class", assessment.Class.String(),
"work_dir", workDir)
res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
if runRes.StopReason == llm.StopAborted {
res.Retryable = true
}
return res, nil
}
b.mu.Lock()
delete(b.lastFailure, workDir)
b.mu.Unlock()
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
if matErr != nil {
return &exec.Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil
}
return &exec.Result{
Success: true,
Patches: patches,
Logs: logs.String(),
PromptTokens: runRes.Usage.PromptTokens,
CompletionTokens: runRes.Usage.CompletionTokens,
}, nil
}
// run 驱动 runtime 循环并桥接过程事件(语义对齐主仓 pinative)。
func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, reg *tools.Registry, store *session.Store,
stream llm.StreamFunc, profile string, req exec.Request, logs *strings.Builder,
onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
execID := req.ExecutionID
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
runRes, err := runtime.Run(ctx, runtime.Config{
Model: model,
SystemPrompt: systemPrompt(req, profile),
Tools: reg,
Stream: stream,
Session: store,
MaxTurns: b.opts.MaxTurns,
Log: b.logger(),
ExecutionID: execID,
Budget: budget,
OnEvent: func(ev runtime.Event) {
logs.WriteString(ev.Type)
if ev.Tool != "" {
logs.WriteString(" ")
logs.WriteString(ev.Tool)
}
if ev.Text != "" {
logs.WriteString(" ")
logs.WriteString(ev.Text)
}
logs.WriteByte('\n')
if onProcess != nil {
onProcess(exec.ProcessEvent{
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
})
}
},
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
return runRes, err
}
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
// 迭代上限/非法请求——重试必须换新会话而非续跑)。
var resumeUnsafeMarkers = []string{
"context_overflow", "context length", "context window", "too long",
"iteration_limit", "max turns", "turn cap",
"invalid_request", "invalid request", "bad request", "400",
}
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
func (b *Backend) rotateIfPoisoned(workDir string) {
b.mu.Lock()
reason := strings.ToLower(b.lastFailure[workDir])
b.mu.Unlock()
if reason == "" {
return
}
unsafe := false
for _, m := range resumeUnsafeMarkers {
if strings.Contains(reason, m) {
unsafe = true
break
}
}
if !unsafe {
return
}
path := filepath.Join(workDir, ".agentx", "transcript.jsonl")
if _, err := os.Stat(path); err != nil {
return
}
b.logger().Warn("rotating poisoned session", "work_dir", workDir)
_ = os.Rename(path, filepath.Join(workDir, ".agentx",
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
}
func (b *Backend) logger() *logsys.Logger {
if b == nil || b.opts.Log == nil {
return logsys.Nop()
}
log := b.opts.Log.Module("native")
if b.opts.ExecutionID == "" {
return log
}
return log.With("execution_id", b.opts.ExecutionID)
}
// systemPrompt 组装系统提示:角色指令 + 交付物与工具档位约束 + 声明的技能。
func systemPrompt(req exec.Request, profile string) string {
var b strings.Builder
if strings.TrimSpace(req.Instructions) != "" {
b.WriteString(req.Instructions)
b.WriteString("\n\n")
}
if len(req.Skills) > 0 {
b.WriteString("Declared skills for this task: ")
b.WriteString(strings.Join(req.Skills, ", "))
b.WriteString(".\n\n")
}
b.WriteString("Deliverable files: ")
b.WriteString(strings.Join(req.Targets, ", "))
b.WriteString(".")
if profile == exec.ToolProfileWorkspace {
b.WriteString(" Use workspace tools to write those files. Do not invent paths outside the workspace.")
} else {
b.WriteString(" Reply with the deliverable text only. Tools are disabled.")
}
return b.String()
}