Compare commits

...
Author SHA1 Message Date
weichen ac91b8436e Pin the nested agent-intent to the U5 snapshot fix
Why: composition consistency - Dev pins agent-intent at 765a7e7; the
native container's nested copy must match (check-pins).
Evidence: pin only; intent library verified by verify-u5.
Impact: nested gitlink pointer change.
Refs: feature/agent-stack-audit-remediation-b1
2026-10-21 17:44:55 +00:00
weichen 5c0d2d87dd Carry side-effect and cancellation semantics in exec results
Why: F05 prep - exec.Result could not distinguish canceled runs from
retryable failures or express side-effect uncertainty, so callers
inferred outcomes from error text.
Evidence: exec/adapters/runtime tests pass (verify-u3a.json);
canceled and side-effect population cases added.
Impact: zero-value results keep legacy meaning; no interface change.
Refs: feature/agent-stack-audit-remediation-b1 U3a
2026-10-21 16:15:03 +00:00
weichen afbb567301 Bind session scope identity, enforce write sets, repair torn JSONL
Why: F02/F04/F15 - contract requests shared one transcript across
session scopes, Writable was dropped, and a torn JSONL tail poisoned
the next append; pure-text contract results lost their text.
Evidence: adapters/native, runtime/session, runtime/tools and
conformance tests pass (verify-u2.json); scope-isolation, write-set and
torn-tail regression cases added.
Impact: empty scope keeps legacy one-shot semantics; identity-less old
transcripts are never auto-adopted by a new scope.
Refs: feature/agent-stack-audit-remediation-b1 U2
2026-10-21 16:03:04 +00:00
weichen 2fc66be652 Merge pull request 'Release tool calls on a verified route contract without [DONE]' (#48) from feature/fast-slow-o2-i1-route-contract into main
ci / docker-checkout-hostgateway (push) Successful in 11s
governance / governance (push) Successful in 53s
ci / vet-and-test (push) Successful in 1m23s
2026-10-20 05:54:30 +00:00
agent-exec-transport 899fd70d27 agent-exec transport e38bcf1cbe415c5d6a3e91773d811b0a/445c377b4484
ci / docker-checkout-hostgateway (pull_request) Successful in 12s
governance / governance (pull_request) Successful in 35s
ci / vet-and-test (pull_request) Successful in 1m24s
2026-10-20 04:03:18 +00:00
weichen 9c24e220dd Merge pull request 'Guard streamed tool terminals and retain Usage' (#46) from feature/fast-slow-u3-r04-stream-guard into main
ci / docker-checkout-hostgateway (push) Successful in 1s
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 55s
2026-10-19 09:20:09 +00:00
agent-exec-transport 28f3ea7674 agent-exec transport d7776a79d43bd8fd44e6c0d78873761b/9585ecb5ab4b
governance / governance (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
ci / vet-and-test (pull_request) Successful in 54s
2026-10-19 03:50:46 +00:00
26 changed files with 2405 additions and 162 deletions
+146 -32
View File
@@ -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 {
if len(req.Targets) == 0 && !allowEmptyTargets(ctx) {
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 &exec.Result{Success: false, ErrorReason: "unknown tool profile: " + profile}, nil
return preflightFail("unknown tool profile: " + profile), nil
}
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
return preflightFail("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 &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil
return preflightFail("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 &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(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 &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
if strings.TrimSpace(req.Instructions) != "" {
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644)
@@ -207,13 +207,28 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
}
for _, name := range req.Skills {
if !indexSet[name] {
return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil
return preflightFail("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)
@@ -237,7 +252,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 &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
default:
b.opts.MCP.StartBackground(ctx, reg)
@@ -246,32 +261,31 @@ 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 &exec.Result{Success: false, ErrorReason: "host tool name conflicts with a builtin: " + tool.Name()}, nil
return preflightFail("host tool name conflicts with a builtin: " + tool.Name()), nil
}
reg.Register(tool)
}
if err := reg.Restrict(req.ToolAllowlist); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
}
// Scope-keyed transcript: empty scope keeps the legacy fixed path.
transcript := transcriptPathForScope(workDir, req.SessionScope)
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
b.rotateIfPoisoned(workDir)
store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl"))
b.rotateIfPoisoned(workDir, transcript)
store, err := session.Open(transcript)
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
stream := b.opts.Stream
if stream == nil {
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
stream = client.Stream
}
_ = store.SaveMeta(map[string]string{
"model": model,
"scope": req.SessionScope,
"exec_id": req.ExecutionID,
"updated": time.Now().UTC().Format(time.RFC3339),
})
if err := b.bindSessionScope(store, req.SessionScope, model, req.ExecutionID); err != nil {
return preflightFail(err.Error()), nil
}
b.maybeCompact(ctx, store, stream, model)
timeout := req.Timeout
@@ -317,36 +331,82 @@ 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 {
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, 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
}
_ = 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 {
path := filepath.Join(workDir, filepath.FromSlash(target))
if b2, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b2))) > 0 {
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 {
continue
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(runRes.Text), 0o644)
_ = os.MkdirAll(filepath.Dir(at.path), 0o755)
_ = os.WriteFile(at.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: runRes.Retryable, Logs: logs.String()},
Result: &exec.Result{ErrorReason: reason, Retryable: retryable, Logs: logs.String()},
})
b.logger().Error("native step failure classified", runRes.Err,
"failure_class", assessment.Class.String(),
"work_dir", workDir)
res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
if runRes.StopReason == llm.StopAborted {
res.Retryable = true
"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
}
return res, nil
}
@@ -354,7 +414,14 @@ 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()}, 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: true,
@@ -362,6 +429,7 @@ 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
}
@@ -522,6 +590,19 @@ 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{
@@ -531,7 +612,7 @@ var resumeUnsafeMarkers = []string{
}
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
func (b *Backend) rotateIfPoisoned(workDir string) {
func (b *Backend) rotateIfPoisoned(workDir, transcript string) {
reason := strings.ToLower(b.failureReason(workDir))
if reason == "" {
return
@@ -546,7 +627,10 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
if !unsafe {
return
}
path := filepath.Join(workDir, ".agentx", "transcript.jsonl")
path := transcript
if strings.TrimSpace(path) == "" {
path = filepath.Join(workDir, ".agentx", "transcript.jsonl")
}
if _, err := os.Stat(path); err != nil {
return
}
@@ -555,6 +639,36 @@ func (b *Backend) rotateIfPoisoned(workDir 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()
+73
View File
@@ -111,6 +111,79 @@ 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"}})
+21 -1
View File
@@ -39,6 +39,7 @@ 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,
@@ -47,6 +48,7 @@ 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() {
@@ -56,6 +58,15 @@ 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 返回业务终态。
@@ -74,7 +85,16 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
switch {
case res.Success:
out.Outcome = exec.OutcomeSucceeded
out.Files = res.Patches
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)
}
}
case errors.Is(ctx.Err(), context.Canceled):
out.Outcome = exec.OutcomeCanceled
out.Err = &exec.Error{Code: exec.ErrCanceled, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
+91
View File
@@ -0,0 +1,91 @@
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
}
+291
View File
@@ -0,0 +1,291 @@
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 -1
View File
@@ -20,7 +20,7 @@ func TestR2PoisonMarkerSurvivesNewProcess(t *testing.T) {
t.Fatal(err)
}
b := New(Options{})
b.rotateIfPoisoned(dir)
b.rotateIfPoisoned(dir, transcript)
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
t.Fatal("poisoned transcript still at the live path")
}
+178
View File
@@ -0,0 +1,178 @@
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)
}
}
+24
View File
@@ -76,10 +76,34 @@ 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 {
+30
View File
@@ -0,0 +1,30 @@
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)
}
}
+85
View File
@@ -0,0 +1,85 @@
// Package argschema checks tool-call arguments against the small JSON-schema
// subset that tool declarations use. It has no dependencies beyond the
// standard library so both the tool registry and the LLM client can share it.
package argschema
import (
"encoding/json"
"fmt"
"strings"
)
// Validate reports whether args satisfy schema: the top-level type is object,
// required keys are present and not blank strings, declared properties have
// their primitive type, and unknown keys are rejected when properties are
// declared.
func Validate(schema, args json.RawMessage) error {
var spec struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties map[string]json.RawMessage `json:"properties"`
}
if len(schema) > 0 {
if err := json.Unmarshal(schema, &spec); err != nil {
return fmt.Errorf("invalid tool schema")
}
}
var obj map[string]any
if err := json.Unmarshal(args, &obj); err != nil {
return fmt.Errorf("arguments must be a JSON object")
}
if spec.Type != "" && spec.Type != "object" {
return fmt.Errorf("unsupported schema type %s", spec.Type)
}
for _, req := range spec.Required {
v, ok := obj[req]
if !ok || v == nil {
return fmt.Errorf("missing required argument %s", req)
}
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
return fmt.Errorf("missing required argument %s", req)
}
}
for key, raw := range spec.Properties {
v, ok := obj[key]
if !ok {
continue
}
var prop struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &prop); err != nil || prop.Type == "" {
continue
}
if !matchesType(prop.Type, v) {
return fmt.Errorf("argument %s must be %s", key, prop.Type)
}
}
if len(spec.Properties) > 0 {
for key := range obj {
if _, ok := spec.Properties[key]; !ok {
return fmt.Errorf("unknown argument %s", key)
}
}
}
return nil
}
func matchesType(kind string, v any) bool {
switch kind {
case "string":
_, ok := v.(string)
return ok
case "number":
_, ok := v.(float64)
return ok
case "integer":
f, ok := v.(float64)
return ok && f == float64(int64(f))
case "boolean":
_, ok := v.(bool)
return ok
default:
return true
}
}
+44
View File
@@ -0,0 +1,44 @@
package argschema
import (
"encoding/json"
"testing"
)
func TestValidate(t *testing.T) {
schema := json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"limit":{"type":"integer"},"ratio":{"type":"number"},"all":{"type":"boolean"},"any":{}}}`)
tests := []struct {
name string
schema json.RawMessage
args string
want string
}{
{"valid", schema, `{"path":"a.txt","limit":3,"ratio":0.5,"all":true,"any":[1]}`, ""},
{"no-schema-accepts-object", nil, `{"x":1}`, ""},
{"no-properties-accepts-unknown", json.RawMessage(`{"type":"object"}`), `{"x":1}`, ""},
{"invalid-schema", json.RawMessage(`[`), `{}`, "invalid tool schema"},
{"array-arguments", schema, `[1]`, "arguments must be a JSON object"},
{"broken-arguments", schema, `{"path":`, "arguments must be a JSON object"},
{"non-object-schema", json.RawMessage(`{"type":"array"}`), `{}`, "unsupported schema type array"},
{"missing-required", schema, `{}`, "missing required argument path"},
{"null-required", schema, `{"path":null}`, "missing required argument path"},
{"blank-required", schema, `{"path":" "}`, "missing required argument path"},
{"wrong-string", schema, `{"path":7}`, "argument path must be string"},
{"fractional-integer", schema, `{"path":"a","limit":1.5}`, "argument limit must be integer"},
{"wrong-number", schema, `{"path":"a","ratio":"x"}`, "argument ratio must be number"},
{"wrong-boolean", schema, `{"path":"a","all":1}`, "argument all must be boolean"},
{"unknown-key", schema, `{"path":"a","extra":1}`, "unknown argument extra"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := Validate(tc.schema, json.RawMessage(tc.args))
got := ""
if err != nil {
got = err.Error()
}
if got != tc.want {
t.Fatalf("Validate(%s, %s) = %q, want %q", tc.schema, tc.args, got, tc.want)
}
})
}
}
+30 -3
View File
@@ -26,6 +26,9 @@ type Client struct {
// SSE 的 reasoning_content 捕获照常生效)。对不认识该字段的服务器
// 属未知字段,多数按忽略处理;宿主按模型名自行决定是否置位。
ReasoningSplit bool
// RouteContract optionally releases tool calls from an openai-chat stream
// that ended without [DONE]; nil or the zero value is off.
RouteContract *RouteContractEOF
}
// Stream implements StreamFunc. Transport and provider failures are encoded as
@@ -127,12 +130,36 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
return
}
observed := newBodyObserver(resp)
for ev := range ParseSSE(ctx, observed) {
contract := c.newRouteContract(model, base, req, resp, tools)
var facts sseFacts
var hooks sseHooks
if contract != nil {
hooks.facts = &facts
hooks.decideEOF = func(success, failure Event) Event {
check := contract.check(observed.snapshot(), failure.TerminalObservation, facts, success.Message.ToolCalls)
if check != "" {
failure.TerminalObservation.ContractCheck = check
return failure
}
success.CompletionBasis = CompletionRouteContractEOF
success.TerminalObservation.CompletionBasis = CompletionRouteContractEOF
return success
}
}
for ev := range parseSSE(ctx, observed, hooks) {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
ev.HTTPStatus = resp.StatusCode
// ParseSSE sends its terminal event only after its last body read.
if ev.TerminalObservation != nil {
ev.TerminalObservation.BodyBoundary = observed.snapshot()
if obs := ev.TerminalObservation; obs != nil {
obs.BodyBoundary = observed.snapshot()
if ev.Kind == KindMessageEnd && ev.Err == nil && ev.StreamComplete {
ev.CompletionBasis = CompletionDoneMarker
obs.CompletionBasis = CompletionDoneMarker
}
if contract != nil && ev.Err != nil && !ev.StreamComplete &&
ev.Message.StopReason != StopAborted && obs.ContractCheck == "" {
obs.ContractCheck = contract.check(obs.BodyBoundary, obs, facts, ev.Message.ToolCalls)
}
}
}
out <- ev
+164
View File
@@ -0,0 +1,164 @@
package llm
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"strings"
"agent-native/runtime/argschema"
)
// Completion bases recorded on a successful OpenAI-chat message_end.
const (
CompletionDoneMarker = "done_marker"
CompletionRouteContractEOF = "route_contract_eof"
)
// RouteContractEOF lets one exact OpenAI-chat route release tool calls from a
// stream that ended without [DONE], when the HTTP body boundary and every
// frame prove the calls complete. The zero value is off. IdentitySHA256 must
// equal RouteContractIdentity(Model, BaseURL), and BaseURL must already be in
// NormalizeRouteBaseURL form.
type RouteContractEOF struct {
Model string
BaseURL string
IdentitySHA256 string
}
// RouteContractIdentity is the lowercase hex sha256 of
// "openai-chat\n" + model + "\n" + baseURL.
func RouteContractIdentity(model, baseURL string) string {
sum := sha256.Sum256([]byte("openai-chat\n" + model + "\n" + baseURL))
return hex.EncodeToString(sum[:])
}
// NormalizeRouteBaseURL returns the https base URL with lowercase scheme and
// host and no trailing '/'. Any other scheme, userinfo, query or fragment is
// not a match.
func NormalizeRouteBaseURL(raw string) (string, bool) {
if strings.ContainsAny(raw, "?#") {
return "", false
}
u, err := url.Parse(raw)
if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Opaque != "" ||
u.User != nil || u.Host == "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return "", false
}
return "https://" + strings.ToLower(u.Host) + strings.TrimRight(u.EscapedPath(), "/"), true
}
func (rc *RouteContractEOF) off() bool {
return rc == nil || *rc == RouteContractEOF{}
}
// admits is condition C1 apart from the redirect check.
func (rc *RouteContractEOF) admits(protocol Protocol, model, base string) bool {
if protocol != "" && protocol != ProtocolOpenAIChat {
return false
}
if model != rc.Model {
return false
}
normalized, ok := NormalizeRouteBaseURL(base)
if !ok || normalized != rc.BaseURL {
return false
}
return RouteContractIdentity(rc.Model, rc.BaseURL) == rc.IdentitySHA256
}
// routeContract evaluates a configured RouteContractEOF for one response.
type routeContract struct {
active bool
tools []ToolDecl
}
// newRouteContract returns nil when the client has no contract configured.
func (c *Client) newRouteContract(model, base string, req *http.Request, resp *http.Response, tools []ToolDecl) *routeContract {
if c.RouteContract.off() {
return nil
}
return &routeContract{
active: c.RouteContract.admits(c.Protocol, model, base) && sameTarget(req, resp),
tools: tools,
}
}
// sameTarget rejects any redirect that changed scheme, host, port or path.
func sameTarget(req *http.Request, resp *http.Response) bool {
if req == nil || req.URL == nil || resp == nil || resp.Request == nil || resp.Request.URL == nil {
return false
}
sent, final := req.URL, resp.Request.URL
return strings.EqualFold(sent.Scheme, final.Scheme) &&
strings.EqualFold(sent.Hostname(), final.Hostname()) &&
sent.Port() == final.Port() &&
sent.EscapedPath() == final.EscapedPath()
}
// check returns the first failing condition id, or "" when all hold.
func (rc *routeContract) check(boundary *BodyBoundaryObservation, obs *TerminalObservation, facts sseFacts, calls []ToolCall) string {
switch {
case !rc.active:
return "C1"
case !boundaryProven(boundary):
return "C2"
case obs == nil || obs.ReadOutcome != "clean_eof":
return "C3"
case facts.finishChunks != 1 || facts.finishReason != "tool_calls" || facts.multiChoice:
return "C4"
case !obs.UsagePresent:
return "C5"
case facts.fragmentAfterFinish:
return "C6"
case !callsComplete(calls):
return "C7"
case !callsDeclared(calls, rc.tools):
return "C8"
case obs.TailEventKind != "json_frame" || !obs.LastFrameJSONComplete:
return "C9"
}
return ""
}
func boundaryProven(b *BodyBoundaryObservation) bool {
if b == nil {
return false
}
return (b.FramingClass == "http2_stream" && b.EOFSeen) ||
(b.FramingClass == "known_length" && b.LengthRelation == "match")
}
func callsComplete(calls []ToolCall) bool {
if len(calls) == 0 {
return false
}
for _, call := range calls {
var args map[string]json.RawMessage
if call.ID == "" || call.Name == "" {
return false
}
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
return false
}
}
return true
}
func callsDeclared(calls []ToolCall, tools []ToolDecl) bool {
for _, call := range calls {
idx := -1
for i, decl := range tools {
if decl.Name == call.Name {
idx = i
break
}
}
if idx < 0 || argschema.Validate(tools[idx].Parameters, json.RawMessage(call.Arguments)) != nil {
return false
}
}
return true
}
+630
View File
@@ -0,0 +1,630 @@
package llm
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
const (
rcModel = "fixture-model"
rcEOFReason = "tool call stream ended before [DONE]"
)
var rcTools = []ToolDecl{
{Name: "read_PRIVATE_I1", Parameters: json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}}`)},
{Name: "list_PRIVATE_I1", Parameters: json.RawMessage(`{"type":"object","properties":{"dir":{"type":"string"}}}`)},
}
func rcFrame(v any) string {
raw, err := json.Marshal(v)
if err != nil {
panic(err)
}
return "data: " + string(raw) + "\n\n"
}
func rcChunk(choices ...map[string]any) string {
return rcFrame(map[string]any{"choices": choices})
}
func rcFrag(index int, id, name, args string) map[string]any {
fn := map[string]any{}
if name != "" {
fn["name"] = name
}
if args != "" {
fn["arguments"] = args
}
frag := map[string]any{"index": index, "function": fn}
if id != "" {
frag["id"] = id
}
return frag
}
func rcToolChoice(finish string, frags ...map[string]any) map[string]any {
choice := map[string]any{"delta": map[string]any{"tool_calls": frags}}
if finish != "" {
choice["finish_reason"] = finish
}
return choice
}
func rcFinishChunk(reason string) string {
return rcChunk(map[string]any{"delta": map[string]any{}, "finish_reason": reason})
}
var (
rcText = rcChunk(map[string]any{"delta": map[string]any{"content": "PRIVATE_TEXT_I1", "reasoning_content": "PRIVATE_THINK_I1"}})
rcCall1 = rcChunk(rcToolChoice("", rcFrag(0, "call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":`)))
rcCall2 = rcChunk(rcToolChoice("", rcFrag(0, "", "", `"PRIVATE_I1.txt"}`)))
rcFinish = rcFinishChunk("tool_calls")
rcUsage = rcFrame(map[string]any{"choices": []any{}, "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}})
rcDone = "data: [DONE]\n\n"
rcStream = rcText + rcCall1 + rcCall2 + rcFinish + rcUsage
rcCall = ToolCall{ID: "call_PRIVATE_I1", Name: "read_PRIVATE_I1", Arguments: `{"path":"PRIVATE_I1.txt"}`}
rcErrorFrame = rcFrame(map[string]any{"error": map[string]any{"message": "PRIVATE_PROVIDER_I1"}})
)
// rcSingleCall streams one complete call in one fragment with the given parts.
func rcSingleCall(id, name, args string) string {
return rcText + rcChunk(rcToolChoice("", rcFrag(0, id, name, args))) + rcFinish + rcUsage
}
func rcWrite(stream string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, stream)
w.(http.Flusher).Flush()
}
}
func rcHTTP2(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(h)
server.EnableHTTP2 = true
server.StartTLS()
t.Cleanup(server.Close)
return server
}
func rcTLS1(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewTLSServer(h)
t.Cleanup(server.Close)
return server
}
func rcPlain(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewServer(h)
t.Cleanup(server.Close)
return server
}
// rcHTTPClient trusts server like server.Client() but owns its connection
// pool, so each run starts on a fresh connection and HTTP/2 stream ids match.
func rcHTTPClient(t *testing.T, server *httptest.Server) *http.Client {
t.Helper()
base := server.Client().Transport.(*http.Transport)
transport := &http.Transport{TLSClientConfig: base.TLSClientConfig.Clone(), ForceAttemptHTTP2: true}
t.Cleanup(transport.CloseIdleConnections)
return &http.Client{Transport: transport}
}
func rcClientFor(t *testing.T, server *httptest.Server) *Client {
return &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: rcHTTPClient(t, server)}
}
func rcContract(model, base string) *RouteContractEOF {
return &RouteContractEOF{Model: model, BaseURL: base, IdentitySHA256: RouteContractIdentity(model, base)}
}
func rcFixtureClient(resp func() *http.Response) *Client {
return &Client{BaseURL: "https://fixture.invalid", APIKey: "fixture", HTTPClient: &http.Client{
Transport: terminalRoundTripFunc(func(r *http.Request) (*http.Response, error) {
res := resp()
res.Request = r
return res, nil
}),
}}
}
type rcSetup struct {
client *Client
contract *RouteContractEOF
opts StreamOptions
tools []ToolDecl
}
type rcResult struct {
end Event
releasedMessages int
releasedCalls int
}
func rcRun(t *testing.T, ctx context.Context, base *Client, contract *RouteContractEOF, tools []ToolDecl, opts StreamOptions, onEvent func(Event)) rcResult {
t.Helper()
client := *base
client.RouteContract = contract
var res rcResult
terminals := 0
for ev := range client.Stream(ctx, rcModel, []Message{{Role: RoleUser, Content: "hello"}}, tools, opts) {
if onEvent != nil {
onEvent(ev)
}
if ev.Kind != KindMessageEnd && ev.Kind != KindError {
continue
}
terminals++
res.end = ev
if ev.Kind == KindMessageEnd && ev.Err == nil && len(ev.Message.ToolCalls) > 0 {
res.releasedMessages++
res.releasedCalls += len(ev.Message.ToolCalls)
}
}
if terminals != 1 {
t.Fatalf("terminal events = %d, want 1", terminals)
}
rcRequireSafeLabels(t, res.end)
return res
}
// rcRequireSafeLabels is the sentinel scan: the new labels hold only their
// fixed enums and the observation never carries content, tool identity,
// arguments or error text.
func rcRequireSafeLabels(t *testing.T, end Event) {
t.Helper()
obs := end.TerminalObservation
if obs == nil {
t.Fatalf("terminal without observation: %#v", end)
}
switch end.CompletionBasis {
case "", CompletionDoneMarker, CompletionRouteContractEOF:
default:
t.Fatalf("completion basis outside enum: %q", end.CompletionBasis)
}
if obs.CompletionBasis != end.CompletionBasis {
t.Fatalf("observation basis %q != event basis %q", obs.CompletionBasis, end.CompletionBasis)
}
if c := obs.ContractCheck; c != "" && (len(c) != 2 || c[0] != 'C' || c[1] < '1' || c[1] > '9') {
t.Fatalf("contract check outside enum: %q", c)
}
raw, err := json.Marshal(obs)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{"PRIVATE", "[DONE]", "tool call", "fixture-model"} {
if strings.Contains(string(raw), secret) {
t.Fatalf("observation leaked %q: %s", secret, raw)
}
}
for _, label := range []string{end.CompletionBasis, obs.CompletionBasis, obs.ContractCheck} {
if strings.Contains(label, "PRIVATE") || strings.Contains(label, " ") {
t.Fatalf("label carries content: %q", label)
}
}
}
func rcStreamCase(server func(*testing.T, http.Handler) *httptest.Server, stream string) func(*testing.T) rcSetup {
return func(t *testing.T) rcSetup {
s := server(t, rcWrite(stream))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}
}
func rcH2(stream string) func(*testing.T) rcSetup { return rcStreamCase(rcHTTP2, stream) }
func rcRedirectCase(target func(t *testing.T) string) func(*testing.T) rcSetup {
return func(t *testing.T) rcSetup {
location := target(t)
mux := http.NewServeMux()
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, location, http.StatusTemporaryRedirect)
})
mux.HandleFunc("/final/chat/completions", rcWrite(rcStream))
s := rcHTTP2(t, mux)
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}
}
type rcCase struct {
name string
setup func(*testing.T) rcSetup
// want is done_marker, route_contract_eof, off, or a check id C1..C9.
want string
// reason is the contract-off failure text; empty skips the check.
reason string
calls []ToolCall
noUsage bool
}
func rcCases() []rcCase {
twoCalls := rcText + rcCall1 + rcCall2 +
rcChunk(rcToolChoice("", rcFrag(1, "call_PRIVATE_I1_b", "list_PRIVATE_I1", `{}`))) + rcFinish + rcUsage
return []rcCase{
// [DONE] keeps today's path whether or not the contract is configured.
{name: "done/contract-on", setup: rcH2(rcStream + rcDone), want: CompletionDoneMarker},
{name: "done/contract-c1-failing", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream + rcDone)(t)
s.contract = rcContract("other-model", s.client.BaseURL)
return s
}, want: CompletionDoneMarker},
{name: "done/known-length", setup: rcKnownLength(rcStream + rcDone), want: CompletionDoneMarker},
// Released only when every condition holds.
{name: "release/http2", setup: rcH2(rcStream), want: CompletionRouteContractEOF},
{name: "release/known-length-match", setup: rcKnownLength(rcStream), want: CompletionRouteContractEOF},
{name: "release/usage-before-finish", setup: rcH2(rcText + rcUsage + rcCall1 + rcCall2 + rcFinish), want: CompletionRouteContractEOF},
{name: "release/usage-after-finish", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + rcUsage), want: CompletionRouteContractEOF},
{name: "release/fragment-in-finish-chunk", setup: rcH2(rcText + rcCall1 +
rcChunk(rcToolChoice("tool_calls", rcFrag(0, "", "", `"PRIVATE_I1.txt"}`))) + rcUsage), want: CompletionRouteContractEOF},
{name: "release/two-calls", setup: rcH2(twoCalls), want: CompletionRouteContractEOF,
calls: []ToolCall{rcCall, {ID: "call_PRIVATE_I1_b", Name: "list_PRIVATE_I1", Arguments: `{}`}}},
{name: "release/api-base-override-to-contract", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.opts.APIBase = s.client.BaseURL + "/"
s.client.BaseURL = "https://unused.invalid"
return s
}, want: CompletionRouteContractEOF},
// The zero value is off.
{name: "off/zero-contract", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract = &RouteContractEOF{}
return s
}, want: "off", reason: rcEOFReason},
// C1 route identity.
{name: "c1/model-mismatch", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract = rcContract("other-model", s.client.BaseURL)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/base-http", setup: rcStreamCase(rcPlain, rcStream), want: "C1", reason: rcEOFReason},
{name: "c1/base-userinfo", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.client.BaseURL = strings.Replace(s.client.BaseURL, "https://", "https://user@", 1)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/base-query", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.client.BaseURL += "?x=1"
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/digest-mismatch", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract.IdentitySHA256 = strings.Repeat("0", 64)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/digest-uppercase", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract.IdentitySHA256 = strings.ToUpper(s.contract.IdentitySHA256)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/protocol-not-openai-chat", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.client.Protocol = ProtocolGoogle
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/api-base-override-elsewhere", setup: func(t *testing.T) rcSetup {
other := rcHTTP2(t, rcWrite(rcStream))
s := rcH2(rcStream)(t)
s.opts.APIBase = other.URL
s.client.HTTPClient = rcHTTPClient(t, other)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/redirect-other-host", setup: rcRedirectCase(func(t *testing.T) string {
return rcHTTP2(t, rcWrite(rcStream)).URL + "/chat/completions"
}), want: "C1", reason: rcEOFReason},
{name: "c1/redirect-same-host-other-path", setup: rcRedirectCase(func(t *testing.T) string {
return "/final/chat/completions"
}), want: "C1", reason: rcEOFReason},
{name: "c1/redirect-https-downgrade", setup: rcRedirectCase(func(t *testing.T) string {
return rcPlain(t, rcWrite(rcStream)).URL + "/chat/completions"
}), want: "C1", reason: rcEOFReason},
// C2 body boundary.
{name: "c2/known-length-mismatch", setup: func(t *testing.T) rcSetup {
client := rcFixtureClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1,
ContentLength: int64(len(rcStream) + 64), Header: http.Header{},
Body: io.NopCloser(strings.NewReader(rcStream))}
})
return rcSetup{client: client, contract: rcContract(rcModel, client.BaseURL)}
}, want: "C2", reason: rcEOFReason},
{name: "c2/unknown-framing", setup: func(t *testing.T) rcSetup {
client := rcFixtureClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ContentLength: -1, Header: http.Header{},
Body: io.NopCloser(strings.NewReader(rcStream))}
})
return rcSetup{client: client, contract: rcContract(rcModel, client.BaseURL)}
}, want: "C2", reason: rcEOFReason},
{name: "c2/chunked", setup: rcStreamCase(rcTLS1, rcStream), want: "C2", reason: rcEOFReason},
{name: "c2/close-delimited", setup: func(t *testing.T) rcSetup {
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Transfer-Encoding", "identity")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, rcStream)
w.(http.Flusher).Flush()
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}, want: "C2", reason: rcEOFReason},
// A reset or dropped stream never reaches body EOF, so the boundary
// condition fails before the read-outcome condition is evaluated.
{name: "c2/http2-rst-stream", setup: func(t *testing.T) rcSetup {
s := rcHTTP2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rcWrite(rcStream)(w, r)
panic(http.ErrAbortHandler)
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}, want: "C2", reason: "stream read failed after reported usage"},
{name: "c2/mid-stream-disconnect", setup: func(t *testing.T) rcSetup {
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rcWrite(rcStream)(w, r)
conn, _, err := w.(http.Hijacker).Hijack()
if err == nil {
_ = conn.Close()
}
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}, want: "C2", reason: "stream read failed after reported usage"},
// The parser stops at a bad frame; with a trailing delimiter it never
// reads to body EOF, so the boundary condition fails first.
{name: "c2/invalid-frame-before-eof", setup: rcH2(rcStream + "data: PRIVATE_not_json\n\n"), want: "C2",
reason: "malformed stream chunk after reported usage"},
{name: "c2/provider-error-before-eof", setup: rcH2(rcStream + rcErrorFrame), want: "C2",
reason: "provider error after reported usage: PRIVATE_PROVIDER_I1"},
// C3 read outcome: the bad frame is the last bytes, so body EOF is seen.
{name: "c3/half-frame", setup: rcH2(rcStream + `data: {"choices":[{"delta":{"content":"PRIVATE_HALF`), want: "C3",
reason: "malformed stream chunk after reported usage"},
{name: "c3/invalid-frame", setup: rcH2(rcStream + "data: PRIVATE_not_json"), want: "C3",
reason: "malformed stream chunk after reported usage"},
{name: "c3/provider-error-after-usage", setup: rcH2(rcStream + strings.TrimRight(rcErrorFrame, "\n")),
want: "C3", reason: "provider error after reported usage: PRIVATE_PROVIDER_I1"},
{name: "c3/provider-error-before-usage", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + strings.TrimRight(rcErrorFrame, "\n")),
want: "C3", reason: "PRIVATE_PROVIDER_I1", noUsage: true},
// C4 finish reason and choices.
{name: "c4/two-finish-chunks", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + rcFinish + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/finish-stop-with-tools", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinishChunk("stop") + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/finish-length-with-tools", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinishChunk("length") + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/multi-choice-chunk", setup: rcH2(rcText +
rcChunk(map[string]any{"delta": map[string]any{"content": "a"}}, map[string]any{"delta": map[string]any{"content": "b"}}) +
rcCall1 + rcCall2 + rcFinish + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/truncated-at-frame-boundary-without-finish", setup: rcH2(rcText + rcCall1 + rcCall2 + rcUsage), want: "C4", reason: rcEOFReason},
// C5 usage.
{name: "c5/missing-usage", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish), want: "C5", reason: rcEOFReason, noUsage: true},
// C6 fragments after finish.
{name: "c6/fragment-after-finish-chunk", setup: rcH2(rcText + rcCall1 + rcFinish + rcCall2 + rcUsage), want: "C6", reason: rcEOFReason},
// C7 call identity and argument shape.
{name: "c7/missing-id", setup: rcH2(rcSingleCall("", "read_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"}`)), want: "C7", reason: rcEOFReason},
{name: "c7/missing-name", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "", `{"path":"PRIVATE_I1.txt"}`)), want: "C7", reason: rcEOFReason},
{name: "c7/array-arguments", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `["PRIVATE_I1.txt"]`)), want: "C7", reason: rcEOFReason},
{name: "c7/broken-arguments", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"`)), want: "C7", reason: rcEOFReason},
{name: "c7/finish-without-call", setup: rcH2(rcText + rcFinish + rcUsage), want: "C7", reason: "tool call finish reason without a tool call"},
// C8 declared tools and argument schema.
{name: "c8/unknown-tool", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "write_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"}`)), want: "C8", reason: rcEOFReason},
{name: "c8/no-tools-declared", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.tools = []ToolDecl{}
return s
}, want: "C8", reason: rcEOFReason},
{name: "c8/missing-required", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{}`)), want: "C8", reason: rcEOFReason},
{name: "c8/blank-required", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":" "}`)), want: "C8", reason: rcEOFReason},
{name: "c8/wrong-property-type", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":7}`)), want: "C8", reason: rcEOFReason},
{name: "c8/unknown-argument", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":"a","PRIVATE":1}`)), want: "C8", reason: rcEOFReason},
// C9 tail event.
{name: "c9/comment-tail", setup: rcH2(rcStream + ": PRIVATE_keepalive\n\n"), want: "C9", reason: rcEOFReason},
}
}
func rcKnownLength(stream string) func(*testing.T) rcSetup {
return func(t *testing.T) rcSetup {
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(stream)))
_, _ = io.WriteString(w, stream)
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}
}
func TestRouteContractEOFMatrix(t *testing.T) {
for _, tc := range rcCases() {
t.Run(tc.name, func(t *testing.T) {
s := tc.setup(t)
tools := s.tools
if tools == nil {
tools = rcTools
}
wantCalls := tc.calls
if wantCalls == nil {
wantCalls = []ToolCall{rcCall}
}
wantUsage := 7
if tc.noUsage {
wantUsage = 0
}
off := rcRun(t, context.Background(), s.client, nil, tools, s.opts, nil)
on := rcRun(t, context.Background(), s.client, s.contract, tools, s.opts, nil)
for _, r := range []rcResult{off, on} {
if r.end.Usage.TotalTokens != wantUsage {
t.Fatalf("usage = %+v, want total %d", r.end.Usage, wantUsage)
}
}
if off.end.TerminalObservation.ContractCheck != "" {
t.Fatalf("contract-off stream carried a contract check: %+v", off.end.TerminalObservation)
}
if tc.reason != "" && off.end.ErrorMessage != tc.reason {
t.Fatalf("contract-off failure = %q, want %q", off.end.ErrorMessage, tc.reason)
}
switch tc.want {
case CompletionDoneMarker:
if !reflect.DeepEqual(on.end, off.end) {
t.Fatalf("contract changed a [DONE] terminal:\n on=%#v\noff=%#v", on.end, off.end)
}
if on.end.Err != nil || !on.end.StreamComplete || on.end.CompletionBasis != CompletionDoneMarker ||
on.end.TerminalObservation.ContractCheck != "" || on.end.TerminalObservation.ReadOutcome != "done_marker" {
t.Fatalf("[DONE] terminal not marked done_marker: %#v", on.end)
}
rcRequireReleased(t, on, wantCalls)
case CompletionRouteContractEOF:
rcRequireRejected(t, off)
if off.end.CompletionBasis != "" || off.end.ErrorMessage != rcEOFReason {
t.Fatalf("contract-off stream was not rejected unchanged: %#v", off.end)
}
end := on.end
if end.Err != nil || end.StreamComplete || end.CompletionBasis != CompletionRouteContractEOF ||
end.TerminalObservation.ContractCheck != "" || end.TerminalObservation.ReadOutcome != "clean_eof" ||
end.Message.StopReason != StopToolCalls || end.FinishReason != "tool_calls" || end.HTTPStatus != http.StatusOK {
t.Fatalf("contract stream was not released: %#v obs=%+v", end, end.TerminalObservation)
}
if end.Message.Content != off.end.Message.Content || !reflect.DeepEqual(end.Message.Thinking, off.end.Message.Thinking) {
t.Fatalf("released message changed content: %#v vs %#v", end.Message, off.end.Message)
}
rcRequireReleased(t, on, wantCalls)
case "off":
if !reflect.DeepEqual(on.end, off.end) {
t.Fatalf("zero contract changed the terminal:\n on=%#v\noff=%#v", on.end, off.end)
}
rcRequireRejected(t, on)
default:
rcRequireRejected(t, off)
rcRequireRejected(t, on)
if got := on.end.TerminalObservation.ContractCheck; got != tc.want {
t.Fatalf("contract check = %q, want %q (obs=%+v)", got, tc.want, on.end.TerminalObservation)
}
stripped := on.end
obs := *on.end.TerminalObservation
obs.ContractCheck = ""
stripped.TerminalObservation = &obs
if !reflect.DeepEqual(stripped, off.end) {
t.Fatalf("rejection differs from contract-off beyond contractCheck:\n on=%#v %+v\noff=%#v %+v",
stripped, stripped.TerminalObservation, off.end, off.end.TerminalObservation)
}
}
})
}
}
func rcRequireReleased(t *testing.T, r rcResult, calls []ToolCall) {
t.Helper()
if r.releasedMessages != 1 || r.releasedCalls != len(calls) || !reflect.DeepEqual(r.end.Message.ToolCalls, calls) {
t.Fatalf("released %d messages / %d calls %#v, want 1 / %#v", r.releasedMessages, r.releasedCalls, r.end.Message.ToolCalls, calls)
}
}
func rcRequireRejected(t *testing.T, r rcResult) {
t.Helper()
end := r.end
if r.releasedMessages != 0 || r.releasedCalls != 0 || len(end.Message.ToolCalls) != 0 || end.Err == nil ||
end.StreamComplete || end.CompletionBasis != "" || end.TerminalObservation.CompletionBasis != "" {
t.Fatalf("stream was released: %#v", end)
}
}
func TestRouteContractEOFCancelDuringReadAborts(t *testing.T) {
server := rcHTTP2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rcWrite(rcText+rcCall1+rcCall2+rcFinish+rcUsage)(w, r)
<-r.Context().Done()
}))
client := rcClientFor(t, server)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
res := rcRun(t, ctx, client, rcContract(rcModel, server.URL), rcTools, StreamOptions{}, func(ev Event) {
if ev.Kind == KindToolCallDelta {
cancel()
}
})
end := res.end
if end.Kind != KindError || end.Message.StopReason != StopAborted || end.TerminalObservation.ReadOutcome != "cancelled" ||
end.TerminalObservation.ContractCheck != "" || end.CompletionBasis != "" || res.releasedMessages != 0 ||
res.releasedCalls != 0 || len(end.Message.ToolCalls) != 0 {
t.Fatalf("cancelled contract stream was not aborted: %#v obs=%+v", end, end.TerminalObservation)
}
}
func TestParseSSEUnchangedWithoutDone(t *testing.T) {
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(rcStream)) {
end = ev
}
if end.Kind != KindMessageEnd || end.ErrorMessage != rcEOFReason || len(end.Message.ToolCalls) != 0 ||
end.StreamComplete || end.CompletionBasis != "" || end.Usage.TotalTokens != 7 ||
end.TerminalObservation == nil || end.TerminalObservation.CompletionBasis != "" ||
end.TerminalObservation.ContractCheck != "" || end.TerminalObservation.BodyBoundary != nil {
t.Fatalf("ParseSSE changed without [DONE]: %#v", end)
}
raw, err := json.Marshal(end.TerminalObservation)
if err != nil || strings.Contains(string(raw), "completionBasis") || strings.Contains(string(raw), "contractCheck") {
t.Fatalf("ParseSSE observation JSON changed: %s err=%v", raw, err)
}
var done Event
for ev := range ParseSSE(context.Background(), strings.NewReader(rcStream+rcDone)) {
done = ev
}
if done.Err != nil || !done.StreamComplete || done.CompletionBasis != "" || done.TerminalObservation.CompletionBasis != "" ||
!reflect.DeepEqual(done.Message.ToolCalls, []ToolCall{rcCall}) {
t.Fatalf("ParseSSE changed with [DONE]: %#v", done)
}
}
func TestRouteContractIdentity(t *testing.T) {
sum := sha256.Sum256([]byte("openai-chat\nm\nhttps://api.example.com/v1"))
if got, want := RouteContractIdentity("m", "https://api.example.com/v1"), hex.EncodeToString(sum[:]); got != want {
t.Fatalf("identity = %s, want %s", got, want)
}
if got := RouteContractIdentity("m", "https://api.example.com/v1"); got != strings.ToLower(got) || len(got) != 64 {
t.Fatalf("identity is not lowercase hex: %s", got)
}
}
func TestNormalizeRouteBaseURL(t *testing.T) {
tests := []struct {
raw, want string
ok bool
}{
{"https://api.example.com/v1", "https://api.example.com/v1", true},
{"HTTPS://API.Example.COM/v1/", "https://api.example.com/v1", true},
{"https://api.example.com:8443/v1//", "https://api.example.com:8443/v1", true},
{"https://api.example.com", "https://api.example.com", true},
{"https://api.example.com/V1", "https://api.example.com/V1", true},
{"http://api.example.com/v1", "", false},
{"https://user@api.example.com/v1", "", false},
{"https://user:pw@api.example.com/v1", "", false},
{"https://api.example.com/v1?x=1", "", false},
{"https://api.example.com/v1?", "", false},
{"https://api.example.com/v1#frag", "", false},
{"https://api.example.com/v1#", "", false},
{"https:api.example.com", "", false},
{"https:///v1", "", false},
{"", "", false},
{"://bad", "", false},
}
for _, tc := range tests {
got, ok := NormalizeRouteBaseURL(tc.raw)
if got != tc.want || ok != tc.ok {
t.Fatalf("NormalizeRouteBaseURL(%q) = %q, %v; want %q, %v", tc.raw, got, ok, tc.want, tc.ok)
}
}
}
+75 -20
View File
@@ -46,18 +46,49 @@ const (
maxObservedTailEvents = 10000
)
// sseFacts are structural stream facts the route contract needs. They are
// written by the parser goroutine before it sends the terminal event.
type sseFacts struct {
// finishChunks counts chunks with a non-empty finish_reason.
finishChunks int
finishReason string
// multiChoice is set when any chunk carried more than one choice.
multiChoice bool
// fragmentAfterFinish is set when a tool-call fragment arrived in a chunk
// after the one carrying finish_reason.
fragmentAfterFinish bool
}
// sseHooks are internal parser options. The zero value is ParseSSE.
type sseHooks struct {
facts *sseFacts
// decideEOF, when set, receives a tool stream whose terminal would be
// "tool call stream ended before [DONE]": success is the message_end the
// stream would end with had [DONE] arrived, failure is the event emitted
// today. It returns the event to send.
decideEOF func(success, failure Event) Event
}
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
// deltas plus one terminal message_end or error event.
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
return parseSSE(ctx, r, sseHooks{})
}
func parseSSE(ctx context.Context, r io.Reader, hooks sseHooks) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
emitParsed(ctx, r, out)
emitParsed(ctx, r, out, hooks)
}()
return out
}
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event, hooks sseHooks) {
facts := hooks.facts
if facts == nil {
facts = &sseFacts{}
}
var content strings.Builder
var reasoning strings.Builder
calls := map[int]*ToolCall{}
@@ -93,20 +124,39 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
TailEventKind: tailKind, TailEventCount: reader.tailCount,
}
}
sendTerminalFailure := func(why string) {
buildTerminalFailure := func(why string) Event {
message := Message{Role: RoleAssistant, Content: content.String(), StopReason: StopError}
if reasoning.Len() > 0 {
message.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
}
failed := terminalStreamFailure(message, usage, finished, sawDone, why)
failed.TerminalObservation = observation()
return failed
}
sendTerminal := func(ev Event) {
select {
case <-ctx.Done():
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
case out <- failed:
case out <- ev:
}
}
sendTerminalFailure := func(why string) {
sendTerminal(buildTerminalFailure(why))
}
buildMessageEnd := func() Event {
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
if reasoning.Len() > 0 {
msg.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
}
for _, idx := range order {
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
}
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
msg.StopReason = StopEnd
}
return Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished, StreamComplete: sawDone, TerminalObservation: observation()}
}
for {
data, ok := reader.next()
if !ok {
@@ -155,12 +205,28 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
usage.CacheReadTokens = chunk.Usage.Details.Cached
}
}
if len(chunk.Choices) > 1 {
facts.multiChoice = true
}
finishedBefore := facts.finishChunks > 0
for _, c := range chunk.Choices {
if c.FinishReason != "" {
if facts.finishChunks < maxObservedTailEvents {
facts.finishChunks++
}
break
}
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != "" {
finished = choice.FinishReason
facts.finishReason = finished
}
if finishedBefore && len(choice.Delta.ToolCalls) > 0 {
facts.fragmentAfterFinish = true
}
if choice.Delta.Content != "" {
content.WriteString(choice.Delta.Content)
@@ -279,26 +345,15 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
}
}
if problem != "" {
if !sawDone && hooks.decideEOF != nil {
sendTerminal(hooks.decideEOF(buildMessageEnd(), buildTerminalFailure(problem)))
return
}
sendTerminalFailure(problem)
return
}
}
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
if reasoning.Len() > 0 {
msg.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
}
for _, idx := range order {
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
}
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
msg.StopReason = StopEnd
}
select {
case <-ctx.Done():
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished, StreamComplete: sawDone, TerminalObservation: observation()}:
}
sendTerminal(buildMessageEnd())
}
func terminalStreamFailure(message Message, usage Usage, reason string, complete bool, why string) Event {
+12 -1
View File
@@ -117,6 +117,12 @@ type TerminalObservation struct {
// BodyBoundary is set only by Client on a response it received; a bare
// ParseSSE call leaves it nil.
BodyBoundary *BodyBoundaryObservation `json:"bodyBoundary,omitempty"`
// CompletionBasis mirrors Event.CompletionBasis.
CompletionBasis string `json:"completionBasis,omitempty"`
// ContractCheck is the first route-contract condition, C1 through C9, that
// failed when a configured RouteContractEOF did not release a stream that
// ended without [DONE]. Empty when no contract evaluated the stream.
ContractCheck string `json:"contractCheck,omitempty"`
}
// BodyBoundaryObservation records how the client read one HTTP response body.
@@ -152,8 +158,13 @@ type Event struct {
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
FinishReason string
// StreamComplete records whether the OpenAI-compatible SSE stream supplied
// its explicit [DONE] marker. Tool calls require this before execution.
// its explicit [DONE] marker. Tool calls require this before execution
// unless CompletionBasis is route_contract_eof.
StreamComplete bool
// CompletionBasis is set by Client on a successful OpenAI-chat message_end:
// done_marker when [DONE] arrived, route_contract_eof when RouteContractEOF
// released tool calls without it. Empty otherwise.
CompletionBasis string
// TerminalObservation is present only on a terminal/error event. It does
// not change StreamComplete or the tool-execution safety gate.
TerminalObservation *TerminalObservation
+33 -14
View File
@@ -3,6 +3,7 @@ package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
@@ -198,6 +199,13 @@ 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.
@@ -332,19 +340,28 @@ 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()}, nil
return Result{
Text: last.Content, Messages: messages, StopReason: llm.StopAborted,
Err: ctx.Err(), Canceled: true, ToolCalls: toolCalls,
}, nil
}
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
return budgetStop(cfg, last, messages, usage, reason), nil
return budgetStop(cfg, last, messages, usage, toolCalls, 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 {
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
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)
emitUsage(cfg, usage)
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
return Result{
Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason,
Err: errEv, Retryable: retryable, Canceled: canceled, ToolCalls: toolCalls,
}, nil
}
last = end.Message
usage.PromptTokens += end.Usage.PromptTokens
@@ -360,7 +377,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}, nil
Usage: usage, Err: end.Err, Retryable: false, ToolCalls: toolCalls}, nil
}
if err := persist(cfg, last); err != nil {
return Result{}, err
@@ -375,7 +392,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, reason), nil
return budgetStop(cfg, last, messages, usage, toolCalls, 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)
@@ -384,7 +401,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, reason), nil
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
}
toolCalls += len(last.ToolCalls)
if cfg.RepeatGuard {
@@ -418,7 +435,8 @@ 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}, nil
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError,
Usage: usage, Err: errCap, ToolCalls: toolCalls}, nil
}
lengthContinues++
lengthNudge = true
@@ -454,9 +472,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
continue
}
return settle(cfg, last, messages, usage, decision.Notice), nil
return settle(cfg, last, messages, usage, toolCalls, decision.Notice), nil
}
return settle(cfg, last, messages, usage, ""), nil
return settle(cfg, last, messages, usage, toolCalls, ""), nil
}
errCap := errString("max turns exceeded")
cfg.logger().Error("runtime turn cap hit", errCap)
@@ -466,6 +484,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
StopReason: llm.StopError,
Usage: usage,
Err: errCap,
ToolCalls: toolCalls,
}, nil
}
@@ -841,11 +860,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, notice string) Result {
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, 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}
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice, ToolCalls: toolCalls}
}
func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
@@ -1200,12 +1219,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, reason string) Result {
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, 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}
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err, ToolCalls: toolCalls}
}
type errString string
+44
View File
@@ -153,6 +153,11 @@ 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
@@ -191,6 +196,45 @@ 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 {
+105
View File
@@ -81,6 +81,111 @@ 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"))
+21 -9
View File
@@ -78,9 +78,10 @@ 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)
root string
files *filestate.Tracker
mark func(rel string, before []byte, existed bool, after []byte)
writable []string // empty = unrestricted (product default)
}
func (t *writeFile) Name() string { return "write_file" }
@@ -102,6 +103,9 @@ 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
}
@@ -119,9 +123,10 @@ 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)
root string
files *filestate.Tracker
mark func(rel string, before []byte, existed bool, after []byte)
writable []string
}
func (t *editFile) Name() string { return "edit_file" }
@@ -147,6 +152,9 @@ 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
}
@@ -294,9 +302,10 @@ 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)
root string
files *filestate.Tracker
mark func(rel string, before []byte, existed bool, after []byte)
writable []string
}
func (t *multiEditTool) Name() string { return "multi_edit" }
@@ -327,6 +336,9 @@ 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
}
+124 -12
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"unicode/utf8"
@@ -23,6 +24,7 @@ type ExecConfig struct {
Bash string
Python string
// Env 传给子进程的完整白名单环境(KEY=VALUE);不含本进程任何隐式变量。
// nil 在注册/启动时归一为空白名单(不继承宿主环境);显式空切片同样为空白名单。
Env []string
// ScriptDir 落盘脚本目录(相对 root,默认 .agent/scripts);空用默认。
ScriptDir string
@@ -34,6 +36,16 @@ 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{
@@ -45,6 +57,7 @@ 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})
}
@@ -81,8 +94,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, timedOut, err := runProcess(ctx, t.cfg.Bash, []string{"-c", obj.Command}, t.root, t.cfg.Env, timeout)
content := formatProcessOutput(out, timedOut, err)
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)
return Result{Content: content, IsError: timedOut || err != nil}, nil
}
@@ -124,19 +137,20 @@ func (t *runPythonTool) Execute(ctx context.Context, args json.RawMessage) (Resu
return Result{Content: err.Error(), IsError: true}, nil
}
timeout := clampTimeout(obj.TimeoutSeconds)
out, timedOut, err := runProcess(ctx, t.cfg.Python, []string{script}, t.root, t.cfg.Env, timeout)
content := formatProcessOutput(out, timedOut, err)
out, total, timedOut, err := runProcess(ctx, t.cfg.Python, []string{script}, t.root, t.cfg.Env, timeout)
content := formatProcessOutput(out, total, timedOut, err)
content = "script: " + filepath.ToSlash(filepath.Join(dir, name)) + "\n" + content
return Result{Content: content, IsError: timedOut || err != nil}, nil
}
// runProcess 启动独立进程组的子进程;ctx 取消或超时先到者杀整组(SIGKILL)。
func runProcess(ctx context.Context, interpreter string, argv []string, dir string, env []string, timeout time.Duration) (out []byte, timedOut bool, err error) {
// 合并输出持续排入有界头尾缓冲,避免 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) {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, interpreter, argv...)
cmd.Dir = dir
cmd.Env = env
cmd.Env = normalizeExecEnv(env)
// 独立进程组:超时/取消时杀 -PGID,确保孙进程一并退出。
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
@@ -146,9 +160,13 @@ func runProcess(ctx context.Context, interpreter string, argv []string, dir stri
return nil
}
cmd.WaitDelay = 3 * time.Second
out, err = cmd.CombinedOutput()
buf := newHeadTailBuffer(execMaxOutput)
cmd.Stdout = buf
cmd.Stderr = buf
err = cmd.Run()
out, total = buf.Snapshot()
timedOut = runCtx.Err() != nil && (ctx.Err() == nil || runCtx.Err() != ctx.Err())
return out, timedOut, err
return out, total, timedOut, err
}
func clampTimeout(seconds int) time.Duration {
@@ -171,14 +189,108 @@ 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)并标注退出状态。
func formatProcessOutput(out []byte, timedOut bool, err error) string {
// out 为有界捕获结果;total 为子进程实际写出字节数(可大于 len(out))。
func formatProcessOutput(out []byte, total int, timedOut bool, err error) string {
text := string(out)
if len(out) > execMaxOutput {
if total > execMaxOutput {
half := execMaxOutput / 2
dropped := len(out) - execMaxOutput
dropped := total - execMaxOutput
head := CutUTF8(string(out), half)
tail := string(out[len(out)-half:])
tail := ""
if len(out) > half {
tail = string(out[len(out)-half:])
} else if len(out) > len(head) {
tail = string(out[len(head):])
}
for len(tail) > 0 && !utf8.RuneStart(tail[0]) {
tail = tail[1:]
}
+96
View File
@@ -54,6 +54,37 @@ 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()
@@ -129,6 +160,71 @@ 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()
+42 -68
View File
@@ -5,10 +5,12 @@ import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"sync"
"time"
"agent-native/runtime/argschema"
"agent-native/runtime/checkpoint"
"agent-native/runtime/filestate"
)
@@ -286,74 +288,7 @@ func (r *Registry) audit(name string, blocked, isError bool, note string) {
}
func validateArgs(schema, args json.RawMessage) error {
var spec struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties map[string]json.RawMessage `json:"properties"`
}
if len(schema) > 0 {
if err := json.Unmarshal(schema, &spec); err != nil {
return fmt.Errorf("invalid tool schema")
}
}
var obj map[string]any
if err := json.Unmarshal(args, &obj); err != nil {
return fmt.Errorf("arguments must be a JSON object")
}
if spec.Type != "" && spec.Type != "object" {
return fmt.Errorf("unsupported schema type %s", spec.Type)
}
for _, req := range spec.Required {
v, ok := obj[req]
if !ok || v == nil {
return fmt.Errorf("missing required argument %s", req)
}
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
return fmt.Errorf("missing required argument %s", req)
}
}
for key, raw := range spec.Properties {
v, ok := obj[key]
if !ok {
continue
}
var prop struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &prop); err != nil || prop.Type == "" {
continue
}
if !matchesType(prop.Type, v) {
return fmt.Errorf("argument %s must be %s", key, prop.Type)
}
}
if len(spec.Properties) > 0 {
for key := range obj {
if _, ok := spec.Properties[key]; !ok {
return fmt.Errorf("unknown argument %s", key)
}
}
}
return nil
}
func matchesType(kind string, v any) bool {
switch kind {
case "string":
_, ok := v.(string)
return ok
case "number":
_, ok := v.(float64)
return ok
case "integer":
f, ok := v.(float64)
return ok && f == float64(int64(f))
case "boolean":
_, ok := v.(bool)
return ok
default:
return true
}
return argschema.Validate(schema, args)
}
// WorkspaceTools registers the document workspace tool set.
@@ -418,6 +353,45 @@ 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)
}
+18
View File
@@ -0,0 +1,18 @@
package tools
import (
"encoding/json"
"testing"
"agent-native/runtime/argschema"
)
func TestValidateArgsDelegatesToArgschema(t *testing.T) {
schema := json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}}`)
for _, args := range []string{`{"path":"a"}`, `{}`, `{"path":" "}`, `{"path":1}`, `{"path":"a","x":1}`, `[1]`} {
got, want := validateArgs(schema, json.RawMessage(args)), argschema.Validate(schema, json.RawMessage(args))
if (got == nil) != (want == nil) || (got != nil && got.Error() != want.Error()) {
t.Fatalf("validateArgs(%s) = %v, argschema.Validate = %v", args, got, want)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
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")
}
}