diff --git a/adapters/intentgate/intentgate.go b/adapters/intentgate/intentgate.go deleted file mode 100644 index c3a9cbb..0000000 --- a/adapters/intentgate/intentgate.go +++ /dev/null @@ -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 -} diff --git a/adapters/intentgate/intentgate_test.go b/adapters/intentgate/intentgate_test.go deleted file mode 100644 index 8445827..0000000 --- a/adapters/intentgate/intentgate_test.go +++ /dev/null @@ -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) - } -} diff --git a/adapters/native/backend.go b/adapters/native/backend.go index a2fee46..1ef2715 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -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(密钥由组合根注入)。 - 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____ 注册进工作区。 - // nil = 不注册(fail-closed:server 不可达时整个步骤失败,不静默跳过)。 - MCP *mcp.Manager - // SkillsRoots 可选:全局技能根目录;执行时自动并入工作区 .agent/skills/。 + Stream llm.StreamFunc +} + +// ToolOptions 是可选工具装配。nil 字段不注册对应工具。 +type ToolOptions struct { + Search tools.SearchProvider + Exec *tools.ExecConfig + Fetch *tools.FetchConfig + MCP *mcp.Manager SkillsRoots []string - // Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。 - Compact *CompactConfig - // Subagent 可选:提供时注册 delegate_task(委派由宿主实现,内核只开端口)。 - Subagent subagent.Factory - // HostTools 是宿主产品工具(登记工件、卡片、知识库)。与内核工具同名时失败,不覆盖。 - HostTools []tools.Tool - // MaxToolCalls 是单步工具调用上限。0 表示不限制。请求自带预算时以请求为准。 + Subagent subagent.Factory + HostTools []tools.Tool +} + +// SessionOptions 是单次执行的会话预算与压缩。 +type SessionOptions struct { + Compact *CompactConfig MaxToolCalls int - // Log 可选。nil 时丢弃日志。 - Log *logsys.Logger - // ExecutionID 写入日志,不参与执行控制。 +} + +// ObserveOptions 是日志。Log 为 nil 时丢弃。 +type ObserveOptions struct { + Log *logsys.Logger ExecutionID string } +// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。 +// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。 +type Options struct { + ModelOptions + ToolOptions + SessionOptions + ObserveOptions +} + // CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。 type CompactConfig struct { // ThresholdChars 触发阈值(按字节数近似),默认 300_000。 diff --git a/adapters/native/backend_test.go b/adapters/native/backend_test.go index ba350ce..589a9ab 100644 --- a/adapters/native/backend_test.go +++ b/adapters/native/backend_test.go @@ -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( - 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}, - )}) + 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( - llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd}, - )}) + 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) diff --git a/adapters/native/compact_test.go b/adapters/native/compact_test.go index 22881bb..04d9e47 100644 --- a/adapters/native/compact_test.go +++ b/adapters/native/compact_test.go @@ -30,11 +30,15 @@ func TestBackendCompactBeforeRun(t *testing.T) { var sawSummary bool // 注入式捕获:验证 Run 收到的消息里有摘要标记。 bk2 := New(Options{ - Model: "fake", - Stream: captureFirstStream(&sawSummary), - Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) { - return "预置历史摘要", nil - }}, + 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"}, diff --git a/adapters/native/exec_tools_test.go b/adapters/native/exec_tools_test.go index 865b634..9cf7bc5 100644 --- a/adapters/native/exec_tools_test.go +++ b/adapters/native/exec_tools_test.go @@ -22,19 +22,23 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) { } dir := t.TempDir() b := New(Options{ - Model: "fake", - Stream: script( - llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ - ID: "c1", Name: "run_command", - Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`, - }}}, - llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ - ID: "c2", Name: "write_file", - Arguments: `{"path":"output.md","content":"done"}`, - }}}, - llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}, - ), - Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}}, + ModelOptions: ModelOptions{ + Model: "fake", + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "run_command", + Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c2", Name: "write_file", + Arguments: `{"path":"output.md","content":"done"}`, + }}}, + 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, - Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}}, + 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,11 +372,15 @@ func TestBackendSubagentWiredViaOptions(t *testing.T) { llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}, ) b := New(Options{ - Model: "fake", - Stream: stream, - Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) { - delegated = &req - return subagent.Result{Success: true, Text: "结论已查明"}, nil + 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() diff --git a/adapters/native/host_tools_test.go b/adapters/native/host_tools_test.go index 89cf594..e6f7025 100644 --- a/adapters/native/host_tools_test.go +++ b/adapters/native/host_tools_test.go @@ -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{ - Model: "fake", - HostTools: []tools.Tool{hostTool{name: "register_artifact"}}, - Stream: usageScript(), + ModelOptions: ModelOptions{ + Model: "fake", + 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{ - Model: "fake", - Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}), - HostTools: []tools.Tool{peekTool{got: &seen}}, + 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{ - Model: "fake", - Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}), - HostTools: []tools.Tool{hostTool{name: "read_file"}}, + 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"}, diff --git a/adapters/native/request_v2_test.go b/adapters/native/request_v2_test.go index 57e3192..11d72da 100644 --- a/adapters/native/request_v2_test.go +++ b/adapters/native/request_v2_test.go @@ -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,12 +184,17 @@ 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"}, Instructions: "你是角色", - ExecutionID: "exec-42", SessionScope: "run-9/step-3", + ExecutionID: "exec-42", SessionScope: "run-9/step-3", ToolProfile: exec.ToolProfileReasoning, }) if err != nil || !res.Success { diff --git a/adapters/native/rotation_test.go b/adapters/native/rotation_test.go index d9801f8..5f95d19 100644 --- a/adapters/native/rotation_test.go +++ b/adapters/native/rotation_test.go @@ -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) diff --git a/catalogcheck/align_test.go b/catalogcheck/align_test.go index 8ffe78d..ef29b2f 100644 --- a/catalogcheck/align_test.go +++ b/catalogcheck/align_test.go @@ -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) { diff --git a/conformance/fake.go b/conformance/fake.go index 72ef049..422c6e8 100644 --- a/conformance/fake.go +++ b/conformance/fake.go @@ -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,23 +96,23 @@ 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) { b := mk() Run(t, Case{ - Name: "succeeded", - Backend: b, - Request: contract.Request{ExecutionID: "e1", Prompt: "hi"}, - WantOutcome: contract.OutcomeSucceeded, + Name: "succeeded", + Backend: b, + 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) } diff --git a/conformance/spec_test.go b/conformance/spec_test.go new file mode 100644 index 0000000..4a5bd7e --- /dev/null +++ b/conformance/spec_test.go @@ -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") + } +} diff --git a/contract/contract_test.go b/contract/contract_test.go deleted file mode 100644 index 9349258..0000000 --- a/contract/contract_test.go +++ /dev/null @@ -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") - } -} diff --git a/deps/agent-intent b/deps/agent-intent index 05e74d8..65aaebc 160000 --- a/deps/agent-intent +++ b/deps/agent-intent @@ -1 +1 @@ -Subproject commit 05e74d8e320647fe41c4ca9cd20e31065323d6af +Subproject commit 65aaebca1ea69ab7d48eda51d33ef1d4eea5a0bf diff --git a/exec/backend.go b/exec/backend.go index 85432c2..c7144d1 100644 --- a/exec/backend.go +++ b/exec/backend.go @@ -16,7 +16,7 @@ type Request struct { // OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。 // 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine; // 回调内不得阻塞(消费方自行节流/异步入队)。 - OnEvent func(ev ProcessEvent) + OnEvent func(ev ProcessEvent) `json:"-"` // ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ---- diff --git a/contract/contract.go b/exec/contract_spec.go similarity index 90% rename from contract/contract.go rename to exec/contract_spec.go index d22d727..49bc93b 100644 --- a/contract/contract.go +++ b/exec/contract_spec.go @@ -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) } diff --git a/exec/error_duck_test.go b/exec/error_duck_test.go new file mode 100644 index 0000000..24abe57 --- /dev/null +++ b/exec/error_duck_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index 7de43dc..f46d604 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3adec61..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +0,0 @@ -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= diff --git a/runtime/llm/sse.go b/runtime/llm/sse.go index d7e8230..0bd3f32 100644 --- a/runtime/llm/sse.go +++ b/runtime/llm/sse.go @@ -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]) diff --git a/schema/request.sample.json b/schema/request.sample.json new file mode 100644 index 0000000..42ad1ad --- /dev/null +++ b/schema/request.sample.json @@ -0,0 +1,7 @@ +{ + "work_dir": "/workspace/demo", + "prompt": "写一份说明", + "instructions": "只写目标文件", + "targets": ["output.md"], + "tool_profile": "workspace" +} diff --git a/schema/result.sample.json b/schema/result.sample.json new file mode 100644 index 0000000..c6d7b81 --- /dev/null +++ b/schema/result.sample.json @@ -0,0 +1,5 @@ +{ + "success": true, + "patches": {"output.md": "ZGVtbw=="}, + "logs": "done" +} diff --git a/schema/roundtrip_test.go b/schema/roundtrip_test.go new file mode 100644 index 0000000..93b08ba --- /dev/null +++ b/schema/roundtrip_test.go @@ -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) + } +}