Merge pull request 'Drop the intent adapter and fold contract into exec' (#10) from feature/agent-stack-d1 into main
This commit was merged in pull request #10.
This commit is contained in:
@@ -1,81 +0,0 @@
|
||||
// Package intentgate 提供可选的意图闸门装饰器:在把 Prompt 交给被装饰的
|
||||
// 执行后端之前,用 agent-intent 做一次确定性识别;分支为守卫/命中/预期命中时
|
||||
// 交由宿主 Handler 解释,Ambiguous/Fallback 或 Handler 放弃时原样透传。
|
||||
//
|
||||
// 本包是 Agent-Native 仓内唯一 import agent-intent 的地方。不使用本包时,
|
||||
// 本仓与 agent-intent 无任何编译关系;未 Wrap 或快照零意图时,被装饰后端的
|
||||
// 行为与原来完全一致。
|
||||
package intentgate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// Handler 解释"识别到意图"的执行请求。返回 handled=false 表示宿主放弃处理,
|
||||
// 装饰器回退为透传原后端。合成结果的语义(拒绝、引导、定向执行)由宿主定义,
|
||||
// 本包不发明结果语义。
|
||||
type Handler func(ctx context.Context, req exec.Request, res intent.Result) (result *exec.Result, err error, handled bool)
|
||||
|
||||
type gated struct {
|
||||
next exec.Backend
|
||||
rec intent.Recognizer
|
||||
snap *intent.Snapshot
|
||||
handler Handler
|
||||
}
|
||||
|
||||
// Wrap 返回带意图闸门的后端。next/rec/snap/handler 任一为 nil 时直接返回 next
|
||||
// (等价于未启用,行为不变)。
|
||||
func Wrap(next exec.Backend, rec intent.Recognizer, snap *intent.Snapshot, h Handler) exec.Backend {
|
||||
if next == nil || rec == nil || snap == nil || h == nil {
|
||||
return next
|
||||
}
|
||||
return &gated{next: next, rec: rec, snap: snap, handler: h}
|
||||
}
|
||||
|
||||
// WrapRegistry 持有 Registry,每次执行读取当前快照。Reload 之后不用重建装饰器。
|
||||
func WrapRegistry(next exec.Backend, rec intent.Recognizer, reg *intent.Registry, h Handler) exec.Backend {
|
||||
if next == nil || rec == nil || reg == nil || h == nil {
|
||||
return next
|
||||
}
|
||||
return &gatedRegistry{next: next, rec: rec, reg: reg, handler: h}
|
||||
}
|
||||
|
||||
type gatedRegistry struct {
|
||||
next exec.Backend
|
||||
rec intent.Recognizer
|
||||
reg *intent.Registry
|
||||
handler Handler
|
||||
}
|
||||
|
||||
func (g *gatedRegistry) Name() string { return g.next.Name() }
|
||||
|
||||
func (g *gatedRegistry) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
return executeGated(ctx, g.next, g.rec, g.reg.Snapshot(), g.handler, req)
|
||||
}
|
||||
|
||||
func (g *gated) Name() string { return g.next.Name() }
|
||||
|
||||
func (g *gated) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
return executeGated(ctx, g.next, g.rec, g.snap, g.handler, req)
|
||||
}
|
||||
|
||||
func executeGated(ctx context.Context, next exec.Backend, rec intent.Recognizer, snap *intent.Snapshot, handler Handler, req exec.Request) (*exec.Result, error) {
|
||||
res, err := rec.Recognize(ctx, req.Prompt, snap)
|
||||
if err != nil {
|
||||
// 识别系统错误 fail-closed:不静默放行可能应拦截的内容,也不吞错。
|
||||
return nil, err
|
||||
}
|
||||
switch res.Branch {
|
||||
case intent.BranchGuardHit, intent.BranchMatched, intent.BranchExpectationHit:
|
||||
default:
|
||||
return next.Execute(ctx, req) // 无裁决:透传原后端
|
||||
}
|
||||
out, herr, handled := handler(ctx, req, res)
|
||||
if !handled {
|
||||
return next.Execute(ctx, req)
|
||||
}
|
||||
return out, herr
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package intentgate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
type stubBackend struct {
|
||||
name string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubBackend) Name() string { return s.name }
|
||||
|
||||
func (s *stubBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
s.calls++
|
||||
return &exec.Result{Success: true, Logs: "stub:" + req.Prompt}, nil
|
||||
}
|
||||
|
||||
func emptySnapshot(t *testing.T) *intent.Snapshot {
|
||||
t.Helper()
|
||||
reg, err := intent.NewRegistryBuilder(intent.Options{}).Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
|
||||
// 带一个守卫 spec 与一个可路由 spec 的快照。
|
||||
func loadedSnapshot(t *testing.T) *intent.Snapshot {
|
||||
t.Helper()
|
||||
b := intent.NewRegistryBuilder(intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
})
|
||||
if err := b.Register(intent.IntentSpec{
|
||||
ID: "guard.config_change", Domain: "guard", Version: 1,
|
||||
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关"}},
|
||||
Risk: intent.RiskHigh,
|
||||
Handler: "refuse.and_guide",
|
||||
Metadata: map[string]string{
|
||||
"guidance": "系统配置请在管理界面修改",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.Snapshot()
|
||||
}
|
||||
|
||||
func newRec() intent.Recognizer { return intent.NewRecognizer(intent.Options{}, nil, nil) }
|
||||
|
||||
func TestZeroSpecSnapshotPassthroughIdentical(t *testing.T) {
|
||||
// T0 行为解耦:零意图快照 + Wrap 后,与裸后端行为逐字节一致。
|
||||
bare := &stubBackend{name: "native"}
|
||||
gatedBe := Wrap(&stubBackend{name: "native"}, newRec(), emptySnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
t.Fatal("handler must not fire on empty snapshot")
|
||||
return nil, nil, false
|
||||
})
|
||||
req := exec.Request{Prompt: "帮我改一下系统网关地址"}
|
||||
want, werr := bare.Execute(context.Background(), req)
|
||||
got, gerr := gatedBe.Execute(context.Background(), req)
|
||||
if werr != nil || gerr != nil {
|
||||
t.Fatalf("execute errors: %v %v", werr, gerr)
|
||||
}
|
||||
if !reflect.DeepEqual(want, got) {
|
||||
t.Fatalf("results differ: %+v vs %+v", want, got)
|
||||
}
|
||||
if gatedBe.(*gated).next.(*stubBackend).calls != 1 {
|
||||
t.Fatal("next backend not invoked exactly once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardHitInterceptsWithoutModel(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
be := Wrap(next, newRec(), loadedSnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
if res.Branch != intent.BranchGuardHit || res.Top == nil || res.Top.ID != "guard.config_change" {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
// 宿主解释守卫意图:拒绝 + 引导(合成结果语义由宿主定义)。
|
||||
return &exec.Result{Success: false, ErrorReason: "guard refused: " + res.Top.Metadata["guidance"]}, nil, true
|
||||
})
|
||||
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Success || out.ErrorReason == "" {
|
||||
t.Fatalf("expected guard refusal, got %+v", out)
|
||||
}
|
||||
if next.calls != 0 {
|
||||
t.Fatalf("model backend must not be called on guard hit, got %d calls", next.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackPassesThroughToBackend(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
be := Wrap(next, newRec(), loadedSnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
t.Fatal("handler must not fire on fallback")
|
||||
return nil, nil, false
|
||||
})
|
||||
out, err := be.Execute(context.Background(), exec.Request{Prompt: "写一份周报"})
|
||||
if err != nil || out == nil || !out.Success {
|
||||
t.Fatalf("fallback must reach backend: %+v %v", out, err)
|
||||
}
|
||||
if next.calls != 1 {
|
||||
t.Fatalf("backend calls = %d", next.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerDeclineFallsBackToBackend(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
be := Wrap(next, newRec(), loadedSnapshot(t),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
return nil, nil, false // 宿主放弃处理
|
||||
})
|
||||
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
|
||||
if err != nil || out == nil || !out.Success {
|
||||
t.Fatalf("declined handling must pass through: %+v %v", out, err)
|
||||
}
|
||||
if next.calls != 1 {
|
||||
t.Fatalf("backend calls = %d", next.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilComponentsReturnNext(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
if got := Wrap(next, nil, nil, nil); got != exec.Backend(next) {
|
||||
t.Fatal("nil components must return next unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeErrorFailsClosed(t *testing.T) {
|
||||
next := &stubBackend{name: "native"}
|
||||
b := intent.NewRegistryBuilder(intent.Options{MaxInputLen: 8})
|
||||
reg, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
be := Wrap(next, newRec(), reg.Snapshot(),
|
||||
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
t.Fatal("handler must not fire on recognize error")
|
||||
return nil, nil, false
|
||||
})
|
||||
_, err = be.Execute(context.Background(), exec.Request{Prompt: "这一段输入明显超过八个字节"})
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error")
|
||||
}
|
||||
var ie *intent.Error
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *intent.Error, got %T", err)
|
||||
}
|
||||
if next.calls != 0 {
|
||||
t.Fatal("backend must not run on recognize error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2RegistryReloadIsVisibleWithoutRewrap(t *testing.T) {
|
||||
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents":[{"id":"guard.block","domain":"guard","version":1,"matcher":{"includes_any":["旧词"]}}]}`)}}
|
||||
reg, err := intent.NewRegistryBuilder(intent.Options{GuardDomains: []string{"guard"}}).BuildFS(context.Background(), fsys, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := &stubBackend{name: "native"}
|
||||
var hits int
|
||||
be := WrapRegistry(next, newRec(), reg, func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
||||
hits++
|
||||
return &exec.Result{Success: false, ErrorReason: "blocked"}, nil, true
|
||||
})
|
||||
if _, err := be.Execute(context.Background(), exec.Request{Prompt: "请处理新词"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 0 || next.calls != 1 {
|
||||
t.Fatalf("before reload hits=%d calls=%d", hits, next.calls)
|
||||
}
|
||||
fsys["intents.json"].Data = []byte(`{"intents":[{"id":"guard.block","domain":"guard","version":2,"matcher":{"includes_any":["新词"]}}]}`)
|
||||
if _, err := reg.Reload(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := be.Execute(context.Background(), exec.Request{Prompt: "请处理新词"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 || next.calls != 1 {
|
||||
t.Fatalf("after reload hits=%d calls=%d", hits, next.calls)
|
||||
}
|
||||
}
|
||||
+24
-17
@@ -25,41 +25,48 @@ import (
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
||||
type Options struct {
|
||||
// ModelOptions 是模型接入。Stream 非空时不再按 APIBase/APIKey 构造客户端。
|
||||
type ModelOptions struct {
|
||||
APIBase string
|
||||
APIKey string
|
||||
Model string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
// Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。
|
||||
Stream llm.StreamFunc
|
||||
// Search 可选:提供时注册 tavily_search / web_search(密钥由组合根注入)。
|
||||
}
|
||||
|
||||
// ToolOptions 是可选工具装配。nil 字段不注册对应工具。
|
||||
type ToolOptions struct {
|
||||
Search tools.SearchProvider
|
||||
// Exec 可选:提供时注册 run_command / run_python(解释器留空项不注册)。
|
||||
// nil = 不注册执行类工具(fail-closed,与 BashPermission 默认 deny 对齐)。
|
||||
Exec *tools.ExecConfig
|
||||
// Fetch 可选:提供时注册 fetch_url;nil = 不注册(fail-closed)。
|
||||
Fetch *tools.FetchConfig
|
||||
// MCP 可选:提供时把各 server 工具以 mcp__<server>__<tool> 注册进工作区。
|
||||
// nil = 不注册(fail-closed:server 不可达时整个步骤失败,不静默跳过)。
|
||||
MCP *mcp.Manager
|
||||
// SkillsRoots 可选:全局技能根目录;执行时自动并入工作区 .agent/skills/。
|
||||
SkillsRoots []string
|
||||
// Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。
|
||||
Compact *CompactConfig
|
||||
// Subagent 可选:提供时注册 delegate_task(委派由宿主实现,内核只开端口)。
|
||||
Subagent subagent.Factory
|
||||
// HostTools 是宿主产品工具(登记工件、卡片、知识库)。与内核工具同名时失败,不覆盖。
|
||||
HostTools []tools.Tool
|
||||
// MaxToolCalls 是单步工具调用上限。0 表示不限制。请求自带预算时以请求为准。
|
||||
}
|
||||
|
||||
// SessionOptions 是单次执行的会话预算与压缩。
|
||||
type SessionOptions struct {
|
||||
Compact *CompactConfig
|
||||
MaxToolCalls int
|
||||
// Log 可选。nil 时丢弃日志。
|
||||
}
|
||||
|
||||
// ObserveOptions 是日志。Log 为 nil 时丢弃。
|
||||
type ObserveOptions struct {
|
||||
Log *logsys.Logger
|
||||
// ExecutionID 写入日志,不参与执行控制。
|
||||
ExecutionID string
|
||||
}
|
||||
|
||||
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
||||
// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。
|
||||
type Options struct {
|
||||
ModelOptions
|
||||
ToolOptions
|
||||
SessionOptions
|
||||
ObserveOptions
|
||||
}
|
||||
|
||||
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
|
||||
type CompactConfig struct {
|
||||
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
|
||||
|
||||
@@ -39,12 +39,17 @@ func script(turns ...llm.Message) llm.StreamFunc {
|
||||
|
||||
func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{Model: "fake", Stream: script(
|
||||
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":"# 交付\n\n工具写入的交付物内容。"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd},
|
||||
)})
|
||||
),
|
||||
},
|
||||
})
|
||||
var events []exec.ProcessEvent
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色",
|
||||
@@ -81,9 +86,14 @@ func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
||||
|
||||
func TestBackendReasoningProfileWritesText(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{Model: "fake", Stream: script(
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
|
||||
)})
|
||||
),
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning,
|
||||
Targets: []string{"notes.md"},
|
||||
@@ -109,12 +119,21 @@ func TestBackendFailClosedWithoutConfig(t *testing.T) {
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "requires") {
|
||||
t.Fatalf("want fail-closed config error, got %+v", res)
|
||||
}
|
||||
res2, _ := New(Options{APIBase: "http://x", APIKey: "k"}).Execute(context.Background(),
|
||||
res2, _ := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
APIBase: "http://x",
|
||||
APIKey: "k",
|
||||
},
|
||||
}).Execute(context.Background(),
|
||||
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
||||
if res2.Success || !strings.Contains(res2.ErrorReason, "model") {
|
||||
t.Fatalf("want model required error, got %+v", res2)
|
||||
}
|
||||
res3, _ := New(Options{Stream: script()}).Execute(context.Background(),
|
||||
res3, _ := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Stream: script(),
|
||||
},
|
||||
}).Execute(context.Background(),
|
||||
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"})
|
||||
if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") {
|
||||
t.Fatalf("want profile error, got %+v", res3)
|
||||
|
||||
@@ -30,11 +30,15 @@ func TestBackendCompactBeforeRun(t *testing.T) {
|
||||
var sawSummary bool
|
||||
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
|
||||
bk2 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: captureFirstStream(&sawSummary),
|
||||
},
|
||||
SessionOptions: SessionOptions{
|
||||
Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) {
|
||||
return "预置历史摘要", nil
|
||||
}},
|
||||
},
|
||||
})
|
||||
res, err := bk2.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "继续", Targets: []string{"output.md"},
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(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{{
|
||||
@@ -34,7 +35,10 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "run", Targets: []string{"output.md"},
|
||||
@@ -54,7 +58,12 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
|
||||
|
||||
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
|
||||
stream, c := captureStream()
|
||||
b := New(Options{Model: "fake", Stream: stream})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
ToolProfile: exec.ToolProfileReasoning,
|
||||
@@ -64,7 +73,12 @@ func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
|
||||
}
|
||||
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
|
||||
stream2, c2 := captureStream()
|
||||
b2 := New(Options{Model: "fake", Stream: stream2})
|
||||
b2 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream2,
|
||||
},
|
||||
})
|
||||
_, _ = b2.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
ToolProfile: exec.ToolProfileWorkspace,
|
||||
@@ -83,8 +97,13 @@ func TestBackendAllowlistCanDisableExecTools(t *testing.T) {
|
||||
bash, _ := osexec.LookPath("bash")
|
||||
stream, c := captureStream()
|
||||
b := New(Options{
|
||||
Model: "fake", Stream: stream,
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}},
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
@@ -125,7 +144,15 @@ func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{Model: "fake", Stream: stream, Search: provider})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Search: provider,
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "search", Targets: []string{"output.md"},
|
||||
@@ -152,7 +179,12 @@ func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
|
||||
|
||||
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
|
||||
stream, c := captureStream()
|
||||
b := New(Options{Model: "fake", Stream: stream})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
})
|
||||
_, _ = b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
ToolProfile: exec.ToolProfileWorkspace,
|
||||
@@ -190,7 +222,15 @@ func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{Model: "fake", Stream: stream, MCP: mgr})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
MCP: mgr,
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
res, err2 := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "call mcp", Targets: []string{"output.md"},
|
||||
@@ -229,7 +269,15 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) {
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{Model: "fake", Stream: stream, SkillsRoots: []string{skillsRoot}})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
SkillsRoots: []string{skillsRoot},
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}, Skills: []string{"docx-author"},
|
||||
@@ -257,7 +305,15 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackendDeclaredSkillMissingFailsClosed(t *testing.T) {
|
||||
b := New(Options{Model: "fake", Stream: script(), SkillsRoots: []string{t.TempDir()}})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
SkillsRoots: []string{t.TempDir()},
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
Skills: []string{"ghost-skill"},
|
||||
@@ -277,7 +333,12 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{Model: "fake", Stream: stream})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
wsSkill := filepath.Join(dir, ".agent", "skills", "ws-skill")
|
||||
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
|
||||
@@ -311,12 +372,16 @@ func TestBackendSubagentWiredViaOptions(t *testing.T) {
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||
delegated = &req
|
||||
return subagent.Result{Success: true, Text: "结论已查明"}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
|
||||
@@ -25,9 +25,13 @@ func (h hostTool) Execute(ctx context.Context, args json.RawMessage) (tools.Resu
|
||||
func TestHostToolAndUsageEvent(t *testing.T) {
|
||||
var events []exec.ProcessEvent
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
|
||||
Stream: usageScript(),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "登记",
|
||||
@@ -65,9 +69,13 @@ func TestHostToolAndUsageEvent(t *testing.T) {
|
||||
func TestHostToolSeesStepWorkDir(t *testing.T) {
|
||||
var seen string
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
HostTools: []tools.Tool{peekTool{got: &seen}},
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
_, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "x", Targets: []string{"output.md"}})
|
||||
@@ -91,9 +99,13 @@ func (p peekTool) Execute(ctx context.Context, args json.RawMessage) (tools.Resu
|
||||
|
||||
func TestHostToolNameConflictFailsClosed(t *testing.T) {
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
HostTools: []tools.Tool{hostTool{name: "read_file"}},
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
|
||||
@@ -58,7 +58,12 @@ func toolNames(decls []llm.ToolDecl) []string {
|
||||
|
||||
func TestBackendRequestModelOverride(t *testing.T) {
|
||||
stream, c := captureStream()
|
||||
b := New(Options{Model: "default-model", Stream: stream})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "default-model",
|
||||
Stream: stream,
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"}, Model: "override-model",
|
||||
@@ -82,7 +87,12 @@ func TestBackendRequestToolAllowlist(t *testing.T) {
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "gave up", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{Model: "fake", Stream: stream})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
// 目标 output2.md 不在 allowlist 内能写吗?——write_file 被整体收窄,
|
||||
// 模型两次尝试都会得到 unknown tool,最终无交付物 → 失败。
|
||||
@@ -109,7 +119,12 @@ func TestBackendRequestToolAllowlist(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackendRequestUnknownAllowlistFailsClosed(t *testing.T) {
|
||||
b := New(Options{Model: "fake", Stream: script()})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(),
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
ToolAllowlist: []string{"read_file", "no_such_tool"},
|
||||
@@ -139,7 +154,12 @@ func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
b := New(Options{Model: "fake", Stream: stream})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
})
|
||||
start := time.Now()
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||
@@ -164,7 +184,12 @@ func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
|
||||
|
||||
func TestBackendRequestExecutionIDInLogs(t *testing.T) {
|
||||
stream, _ := captureStream()
|
||||
b := New(Options{Model: "fake", Stream: stream})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: stream,
|
||||
},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"},
|
||||
|
||||
@@ -38,7 +38,12 @@ func failThenSucceed(failMsg string) llm.StreamFunc {
|
||||
|
||||
func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{Model: "fake", Stream: failThenSucceed("maximum context length exceeded (context_overflow)")})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: failThenSucceed("maximum context length exceeded (context_overflow)"),
|
||||
},
|
||||
})
|
||||
|
||||
// 第一次执行:失败(上下文溢出),留下毒化会话。
|
||||
res1, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
||||
@@ -87,7 +92,12 @@ func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
||||
|
||||
func TestNoRotationOnRetryableNetworkFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{Model: "fake", Stream: failThenSucceed("connection reset by peer (provider_network)")})
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: failThenSucceed("connection reset by peer (provider_network)"),
|
||||
},
|
||||
})
|
||||
_, _ = b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
||||
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
||||
_ = os.MkdirAll(filepath.Dir(transcript), 0o755)
|
||||
|
||||
+14
-14
@@ -3,28 +3,28 @@ package catalogcheck
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-native/contract"
|
||||
"agent-native/exec"
|
||||
"error-code-system/errcode"
|
||||
)
|
||||
|
||||
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
|
||||
func TestContractCodesMatchCatalog(t *testing.T) {
|
||||
pairs := []struct {
|
||||
got contract.ErrorCode
|
||||
got exec.ErrorCode
|
||||
want errcode.Code
|
||||
}{
|
||||
{contract.ErrCanceled, errcode.AgentCanceled},
|
||||
{contract.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
|
||||
{contract.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
|
||||
{contract.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
|
||||
{contract.ErrProviderRejected, errcode.AgentProviderRejected},
|
||||
{contract.ErrToolRejected, errcode.AgentToolRejected},
|
||||
{contract.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
|
||||
{contract.ErrMissingCapability, errcode.AgentMissingCapability},
|
||||
{contract.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
|
||||
{contract.ErrEmptyOutput, errcode.AgentEmptyOutput},
|
||||
{contract.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
|
||||
{contract.ErrInternal, errcode.AgentInternal},
|
||||
{exec.ErrCanceled, errcode.AgentCanceled},
|
||||
{exec.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
|
||||
{exec.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
|
||||
{exec.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
|
||||
{exec.ErrProviderRejected, errcode.AgentProviderRejected},
|
||||
{exec.ErrToolRejected, errcode.AgentToolRejected},
|
||||
{exec.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
|
||||
{exec.ErrMissingCapability, errcode.AgentMissingCapability},
|
||||
{exec.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
|
||||
{exec.ErrEmptyOutput, errcode.AgentEmptyOutput},
|
||||
{exec.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
|
||||
{exec.ErrInternal, errcode.AgentInternal},
|
||||
}
|
||||
for _, pair := range pairs {
|
||||
if string(pair.got) != string(pair.want) {
|
||||
|
||||
+21
-21
@@ -11,27 +11,27 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/contract"
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。
|
||||
// 不模拟任何模型行为;按 Script 逐项返回。
|
||||
type FakeBackend struct {
|
||||
Desc contract.BackendDescriptor
|
||||
Desc exec.BackendDescriptor
|
||||
// Result 为每次 Execute 返回的终态(浅拷贝后返回)。
|
||||
Result contract.Result
|
||||
Result exec.ContractResult
|
||||
// BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消,
|
||||
// 用于验证取消语义。
|
||||
BlockUntil <-chan struct{}
|
||||
// CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。
|
||||
CanceledWithCtx bool
|
||||
// LastRequest 记录最近一次请求。
|
||||
LastRequest contract.Request
|
||||
LastRequest exec.ContractRequest
|
||||
}
|
||||
|
||||
func (f *FakeBackend) Descriptor() contract.BackendDescriptor { return f.Desc }
|
||||
func (f *FakeBackend) Descriptor() exec.BackendDescriptor { return f.Desc }
|
||||
|
||||
func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contract.Result, error) {
|
||||
func (f *FakeBackend) Execute(ctx context.Context, req exec.ContractRequest) (*exec.ContractResult, error) {
|
||||
f.LastRequest = req
|
||||
if f.BlockUntil != nil {
|
||||
select {
|
||||
@@ -39,10 +39,10 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
|
||||
case <-ctx.Done():
|
||||
f.CanceledWithCtx = true
|
||||
res := *(&f.Result)
|
||||
res.Outcome = contract.OutcomeCanceled
|
||||
res.Err = &contract.Error{
|
||||
Code: contract.ErrCanceled,
|
||||
SideEffects: contract.SideEffectNone,
|
||||
res.Outcome = exec.OutcomeCanceled
|
||||
res.Err = &exec.Error{
|
||||
Code: exec.ErrCanceled,
|
||||
SideEffects: exec.SideEffectNone,
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
@@ -55,15 +55,15 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
|
||||
type Case struct {
|
||||
Name string
|
||||
// Backend 为被测实现。
|
||||
Backend contract.Backend
|
||||
Backend exec.ContractBackend
|
||||
// Request 为请求模板。
|
||||
Request contract.Request
|
||||
Request exec.ContractRequest
|
||||
// CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。
|
||||
CancelAfter time.Duration
|
||||
// WantOutcome 为期望终态。
|
||||
WantOutcome contract.Outcome
|
||||
WantOutcome exec.Outcome
|
||||
// WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。
|
||||
WantErrCode contract.ErrorCode
|
||||
WantErrCode exec.ErrorCode
|
||||
}
|
||||
|
||||
// Run 执行一条契约用例。
|
||||
@@ -96,14 +96,14 @@ func Run(t *testing.T, c Case) {
|
||||
t.Fatalf("[%s] Err.SideEffects must be explicit (none|possible), got empty", c.Name)
|
||||
}
|
||||
}
|
||||
if res.Outcome == contract.OutcomeSucceeded && res.Err != nil {
|
||||
if res.Outcome == exec.OutcomeSucceeded && res.Err != nil {
|
||||
t.Fatalf("[%s] Succeeded result must not carry Err", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Suite 是最小契约套件:成功、失败带副作用标记、取消三态。
|
||||
// 每个真实 Backend 实现都必须通过;套件外按实现能力补充专项用例。
|
||||
func Suite(t *testing.T, mk func() contract.Backend) {
|
||||
func Suite(t *testing.T, mk func() exec.ContractBackend) {
|
||||
t.Helper()
|
||||
|
||||
t.Run("succeeded_minimal", func(t *testing.T) {
|
||||
@@ -111,8 +111,8 @@ func Suite(t *testing.T, mk func() contract.Backend) {
|
||||
Run(t, Case{
|
||||
Name: "succeeded",
|
||||
Backend: b,
|
||||
Request: contract.Request{ExecutionID: "e1", Prompt: "hi"},
|
||||
WantOutcome: contract.OutcomeSucceeded,
|
||||
Request: exec.ContractRequest{ExecutionID: "e1", Prompt: "hi"},
|
||||
WantOutcome: exec.OutcomeSucceeded,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,13 +130,13 @@ func Suite(t *testing.T, mk func() contract.Backend) {
|
||||
t.Run("error_sideeffects_explicit", func(t *testing.T) {
|
||||
b := mk()
|
||||
// 通过描述能力判定是否有失败路径可测;无失败路径的实现跳过。
|
||||
if !b.Descriptor().Has(contract.CapText) && !b.Descriptor().Has(contract.CapFiles) {
|
||||
if !b.Descriptor().Has(exec.CapText) && !b.Descriptor().Has(exec.CapFiles) {
|
||||
t.Skip("no text/files capability to force failure path")
|
||||
}
|
||||
// 空提示在多数实现下是确定性失败或成功空输出;仅验证当 Outcome=Failed
|
||||
// 时错误字段完整。具体实现应补充自身失败注入用例。
|
||||
res, _ := b.Execute(context.Background(), contract.Request{ExecutionID: "e2"})
|
||||
if res != nil && res.Outcome == contract.OutcomeFailed {
|
||||
res, _ := b.Execute(context.Background(), exec.ContractRequest{ExecutionID: "e2"})
|
||||
if res != nil && res.Outcome == exec.OutcomeFailed {
|
||||
if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" {
|
||||
t.Fatalf("failed result must carry Code and SideEffects, got %+v", res.Err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
func TestFakeBackendSuite(t *testing.T) {
|
||||
Suite(t, func() exec.ContractBackend {
|
||||
return &FakeBackend{
|
||||
Desc: exec.BackendDescriptor{
|
||||
Name: "fake",
|
||||
Capabilities: []exec.Capability{exec.CapText},
|
||||
},
|
||||
Result: exec.ContractResult{
|
||||
Outcome: exec.OutcomeSucceeded,
|
||||
Text: "ok",
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFakeCancelSemantics(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
b := &FakeBackend{
|
||||
Desc: exec.BackendDescriptor{Name: "fake", Capabilities: []exec.Capability{exec.CapCancel}},
|
||||
BlockUntil: block,
|
||||
}
|
||||
Run(t, Case{
|
||||
Name: "cancel",
|
||||
Backend: b,
|
||||
Request: exec.ContractRequest{ExecutionID: "e-cancel"},
|
||||
CancelAfter: 50_000_000,
|
||||
WantOutcome: exec.OutcomeCanceled,
|
||||
WantErrCode: exec.ErrCanceled,
|
||||
})
|
||||
if !b.CanceledWithCtx {
|
||||
t.Fatal("backend must observe ctx cancellation before returning")
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-native/conformance"
|
||||
"agent-native/contract"
|
||||
)
|
||||
|
||||
// 契约自测:fake 后端通过最小套件,验证套件本身与类型语义一致。
|
||||
func TestFakeBackendSuite(t *testing.T) {
|
||||
conformance.Suite(t, func() contract.Backend {
|
||||
return &conformance.FakeBackend{
|
||||
Desc: contract.BackendDescriptor{
|
||||
Name: "fake",
|
||||
Capabilities: []contract.Capability{contract.CapText},
|
||||
},
|
||||
Result: contract.Result{
|
||||
Outcome: contract.OutcomeSucceeded,
|
||||
Text: "ok",
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFakeCancelSemantics(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
b := &conformance.FakeBackend{
|
||||
Desc: contract.BackendDescriptor{Name: "fake", Capabilities: []contract.Capability{contract.CapCancel}},
|
||||
BlockUntil: block,
|
||||
}
|
||||
conformance.Run(t, conformance.Case{
|
||||
Name: "cancel",
|
||||
Backend: b,
|
||||
Request: contract.Request{ExecutionID: "e-cancel"},
|
||||
CancelAfter: 50_000_000, // 50ms in ns (avoid time import here)
|
||||
WantOutcome: contract.OutcomeCanceled,
|
||||
WantErrCode: contract.ErrCanceled,
|
||||
})
|
||||
if !b.CanceledWithCtx {
|
||||
t.Fatal("backend must observe ctx cancellation before returning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorDuckTypeForLogs(t *testing.T) {
|
||||
e := &contract.Error{
|
||||
Code: contract.ErrProviderUnavailable,
|
||||
Reason: "down",
|
||||
Retryable: true,
|
||||
SideEffects: contract.SideEffectNone,
|
||||
}
|
||||
if e.ErrorCode() != "agent.provider_unavailable" {
|
||||
t.Fatal(e.ErrorCode())
|
||||
}
|
||||
fields := e.ErrorFields()
|
||||
if fields["reason"] != "down" || fields["side_effects"] != "none" || fields["retryable"] != true {
|
||||
t.Fatalf("%v", fields)
|
||||
}
|
||||
var nilErr *contract.Error
|
||||
if nilErr.ErrorCode() != "" || nilErr.ErrorFields() != nil {
|
||||
t.Fatal("nil error")
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 05e74d8e32...65aaebca1e
+1
-1
@@ -16,7 +16,7 @@ type Request struct {
|
||||
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
|
||||
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
|
||||
// 回调内不得阻塞(消费方自行节流/异步入队)。
|
||||
OnEvent func(ev ProcessEvent)
|
||||
OnEvent func(ev ProcessEvent) `json:"-"`
|
||||
|
||||
// ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ----
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package contract 定义 Agent-Native 的公共执行契约。
|
||||
// 原 contract 包并入 exec。与本文件同名的执行门面仍是 Request、Result、Backend;
|
||||
// 设计契约里冲突的四个名字加 Contract 前缀。
|
||||
//
|
||||
// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体
|
||||
// Backend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
|
||||
// ContractBackend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
|
||||
// 内核、产品类型、数据库或配置包。
|
||||
//
|
||||
// 语义不变量(实现方与消费方都必须遵守):
|
||||
@@ -20,7 +21,7 @@
|
||||
// 上下文,不授予跨会话可见性。
|
||||
// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方
|
||||
// 提供的进程/容器隔离,不能由本契约推导。
|
||||
package contract
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -50,12 +51,6 @@ const (
|
||||
CapStructuredOutput Capability = "structured-output"
|
||||
)
|
||||
|
||||
// ToolProfile 是预定义工具策略名;与现有产品配置兼容。
|
||||
const (
|
||||
ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合
|
||||
ToolProfileReasoning = "reasoning" // 无工具,仅推理
|
||||
)
|
||||
|
||||
// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。
|
||||
type ToolPolicy struct {
|
||||
// Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。
|
||||
@@ -82,8 +77,8 @@ type SessionScope struct {
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// Budget 约束一次执行(含共享 ExecutionID 的重试)。
|
||||
type Budget struct {
|
||||
// ContractBudget 约束一次执行(含共享 ExecutionID 的重试)。
|
||||
type ContractBudget struct {
|
||||
// MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。
|
||||
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
@@ -98,9 +93,9 @@ type Message struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Request 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
|
||||
// ContractRequest 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
|
||||
// (Prompt+Instructions+Targets→reply.md)是其子集 facade。
|
||||
type Request struct {
|
||||
type ContractRequest struct {
|
||||
// ExecutionID 由调用方生成,重试与受控 fallback 复用同值。
|
||||
ExecutionID string `json:"execution_id"`
|
||||
// SessionScope 为隔离身份;可为零值(一次性任务)。
|
||||
@@ -118,8 +113,8 @@ type Request struct {
|
||||
Workspace WorkspaceHandle `json:"workspace"`
|
||||
// ToolPolicy 为工具范围;空 Profile 等同 reasoning。
|
||||
ToolPolicy ToolPolicy `json:"tool_policy"`
|
||||
// Budget 为本次执行预算。
|
||||
Budget Budget `json:"budget"`
|
||||
// ContractBudget 为本次执行预算。
|
||||
ContractBudget ContractBudget `json:"budget"`
|
||||
// Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。
|
||||
Required []Capability `json:"required,omitempty"`
|
||||
|
||||
@@ -235,14 +230,14 @@ type Usage struct {
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// Result 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
|
||||
type Result struct {
|
||||
// ContractResult 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
|
||||
type ContractResult struct {
|
||||
Outcome Outcome `json:"outcome"`
|
||||
// Text 为文本结果(CapText 任务必填)。
|
||||
Text string `json:"text,omitempty"`
|
||||
// Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。
|
||||
Files map[string][]byte `json:"files,omitempty"`
|
||||
// Logs 为执行日志/过程摘要(兼容现有 Result.Logs)。
|
||||
// Logs 为执行日志/过程摘要(兼容现有 ContractResult.Logs)。
|
||||
Logs string `json:"logs,omitempty"`
|
||||
// Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。
|
||||
Err *Error `json:"err,omitempty"`
|
||||
@@ -271,19 +266,19 @@ func (d BackendDescriptor) Has(c Capability) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Backend 是统一执行接口。实现必须:
|
||||
// ContractBackend 是统一执行接口。实现必须:
|
||||
// - 在 ctx 取消时尽快返回(Canceled 或 Unknown);
|
||||
// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用);
|
||||
// - 不自行扩大工具范围或读取 Workspace 之外的路径。
|
||||
type Backend interface {
|
||||
type ContractBackend interface {
|
||||
Descriptor() BackendDescriptor
|
||||
// Execute 执行一次授权调用。返回的 Result 永不为 nil;ctx 错误映射为
|
||||
// Execute 执行一次授权调用。返回的 ContractResult 永不为 nil;ctx 错误映射为
|
||||
// Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。
|
||||
Execute(ctx context.Context, req Request) (*Result, error)
|
||||
Execute(ctx context.Context, req ContractRequest) (*ContractResult, error)
|
||||
}
|
||||
|
||||
// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得
|
||||
// 回落到 Stub。
|
||||
type Resolver interface {
|
||||
Resolve(name string, required []Capability) (Backend, error)
|
||||
Resolve(name string, required []Capability) (ContractBackend, error)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package exec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestErrorDuckTypeForLogs(t *testing.T) {
|
||||
e := &Error{
|
||||
Code: ErrProviderUnavailable,
|
||||
Reason: "down",
|
||||
Retryable: true,
|
||||
SideEffects: SideEffectNone,
|
||||
}
|
||||
if e.ErrorCode() != "agent.provider_unavailable" {
|
||||
t.Fatal(e.ErrorCode())
|
||||
}
|
||||
fields := e.ErrorFields()
|
||||
if fields["reason"] != "down" || fields["side_effects"] != "none" || fields["retryable"] != true {
|
||||
t.Fatalf("%v", fields)
|
||||
}
|
||||
var nilErr *Error
|
||||
if nilErr.ErrorCode() != "" || nilErr.ErrorFields() != nil {
|
||||
t.Fatal("nil error")
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,10 @@ module agent-native
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0
|
||||
error-code-system v0.0.0
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.29.0 // indirect
|
||||
|
||||
replace agent-intent => ./deps/agent-intent
|
||||
|
||||
replace error-code-system => ./deps/error-code-system
|
||||
|
||||
replace logging-system => ./deps/logging-system
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
|
||||
@@ -151,6 +151,12 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||
emitError(out, content.String(), true, err.Error())
|
||||
return
|
||||
}
|
||||
// 取消必须压过正常结束。select 在两个分支都就绪时随机挑选,
|
||||
// 竞态下会把 message_end 发给已经取消的调用方。
|
||||
if ctx.Err() != nil {
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
return
|
||||
}
|
||||
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
|
||||
for _, idx := range order {
|
||||
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"work_dir": "/workspace/demo",
|
||||
"prompt": "写一份说明",
|
||||
"instructions": "只写目标文件",
|
||||
"targets": ["output.md"],
|
||||
"tool_profile": "workspace"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"success": true,
|
||||
"patches": {"output.md": "ZGVtbw=="},
|
||||
"logs": "done"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
func TestRequestAndResultRoundTrip(t *testing.T) {
|
||||
reqRaw, err := os.ReadFile(filepath.Join("request.sample.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req exec.Request
|
||||
if err := json.Unmarshal(reqRaw, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Prompt != "写一份说明" || req.ToolProfile != exec.ToolProfileWorkspace {
|
||||
t.Fatalf("%+v", req)
|
||||
}
|
||||
back, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var again exec.Request
|
||||
if err := json.Unmarshal(back, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Prompt != req.Prompt || len(again.Targets) != 1 {
|
||||
t.Fatalf("%+v", again)
|
||||
}
|
||||
|
||||
resRaw, err := os.ReadFile("result.sample.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res exec.Result
|
||||
if err := json.Unmarshal(resRaw, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Success || string(res.Patches["output.md"]) != "demo" {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
out, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(out, []byte(`"success":true`)) {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user