Files
Agent-Native/adapters/native/backend.go
T
weichenandCursor 4a10818535 Drop the intent adapter and fold contract into exec
Why: native must compile without agent-intent, and the two execution packages were the same contract under two names.
Evidence: GOWORK=off go test -count=1 ./... passed, including schema.TestRequestAndResultRoundTrip and the native adapter tests after Options was split into embedded sub-structs.
Impact: adapters/intentgate is gone, go.mod no longer requires agent-intent, and the nested gitlink stays for the release task. Callers of Options composite literals name the sub-struct.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:31:48 +00:00

527 lines
17 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/mcp"
"agent-native/runtime/promptx"
"agent-native/runtime/session"
"agent-native/runtime/skills"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
"logging-system/logsys"
)
// ModelOptions 是模型接入。Stream 非空时不再按 APIBase/APIKey 构造客户端。
type ModelOptions struct {
APIBase string
APIKey string
Model string
MaxTurns int
Timeout time.Duration
Stream llm.StreamFunc
}
// ToolOptions 是可选工具装配。nil 字段不注册对应工具。
type ToolOptions struct {
Search tools.SearchProvider
Exec *tools.ExecConfig
Fetch *tools.FetchConfig
MCP *mcp.Manager
SkillsRoots []string
Subagent subagent.Factory
HostTools []tools.Tool
}
// SessionOptions 是单次执行的会话预算与压缩。
type SessionOptions struct {
Compact *CompactConfig
MaxToolCalls int
}
// ObserveOptions 是日志。Log 为 nil 时丢弃。
type ObserveOptions struct {
Log *logsys.Logger
ExecutionID string
}
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。
type Options struct {
ModelOptions
ToolOptions
SessionOptions
ObserveOptions
}
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
type CompactConfig struct {
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
ThresholdChars int
// KeepLastN 压缩后保留的最近消息条数,默认 8。
KeepLastN int
// Summarizer 可选注入摘要函数;缺省用后端 stream 做一次无工具调用。
Summarizer func(ctx context.Context, messages []llm.Message) (string, error)
}
// Backend 以自研内核实现 exec.Backend。
type Backend struct {
opts Options
// lastFailure 记录各工作目录上次失败原因,用于会话毒化检测
// (context_overflow 等错误续跑同一会话只会复现失败,须换新会话)。
mu sync.Mutex
lastFailure map[string]string
}
type workDirKey struct{}
// WithWorkDir 把本步工作目录放进上下文,供宿主工具读取内核刚写出的文件。
func WithWorkDir(ctx context.Context, dir string) context.Context {
return context.WithValue(ctx, workDirKey{}, dir)
}
// WorkDir 返回 WithWorkDir 写入的目录。没有时返回空串。
func WorkDir(ctx context.Context) string {
dir, _ := ctx.Value(workDirKey{}).(string)
return dir
}
// New 创建 native 后端。
func New(opts Options) *Backend {
return &Backend{opts: opts, lastFailure: map[string]string{}}
}
// Name 返回规范后端名。
func (b *Backend) Name() string { return "agent-native" }
// Close 释放后端持有的长连接资源(MCP server 子进程等)。
func (b *Backend) Close() {
if b.opts.MCP != nil {
b.opts.MCP.Close()
}
}
// 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)
}
// 技能:全局根 + 工作区 .agent/skills;声明的技能必须存在(fail-closed)。
roots := make([]string, 0, len(b.opts.SkillsRoots)+1)
roots = append(roots, b.opts.SkillsRoots...)
roots = append(roots, filepath.Join(workDir, ".agent", "skills"))
skillStore := skills.NewStore(roots...)
skillIndex := skillStore.List()
indexSet := make(map[string]bool, len(skillIndex))
for _, s := range skillIndex {
indexSet[s.Name] = true
}
for _, name := range req.Skills {
if !indexSet[name] {
return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil
}
}
var reg *tools.Registry
if profile == exec.ToolProfileWorkspace {
reg = tools.WorkspaceTools(workDir)
skills.RegisterLoader(reg, skillStore)
if b.opts.Search != nil {
tools.RegisterSearch(reg, b.opts.Search)
}
if b.opts.Fetch != nil {
tools.RegisterFetch(reg, *b.opts.Fetch)
}
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 b.opts.MCP != nil {
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
}
subagent.Register(reg, b.opts.Subagent)
for _, tool := range b.opts.HostTools {
if reg.Has(tool.Name()) {
return &exec.Result{Success: false, ErrorReason: "host tool name conflicts with a builtin: " + tool.Name()}, nil
}
reg.Register(tool)
}
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
}
_ = store.SaveMeta(map[string]string{
"model": model,
"scope": req.SessionScope,
"exec_id": req.ExecutionID,
"updated": time.Now().UTC().Format(time.RFC3339),
})
b.maybeCompact(ctx, store, stream, model)
timeout := req.Timeout
if timeout <= 0 {
timeout = b.opts.Timeout
}
var runBudget *runtime.Budget
if b.opts.MaxToolCalls > 0 {
runBudget = &runtime.Budget{MaxToolCalls: b.opts.MaxToolCalls}
}
if req.Budget != nil {
runBudget = &runtime.Budget{
MaxPromptTokens: req.Budget.MaxPromptTokens,
MaxCompletionTokens: req.Budget.MaxCompletionTokens,
MaxToolCalls: req.Budget.MaxToolCalls,
Deadline: req.Budget.Deadline,
}
if runBudget.MaxToolCalls == 0 {
runBudget.MaxToolCalls = b.opts.MaxToolCalls
}
if !req.Budget.Deadline.IsZero() {
// Deadline 与 Timeout 先到者生效:把更短者作为 ctx 超时。
if until := time.Until(req.Budget.Deadline); timeout <= 0 || until < timeout {
timeout = until
}
}
}
runCtx := WithWorkDir(ctx, workDir)
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(runCtx, 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, workDir, skillIndex, &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.noteFailure(workDir, reason)
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.clearFailure(workDir)
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, workDir string, skillIndex []skills.Skill,
logs *strings.Builder, onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
execID := req.ExecutionID
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
agent := runtime.NewAgentSession(runtime.Config{
Model: model,
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
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,
PromptTokens: ev.PromptTokens,
CompletionTokens: ev.CompletionTokens,
})
}
},
})
return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
}
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
// 失败不阻断执行(按原文继续),但必须留日志,不静默。
func (b *Backend) maybeCompact(ctx context.Context, store *session.Store, stream llm.StreamFunc, model string) {
cfg := b.opts.Compact
if cfg == nil {
return
}
threshold := int64(cfg.ThresholdChars)
if threshold <= 0 {
threshold = 300_000
}
keep := cfg.KeepLastN
if keep <= 0 {
keep = 8
}
if store.Stats().Bytes < threshold {
return
}
summarize := cfg.Summarizer
if summarize == nil {
summarize = defaultSummarizer(stream, model)
}
done, err := store.Compact(keep, func(messages []llm.Message) (string, error) {
return summarize(ctx, messages)
})
if err != nil {
b.logger().Warn("session compaction failed; continuing with full transcript", "error", err.Error())
return
}
if done {
b.logger().Info("session compacted", "keep_last", keep)
}
}
// defaultSummarizer 用后端 stream 做一次无工具调用,把历史压成摘要。
func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Context, messages []llm.Message) (string, error) {
return func(ctx context.Context, messages []llm.Message) (string, error) {
var b strings.Builder
for _, m := range messages {
b.WriteString(string(m.Role))
b.WriteString(": ")
b.WriteString(m.Content)
b.WriteString("\n")
if b.Len() > 120_000 {
break
}
}
prompt := "把以下智能体对话历史压缩成摘要:保留全部关键事实、已做决定、文件变更与未决问题;中文;不超过 800 字;只输出摘要正文。\n\n" + b.String()
var out strings.Builder
for ev := range stream(ctx, model, []llm.Message{{Role: llm.RoleUser, Content: prompt}}, nil, llm.StreamOptions{}) {
switch ev.Kind {
case llm.KindTextDelta:
out.WriteString(ev.Text)
case llm.KindError:
return "", ev.Err
}
}
if strings.TrimSpace(out.String()) == "" {
return "", errString("summarizer produced empty output")
}
return out.String(), nil
}
}
type errString string
func (e errString) Error() string { return string(e) }
// 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) {
reason := strings.ToLower(b.failureReason(workDir))
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)
}
const lastFailureFile = ".native-last-failure"
func (b *Backend) noteFailure(workDir, reason string) {
b.mu.Lock()
b.lastFailure[workDir] = reason
b.mu.Unlock()
_ = os.WriteFile(filepath.Join(workDir, lastFailureFile), []byte(reason), 0o644)
}
func (b *Backend) clearFailure(workDir string) {
b.mu.Lock()
delete(b.lastFailure, workDir)
b.mu.Unlock()
_ = os.Remove(filepath.Join(workDir, lastFailureFile))
}
func (b *Backend) failureReason(workDir string) string {
b.mu.Lock()
reason := b.lastFailure[workDir]
b.mu.Unlock()
if reason != "" {
return reason
}
raw, err := os.ReadFile(filepath.Join(workDir, lastFailureFile))
if err != nil {
return ""
}
return string(raw)
}
// systemPrompt 经 promptx 组装:角色指令 + 环境事实 + 技能索引 + 交付物约束。
func (b *Backend) systemPrompt(req exec.Request, model, profile, workDir string, skillIndex []skills.Skill) string {
var index []promptx.IndexEntry
for _, s := range skillIndex {
index = append(index, promptx.IndexEntry{Name: s.Name, Description: s.Description})
}
now := time.Now()
weekday := map[string]string{
"Sunday": "周日", "Monday": "周一", "Tuesday": "周二", "Wednesday": "周三",
"Thursday": "周四", "Friday": "周五", "Saturday": "周六",
}[now.Weekday().String()]
return promptx.Compose(promptx.Parts{
Instructions: req.Instructions,
SkillsDeclared: req.Skills,
SkillsIndex: index,
Deliverables: req.Targets,
Env: promptx.EnvFacts{
Date: now.Format("2006-01-02") + "(" + weekday + ")",
Model: model,
WorkspaceRoot: workDir,
Profile: profile,
},
})
}