Drop the intent adapter and fold contract into exec
Why: native must compile without agent-intent, and the two execution packages were the same contract under two names. Evidence: GOWORK=off go test -count=1 ./... passed, including schema.TestRequestAndResultRoundTrip and the native adapter tests after Options was split into embedded sub-structs. Impact: adapters/intentgate is gone, go.mod no longer requires agent-intent, and the nested gitlink stays for the release task. Callers of Options composite literals name the sub-struct. Refs: D1 Co-authored-by: Cursor <cursoragent@cursor.com>
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+32
-25
@@ -25,41 +25,48 @@ import (
|
|||||||
"logging-system/logsys"
|
"logging-system/logsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
// ModelOptions 是模型接入。Stream 非空时不再按 APIBase/APIKey 构造客户端。
|
||||||
type Options struct {
|
type ModelOptions struct {
|
||||||
APIBase string
|
APIBase string
|
||||||
APIKey string
|
APIKey string
|
||||||
Model string
|
Model string
|
||||||
MaxTurns int
|
MaxTurns int
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。
|
Stream llm.StreamFunc
|
||||||
Stream llm.StreamFunc
|
}
|
||||||
// Search 可选:提供时注册 tavily_search / web_search(密钥由组合根注入)。
|
|
||||||
Search tools.SearchProvider
|
// ToolOptions 是可选工具装配。nil 字段不注册对应工具。
|
||||||
// Exec 可选:提供时注册 run_command / run_python(解释器留空项不注册)。
|
type ToolOptions struct {
|
||||||
// nil = 不注册执行类工具(fail-closed,与 BashPermission 默认 deny 对齐)。
|
Search tools.SearchProvider
|
||||||
Exec *tools.ExecConfig
|
Exec *tools.ExecConfig
|
||||||
// Fetch 可选:提供时注册 fetch_url;nil = 不注册(fail-closed)。
|
Fetch *tools.FetchConfig
|
||||||
Fetch *tools.FetchConfig
|
MCP *mcp.Manager
|
||||||
// MCP 可选:提供时把各 server 工具以 mcp__<server>__<tool> 注册进工作区。
|
|
||||||
// nil = 不注册(fail-closed:server 不可达时整个步骤失败,不静默跳过)。
|
|
||||||
MCP *mcp.Manager
|
|
||||||
// SkillsRoots 可选:全局技能根目录;执行时自动并入工作区 .agent/skills/。
|
|
||||||
SkillsRoots []string
|
SkillsRoots []string
|
||||||
// Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。
|
Subagent subagent.Factory
|
||||||
Compact *CompactConfig
|
HostTools []tools.Tool
|
||||||
// Subagent 可选:提供时注册 delegate_task(委派由宿主实现,内核只开端口)。
|
}
|
||||||
Subagent subagent.Factory
|
|
||||||
// HostTools 是宿主产品工具(登记工件、卡片、知识库)。与内核工具同名时失败,不覆盖。
|
// SessionOptions 是单次执行的会话预算与压缩。
|
||||||
HostTools []tools.Tool
|
type SessionOptions struct {
|
||||||
// MaxToolCalls 是单步工具调用上限。0 表示不限制。请求自带预算时以请求为准。
|
Compact *CompactConfig
|
||||||
MaxToolCalls int
|
MaxToolCalls int
|
||||||
// Log 可选。nil 时丢弃日志。
|
}
|
||||||
Log *logsys.Logger
|
|
||||||
// ExecutionID 写入日志,不参与执行控制。
|
// ObserveOptions 是日志。Log 为 nil 时丢弃。
|
||||||
|
type ObserveOptions struct {
|
||||||
|
Log *logsys.Logger
|
||||||
ExecutionID string
|
ExecutionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
||||||
|
// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。
|
||||||
|
type Options struct {
|
||||||
|
ModelOptions
|
||||||
|
ToolOptions
|
||||||
|
SessionOptions
|
||||||
|
ObserveOptions
|
||||||
|
}
|
||||||
|
|
||||||
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
|
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
|
||||||
type CompactConfig struct {
|
type CompactConfig struct {
|
||||||
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
|
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
|
||||||
|
|||||||
@@ -39,12 +39,17 @@ func script(turns ...llm.Message) llm.StreamFunc {
|
|||||||
|
|
||||||
func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := New(Options{Model: "fake", Stream: script(
|
b := New(Options{
|
||||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
ModelOptions: ModelOptions{
|
||||||
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"# 交付\n\n工具写入的交付物内容。"}`,
|
Model: "fake",
|
||||||
}}},
|
Stream: script(
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd},
|
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
|
var events []exec.ProcessEvent
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色",
|
WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色",
|
||||||
@@ -81,9 +86,14 @@ func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
|||||||
|
|
||||||
func TestBackendReasoningProfileWritesText(t *testing.T) {
|
func TestBackendReasoningProfileWritesText(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := New(Options{Model: "fake", Stream: script(
|
b := New(Options{
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
|
ModelOptions: ModelOptions{
|
||||||
)})
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning,
|
WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning,
|
||||||
Targets: []string{"notes.md"},
|
Targets: []string{"notes.md"},
|
||||||
@@ -109,12 +119,21 @@ func TestBackendFailClosedWithoutConfig(t *testing.T) {
|
|||||||
if res.Success || !strings.Contains(res.ErrorReason, "requires") {
|
if res.Success || !strings.Contains(res.ErrorReason, "requires") {
|
||||||
t.Fatalf("want fail-closed config error, got %+v", res)
|
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"}})
|
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
||||||
if res2.Success || !strings.Contains(res2.ErrorReason, "model") {
|
if res2.Success || !strings.Contains(res2.ErrorReason, "model") {
|
||||||
t.Fatalf("want model required error, got %+v", res2)
|
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"})
|
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"})
|
||||||
if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") {
|
if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") {
|
||||||
t.Fatalf("want profile error, got %+v", res3)
|
t.Fatalf("want profile error, got %+v", res3)
|
||||||
|
|||||||
@@ -30,11 +30,15 @@ func TestBackendCompactBeforeRun(t *testing.T) {
|
|||||||
var sawSummary bool
|
var sawSummary bool
|
||||||
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
|
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
|
||||||
bk2 := New(Options{
|
bk2 := New(Options{
|
||||||
Model: "fake",
|
ModelOptions: ModelOptions{
|
||||||
Stream: captureFirstStream(&sawSummary),
|
Model: "fake",
|
||||||
Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) {
|
Stream: captureFirstStream(&sawSummary),
|
||||||
return "预置历史摘要", nil
|
},
|
||||||
}},
|
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{
|
res, err := bk2.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "继续", Targets: []string{"output.md"},
|
WorkDir: dir, Prompt: "继续", Targets: []string{"output.md"},
|
||||||
|
|||||||
@@ -22,19 +22,23 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := New(Options{
|
b := New(Options{
|
||||||
Model: "fake",
|
ModelOptions: ModelOptions{
|
||||||
Stream: script(
|
Model: "fake",
|
||||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
Stream: script(
|
||||||
ID: "c1", Name: "run_command",
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`,
|
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",
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
Arguments: `{"path":"output.md","content":"done"}`,
|
ID: "c2", Name: "write_file",
|
||||||
}}},
|
Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
}}},
|
||||||
),
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
|
),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "run", Targets: []string{"output.md"},
|
WorkDir: dir, Prompt: "run", Targets: []string{"output.md"},
|
||||||
@@ -54,7 +58,12 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
|
|||||||
|
|
||||||
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
|
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
|
||||||
stream, c := captureStream()
|
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{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
ToolProfile: exec.ToolProfileReasoning,
|
ToolProfile: exec.ToolProfileReasoning,
|
||||||
@@ -64,7 +73,12 @@ func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
|
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
|
||||||
stream2, c2 := captureStream()
|
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{
|
_, _ = b2.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
ToolProfile: exec.ToolProfileWorkspace,
|
ToolProfile: exec.ToolProfileWorkspace,
|
||||||
@@ -83,8 +97,13 @@ func TestBackendAllowlistCanDisableExecTools(t *testing.T) {
|
|||||||
bash, _ := osexec.LookPath("bash")
|
bash, _ := osexec.LookPath("bash")
|
||||||
stream, c := captureStream()
|
stream, c := captureStream()
|
||||||
b := New(Options{
|
b := New(Options{
|
||||||
Model: "fake", Stream: stream,
|
ModelOptions: ModelOptions{
|
||||||
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}},
|
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{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
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},
|
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()
|
dir := t.TempDir()
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "search", Targets: []string{"output.md"},
|
WorkDir: dir, Prompt: "search", Targets: []string{"output.md"},
|
||||||
@@ -152,7 +179,12 @@ func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
|
|||||||
|
|
||||||
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
|
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
|
||||||
stream, c := captureStream()
|
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{
|
_, _ = b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
ToolProfile: exec.ToolProfileWorkspace,
|
ToolProfile: exec.ToolProfileWorkspace,
|
||||||
@@ -190,7 +222,15 @@ func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
|
|||||||
}}},
|
}}},
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
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()
|
dir := t.TempDir()
|
||||||
res, err2 := b.Execute(context.Background(), exec.Request{
|
res, err2 := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "call mcp", Targets: []string{"output.md"},
|
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},
|
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()
|
dir := t.TempDir()
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}, Skills: []string{"docx-author"},
|
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) {
|
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{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
Skills: []string{"ghost-skill"},
|
Skills: []string{"ghost-skill"},
|
||||||
@@ -277,7 +333,12 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
|
|||||||
}}},
|
}}},
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
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()
|
dir := t.TempDir()
|
||||||
wsSkill := filepath.Join(dir, ".agent", "skills", "ws-skill")
|
wsSkill := filepath.Join(dir, ".agent", "skills", "ws-skill")
|
||||||
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
|
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},
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
)
|
)
|
||||||
b := New(Options{
|
b := New(Options{
|
||||||
Model: "fake",
|
ModelOptions: ModelOptions{
|
||||||
Stream: stream,
|
Model: "fake",
|
||||||
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
Stream: stream,
|
||||||
delegated = &req
|
},
|
||||||
return subagent.Result{Success: true, Text: "结论已查明"}, nil
|
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()
|
dir := t.TempDir()
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ func (h hostTool) Execute(ctx context.Context, args json.RawMessage) (tools.Resu
|
|||||||
func TestHostToolAndUsageEvent(t *testing.T) {
|
func TestHostToolAndUsageEvent(t *testing.T) {
|
||||||
var events []exec.ProcessEvent
|
var events []exec.ProcessEvent
|
||||||
b := New(Options{
|
b := New(Options{
|
||||||
Model: "fake",
|
ModelOptions: ModelOptions{
|
||||||
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
|
Model: "fake",
|
||||||
Stream: usageScript(),
|
Stream: usageScript(),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "登记",
|
WorkDir: t.TempDir(), Prompt: "登记",
|
||||||
@@ -65,9 +69,13 @@ func TestHostToolAndUsageEvent(t *testing.T) {
|
|||||||
func TestHostToolSeesStepWorkDir(t *testing.T) {
|
func TestHostToolSeesStepWorkDir(t *testing.T) {
|
||||||
var seen string
|
var seen string
|
||||||
b := New(Options{
|
b := New(Options{
|
||||||
Model: "fake",
|
ModelOptions: ModelOptions{
|
||||||
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}),
|
Model: "fake",
|
||||||
HostTools: []tools.Tool{peekTool{got: &seen}},
|
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()
|
dir := t.TempDir()
|
||||||
_, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "x", Targets: []string{"output.md"}})
|
_, 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) {
|
func TestHostToolNameConflictFailsClosed(t *testing.T) {
|
||||||
b := New(Options{
|
b := New(Options{
|
||||||
Model: "fake",
|
ModelOptions: ModelOptions{
|
||||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
Model: "fake",
|
||||||
HostTools: []tools.Tool{hostTool{name: "read_file"}},
|
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{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ func toolNames(decls []llm.ToolDecl) []string {
|
|||||||
|
|
||||||
func TestBackendRequestModelOverride(t *testing.T) {
|
func TestBackendRequestModelOverride(t *testing.T) {
|
||||||
stream, c := captureStream()
|
stream, c := captureStream()
|
||||||
b := New(Options{Model: "default-model", Stream: stream})
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "default-model",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"}, Model: "override-model",
|
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},
|
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()
|
dir := t.TempDir()
|
||||||
// 目标 output2.md 不在 allowlist 内能写吗?——write_file 被整体收窄,
|
// 目标 output2.md 不在 allowlist 内能写吗?——write_file 被整体收窄,
|
||||||
// 模型两次尝试都会得到 unknown tool,最终无交付物 → 失败。
|
// 模型两次尝试都会得到 unknown tool,最终无交付物 → 失败。
|
||||||
@@ -109,7 +119,12 @@ func TestBackendRequestToolAllowlist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBackendRequestUnknownAllowlistFailsClosed(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{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
ToolAllowlist: []string{"read_file", "no_such_tool"},
|
ToolAllowlist: []string{"read_file", "no_such_tool"},
|
||||||
@@ -139,7 +154,12 @@ func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
b := New(Options{Model: "fake", Stream: stream})
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
@@ -164,12 +184,17 @@ func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
|
|||||||
|
|
||||||
func TestBackendRequestExecutionIDInLogs(t *testing.T) {
|
func TestBackendRequestExecutionIDInLogs(t *testing.T) {
|
||||||
stream, _ := captureStream()
|
stream, _ := captureStream()
|
||||||
b := New(Options{Model: "fake", Stream: stream})
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"},
|
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"},
|
||||||
Instructions: "你是角色",
|
Instructions: "你是角色",
|
||||||
ExecutionID: "exec-42", SessionScope: "run-9/step-3",
|
ExecutionID: "exec-42", SessionScope: "run-9/step-3",
|
||||||
ToolProfile: exec.ToolProfileReasoning,
|
ToolProfile: exec.ToolProfileReasoning,
|
||||||
})
|
})
|
||||||
if err != nil || !res.Success {
|
if err != nil || !res.Success {
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ func failThenSucceed(failMsg string) llm.StreamFunc {
|
|||||||
|
|
||||||
func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
||||||
dir := t.TempDir()
|
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"}))
|
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) {
|
func TestNoRotationOnRetryableNetworkFailure(t *testing.T) {
|
||||||
dir := t.TempDir()
|
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"}))
|
_, _ = b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
||||||
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
||||||
_ = os.MkdirAll(filepath.Dir(transcript), 0o755)
|
_ = os.MkdirAll(filepath.Dir(transcript), 0o755)
|
||||||
|
|||||||
+14
-14
@@ -3,28 +3,28 @@ package catalogcheck
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agent-native/contract"
|
"agent-native/exec"
|
||||||
"error-code-system/errcode"
|
"error-code-system/errcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
|
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
|
||||||
func TestContractCodesMatchCatalog(t *testing.T) {
|
func TestContractCodesMatchCatalog(t *testing.T) {
|
||||||
pairs := []struct {
|
pairs := []struct {
|
||||||
got contract.ErrorCode
|
got exec.ErrorCode
|
||||||
want errcode.Code
|
want errcode.Code
|
||||||
}{
|
}{
|
||||||
{contract.ErrCanceled, errcode.AgentCanceled},
|
{exec.ErrCanceled, errcode.AgentCanceled},
|
||||||
{contract.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
|
{exec.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
|
||||||
{contract.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
|
{exec.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
|
||||||
{contract.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
|
{exec.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
|
||||||
{contract.ErrProviderRejected, errcode.AgentProviderRejected},
|
{exec.ErrProviderRejected, errcode.AgentProviderRejected},
|
||||||
{contract.ErrToolRejected, errcode.AgentToolRejected},
|
{exec.ErrToolRejected, errcode.AgentToolRejected},
|
||||||
{contract.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
|
{exec.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
|
||||||
{contract.ErrMissingCapability, errcode.AgentMissingCapability},
|
{exec.ErrMissingCapability, errcode.AgentMissingCapability},
|
||||||
{contract.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
|
{exec.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
|
||||||
{contract.ErrEmptyOutput, errcode.AgentEmptyOutput},
|
{exec.ErrEmptyOutput, errcode.AgentEmptyOutput},
|
||||||
{contract.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
|
{exec.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
|
||||||
{contract.ErrInternal, errcode.AgentInternal},
|
{exec.ErrInternal, errcode.AgentInternal},
|
||||||
}
|
}
|
||||||
for _, pair := range pairs {
|
for _, pair := range pairs {
|
||||||
if string(pair.got) != string(pair.want) {
|
if string(pair.got) != string(pair.want) {
|
||||||
|
|||||||
+23
-23
@@ -11,27 +11,27 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agent-native/contract"
|
"agent-native/exec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。
|
// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。
|
||||||
// 不模拟任何模型行为;按 Script 逐项返回。
|
// 不模拟任何模型行为;按 Script 逐项返回。
|
||||||
type FakeBackend struct {
|
type FakeBackend struct {
|
||||||
Desc contract.BackendDescriptor
|
Desc exec.BackendDescriptor
|
||||||
// Result 为每次 Execute 返回的终态(浅拷贝后返回)。
|
// Result 为每次 Execute 返回的终态(浅拷贝后返回)。
|
||||||
Result contract.Result
|
Result exec.ContractResult
|
||||||
// BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消,
|
// BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消,
|
||||||
// 用于验证取消语义。
|
// 用于验证取消语义。
|
||||||
BlockUntil <-chan struct{}
|
BlockUntil <-chan struct{}
|
||||||
// CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。
|
// CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。
|
||||||
CanceledWithCtx bool
|
CanceledWithCtx bool
|
||||||
// LastRequest 记录最近一次请求。
|
// 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
|
f.LastRequest = req
|
||||||
if f.BlockUntil != nil {
|
if f.BlockUntil != nil {
|
||||||
select {
|
select {
|
||||||
@@ -39,10 +39,10 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
f.CanceledWithCtx = true
|
f.CanceledWithCtx = true
|
||||||
res := *(&f.Result)
|
res := *(&f.Result)
|
||||||
res.Outcome = contract.OutcomeCanceled
|
res.Outcome = exec.OutcomeCanceled
|
||||||
res.Err = &contract.Error{
|
res.Err = &exec.Error{
|
||||||
Code: contract.ErrCanceled,
|
Code: exec.ErrCanceled,
|
||||||
SideEffects: contract.SideEffectNone,
|
SideEffects: exec.SideEffectNone,
|
||||||
}
|
}
|
||||||
return &res, nil
|
return &res, nil
|
||||||
}
|
}
|
||||||
@@ -55,15 +55,15 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
|
|||||||
type Case struct {
|
type Case struct {
|
||||||
Name string
|
Name string
|
||||||
// Backend 为被测实现。
|
// Backend 为被测实现。
|
||||||
Backend contract.Backend
|
Backend exec.ContractBackend
|
||||||
// Request 为请求模板。
|
// Request 为请求模板。
|
||||||
Request contract.Request
|
Request exec.ContractRequest
|
||||||
// CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。
|
// CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。
|
||||||
CancelAfter time.Duration
|
CancelAfter time.Duration
|
||||||
// WantOutcome 为期望终态。
|
// WantOutcome 为期望终态。
|
||||||
WantOutcome contract.Outcome
|
WantOutcome exec.Outcome
|
||||||
// WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。
|
// WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。
|
||||||
WantErrCode contract.ErrorCode
|
WantErrCode exec.ErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run 执行一条契约用例。
|
// 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)
|
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)
|
t.Fatalf("[%s] Succeeded result must not carry Err", c.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suite 是最小契约套件:成功、失败带副作用标记、取消三态。
|
// Suite 是最小契约套件:成功、失败带副作用标记、取消三态。
|
||||||
// 每个真实 Backend 实现都必须通过;套件外按实现能力补充专项用例。
|
// 每个真实 Backend 实现都必须通过;套件外按实现能力补充专项用例。
|
||||||
func Suite(t *testing.T, mk func() contract.Backend) {
|
func Suite(t *testing.T, mk func() exec.ContractBackend) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
t.Run("succeeded_minimal", func(t *testing.T) {
|
t.Run("succeeded_minimal", func(t *testing.T) {
|
||||||
b := mk()
|
b := mk()
|
||||||
Run(t, Case{
|
Run(t, Case{
|
||||||
Name: "succeeded",
|
Name: "succeeded",
|
||||||
Backend: b,
|
Backend: b,
|
||||||
Request: contract.Request{ExecutionID: "e1", Prompt: "hi"},
|
Request: exec.ContractRequest{ExecutionID: "e1", Prompt: "hi"},
|
||||||
WantOutcome: contract.OutcomeSucceeded,
|
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) {
|
t.Run("error_sideeffects_explicit", func(t *testing.T) {
|
||||||
b := mk()
|
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")
|
t.Skip("no text/files capability to force failure path")
|
||||||
}
|
}
|
||||||
// 空提示在多数实现下是确定性失败或成功空输出;仅验证当 Outcome=Failed
|
// 空提示在多数实现下是确定性失败或成功空输出;仅验证当 Outcome=Failed
|
||||||
// 时错误字段完整。具体实现应补充自身失败注入用例。
|
// 时错误字段完整。具体实现应补充自身失败注入用例。
|
||||||
res, _ := b.Execute(context.Background(), contract.Request{ExecutionID: "e2"})
|
res, _ := b.Execute(context.Background(), exec.ContractRequest{ExecutionID: "e2"})
|
||||||
if res != nil && res.Outcome == contract.OutcomeFailed {
|
if res != nil && res.Outcome == exec.OutcomeFailed {
|
||||||
if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" {
|
if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" {
|
||||||
t.Fatalf("failed result must carry Code and SideEffects, got %+v", res.Err)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -16,7 +16,7 @@ type Request struct {
|
|||||||
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
|
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
|
||||||
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
|
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
|
||||||
// 回调内不得阻塞(消费方自行节流/异步入队)。
|
// 回调内不得阻塞(消费方自行节流/异步入队)。
|
||||||
OnEvent func(ev ProcessEvent)
|
OnEvent func(ev ProcessEvent) `json:"-"`
|
||||||
|
|
||||||
// ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ----
|
// ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ----
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package contract 定义 Agent-Native 的公共执行契约。
|
// 原 contract 包并入 exec。与本文件同名的执行门面仍是 Request、Result、Backend;
|
||||||
|
// 设计契约里冲突的四个名字加 Contract 前缀。
|
||||||
//
|
//
|
||||||
// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体
|
// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体
|
||||||
// Backend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
|
// ContractBackend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
|
||||||
// 内核、产品类型、数据库或配置包。
|
// 内核、产品类型、数据库或配置包。
|
||||||
//
|
//
|
||||||
// 语义不变量(实现方与消费方都必须遵守):
|
// 语义不变量(实现方与消费方都必须遵守):
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
// 上下文,不授予跨会话可见性。
|
// 上下文,不授予跨会话可见性。
|
||||||
// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方
|
// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方
|
||||||
// 提供的进程/容器隔离,不能由本契约推导。
|
// 提供的进程/容器隔离,不能由本契约推导。
|
||||||
package contract
|
package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -50,12 +51,6 @@ const (
|
|||||||
CapStructuredOutput Capability = "structured-output"
|
CapStructuredOutput Capability = "structured-output"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToolProfile 是预定义工具策略名;与现有产品配置兼容。
|
|
||||||
const (
|
|
||||||
ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合
|
|
||||||
ToolProfileReasoning = "reasoning" // 无工具,仅推理
|
|
||||||
)
|
|
||||||
|
|
||||||
// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。
|
// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。
|
||||||
type ToolPolicy struct {
|
type ToolPolicy struct {
|
||||||
// Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。
|
// Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。
|
||||||
@@ -82,8 +77,8 @@ type SessionScope struct {
|
|||||||
Session string `json:"session,omitempty"`
|
Session string `json:"session,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budget 约束一次执行(含共享 ExecutionID 的重试)。
|
// ContractBudget 约束一次执行(含共享 ExecutionID 的重试)。
|
||||||
type Budget struct {
|
type ContractBudget struct {
|
||||||
// MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。
|
// MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。
|
||||||
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
||||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||||
@@ -98,9 +93,9 @@ type Message struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
|
// ContractRequest 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
|
||||||
// (Prompt+Instructions+Targets→reply.md)是其子集 facade。
|
// (Prompt+Instructions+Targets→reply.md)是其子集 facade。
|
||||||
type Request struct {
|
type ContractRequest struct {
|
||||||
// ExecutionID 由调用方生成,重试与受控 fallback 复用同值。
|
// ExecutionID 由调用方生成,重试与受控 fallback 复用同值。
|
||||||
ExecutionID string `json:"execution_id"`
|
ExecutionID string `json:"execution_id"`
|
||||||
// SessionScope 为隔离身份;可为零值(一次性任务)。
|
// SessionScope 为隔离身份;可为零值(一次性任务)。
|
||||||
@@ -118,8 +113,8 @@ type Request struct {
|
|||||||
Workspace WorkspaceHandle `json:"workspace"`
|
Workspace WorkspaceHandle `json:"workspace"`
|
||||||
// ToolPolicy 为工具范围;空 Profile 等同 reasoning。
|
// ToolPolicy 为工具范围;空 Profile 等同 reasoning。
|
||||||
ToolPolicy ToolPolicy `json:"tool_policy"`
|
ToolPolicy ToolPolicy `json:"tool_policy"`
|
||||||
// Budget 为本次执行预算。
|
// ContractBudget 为本次执行预算。
|
||||||
Budget Budget `json:"budget"`
|
ContractBudget ContractBudget `json:"budget"`
|
||||||
// Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。
|
// Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。
|
||||||
Required []Capability `json:"required,omitempty"`
|
Required []Capability `json:"required,omitempty"`
|
||||||
|
|
||||||
@@ -235,14 +230,14 @@ type Usage struct {
|
|||||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
|
// ContractResult 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
|
||||||
type Result struct {
|
type ContractResult struct {
|
||||||
Outcome Outcome `json:"outcome"`
|
Outcome Outcome `json:"outcome"`
|
||||||
// Text 为文本结果(CapText 任务必填)。
|
// Text 为文本结果(CapText 任务必填)。
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
// Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。
|
// Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。
|
||||||
Files map[string][]byte `json:"files,omitempty"`
|
Files map[string][]byte `json:"files,omitempty"`
|
||||||
// Logs 为执行日志/过程摘要(兼容现有 Result.Logs)。
|
// Logs 为执行日志/过程摘要(兼容现有 ContractResult.Logs)。
|
||||||
Logs string `json:"logs,omitempty"`
|
Logs string `json:"logs,omitempty"`
|
||||||
// Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。
|
// Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。
|
||||||
Err *Error `json:"err,omitempty"`
|
Err *Error `json:"err,omitempty"`
|
||||||
@@ -271,19 +266,19 @@ func (d BackendDescriptor) Has(c Capability) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend 是统一执行接口。实现必须:
|
// ContractBackend 是统一执行接口。实现必须:
|
||||||
// - 在 ctx 取消时尽快返回(Canceled 或 Unknown);
|
// - 在 ctx 取消时尽快返回(Canceled 或 Unknown);
|
||||||
// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用);
|
// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用);
|
||||||
// - 不自行扩大工具范围或读取 Workspace 之外的路径。
|
// - 不自行扩大工具范围或读取 Workspace 之外的路径。
|
||||||
type Backend interface {
|
type ContractBackend interface {
|
||||||
Descriptor() BackendDescriptor
|
Descriptor() BackendDescriptor
|
||||||
// Execute 执行一次授权调用。返回的 Result 永不为 nil;ctx 错误映射为
|
// Execute 执行一次授权调用。返回的 ContractResult 永不为 nil;ctx 错误映射为
|
||||||
// Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。
|
// Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。
|
||||||
Execute(ctx context.Context, req Request) (*Result, error)
|
Execute(ctx context.Context, req ContractRequest) (*ContractResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得
|
// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得
|
||||||
// 回落到 Stub。
|
// 回落到 Stub。
|
||||||
type Resolver interface {
|
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
|
go 1.27
|
||||||
|
|
||||||
require (
|
require (
|
||||||
agent-intent v0.0.0
|
|
||||||
error-code-system v0.0.0
|
error-code-system v0.0.0
|
||||||
logging-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 error-code-system => ./deps/error-code-system
|
||||||
|
|
||||||
replace logging-system => ./deps/logging-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=
|
|
||||||
|
|||||||
@@ -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