Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df0d10d96 | ||
|
|
8f113e2376 | ||
|
|
277392acf3 | ||
|
|
e05587f165 | ||
|
|
ad2649b4ab |
@@ -33,7 +33,7 @@
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
|
||||
+32
-149
@@ -141,7 +141,7 @@ func (b *Backend) Close() {
|
||||
|
||||
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
|
||||
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
if len(req.Targets) == 0 && !allowEmptyTargets(ctx) {
|
||||
if len(req.Targets) == 0 {
|
||||
req.Targets = []string{"output.md"}
|
||||
}
|
||||
profile := strings.TrimSpace(req.ToolProfile)
|
||||
@@ -149,10 +149,10 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
profile = exec.ToolProfileWorkspace
|
||||
}
|
||||
if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning {
|
||||
return preflightFail("unknown tool profile: " + profile), nil
|
||||
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 preflightFail("agent-native backend requires api base/key (or injected stream)"), nil
|
||||
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
|
||||
}
|
||||
if b.opts.Ask != nil {
|
||||
ctx = mcp.WithAsk(ctx, b.opts.Ask)
|
||||
@@ -168,7 +168,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
model = strings.TrimSpace(req.Model)
|
||||
}
|
||||
if model == "" {
|
||||
return preflightFail("agent-native backend requires model"), nil
|
||||
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil
|
||||
}
|
||||
|
||||
workDir := req.WorkDir
|
||||
@@ -177,7 +177,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
// 落一次性临时目录,不污染进程 CWD(契约套件即发此类请求)。
|
||||
tmp, err := os.MkdirTemp("", "agent-native-run-*")
|
||||
if err != nil {
|
||||
return preflightFail(err.Error()), nil
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
}
|
||||
workDir = tmp
|
||||
} else if !filepath.IsAbs(workDir) {
|
||||
@@ -185,7 +185,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
workDir = filepath.Join(wd, workDir)
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return preflightFail(err.Error()), 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)
|
||||
@@ -207,28 +207,13 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
}
|
||||
for _, name := range req.Skills {
|
||||
if !indexSet[name] {
|
||||
return preflightFail("declared skill not installed: " + name), nil
|
||||
return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil
|
||||
}
|
||||
}
|
||||
|
||||
writable := writableFrom(ctx)
|
||||
// F02: a non-empty Writable cannot be enforced for exec/MCP/host tools —
|
||||
// reject in preflight rather than pretend the write-set holds.
|
||||
if len(writable) > 0 && profile == exec.ToolProfileWorkspace {
|
||||
if b.opts.Exec != nil {
|
||||
return preflightFail("writable write-set cannot be enforced when exec tools are enabled"), nil
|
||||
}
|
||||
if b.opts.MCP != nil {
|
||||
return preflightFail("writable write-set cannot be enforced when MCP tools are enabled"), nil
|
||||
}
|
||||
if len(b.opts.HostTools) > 0 {
|
||||
return preflightFail("writable write-set cannot be enforced when host tools are enabled"), nil
|
||||
}
|
||||
}
|
||||
var reg *tools.Registry
|
||||
if profile == exec.ToolProfileWorkspace {
|
||||
reg = tools.WorkspaceToolsCheckpointed(workDir, filestate.New(), b.opts.Checkpoints, b.opts.CheckpointAnchor)
|
||||
tools.ApplyWriteAllowlist(reg, writable)
|
||||
skills.RegisterLoader(reg, skillStore)
|
||||
if b.opts.Search != nil {
|
||||
tools.RegisterSearch(reg, b.opts.Search)
|
||||
@@ -252,7 +237,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
switch {
|
||||
case len(req.ToolAllowlist) > 0 || b.opts.MCP.Ready():
|
||||
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
|
||||
return preflightFail(err.Error()), nil
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
}
|
||||
default:
|
||||
b.opts.MCP.StartBackground(ctx, reg)
|
||||
@@ -261,31 +246,32 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
subagent.Register(reg, b.opts.Subagent)
|
||||
for _, tool := range b.opts.HostTools {
|
||||
if reg.Has(tool.Name()) {
|
||||
return preflightFail("host tool name conflicts with a builtin: " + tool.Name()), nil
|
||||
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 preflightFail(err.Error()), nil
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
}
|
||||
}
|
||||
// Scope-keyed transcript: empty scope keeps the legacy fixed path.
|
||||
transcript := transcriptPathForScope(workDir, req.SessionScope)
|
||||
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
|
||||
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
|
||||
b.rotateIfPoisoned(workDir, transcript)
|
||||
store, err := session.Open(transcript)
|
||||
b.rotateIfPoisoned(workDir)
|
||||
store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl"))
|
||||
if err != nil {
|
||||
return preflightFail(err.Error()), 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
|
||||
}
|
||||
if err := b.bindSessionScope(store, req.SessionScope, model, req.ExecutionID); err != nil {
|
||||
return preflightFail(err.Error()), nil
|
||||
}
|
||||
_ = 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
|
||||
@@ -331,82 +317,36 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
onProcess := req.OnEvent
|
||||
runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, workDir, skillIndex, &logs, onProcess)
|
||||
if err != nil {
|
||||
// Persist/session transport failure after the run started: outcome uncertain.
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: err.Error(), Logs: logs.String(),
|
||||
Unknown: true, SideEffects: exec.SideEffectPossible,
|
||||
}, nil
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644)
|
||||
|
||||
// CapText sink: always capture the final assistant text for contract mapping.
|
||||
captureText(ctx, runRes.Text)
|
||||
|
||||
// reasoning 档无工具:把最终文本写入仍为空的目标文件。
|
||||
// 先授权全部 target,任一拒绝则零写入(避免 ../、绝对路径、符号链接逃逸先落地)。
|
||||
if profile == exec.ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" {
|
||||
type reasoningTarget struct {
|
||||
path string
|
||||
skip bool
|
||||
}
|
||||
authorized := make([]reasoningTarget, 0, len(req.Targets))
|
||||
for _, target := range req.Targets {
|
||||
if !pathInWriteSet(writable, target) {
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: "target is outside the declared writable set: " + target,
|
||||
Logs: logs.String(), SideEffects: sideEffectsForTools(runRes.ToolCalls),
|
||||
}, nil
|
||||
}
|
||||
path, resolveErr := tools.Resolve(workDir, filepath.FromSlash(target))
|
||||
if resolveErr != nil {
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: resolveErr.Error(), Logs: logs.String(),
|
||||
SideEffects: sideEffectsForTools(runRes.ToolCalls),
|
||||
}, nil
|
||||
}
|
||||
skip := false
|
||||
if b2, readErr := os.ReadFile(path); readErr == nil && len(strings.TrimSpace(string(b2))) > 0 {
|
||||
skip = true
|
||||
}
|
||||
authorized = append(authorized, reasoningTarget{path: path, skip: skip})
|
||||
}
|
||||
for _, at := range authorized {
|
||||
if at.skip {
|
||||
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(at.path), 0o755)
|
||||
_ = os.WriteFile(at.path, []byte(runRes.Text), 0o644)
|
||||
_ = 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)
|
||||
canceled := runRes.Canceled || runRes.StopReason == llm.StopAborted
|
||||
retryable := runRes.Retryable && !canceled
|
||||
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
|
||||
Err: runRes.Err,
|
||||
WorkDir: workDir,
|
||||
Result: &exec.Result{ErrorReason: reason, Retryable: retryable, Logs: logs.String()},
|
||||
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,
|
||||
"canceled", canceled)
|
||||
res := &exec.Result{
|
||||
Success: false,
|
||||
ErrorReason: reason,
|
||||
Logs: logs.String(),
|
||||
Retryable: retryable,
|
||||
Canceled: canceled,
|
||||
SideEffects: exec.SideEffectPossible,
|
||||
}
|
||||
if canceled {
|
||||
// Aborted/canceled: never retryable; side effects may already exist.
|
||||
res.Retryable = false
|
||||
res.SideEffects = exec.SideEffectPossible
|
||||
} else if runRes.ToolCalls == 0 {
|
||||
res.SideEffects = exec.SideEffectNone
|
||||
"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
|
||||
}
|
||||
@@ -414,14 +354,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
|
||||
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
|
||||
if matErr != nil {
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String(),
|
||||
SideEffects: exec.SideEffectPossible,
|
||||
}, nil
|
||||
}
|
||||
side := sideEffectsForTools(runRes.ToolCalls)
|
||||
if len(patches) > 0 {
|
||||
side = exec.SideEffectPossible
|
||||
return &exec.Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil
|
||||
}
|
||||
return &exec.Result{
|
||||
Success: true,
|
||||
@@ -429,7 +362,6 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
Logs: logs.String(),
|
||||
PromptTokens: runRes.Usage.PromptTokens,
|
||||
CompletionTokens: runRes.Usage.CompletionTokens,
|
||||
SideEffects: side,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -475,9 +407,6 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
||||
Text: ev.Text,
|
||||
Tool: ev.Tool,
|
||||
Detail: ev.Detail,
|
||||
CallID: ev.CallID,
|
||||
Status: ev.Status,
|
||||
ErrorReason: ev.ErrorReason,
|
||||
PromptTokens: ev.PromptTokens,
|
||||
CompletionTokens: ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens,
|
||||
@@ -593,19 +522,6 @@ type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
// preflightFail is a definitive rejection before any tool dispatch.
|
||||
func preflightFail(reason string) *exec.Result {
|
||||
return &exec.Result{Success: false, ErrorReason: reason, SideEffects: exec.SideEffectNone}
|
||||
}
|
||||
|
||||
// sideEffectsForTools maps dispatched tool count to a SideEffects qualifier.
|
||||
func sideEffectsForTools(toolCalls int) exec.SideEffects {
|
||||
if toolCalls > 0 {
|
||||
return exec.SideEffectPossible
|
||||
}
|
||||
return exec.SideEffectNone
|
||||
}
|
||||
|
||||
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
|
||||
// 迭代上限/非法请求——重试必须换新会话而非续跑)。
|
||||
var resumeUnsafeMarkers = []string{
|
||||
@@ -615,7 +531,7 @@ var resumeUnsafeMarkers = []string{
|
||||
}
|
||||
|
||||
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
|
||||
func (b *Backend) rotateIfPoisoned(workDir, transcript string) {
|
||||
func (b *Backend) rotateIfPoisoned(workDir string) {
|
||||
reason := strings.ToLower(b.failureReason(workDir))
|
||||
if reason == "" {
|
||||
return
|
||||
@@ -630,10 +546,7 @@ func (b *Backend) rotateIfPoisoned(workDir, transcript string) {
|
||||
if !unsafe {
|
||||
return
|
||||
}
|
||||
path := transcript
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||
}
|
||||
path := filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -642,36 +555,6 @@ func (b *Backend) rotateIfPoisoned(workDir, transcript string) {
|
||||
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
|
||||
}
|
||||
|
||||
// bindSessionScope records scope identity on the store and fail-closes when an
|
||||
// existing store's recorded identity differs or is absent for a non-empty scope
|
||||
// (do not auto-attribute old records to a new identity).
|
||||
func (b *Backend) bindSessionScope(store *session.Store, scopeID, model, execID string) error {
|
||||
meta := store.ReadMeta()
|
||||
if meta == nil {
|
||||
meta = map[string]string{}
|
||||
}
|
||||
if scopeID != "" {
|
||||
prev := strings.TrimSpace(meta["scope"])
|
||||
if prev != "" && prev != scopeID {
|
||||
return errString("session store scope identity mismatch: store has " + prev + ", request has " + scopeID)
|
||||
}
|
||||
if prev == "" {
|
||||
entries, err := store.Entries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return errString("session store lacks scope identity; refusing to adopt for scope " + scopeID)
|
||||
}
|
||||
}
|
||||
meta["scope"] = scopeID
|
||||
}
|
||||
meta["model"] = model
|
||||
meta["exec_id"] = execID
|
||||
meta["updated"] = time.Now().UTC().Format(time.RFC3339)
|
||||
return store.SaveMeta(meta)
|
||||
}
|
||||
|
||||
func (b *Backend) logger() *logsys.Logger {
|
||||
if b == nil || b.opts.Log == nil {
|
||||
return logsys.Nop()
|
||||
|
||||
@@ -111,79 +111,6 @@ func TestBackendReasoningProfileWritesText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendReasoningProfileRejectsPathEscape(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "work")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(parent, "pwned.md")
|
||||
leak := "SHOULD-NOT-ESCAPE"
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: leak, StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
// Traversal: authorize-before-write must reject and leave zero target writes.
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "escape", ToolProfile: exec.ToolProfileReasoning,
|
||||
Targets: []string{"safe.md", "../pwned.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||
t.Fatalf("want path escape failure, got %+v", res)
|
||||
}
|
||||
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("traversal must not create outside file: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "safe.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatal("rejected batch must perform zero target writes")
|
||||
}
|
||||
|
||||
// Absolute path outside workDir.
|
||||
res, err = b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "escape-abs", ToolProfile: exec.ToolProfileReasoning,
|
||||
Targets: []string{outside},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||
t.Fatalf("want absolute escape failure, got %+v", res)
|
||||
}
|
||||
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("absolute escape must not create outside file: %v", statErr)
|
||||
}
|
||||
|
||||
// Symlink component leaving the workspace.
|
||||
outsideDir := filepath.Join(parent, "outside-dir")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "escape-symlink", ToolProfile: exec.ToolProfileReasoning,
|
||||
Targets: []string{"link/secret.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||
t.Fatalf("want symlink escape failure, got %+v", res)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outsideDir, "secret.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("symlink escape must not write outside: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFailClosedWithoutConfig(t *testing.T) {
|
||||
b := New(Options{})
|
||||
res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
||||
|
||||
@@ -39,7 +39,6 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
if profile == "" {
|
||||
profile = exec.ToolProfileReasoning
|
||||
}
|
||||
scopeID := scopeIdentity(req.SessionScope)
|
||||
old := exec.Request{
|
||||
Prompt: req.Prompt,
|
||||
Instructions: req.Instructions,
|
||||
@@ -48,7 +47,6 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
ToolProfile: profile,
|
||||
ToolAllowlist: append([]string(nil), req.ToolPolicy.Allowlist...),
|
||||
WorkDir: req.Workspace.Root,
|
||||
SessionScope: scopeID,
|
||||
OnEvent: onEvent,
|
||||
}
|
||||
if b := req.ContractBudget; b.MaxPromptTokens > 0 || b.MaxCompletionTokens > 0 || !b.Deadline.IsZero() {
|
||||
@@ -58,15 +56,6 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
Deadline: b.Deadline,
|
||||
}
|
||||
}
|
||||
// CapText / F15: pure-text contract tasks must not force implicit file delivery.
|
||||
explicitTargets := len(req.Targets) > 0
|
||||
if !explicitTargets && profile == exec.ToolProfileReasoning {
|
||||
ctx = withAllowEmptyTargets(ctx)
|
||||
}
|
||||
ctx = withWritable(ctx, req.Workspace.Writable)
|
||||
var finalText string
|
||||
ctx = withTextSink(ctx, &finalText)
|
||||
|
||||
res, err := c.b.Execute(ctx, old)
|
||||
if err != nil {
|
||||
// 传输/实现级错误:按契约映射为 Unknown,不经 Go error 返回业务终态。
|
||||
@@ -85,16 +74,7 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
switch {
|
||||
case res.Success:
|
||||
out.Outcome = exec.OutcomeSucceeded
|
||||
if explicitTargets {
|
||||
out.Files = res.Patches
|
||||
}
|
||||
// CapText: surface the real assistant text for pure-text success.
|
||||
out.Text = finalText
|
||||
if out.Text == "" && len(res.Patches) == 1 {
|
||||
for _, body := range res.Patches {
|
||||
out.Text = string(body)
|
||||
}
|
||||
}
|
||||
out.Files = res.Patches
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
out.Outcome = exec.OutcomeCanceled
|
||||
out.Err = &exec.Error{Code: exec.ErrCanceled, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// Contract-binding context keys: Writable / CapText / empty-target semantics
|
||||
// stay inside the adapter so exec.Request remains unchanged for the legacy facade.
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
keyWritable ctxKey = iota
|
||||
keyAllowEmptyTargets
|
||||
keyTextSink
|
||||
)
|
||||
|
||||
func withWritable(ctx context.Context, paths []string) context.Context {
|
||||
if len(paths) == 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyWritable, append([]string(nil), paths...))
|
||||
}
|
||||
|
||||
func writableFrom(ctx context.Context) []string {
|
||||
v, _ := ctx.Value(keyWritable).([]string)
|
||||
return v
|
||||
}
|
||||
|
||||
func withAllowEmptyTargets(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, keyAllowEmptyTargets, true)
|
||||
}
|
||||
|
||||
func allowEmptyTargets(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(keyAllowEmptyTargets).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func withTextSink(ctx context.Context, sink *string) context.Context {
|
||||
if sink == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyTextSink, sink)
|
||||
}
|
||||
|
||||
func captureText(ctx context.Context, text string) {
|
||||
if sink, ok := ctx.Value(keyTextSink).(*string); ok && sink != nil {
|
||||
*sink = text
|
||||
}
|
||||
}
|
||||
|
||||
// scopeIdentity returns a stable non-empty identity string for a non-zero
|
||||
// SessionScope, or "" for the legacy one-shot / empty-scope path.
|
||||
func scopeIdentity(s exec.SessionScope) string {
|
||||
t := strings.TrimSpace(s.Tenant)
|
||||
p := strings.TrimSpace(s.Project)
|
||||
sess := strings.TrimSpace(s.Session)
|
||||
if t == "" && p == "" && sess == "" {
|
||||
return ""
|
||||
}
|
||||
return t + "/" + p + "/" + sess
|
||||
}
|
||||
|
||||
// transcriptPathForScope binds storage identity to SessionScope.
|
||||
// Empty scope keeps the historical fixed path under .agentx/transcript.jsonl.
|
||||
func transcriptPathForScope(workDir, scopeID string) string {
|
||||
if strings.TrimSpace(scopeID) == "" {
|
||||
return filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(scopeID))
|
||||
return filepath.Join(workDir, ".agentx", "transcript."+hex.EncodeToString(sum[:16])+".jsonl")
|
||||
}
|
||||
|
||||
func pathInWriteSet(allow []string, rel string) bool {
|
||||
if len(allow) == 0 {
|
||||
return true
|
||||
}
|
||||
rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
|
||||
for _, a := range allow {
|
||||
if filepath.ToSlash(filepath.Clean(strings.TrimSpace(a))) == rel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/session"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
func TestContractScopeIsolationNoSharedTranscript(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "from-a", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
}).Contract()
|
||||
|
||||
scopeA := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-a"}
|
||||
scopeB := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-b"}
|
||||
resA, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-a",
|
||||
Prompt: "hello-a",
|
||||
SessionScope: scopeA,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
})
|
||||
if err != nil || resA.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("scope A: %+v %v", resA, err)
|
||||
}
|
||||
|
||||
// Fresh backend/stream for scope B so replies differ.
|
||||
b2 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "from-b", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
}).Contract()
|
||||
resB, err := b2.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-b",
|
||||
Prompt: "hello-b",
|
||||
SessionScope: scopeB,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
})
|
||||
if err != nil || resB.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("scope B: %+v %v", resB, err)
|
||||
}
|
||||
|
||||
pathA := transcriptPathForScope(dir, scopeIdentity(scopeA))
|
||||
pathB := transcriptPathForScope(dir, scopeIdentity(scopeB))
|
||||
if pathA == pathB {
|
||||
t.Fatal("different scopes must not share a transcript path")
|
||||
}
|
||||
if pathA == filepath.Join(dir, ".agentx", "transcript.jsonl") {
|
||||
t.Fatal("non-empty scope must not use the legacy fixed transcript path")
|
||||
}
|
||||
storeA, err := session.Open(pathA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storeB, err := session.Open(pathB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgsA, err := storeA.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgsB, err := storeB.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joinedA := messagesBlob(msgsA)
|
||||
joinedB := messagesBlob(msgsB)
|
||||
if strings.Contains(joinedA, "hello-b") || strings.Contains(joinedA, "from-b") {
|
||||
t.Fatalf("scope A transcript leaked B context: %s", joinedA)
|
||||
}
|
||||
if strings.Contains(joinedB, "hello-a") || strings.Contains(joinedB, "from-a") {
|
||||
t.Fatalf("scope B transcript leaked A context: %s", joinedB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractMatchingScopeCanResume(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "resume-me"}
|
||||
b1 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "first", StopReason: llm.StopEnd}),
|
||||
},
|
||||
}).Contract()
|
||||
if res, err := b1.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e1", Prompt: "turn-1", SessionScope: scope,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("first turn: %+v %v", res, err)
|
||||
}
|
||||
|
||||
var sawPrior bool
|
||||
b2 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
for _, m := range messages {
|
||||
if m.Role == llm.RoleUser && m.Content == "turn-1" {
|
||||
sawPrior = true
|
||||
}
|
||||
if m.Role == llm.RoleAssistant && m.Content == "first" {
|
||||
sawPrior = true
|
||||
}
|
||||
}
|
||||
ch := make(chan llm.Event, 2)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "second"}
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "second", StopReason: llm.StopEnd}}
|
||||
}()
|
||||
return ch
|
||||
},
|
||||
},
|
||||
}).Contract()
|
||||
if res, err := b2.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e2", Prompt: "turn-2", SessionScope: scope,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("resume turn: %+v %v", res, err)
|
||||
}
|
||||
if !sawPrior {
|
||||
t.Fatal("matching scope must resume prior transcript context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractOldStoreWithoutIdentityNotAdopted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "orphan"}
|
||||
path := transcriptPathForScope(dir, scopeIdentity(scope))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pre-seed records with no scope identity in meta.
|
||||
body := "{\"role\":\"user\",\"content\":\"old\"}\n{\"role\":\"assistant\",\"content\":\"ghost\"}\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "new", StopReason: llm.StopEnd}),
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-orphan", Prompt: "hi", SessionScope: scope,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transport: %v", err)
|
||||
}
|
||||
if res.Outcome == exec.OutcomeSucceeded {
|
||||
t.Fatal("must not auto-adopt a store lacking scope identity")
|
||||
}
|
||||
if res.Err == nil || !strings.Contains(res.Err.Reason, "scope identity") {
|
||||
t.Fatalf("want scope-identity refusal, got %+v", res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractWritableFileToolHonorsAllowlist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "write_file", Arguments: `{"path":"allowed.md","content":"ok"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c2", Name: "write_file", Arguments: `{"path":"denied.md","content":"no"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-w",
|
||||
Prompt: "write",
|
||||
Workspace: exec.WorkspaceHandle{
|
||||
Root: dir,
|
||||
Writable: []string{"allowed.md"},
|
||||
},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"allowed.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome != exec.OutcomeSucceeded {
|
||||
// Tool denial is soft (IsError to model); run may still succeed if allowed.md exists.
|
||||
t.Logf("outcome=%s err=%+v", res.Outcome, res.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "allowed.md")); err != nil {
|
||||
t.Fatalf("allowed write missing: %v (outcome=%s)", err, res.Outcome)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "denied.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("denied.md must not be written under Writable allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractWritableRejectsExecInPreflight(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-exec",
|
||||
Prompt: "x",
|
||||
Workspace: exec.WorkspaceHandle{
|
||||
Root: dir,
|
||||
Writable: []string{"a.md"},
|
||||
},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"a.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome == exec.OutcomeSucceeded {
|
||||
t.Fatal("Writable + exec tools enabled must be rejected in preflight")
|
||||
}
|
||||
if res.Err == nil || !strings.Contains(res.Err.Reason, "writable") {
|
||||
t.Fatalf("want writable preflight error, got %+v", res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractPureTextSuccessCarriesText(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "纯文本答复内容", StopReason: llm.StopEnd}),
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-text",
|
||||
Prompt: "说点什么",
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
// empty ToolPolicy → reasoning; empty Targets → CapText, no file delivery
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("outcome=%s err=%+v", res.Outcome, res.Err)
|
||||
}
|
||||
if strings.TrimSpace(res.Text) == "" {
|
||||
t.Fatal("CapText pure-text success must carry non-empty Text")
|
||||
}
|
||||
if !strings.Contains(res.Text, "纯文本答复内容") {
|
||||
t.Fatalf("Text=%q", res.Text)
|
||||
}
|
||||
if len(res.Files) != 0 {
|
||||
t.Fatalf("pure-text must not force file delivery, Files=%v", res.Files)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "output.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("pure-text must not implicitly write output.md")
|
||||
}
|
||||
}
|
||||
|
||||
func messagesBlob(msgs []llm.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
b.WriteString(string(m.Role))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(m.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
type fidelityEcho struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f fidelityEcho) Name() string { return "echo" }
|
||||
func (f fidelityEcho) Description() string { return "echo" }
|
||||
func (f fidelityEcho) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (f fidelityEcho) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||
if f.fail {
|
||||
return tools.Result{Content: "native-boom", IsError: true}, nil
|
||||
}
|
||||
return tools.Result{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func TestNativeProcessEventPassesCallIDAndStatus(t *testing.T) {
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "n-a", Name: "echo", Arguments: `{}`},
|
||||
{ID: "n-b", Name: "echo", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
HostTools: []tools.Tool{fidelityEcho{}},
|
||||
},
|
||||
})
|
||||
var starts, ends []exec.ProcessEvent
|
||||
_, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||
OnEvent: func(ev exec.ProcessEvent) {
|
||||
switch ev.Type {
|
||||
case "tool_execution_start":
|
||||
starts = append(starts, ev)
|
||||
case "tool_execution_end":
|
||||
ends = append(ends, ev)
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(starts) != 2 || len(ends) != 2 {
|
||||
t.Fatalf("starts=%d ends=%d", len(starts), len(ends))
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, ev := range starts {
|
||||
if ev.CallID == "" {
|
||||
t.Fatalf("start missing CallID: %+v", ev)
|
||||
}
|
||||
ids[ev.CallID] = true
|
||||
}
|
||||
if !ids["n-a"] || !ids["n-b"] {
|
||||
t.Fatalf("expected distinct CallIDs n-a/n-b, got %v", ids)
|
||||
}
|
||||
for _, ev := range ends {
|
||||
if ev.CallID == "" || !ids[ev.CallID] {
|
||||
t.Fatalf("end CallID unpaired: %+v", ev)
|
||||
}
|
||||
if ev.Status != "ok" {
|
||||
t.Fatalf("end Status=%q want ok: %+v", ev.Status, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeProcessEventFailingToolStatusError(t *testing.T) {
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "n-fail", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
HostTools: []tools.Tool{fidelityEcho{fail: true}},
|
||||
},
|
||||
})
|
||||
var end exec.ProcessEvent
|
||||
_, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||
OnEvent: func(ev exec.ProcessEvent) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
end = ev
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if end.CallID != "n-fail" || end.Status != "error" || end.ErrorReason != "native-boom" {
|
||||
t.Fatalf("end = %+v", end)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestR2PoisonMarkerSurvivesNewProcess(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := New(Options{})
|
||||
b.rotateIfPoisoned(dir, transcript)
|
||||
b.rotateIfPoisoned(dir)
|
||||
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
|
||||
t.Fatal("poisoned transcript still at the live path")
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// TestExecuteCanceledIsNotRetryable: aborted/canceled runs carry Canceled
|
||||
// semantics with Retryable=false and SideEffects=possible.
|
||||
func TestExecuteCanceledIsNotRetryable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 1)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
ch <- llm.Event{
|
||||
Kind: llm.KindError,
|
||||
Err: ctx.Err(),
|
||||
Retryable: true, // provider may mark abort retryable; adapter must not.
|
||||
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopAborted},
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
res, err := b.Execute(ctx, exec.Request{
|
||||
WorkDir: dir, Prompt: "cancel me", Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Fatalf("canceled run must not succeed: %+v", res)
|
||||
}
|
||||
if !res.Canceled {
|
||||
t.Fatalf("want Canceled=true, got %+v", res)
|
||||
}
|
||||
if res.Retryable {
|
||||
t.Fatalf("canceled must not be Retryable: %+v", res)
|
||||
}
|
||||
if res.EffectiveSideEffects() != exec.SideEffectPossible {
|
||||
t.Fatalf("canceled SideEffects = %q, want possible", res.EffectiveSideEffects())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteToolFailureSideEffectsPossible: failure after tools may have run
|
||||
// reports SideEffects=possible.
|
||||
func TestExecuteToolFailureSideEffectsPossible(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var call int
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 2)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
call++
|
||||
if call == 1 {
|
||||
msg := llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "write_file", Arguments: `{"path":"scratch.md","content":"side effect"}`,
|
||||
}}}
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
|
||||
return
|
||||
}
|
||||
ch <- llm.Event{
|
||||
Kind: llm.KindError,
|
||||
Err: errString("provider failed after tools"),
|
||||
Message: llm.Message{Role: llm.RoleAssistant, Content: "boom", StopReason: llm.StopError},
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
},
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "write then fail", Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
if res.Success || res.Canceled {
|
||||
t.Fatalf("want definitive failure after tools, got %+v", res)
|
||||
}
|
||||
if res.SideEffects != exec.SideEffectPossible {
|
||||
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "scratch.md")); statErr != nil {
|
||||
t.Fatalf("tool side effect missing: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutePreflightSideEffectsNone: validation rejection before tools
|
||||
// reports SideEffects=none.
|
||||
func TestExecutePreflightSideEffectsNone(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
opts Options
|
||||
req exec.Request
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing-config",
|
||||
opts: Options{},
|
||||
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
|
||||
want: "requires",
|
||||
},
|
||||
{
|
||||
name: "bad-profile",
|
||||
opts: Options{ModelOptions: ModelOptions{Stream: script(), Model: "fake"}},
|
||||
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"},
|
||||
want: "tool profile",
|
||||
},
|
||||
{
|
||||
name: "missing-model",
|
||||
opts: Options{ModelOptions: ModelOptions{APIBase: "http://x", APIKey: "k"}},
|
||||
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
|
||||
want: "model",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := New(tc.opts).Execute(context.Background(), tc.req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
if res.Success || res.Canceled || res.Unknown || res.Retryable {
|
||||
t.Fatalf("preflight must be definitive non-retry failure: %+v", res)
|
||||
}
|
||||
if res.SideEffects != exec.SideEffectNone {
|
||||
t.Fatalf("SideEffects = %q, want none", res.SideEffects)
|
||||
}
|
||||
if !strings.Contains(res.ErrorReason, tc.want) {
|
||||
t.Fatalf("ErrorReason = %q, want substring %q", res.ErrorReason, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSuccessSideEffectsReflectTools: success after tool dispatch
|
||||
// reports SideEffects=possible; patches also imply possible.
|
||||
func TestExecuteSuccessSideEffectsReflectTools(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"ok"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "write", Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil || !res.Success {
|
||||
t.Fatalf("want success, got err=%v res=%+v", err, res)
|
||||
}
|
||||
if res.SideEffects != exec.SideEffectPossible {
|
||||
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 70b39a9f70...8b54508964
@@ -54,14 +54,6 @@ type ProcessEvent struct {
|
||||
Text string // delta 文本(Type 为 *_delta 时)
|
||||
Tool string // 工具名(tool_* 时)
|
||||
Detail string // 工具参数/结果摘要(tool_execution_* 时)
|
||||
// CallID 是提供方 tool-call id;在 tool_call_delta / tool_execution_start /
|
||||
// tool_execution_end 时填写。零值 = 旧行为(消费方仍可按 Tool 名启发式配对)。
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
|
||||
// 空串 = legacy/unknown,不得臆造。
|
||||
Status string `json:"status,omitempty"`
|
||||
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
|
||||
ErrorReason string `json:"error_reason,omitempty"`
|
||||
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
@@ -84,34 +76,10 @@ type Result struct {
|
||||
ErrorReason string `json:"error_reason"`
|
||||
// Retryable marks a provider-transient failure that may receive a
|
||||
// fresh step invocation. Permanent missing deliverables stay false.
|
||||
// Canceled and Unknown are never retryable; do not set Retryable with them.
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
// PromptTokens and CompletionTokens are set by pi-native when the provider reports usage.
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
|
||||
// ---- terminal fidelity (optional; zero-value = legacy behavior) ----
|
||||
|
||||
// Canceled marks an aborted/canceled run. Distinct from Retryable:
|
||||
// a canceled action must not be auto-retried (Retryable must stay false).
|
||||
Canceled bool `json:"canceled,omitempty"`
|
||||
// Unknown marks that execution reached the provider/tool stage and the
|
||||
// terminal outcome could not be determined (side effects may exist).
|
||||
// Unknown is not a retryable failure.
|
||||
Unknown bool `json:"unknown,omitempty"`
|
||||
// SideEffects qualifies external side-effect risk for retry/fallback.
|
||||
// Empty means SideEffectPossible — the safe legacy reading. Set
|
||||
// SideEffectNone only when no tool dispatch (or equivalent) is provable.
|
||||
SideEffects SideEffects `json:"side_effects,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveSideEffects returns the side-effect qualifier for retry decisions.
|
||||
// An empty SideEffects value means SideEffectPossible (backward compatible).
|
||||
func (r *Result) EffectiveSideEffects() SideEffects {
|
||||
if r == nil || r.SideEffects == "" {
|
||||
return SideEffectPossible
|
||||
}
|
||||
return r.SideEffects
|
||||
}
|
||||
|
||||
type Backend interface {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProcessEventLegacyOmitempty:零值新字段不出现在 JSON 中,旧消费方
|
||||
// 忽略未知字段时看到的遗留载荷与仅填 Type/Tool/Text/Detail 一致。
|
||||
func TestProcessEventLegacyOmitempty(t *testing.T) {
|
||||
legacy := ProcessEvent{Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}"}
|
||||
withZeros := ProcessEvent{
|
||||
Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}",
|
||||
CallID: "", Status: "", ErrorReason: "",
|
||||
}
|
||||
a, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := json.Marshal(withZeros)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(a) != string(b) {
|
||||
t.Fatalf("zero new fields changed JSON:\n legacy=%s\n zeros=%s", a, b)
|
||||
}
|
||||
if strings.Contains(string(a), "call_id") || strings.Contains(string(a), "status") ||
|
||||
strings.Contains(string(a), "error_reason") {
|
||||
t.Fatalf("omitempty leaked zero fields: %s", a)
|
||||
}
|
||||
|
||||
populated := ProcessEvent{
|
||||
Type: "tool_execution_end", Tool: "echo", Text: "boom",
|
||||
CallID: "c1", Status: "error", ErrorReason: "boom",
|
||||
}
|
||||
raw, err := json.Marshal(populated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
for _, want := range []string{`"call_id":"c1"`, `"status":"error"`, `"error_reason":"boom"`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("missing %s in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package exec_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// TestResultZeroValueKeepsLegacyMeaning pins backward compatibility:
|
||||
// empty SideEffects reads as possible; Canceled/Unknown stay false.
|
||||
func TestResultZeroValueKeepsLegacyMeaning(t *testing.T) {
|
||||
var r exec.Result
|
||||
if r.Success || r.Retryable || r.Canceled || r.Unknown {
|
||||
t.Fatalf("zero Result must keep legacy false flags, got %+v", r)
|
||||
}
|
||||
if r.SideEffects != "" {
|
||||
t.Fatalf("zero SideEffects must be empty (legacy), got %q", r.SideEffects)
|
||||
}
|
||||
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
|
||||
t.Fatalf("EffectiveSideEffects() = %q, want %q", got, exec.SideEffectPossible)
|
||||
}
|
||||
r.SideEffects = exec.SideEffectNone
|
||||
if got := r.EffectiveSideEffects(); got != exec.SideEffectNone {
|
||||
t.Fatalf("explicit none: EffectiveSideEffects() = %q", got)
|
||||
}
|
||||
r.SideEffects = exec.SideEffectPossible
|
||||
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
|
||||
t.Fatalf("explicit possible: EffectiveSideEffects() = %q", got)
|
||||
}
|
||||
}
|
||||
+22
-81
@@ -3,7 +3,6 @@ package runtime
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -43,13 +42,7 @@ type Event struct {
|
||||
ToolIndex int
|
||||
// CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的
|
||||
// ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。
|
||||
// 不得从 ToolIndex 反推;ToolIndex 语义不变。
|
||||
CallID string
|
||||
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
|
||||
// 空串 = legacy/unknown,不得臆造。
|
||||
Status string
|
||||
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
|
||||
ErrorReason string
|
||||
// Detail carries tool arguments/results or reasoning deltas for
|
||||
// process-visualization consumers; log writers may ignore it.
|
||||
Detail string
|
||||
@@ -205,13 +198,6 @@ type Result struct {
|
||||
Notice string
|
||||
Err error
|
||||
Retryable bool
|
||||
// Canceled is true when the run stopped due to abort/cancel (StopAborted
|
||||
// or context cancellation). Canceled runs are never retryable.
|
||||
Canceled bool
|
||||
// ToolCalls is the number of tool invocations dispatched during the run.
|
||||
// Adapters use it to set SideEffects (0 before any dispatch => none where
|
||||
// the failure is otherwise definitive; otherwise possible).
|
||||
ToolCalls int
|
||||
}
|
||||
|
||||
// Run executes the turn loop until the model stops, follow-ups drain, or the turn cap is hit.
|
||||
@@ -346,28 +332,19 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
for turn := 0; turn < cfg.MaxTurns; turn++ {
|
||||
if ctx.Err() != nil {
|
||||
cfg.logger().Error("runtime turn aborted", ctx.Err())
|
||||
return Result{
|
||||
Text: last.Content, Messages: messages, StopReason: llm.StopAborted,
|
||||
Err: ctx.Err(), Canceled: true, ToolCalls: toolCalls,
|
||||
}, nil
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopAborted, Err: ctx.Err()}, nil
|
||||
}
|
||||
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
|
||||
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
|
||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||
}
|
||||
emit(cfg, Event{Type: "turn_start"})
|
||||
emit(cfg, Event{Type: "message_start"})
|
||||
request := prepareRequest(cfg, messages, turn, &repeats, &lengthNudge)
|
||||
end, errEv := consume(ctx, cfg, request)
|
||||
if errEv != nil {
|
||||
canceled := end.Message.StopReason == llm.StopAborted ||
|
||||
errors.Is(errEv, context.Canceled) || errors.Is(ctx.Err(), context.Canceled)
|
||||
retryable := end.Retryable && !canceled
|
||||
cfg.logger().Error("runtime turn failed", errEv, "retryable", retryable, "canceled", canceled)
|
||||
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
|
||||
emitUsage(cfg, usage)
|
||||
return Result{
|
||||
Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason,
|
||||
Err: errEv, Retryable: retryable, Canceled: canceled, ToolCalls: toolCalls,
|
||||
}, nil
|
||||
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
|
||||
}
|
||||
last = end.Message
|
||||
usage.PromptTokens += end.Usage.PromptTokens
|
||||
@@ -383,7 +360,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
cfg.logger().Error("runtime terminal stream failed", end.Err)
|
||||
emitUsage(cfg, usage)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError,
|
||||
Usage: usage, Err: end.Err, Retryable: false, ToolCalls: toolCalls}, nil
|
||||
Usage: usage, Err: end.Err, Retryable: false}, nil
|
||||
}
|
||||
if err := persist(cfg, last); err != nil {
|
||||
return Result{}, err
|
||||
@@ -398,7 +375,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
|
||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||
}
|
||||
if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls {
|
||||
reason := fmt.Sprintf("budget_exceeded: tool calls %d+%d > max %d", toolCalls, len(last.ToolCalls), cfg.Budget.MaxToolCalls)
|
||||
@@ -407,7 +384,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
|
||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||
}
|
||||
toolCalls += len(last.ToolCalls)
|
||||
if cfg.RepeatGuard {
|
||||
@@ -441,8 +418,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
if lengthCap > 0 && lengthContinues >= lengthCap {
|
||||
errCap := errString("length continuation exceeded")
|
||||
cfg.logger().Error("runtime length continuation exceeded", errCap)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError,
|
||||
Usage: usage, Err: errCap, ToolCalls: toolCalls}, nil
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: errCap}, nil
|
||||
}
|
||||
lengthContinues++
|
||||
lengthNudge = true
|
||||
@@ -478,9 +454,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
return settle(cfg, last, messages, usage, toolCalls, decision.Notice), nil
|
||||
return settle(cfg, last, messages, usage, decision.Notice), nil
|
||||
}
|
||||
return settle(cfg, last, messages, usage, toolCalls, ""), nil
|
||||
return settle(cfg, last, messages, usage, ""), nil
|
||||
}
|
||||
errCap := errString("max turns exceeded")
|
||||
cfg.logger().Error("runtime turn cap hit", errCap)
|
||||
@@ -490,7 +466,6 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
StopReason: llm.StopError,
|
||||
Usage: usage,
|
||||
Err: errCap,
|
||||
ToolCalls: toolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -604,9 +579,6 @@ func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.M
|
||||
}
|
||||
|
||||
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
|
||||
// 并行工具执行下各 goroutine 各自构造 Event 值(无跨调用共享缓冲);
|
||||
// 此处再串行化 OnEvent,避免消费方在回调内聚合时字段交错。回调须保持
|
||||
// 同步、非阻塞(与 Config.OnEvent 文档一致)。
|
||||
func serializeHostCallbacks(cfg *Config) {
|
||||
var mu sync.Mutex
|
||||
if cfg.OnEvent != nil {
|
||||
@@ -645,9 +617,7 @@ func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.
|
||||
out := make([]llm.Message, 0, len(assistant.ToolCalls))
|
||||
for _, call := range assistant.ToolCalls {
|
||||
if err := ctx.Err(); err != nil {
|
||||
msg := cancelledToolMessage(call, err)
|
||||
emitCancelledTool(cfg, call, msg)
|
||||
out = append(out, msg)
|
||||
out = append(out, cancelledToolMessage(call, err))
|
||||
continue
|
||||
}
|
||||
out = append(out, runOne(ctx, cfg, call))
|
||||
@@ -675,10 +645,7 @@ func executeMixed(ctx context.Context, cfg Config, assistant llm.Message) []llm.
|
||||
}
|
||||
for _, i := range serialIdx {
|
||||
if err := ctx.Err(); err != nil {
|
||||
call := assistant.ToolCalls[i]
|
||||
msg := cancelledToolMessage(call, err)
|
||||
emitCancelledTool(cfg, call, msg)
|
||||
out[i] = msg
|
||||
out[i] = cancelledToolMessage(assistant.ToolCalls[i], err)
|
||||
continue
|
||||
}
|
||||
out[i] = runOne(ctx, cfg, assistant.ToolCalls[i])
|
||||
@@ -711,22 +678,6 @@ func cancelledToolMessage(call llm.ToolCall, err error) llm.Message {
|
||||
}
|
||||
}
|
||||
|
||||
// emitCancelledTool 发出未执行/中止的 start+end;Status=canceled,不得报 ok。
|
||||
func emitCancelledTool(cfg Config, call llm.ToolCall, msg llm.Message) {
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||
emitToolEnd(cfg, call, msg.Content, "canceled")
|
||||
}
|
||||
|
||||
// emitToolEnd 投影 tool_execution_end:CallID 取自 call,Status/ErrorReason
|
||||
// 仅在已知时填写(空 Status = legacy/unknown)。
|
||||
func emitToolEnd(cfg Config, call llm.ToolCall, text, status string) {
|
||||
ev := Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: text, Status: status}
|
||||
if status == "error" || status == "denied" || status == "canceled" {
|
||||
ev.ErrorReason = text
|
||||
}
|
||||
emit(cfg, ev)
|
||||
}
|
||||
|
||||
func rejectIncompleteToolCall(call llm.ToolCall) string {
|
||||
if strings.TrimSpace(call.Name) == "" {
|
||||
return "工具调用未执行:名称不完整"
|
||||
@@ -820,14 +771,12 @@ func ToolCallFromContext(ctx context.Context) (llm.ToolCall, bool) {
|
||||
|
||||
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
if err := ctx.Err(); err != nil {
|
||||
msg := cancelledToolMessage(call, err)
|
||||
emitCancelledTool(cfg, call, msg)
|
||||
return msg
|
||||
return cancelledToolMessage(call, err)
|
||||
}
|
||||
if why := rejectIncompleteToolCall(call); why != "" {
|
||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||
emitToolEnd(cfg, call, why, "error")
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: why})
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
}
|
||||
@@ -835,7 +784,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
}
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||
if gated, msg, blocked := gateTool(ctx, cfg, call); blocked {
|
||||
emitToolEnd(cfg, call, msg.Content, "denied")
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -847,7 +796,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
if cfg.BeforeTool != nil {
|
||||
if err := cfg.BeforeTool(call); err != nil {
|
||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||
emitToolEnd(cfg, call, msg.Content, "error")
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -858,7 +807,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
|
||||
if err := commitToolIntent(cfg, call); err != nil {
|
||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||
emitToolEnd(cfg, call, msg.Content, "error")
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
||||
return msg
|
||||
}
|
||||
endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{
|
||||
@@ -884,15 +833,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
Content: res.Content,
|
||||
IsError: res.IsError,
|
||||
}
|
||||
status := "ok"
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
// 取消优先于工具自报的 ok/error:过程事件不得把中止报成 ok。
|
||||
status = "canceled"
|
||||
case res.IsError:
|
||||
status = "error"
|
||||
}
|
||||
emitToolEnd(cfg, call, res.Content, status)
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: res.Content})
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -900,11 +841,11 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, notice string) Result {
|
||||
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, notice string) Result {
|
||||
emit(cfg, Event{Type: "agent_end", Message: last})
|
||||
emit(cfg, Event{Type: "agent_settled", Message: last})
|
||||
emitUsage(cfg, usage)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice, ToolCalls: toolCalls}
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice}
|
||||
}
|
||||
|
||||
func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
|
||||
@@ -1259,12 +1200,12 @@ func budgetExceeded(b *Budget, usage llm.Usage, toolCalls int, now time.Time) st
|
||||
}
|
||||
|
||||
// budgetStop 以预算超限收尾:发事件、留日志、按 StopError 返回(不可重试)。
|
||||
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, reason string) Result {
|
||||
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, reason string) Result {
|
||||
emit(cfg, Event{Type: "budget_exceeded", Text: reason})
|
||||
emitUsage(cfg, usage)
|
||||
err := errString(reason)
|
||||
cfg.logger().Error("runtime budget exceeded", err)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err, ToolCalls: toolCalls}
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err}
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
@@ -45,8 +45,5 @@ func TestLoopToolEventsCarryCallID(t *testing.T) {
|
||||
if ends[0].CallID != "call-7" || ends[0].Tool != "echo" {
|
||||
t.Fatalf("end = %+v", ends[0])
|
||||
}
|
||||
if ends[0].Status != "ok" || ends[0].ErrorReason != "" {
|
||||
t.Fatalf("successful end must be ok with empty ErrorReason: %+v", ends[0])
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
type failTool struct{ reason string }
|
||||
|
||||
func (f failTool) Name() string { return "echo" }
|
||||
func (f failTool) Description() string { return "echo that fails" }
|
||||
func (f failTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (f failTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||
return tools.Result{Content: f.reason, IsError: true}, nil
|
||||
}
|
||||
|
||||
// TestLoopSameNameToolsCarryDistinctCallIDs 同名工具连续/并行调用时,
|
||||
// start/end 的 CallID 必须互异且可按 ID 配对(不得按 Tool 名合并)。
|
||||
func TestLoopSameNameToolsCarryDistinctCallIDs(t *testing.T) {
|
||||
for _, mode := range []string{ExecSequential, ExecParallel} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
var starts, ends []Event
|
||||
var mu sync.Mutex
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
ToolExecution: mode,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "call-a", Name: "echo", Arguments: `{}`},
|
||||
{ID: "call-b", Name: "echo", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch ev.Type {
|
||||
case "tool_execution_start":
|
||||
starts = append(starts, ev)
|
||||
case "tool_execution_end":
|
||||
ends = append(ends, ev)
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 2 || len(starts) != 2 || len(ends) != 2 {
|
||||
t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends))
|
||||
}
|
||||
byStart := map[string]Event{}
|
||||
for _, ev := range starts {
|
||||
if ev.CallID == "" || ev.Tool != "echo" {
|
||||
t.Fatalf("start missing identity: %+v", ev)
|
||||
}
|
||||
if _, dup := byStart[ev.CallID]; dup {
|
||||
t.Fatalf("duplicate start CallID %q", ev.CallID)
|
||||
}
|
||||
byStart[ev.CallID] = ev
|
||||
}
|
||||
for _, ev := range ends {
|
||||
if ev.Status != "ok" {
|
||||
t.Fatalf("end status = %q, want ok: %+v", ev.Status, ev)
|
||||
}
|
||||
if _, ok := byStart[ev.CallID]; !ok {
|
||||
t.Fatalf("end CallID %q has no matching start", ev.CallID)
|
||||
}
|
||||
delete(byStart, ev.CallID)
|
||||
}
|
||||
if len(byStart) != 0 {
|
||||
t.Fatalf("unpaired starts: %+v", byStart)
|
||||
}
|
||||
if _, ok := map[string]Event{ends[0].CallID: {}, ends[1].CallID: {}}["call-a"]; !ok {
|
||||
t.Fatalf("missing call-a in ends %+v", ends)
|
||||
}
|
||||
if ends[0].CallID == ends[1].CallID {
|
||||
t.Fatalf("CallIDs must differ, both %q", ends[0].CallID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFailingToolEndStatusError(t *testing.T) {
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(failTool{reason: "boom-detail"})
|
||||
var end Event
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c-fail", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
end = ev
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if end.CallID != "c-fail" || end.Status != "error" || end.ErrorReason != "boom-detail" {
|
||||
t.Fatalf("end = %+v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDeniedToolEndStatusDenied(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.PreToolUse {
|
||||
return hooks.Outcome{Decision: hooks.Deny, Reason: "策略拒绝"}
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
var end Event
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c-deny", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
end = ev
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("tool ran %d times", hits)
|
||||
}
|
||||
if end.CallID != "c-deny" || end.Status != "denied" || end.ErrorReason != "策略拒绝" {
|
||||
t.Fatalf("end = %+v", end)
|
||||
}
|
||||
if end.Status == "ok" {
|
||||
t.Fatal("denied must not be reported ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopCanceledToolEndStatusCanceled(t *testing.T) {
|
||||
bt := &blockingTool{canceled: make(chan struct{})}
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(bt)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
firstCall := true
|
||||
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 4)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
if firstCall {
|
||||
firstCall = false
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||
ToolCalls: []llm.ToolCall{{ID: "c-cancel", Name: "block_forever", Arguments: `{}`}},
|
||||
}}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
|
||||
case <-time.After(3 * time.Second):
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
var ends []Event
|
||||
var mu sync.Mutex
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
_, err := Run(ctx, Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Stream: stream,
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
mu.Lock()
|
||||
ends = append(ends, ev)
|
||||
mu.Unlock()
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "block"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(ends) == 0 {
|
||||
t.Fatal("expected tool_execution_end")
|
||||
}
|
||||
end := ends[0]
|
||||
if end.CallID != "c-cancel" {
|
||||
t.Fatalf("CallID = %q", end.CallID)
|
||||
}
|
||||
if end.Status != "canceled" {
|
||||
t.Fatalf("Status = %q, want canceled (not ok); end=%+v", end.Status, end)
|
||||
}
|
||||
if end.ErrorReason == "" {
|
||||
t.Fatalf("ErrorReason empty on canceled: %+v", end)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelBatchTool struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c cancelBatchTool) Name() string { return "cancel_batch" }
|
||||
func (c cancelBatchTool) Description() string { return "cancels ctx then returns" }
|
||||
func (c cancelBatchTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (c cancelBatchTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||
c.cancel()
|
||||
return tools.Result{Content: "cancelled-siblings"}, nil
|
||||
}
|
||||
|
||||
// TestLoopSkippedCancelEmitsCanceledNotOK:串行批内前一个调用取消 ctx 后,
|
||||
// 后续未执行的调用必须发 Status=canceled,不得报 ok。
|
||||
func TestLoopSkippedCancelEmitsCanceledNotOK(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(cancelBatchTool{cancel: cancel})
|
||||
hits := 0
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
var ends []Event
|
||||
_, err := Run(ctx, Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
ToolExecution: ExecSequential,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "c-first", Name: "cancel_batch", Arguments: `{}`},
|
||||
{ID: "c-skip", Name: "echo", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
ends = append(ends, ev)
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
_ = err
|
||||
if hits != 0 {
|
||||
t.Fatalf("echo should not run after cancel, hits=%d", hits)
|
||||
}
|
||||
var skip *Event
|
||||
for i := range ends {
|
||||
if ends[i].CallID == "c-skip" {
|
||||
skip = &ends[i]
|
||||
}
|
||||
}
|
||||
if skip == nil {
|
||||
t.Fatalf("missing end for skipped call: %+v", ends)
|
||||
}
|
||||
if skip.Status != "canceled" {
|
||||
t.Fatalf("skipped end Status=%q want canceled: %+v", skip.Status, skip)
|
||||
}
|
||||
if skip.ErrorReason == "" || strings.Contains(skip.Status, "ok") {
|
||||
t.Fatalf("skipped end malformed: %+v", skip)
|
||||
}
|
||||
}
|
||||
@@ -153,11 +153,6 @@ func (s *Store) Name() string {
|
||||
}
|
||||
|
||||
func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
|
||||
// Exclusive-writer: repair a torn/missing-newline tail before append so the
|
||||
// next load does not see mid-file JSON corruption from concatenated bytes.
|
||||
if err := s.repairTailLocked(); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
entries, err := s.readEntriesLocked()
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
@@ -196,45 +191,6 @@ func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// repairTailLocked fixes only the final line under exclusive-writer semantics:
|
||||
// truncate an invalid torn fragment, or append a missing newline after a valid
|
||||
// final record. Mid-file corruption is left untouched so the next read still
|
||||
// fails closed.
|
||||
func (s *Store) repairTailLocked() error {
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(raw) == 0 || raw[len(raw)-1] == '\n' {
|
||||
return nil
|
||||
}
|
||||
text := string(raw)
|
||||
idx := strings.LastIndexByte(text, '\n')
|
||||
var prefix, fragment string
|
||||
if idx >= 0 {
|
||||
prefix = text[:idx+1]
|
||||
fragment = text[idx+1:]
|
||||
} else {
|
||||
fragment = text
|
||||
}
|
||||
if fragment == "" {
|
||||
return nil
|
||||
}
|
||||
var fixed string
|
||||
if json.Valid([]byte(fragment)) {
|
||||
fixed = prefix + fragment + "\n"
|
||||
} else {
|
||||
fixed = prefix
|
||||
}
|
||||
if fixed == text {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(s.path, []byte(fixed), 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) readEntriesLocked() ([]Entry, error) {
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
|
||||
@@ -81,111 +81,6 @@ func TestLegacyTranscriptStillLoads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairTornTailBeforeAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "torn.jsonl")
|
||||
// Valid record + torn final fragment (no closing brace / newline).
|
||||
body := "{\"role\":\"user\",\"content\":\"ok\"}\n{\"role\":\"assistant\",\"content\":\"tor"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "repaired"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs, err := s.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load after repair: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 || msgs[0].Content != "ok" || msgs[1].Content != "repaired" {
|
||||
t.Fatalf("msgs after torn repair: %+v", msgs)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !strings.HasSuffix(string(raw), "\n") {
|
||||
t.Fatal("file must end with newline after repair-append")
|
||||
}
|
||||
if strings.Contains(string(raw), "tor\n{") || strings.Contains(string(raw), "tor{") {
|
||||
t.Fatalf("torn fragment was concatenated: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairMissingNewlineBeforeAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nonewline.jsonl")
|
||||
// Complete JSON record without trailing newline.
|
||||
body := "{\"role\":\"user\",\"content\":\"hi\"}"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "next"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs, err := s.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load after newline repair: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 || msgs[0].Content != "hi" || msgs[1].Content != "next" {
|
||||
t.Fatalf("msgs: %+v", msgs)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
// Two separate JSON lines — not one concatenated object.
|
||||
lines := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("want 2 lines, got %d in %q", len(lines), raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidFileCorruptionStillErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mid.jsonl")
|
||||
body := "{\"role\":\"user\",\"content\":\"a\"}\n{not-json}\n{\"role\":\"assistant\",\"content\":\"b\"}\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Load(); err == nil {
|
||||
t.Fatal("mid-file corruption must be a hard error on load")
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "x"}); err == nil {
|
||||
t.Fatal("mid-file corruption must be a hard error on append")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairThenAppendThenLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "roundtrip.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"one\"}\n{\"role\":\"as"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "two"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "three"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs, err := s.Load()
|
||||
if err != nil || len(msgs) != 3 {
|
||||
t.Fatalf("roundtrip load: %+v %v", msgs, err)
|
||||
}
|
||||
if msgs[0].Content != "one" || msgs[1].Content != "two" || msgs[2].Content != "three" {
|
||||
t.Fatalf("contents: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogContinueResumeAndHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(filepath.Join(dir, "sess.jsonl"))
|
||||
|
||||
@@ -78,10 +78,9 @@ func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, err
|
||||
}
|
||||
|
||||
type writeFile struct {
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
writable []string // empty = unrestricted (product default)
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
}
|
||||
|
||||
func (t *writeFile) Name() string { return "write_file" }
|
||||
@@ -103,9 +102,6 @@ func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, er
|
||||
if err != nil {
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
if msg := rejectStaleWrite(t.files, path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
@@ -123,10 +119,9 @@ func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, er
|
||||
}
|
||||
|
||||
type editFile struct {
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
writable []string
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
}
|
||||
|
||||
func (t *editFile) Name() string { return "edit_file" }
|
||||
@@ -152,9 +147,6 @@ func (t *editFile) Execute(_ context.Context, args json.RawMessage) (Result, err
|
||||
if err != nil {
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
if msg := rejectStaleWrite(t.files, path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
@@ -302,10 +294,9 @@ func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, err
|
||||
|
||||
// multiEditTool 一次多处替换:全部命中且互不冲突才落盘,任一失败即整体不动。
|
||||
type multiEditTool struct {
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
writable []string
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
}
|
||||
|
||||
func (t *multiEditTool) Name() string { return "multi_edit" }
|
||||
@@ -336,9 +327,6 @@ func (t *multiEditTool) Execute(_ context.Context, args json.RawMessage) (Result
|
||||
if err != nil {
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
if msg := rejectStaleWrite(t.files, path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
|
||||
+12
-124
@@ -9,7 +9,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -24,7 +23,6 @@ type ExecConfig struct {
|
||||
Bash string
|
||||
Python string
|
||||
// Env 传给子进程的完整白名单环境(KEY=VALUE);不含本进程任何隐式变量。
|
||||
// nil 在注册/启动时归一为空白名单(不继承宿主环境);显式空切片同样为空白名单。
|
||||
Env []string
|
||||
// ScriptDir 落盘脚本目录(相对 root,默认 .agent/scripts);空用默认。
|
||||
ScriptDir string
|
||||
@@ -36,16 +34,6 @@ const (
|
||||
execMaxOutput = 64 << 10
|
||||
)
|
||||
|
||||
// normalizeExecEnv maps a nil env to an empty whitelist baseline so os/exec
|
||||
// does not inherit the host environment. An explicit empty or non-nil slice
|
||||
// is returned unchanged.
|
||||
func normalizeExecEnv(env []string) []string {
|
||||
if env == nil {
|
||||
return []string{}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// blockedCommandMarkers 最小命令黑名单:明显提权/自毁模式直接拒绝。
|
||||
// 这不是沙箱,只是防呆;真实隔离靠宿主环境。
|
||||
var blockedCommandMarkers = []string{
|
||||
@@ -57,7 +45,6 @@ var blockedCommandMarkers = []string{
|
||||
|
||||
// RegisterExecTools 注册 run_command / run_python(解释器未配置的项跳过注册)。
|
||||
func RegisterExecTools(r *Registry, root string, cfg ExecConfig) {
|
||||
cfg.Env = normalizeExecEnv(cfg.Env)
|
||||
if strings.TrimSpace(cfg.Bash) != "" {
|
||||
r.Register(&runCommandTool{root: root, cfg: cfg})
|
||||
}
|
||||
@@ -94,8 +81,8 @@ func (t *runCommandTool) Execute(ctx context.Context, args json.RawMessage) (Res
|
||||
return Result{Content: "command rejected by blocklist (sudo / destructive patterns are not allowed)", IsError: true}, nil
|
||||
}
|
||||
timeout := clampTimeout(obj.TimeoutSeconds)
|
||||
out, total, timedOut, err := runProcess(ctx, t.cfg.Bash, []string{"-c", obj.Command}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, total, timedOut, err)
|
||||
out, timedOut, err := runProcess(ctx, t.cfg.Bash, []string{"-c", obj.Command}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, timedOut, err)
|
||||
return Result{Content: content, IsError: timedOut || err != nil}, nil
|
||||
}
|
||||
|
||||
@@ -137,20 +124,19 @@ func (t *runPythonTool) Execute(ctx context.Context, args json.RawMessage) (Resu
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
timeout := clampTimeout(obj.TimeoutSeconds)
|
||||
out, total, timedOut, err := runProcess(ctx, t.cfg.Python, []string{script}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, total, timedOut, err)
|
||||
out, timedOut, err := runProcess(ctx, t.cfg.Python, []string{script}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, timedOut, err)
|
||||
content = "script: " + filepath.ToSlash(filepath.Join(dir, name)) + "\n" + content
|
||||
return Result{Content: content, IsError: timedOut || err != nil}, nil
|
||||
}
|
||||
|
||||
// runProcess 启动独立进程组的子进程;ctx 取消或超时先到者杀整组(SIGKILL)。
|
||||
// 合并输出持续排入有界头尾缓冲,避免 CombinedOutput 无界增长;Wait 回收子进程。
|
||||
func runProcess(ctx context.Context, interpreter string, argv []string, dir string, env []string, timeout time.Duration) (out []byte, total int, timedOut bool, err error) {
|
||||
func runProcess(ctx context.Context, interpreter string, argv []string, dir string, env []string, timeout time.Duration) (out []byte, timedOut bool, err error) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(runCtx, interpreter, argv...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = normalizeExecEnv(env)
|
||||
cmd.Env = env
|
||||
// 独立进程组:超时/取消时杀 -PGID,确保孙进程一并退出。
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
@@ -160,13 +146,9 @@ func runProcess(ctx context.Context, interpreter string, argv []string, dir stri
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 3 * time.Second
|
||||
buf := newHeadTailBuffer(execMaxOutput)
|
||||
cmd.Stdout = buf
|
||||
cmd.Stderr = buf
|
||||
err = cmd.Run()
|
||||
out, total = buf.Snapshot()
|
||||
out, err = cmd.CombinedOutput()
|
||||
timedOut = runCtx.Err() != nil && (ctx.Err() == nil || runCtx.Err() != ctx.Err())
|
||||
return out, total, timedOut, err
|
||||
return out, timedOut, err
|
||||
}
|
||||
|
||||
func clampTimeout(seconds int) time.Duration {
|
||||
@@ -189,108 +171,14 @@ func blocked(command string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// headTailBuffer keeps a fixed-size head and a ring-buffer tail while counting
|
||||
// all bytes written, so chatty children cannot grow memory without bound.
|
||||
type headTailBuffer struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
half int
|
||||
head []byte
|
||||
tail []byte
|
||||
tailPos int
|
||||
tailFilled int
|
||||
total int
|
||||
}
|
||||
|
||||
func newHeadTailBuffer(max int) *headTailBuffer {
|
||||
if max < 2 {
|
||||
max = 2
|
||||
}
|
||||
half := max / 2
|
||||
return &headTailBuffer{
|
||||
max: max,
|
||||
half: half,
|
||||
head: make([]byte, 0, half),
|
||||
tail: make([]byte, half),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *headTailBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
n := len(p)
|
||||
for len(p) > 0 {
|
||||
if len(b.head) < b.half {
|
||||
take := b.half - len(b.head)
|
||||
if take > len(p) {
|
||||
take = len(p)
|
||||
}
|
||||
b.head = append(b.head, p[:take]...)
|
||||
b.total += take
|
||||
p = p[take:]
|
||||
continue
|
||||
}
|
||||
// Head is full: remaining bytes rotate through the fixed tail ring.
|
||||
for i := 0; i < len(p); i++ {
|
||||
b.tail[b.tailPos] = p[i]
|
||||
b.tailPos = (b.tailPos + 1) % b.half
|
||||
if b.tailFilled < b.half {
|
||||
b.tailFilled++
|
||||
}
|
||||
}
|
||||
b.total += len(p)
|
||||
break
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Snapshot returns retained bytes and the total byte count observed.
|
||||
// When total <= max the slice is the full stream; when truncated it is
|
||||
// head||tail of length max (callers use total for the dropped-bytes marker).
|
||||
func (b *headTailBuffer) Snapshot() ([]byte, int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.total <= b.half {
|
||||
out := make([]byte, len(b.head))
|
||||
copy(out, b.head)
|
||||
return out, b.total
|
||||
}
|
||||
tail := b.tailBytesLocked()
|
||||
out := make([]byte, 0, len(b.head)+len(tail))
|
||||
out = append(out, b.head...)
|
||||
out = append(out, tail...)
|
||||
return out, b.total
|
||||
}
|
||||
|
||||
func (b *headTailBuffer) tailBytesLocked() []byte {
|
||||
if b.tailFilled == 0 {
|
||||
return nil
|
||||
}
|
||||
if b.tailFilled < b.half {
|
||||
out := make([]byte, b.tailFilled)
|
||||
copy(out, b.tail[:b.tailFilled])
|
||||
return out
|
||||
}
|
||||
out := make([]byte, b.half)
|
||||
n := copy(out, b.tail[b.tailPos:])
|
||||
copy(out[n:], b.tail[:b.tailPos])
|
||||
return out
|
||||
}
|
||||
|
||||
// formatProcessOutput 截断为头尾各半(总上限 execMaxOutput)并标注退出状态。
|
||||
// out 为有界捕获结果;total 为子进程实际写出字节数(可大于 len(out))。
|
||||
func formatProcessOutput(out []byte, total int, timedOut bool, err error) string {
|
||||
func formatProcessOutput(out []byte, timedOut bool, err error) string {
|
||||
text := string(out)
|
||||
if total > execMaxOutput {
|
||||
if len(out) > execMaxOutput {
|
||||
half := execMaxOutput / 2
|
||||
dropped := total - execMaxOutput
|
||||
dropped := len(out) - execMaxOutput
|
||||
head := CutUTF8(string(out), half)
|
||||
tail := ""
|
||||
if len(out) > half {
|
||||
tail = string(out[len(out)-half:])
|
||||
} else if len(out) > len(head) {
|
||||
tail = string(out[len(head):])
|
||||
}
|
||||
tail := string(out[len(out)-half:])
|
||||
for len(tail) > 0 && !utf8.RuneStart(tail[0]) {
|
||||
tail = tail[1:]
|
||||
}
|
||||
|
||||
@@ -54,37 +54,6 @@ func TestRunCommandEnvWhitelist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandNilEnvIsEmptyWhitelist(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak")
|
||||
root := t.TempDir()
|
||||
reg := NewRegistry(root)
|
||||
// Env left nil: must normalize to empty whitelist (not inherit host).
|
||||
RegisterExecTools(reg, root, ExecConfig{Bash: bash})
|
||||
res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`)
|
||||
if res.IsError {
|
||||
t.Fatal(res.Content)
|
||||
}
|
||||
if !strings.Contains(res.Content, "secret=absent") {
|
||||
t.Fatalf("nil Env must not inherit host secrets, got:\n%s", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandExplicitEmptyEnvStaysEmpty(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak")
|
||||
root := t.TempDir()
|
||||
reg := NewRegistry(root)
|
||||
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{}})
|
||||
res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`)
|
||||
if res.IsError {
|
||||
t.Fatal(res.Content)
|
||||
}
|
||||
if !strings.Contains(res.Content, "secret=absent") {
|
||||
t.Fatalf("explicit empty Env must stay empty, got:\n%s", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandTimeoutKillsProcessGroup(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
root := t.TempDir()
|
||||
@@ -160,71 +129,6 @@ func TestRunCommandOutputTruncation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadTailBufferBoundsMemoryAndPreservesSmall(t *testing.T) {
|
||||
buf := newHeadTailBuffer(64)
|
||||
if _, err := buf.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, total := buf.Snapshot()
|
||||
if total != 5 || string(out) != "hello" {
|
||||
t.Fatalf("small output must be unchanged, got %q total=%d", out, total)
|
||||
}
|
||||
|
||||
buf = newHeadTailBuffer(64)
|
||||
var payload strings.Builder
|
||||
for i := 0; i < 200; i++ {
|
||||
payload.WriteByte(byte('A' + (i % 26)))
|
||||
}
|
||||
if _, err := buf.Write([]byte(payload.String())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, total = buf.Snapshot()
|
||||
if total != 200 {
|
||||
t.Fatalf("total must count all bytes, got %d", total)
|
||||
}
|
||||
if len(out) != 64 {
|
||||
t.Fatalf("retained buffer must be capped at 64, got %d", len(out))
|
||||
}
|
||||
if string(out[:32]) != payload.String()[:32] {
|
||||
t.Fatalf("head mismatch: %q", out[:32])
|
||||
}
|
||||
if string(out[32:]) != payload.String()[200-32:] {
|
||||
t.Fatalf("tail mismatch: %q", out[32:])
|
||||
}
|
||||
// Internal retention stays O(max), not O(total).
|
||||
if cap(buf.head)+cap(buf.tail) > 64+8 {
|
||||
t.Fatalf("buffer capacity must stay near max, head=%d tail=%d", cap(buf.head), cap(buf.tail))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandChattyOutputCancelDrains(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
root := t.TempDir()
|
||||
reg := NewRegistry(root)
|
||||
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
// Chatty child: continuous stdout until killed; capture must stay bounded and Wait must reap.
|
||||
res := reg.Execute(ctx, "run_command", []byte(
|
||||
`{"command":"while true; do head -c 8192 /dev/zero | tr '\\0' 'y'; done","timeout_seconds":60}`,
|
||||
))
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 10*time.Second {
|
||||
t.Fatalf("cancel must drain/reap promptly, took %s", elapsed)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatalf("canceled chatty command must be an error result, got %+v", res)
|
||||
}
|
||||
if len(res.Content) > execMaxOutput+512 {
|
||||
t.Fatalf("chatty cancel output must stay capped, got %d", len(res.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPythonPersistsScriptAndRuns(t *testing.T) {
|
||||
python := lookInterpreter(t, "python3")
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -353,45 +352,6 @@ func rejectStaleWrite(files *filestate.Tracker, path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// rejectOutsideWriteSet rejects rel when a non-empty write-set was declared and
|
||||
// rel is not one of the listed relative paths. Empty allow = unrestricted.
|
||||
func rejectOutsideWriteSet(allow []string, rel string) string {
|
||||
if len(allow) == 0 {
|
||||
return ""
|
||||
}
|
||||
rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
|
||||
if rel == "." || rel == "" {
|
||||
return "path is outside the declared writable set"
|
||||
}
|
||||
for _, a := range allow {
|
||||
if filepath.ToSlash(filepath.Clean(strings.TrimSpace(a))) == rel {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "path is outside the declared writable set"
|
||||
}
|
||||
|
||||
// ApplyWriteAllowlist threads a WorkspaceHandle.Writable set into document
|
||||
// write tools (write_file / edit_file / multi_edit). Empty allow is a no-op.
|
||||
func ApplyWriteAllowlist(r *Registry, allow []string) {
|
||||
if r == nil || len(allow) == 0 {
|
||||
return
|
||||
}
|
||||
cp := append([]string(nil), allow...)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, t := range r.tools {
|
||||
switch w := t.(type) {
|
||||
case *writeFile:
|
||||
w.writable = cp
|
||||
case *editFile:
|
||||
w.writable = cp
|
||||
case *multiEditTool:
|
||||
w.writable = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustPath(root, rel string) (string, error) {
|
||||
return Resolve(root, rel)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteAllowlistRestrictsDocumentTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
reg := WorkspaceTools(root)
|
||||
ApplyWriteAllowlist(reg, []string{"allowed.md"})
|
||||
|
||||
ok := reg.Execute(context.Background(), "write_file", []byte(`{"path":"allowed.md","content":"yes"}`))
|
||||
if ok.IsError {
|
||||
t.Fatalf("allowed write rejected: %s", ok.Content)
|
||||
}
|
||||
denied := reg.Execute(context.Background(), "write_file", []byte(`{"path":"other.md","content":"no"}`))
|
||||
if !denied.IsError {
|
||||
t.Fatal("write outside writable set must fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "other.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("denied path must not be created")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user