From f66a0af4f1e5a908cbf817198478f958bf1bd614 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Tue, 29 Sep 2026 02:39:31 +0000 Subject: [PATCH] feat(contract): minimal public execution contract + conformance suite (U7a) - contract: Request/Result/Outcome(4-state)/Error(taxonomy+side-effects)/ Capability/BackendDescriptor/Backend/Resolver per plan KTD9/KTD10 - conformance: reusable behavior suite + deterministic fake - semantics: single ExecutionID across retry/fallback, no silent fallback, explicit side-effect classification, single-owner retry layering, workspace path authorization is not an OS sandbox - CI: go vet+test on host runner (.gitea/workflows) --- .gitea/workflows/ci.yml | 11 ++ README.md | 37 ++++++ conformance/fake.go | 147 ++++++++++++++++++++++ contract/contract.go | 259 ++++++++++++++++++++++++++++++++++++++ contract/contract_test.go | 43 +++++++ go.mod | 3 + 6 files changed, 500 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 README.md create mode 100644 conformance/fake.go create mode 100644 contract/contract.go create mode 100644 contract/contract_test.go create mode 100644 go.mod diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5314fb1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,11 @@ +name: ci +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + vet-and-test: + runs-on: nightly + steps: + - run: go vet ./... && go test -count=1 ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bd7c46 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Agent-Native + +可复用的 Agent 执行基础:自研执行内核(runtime)、模型/工具能力接入、外部 Agent +适配器(现有 PI Runner 等)与共用契约验收(conformance)。 + +## 仓库分层(U7a 首切片:仅 contract + conformance) + +| 层 | 职责 | 依赖边界 | +|---|---|---| +| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置 | +| runtime | agentx 演进的多轮循环、工具调度、模型调用、执行会话(U7b 迁入) | 实现 contract;不导入 Core/Squad | +| adapters | PI Runner transport / typed-result-v1(U7b 迁入) | 显式映射外部差异,不伪造缺失能力 | +| conformance | 所有 Backend 共用的行为验收 | fake 验证契约,真实 adapter 验证能力 | + +规范后端名:`agent-native`(`pi-native` 为兼容别名,由 Core 侧注册表解析)。 + +## 关键语义(详见 contract 包 doc.go) + +- ExecutionID 一次逻辑执行唯一;重试与受控 fallback 复用。 +- Outcome 四态:Succeeded/Failed/Canceled/Unknown;Unknown 不静默降级。 +- 失败副作用显式分类(none/possible);possible 禁止无条件重放。 +- 重试单一责任层:步骤级归调用方,传输级有界归 adapter,turn 上限归 runtime。 +- Workspace 路径授权 ≠ OS 沙箱。 + +## 消费方式(首期) + +外层组合(Agent-System 主仓 gitlink)固定精确 SHA,Go 相邻 replace 引用: + +``` +replace agent-native => ../agent-native +``` + +构建须 `GOWORK=off`;不通过 go get 路由依赖(实例 URL 带端口,见主仓方案 KTD3)。 + +## CI + +`.gitea/workflows/ci.yml`:go vet + go test(host runner)。 diff --git a/conformance/fake.go b/conformance/fake.go new file mode 100644 index 0000000..72ef049 --- /dev/null +++ b/conformance/fake.go @@ -0,0 +1,147 @@ +// Package conformance 提供所有 Backend 实现共用的行为验收套件。 +// +// 用法:实现方在自己的测试中调用 conformance.Run(t, backend, conformance.Case{...})。 +// fake 后端用于契约自测;真实实现(runtime、PI adapter)必须通过同一套件, +// fake 通过不等于真实适配完成(分别记证据)。 +package conformance + +import ( + "context" + "fmt" + "testing" + "time" + + "agent-native/contract" +) + +// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。 +// 不模拟任何模型行为;按 Script 逐项返回。 +type FakeBackend struct { + Desc contract.BackendDescriptor + // Result 为每次 Execute 返回的终态(浅拷贝后返回)。 + Result contract.Result + // BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消, + // 用于验证取消语义。 + BlockUntil <-chan struct{} + // CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。 + CanceledWithCtx bool + // LastRequest 记录最近一次请求。 + LastRequest contract.Request +} + +func (f *FakeBackend) Descriptor() contract.BackendDescriptor { return f.Desc } + +func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contract.Result, error) { + f.LastRequest = req + if f.BlockUntil != nil { + select { + case <-f.BlockUntil: + case <-ctx.Done(): + f.CanceledWithCtx = true + res := *(&f.Result) + res.Outcome = contract.OutcomeCanceled + res.Err = &contract.Error{ + Code: contract.ErrCanceled, + SideEffects: contract.SideEffectNone, + } + return &res, nil + } + } + res := f.Result + return &res, nil +} + +// Case 是一条契约验收用例。 +type Case struct { + Name string + // Backend 为被测实现。 + Backend contract.Backend + // Request 为请求模板。 + Request contract.Request + // CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。 + CancelAfter time.Duration + // WantOutcome 为期望终态。 + WantOutcome contract.Outcome + // WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。 + WantErrCode contract.ErrorCode +} + +// Run 执行一条契约用例。 +func Run(t *testing.T, c Case) { + t.Helper() + ctx := context.Background() + var cancel context.CancelFunc + if c.CancelAfter > 0 { + ctx, cancel = context.WithTimeout(ctx, c.CancelAfter) + defer cancel() + } + res, err := c.Backend.Execute(ctx, c.Request) + if err != nil { + t.Fatalf("[%s] Execute returned transport error %v; business outcomes must be in Result", c.Name, err) + } + if res == nil { + t.Fatalf("[%s] Execute returned nil Result", c.Name) + } + if res.Outcome != c.WantOutcome { + t.Fatalf("[%s] Outcome = %q, want %q", c.Name, res.Outcome, c.WantOutcome) + } + if c.WantErrCode != "" { + if res.Err == nil { + t.Fatalf("[%s] expected error code %q, got nil Err", c.Name, c.WantErrCode) + } + if res.Err.Code != c.WantErrCode { + t.Fatalf("[%s] Err.Code = %q, want %q", c.Name, res.Err.Code, c.WantErrCode) + } + if res.Err.SideEffects == "" { + t.Fatalf("[%s] Err.SideEffects must be explicit (none|possible), got empty", c.Name) + } + } + if res.Outcome == contract.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) { + 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, + }) + }) + + t.Run("descriptor_has_name", func(t *testing.T) { + b := mk() + d := b.Descriptor() + if d.Name == "" { + t.Fatal("Descriptor().Name must not be empty") + } + if len(d.Capabilities) == 0 { + t.Fatal("Descriptor().Capabilities must declare at least one capability") + } + }) + + t.Run("error_sideeffects_explicit", func(t *testing.T) { + b := mk() + // 通过描述能力判定是否有失败路径可测;无失败路径的实现跳过。 + if !b.Descriptor().Has(contract.CapText) && !b.Descriptor().Has(contract.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 { + if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" { + t.Fatalf("failed result must carry Code and SideEffects, got %+v", res.Err) + } + } + }) + + _ = fmt.Sprint() // keep fmt import stable for future diagnostics +} diff --git a/contract/contract.go b/contract/contract.go new file mode 100644 index 0000000..450cb50 --- /dev/null +++ b/contract/contract.go @@ -0,0 +1,259 @@ +// Package contract 定义 Agent-Native 的公共执行契约。 +// +// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体 +// Backend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体 +// 内核、产品类型、数据库或配置包。 +// +// 语义不变量(实现方与消费方都必须遵守): +// +// 1. ExecutionID 在一次逻辑执行内唯一;步骤重试与受控 fallback 必须复用 +// 同一个 ExecutionID,并共享预算与截止时间。 +// 2. Outcome 取值 Succeeded/Failed/Canceled/Unknown 四态之一;Unknown 表示 +// 结果不确定(可能已有副作用),绝不静默降级为 Succeeded 或 Failed。 +// 3. 重试只有一个责任层:步骤级重试归调用方(Squad),传输级有界重试归 +// adapter,循环 turn 上限归 runtime;不得多层重试产生重复写入。 +// 4. 取消必须传播到模型请求、工具调用与子进程;不支持可靠取消的实现必须 +// 报告 cancel_requested 并保持 Unknown,不得伪报 Canceled。 +// 5. 原生执行失败且已有文件或外部动作、或结果 Unknown 时,禁止无条件换 +// 后端重放;是否可重放由调用方依据 Error.SideEffects 判定。 +// 6. 执行 transcript 与产品聊天历史不是同一账本;SessionScope 仅用于隔离 +// 上下文,不授予跨会话可见性。 +// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方 +// 提供的进程/容器隔离,不能由本契约推导。 +package contract + +import ( + "context" + "time" +) + +// Capability 声明后端可验证的能力。解析器按任务需求选择实现;必须能力 +// 缺失时拒绝执行,而不是静默降级到 Stub。 +type Capability string + +const ( + // CapText 表示能返回非空文本结果(对话、摘要等纯文本任务)。 + CapText Capability = "text" + // CapFiles 表示能按 Targets 交付文件候选产物(reply.md/output 流程)。 + CapFiles Capability = "files" + // CapStream 表示支持过程事件流(OnEvent 回调有实际内容)。 + CapStream Capability = "stream" + // CapTools 表示支持受控工具调用(受 ToolPolicy 约束)。 + CapTools Capability = "tools" + // CapCancel 表示支持可靠取消(取消传播到模型/工具/子进程)。 + CapCancel Capability = "cancel" + // CapResume 表示支持跨进程恢复执行(配合 ExecutionJournal 端口)。 + CapResume Capability = "resume" + // CapUsage 表示能报告 token 用量。 + CapUsage Capability = "usage" + // CapStructuredOutput 表示支持结构化(非纯文本)输出。 + CapStructuredOutput Capability = "structured-output" +) + +// ToolProfile 是预定义工具策略名;与现有产品配置兼容。 +const ( + ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合 + ToolProfileReasoning = "reasoning" // 无工具,仅推理 +) + +// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。 +type ToolPolicy struct { + // Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。 + Profile string `json:"profile"` + // Allowlist 显式列出额外允许的工具名;与 Profile 取交集语义由实现声明, + // 未声明时视为仅 Allowlist。 + Allowlist []string `json:"allowlist,omitempty"` +} + +// WorkspaceHandle 是调用方已授权的工作区句柄。Root 之外的路径访问必须失败。 +// 它表达路径授权,不构成操作系统级隔离。 +type WorkspaceHandle struct { + // Root 为授权根目录的绝对路径(已归一化)。 + Root string `json:"root"` + // Writable 声明允许写入的相对路径集合;空表示由产品规则决定(兼容现状)。 + Writable []string `json:"writable,omitempty"` +} + +// SessionScope 隔离执行上下文:不同 tenant/project/session 的会话、transcript、 +// 授权与预算互不可见。字段为产品侧标识,契约层只要求非空时保持隔离。 +type SessionScope struct { + Tenant string `json:"tenant,omitempty"` + Project string `json:"project,omitempty"` + Session string `json:"session,omitempty"` +} + +// Budget 约束一次执行(含共享 ExecutionID 的重试)。 +type Budget struct { + // MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。 + MaxPromptTokens int `json:"max_prompt_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + // Deadline 为截止时间;零值表示无外部截止(仍受 ctx 控制)。 + Deadline time.Time `json:"deadline,omitempty"` +} + +// Message 是可选的历史上下文(多轮对话/延续任务)。实现可以忽略,但不得 +// 跨 SessionScope 混入。 +type Message struct { + Role string `json:"role"` // user | assistant | tool | system + Content string `json:"content"` +} + +// Request 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程 +// (Prompt+Instructions+Targets→reply.md)是其子集 facade。 +type Request struct { + // ExecutionID 由调用方生成,重试与受控 fallback 复用同值。 + ExecutionID string `json:"execution_id"` + // SessionScope 为隔离身份;可为零值(一次性任务)。 + SessionScope SessionScope `json:"session_scope"` + + Prompt string `json:"prompt"` + Instructions string `json:"instructions,omitempty"` + // Context 为可选历史消息;实现不得将其与 Prompt 之外的数据混合。 + Context []Message `json:"context,omitempty"` + + // Targets 为期望交付的文件相对路径(文件任务);纯文本任务可空。 + Targets []string `json:"targets,omitempty"` + + // Workspace 为授权工作区;文件任务的读写都必须限制在 Root 内。 + Workspace WorkspaceHandle `json:"workspace"` + // ToolPolicy 为工具范围;空 Profile 等同 reasoning。 + ToolPolicy ToolPolicy `json:"tool_policy"` + // Budget 为本次执行预算。 + Budget Budget `json:"budget"` + // Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。 + Required []Capability `json:"required,omitempty"` + + // OnEvent 为可选过程事件回调(nil = 调用方不需要过程流)。 + // 回调必须在 Execute 返回前完成;回调内不得阻塞。 + OnEvent func(Event) `json:"-"` +} + +// Outcome 是执行终态。Unknown 优先于乐观判定:凡无法确认结果(含进程崩溃、 +// 传输中断后未收到终态)一律 Unknown,由调用方恢复。 +type Outcome string + +const ( + OutcomeSucceeded Outcome = "succeeded" + OutcomeFailed Outcome = "failed" + OutcomeCanceled Outcome = "canceled" + OutcomeUnknown Outcome = "unknown" +) + +// EventKind 过程事件类型(与现有 ProcessEvent 投影兼容)。 +type EventKind string + +const ( + EventTextDelta EventKind = "text_delta" + EventReasoningDelta EventKind = "reasoning_delta" + EventToolCallDelta EventKind = "tool_call_delta" + EventToolExecStart EventKind = "tool_execution_start" + EventToolExecEnd EventKind = "tool_execution_end" + EventMessageEnd EventKind = "message_end" +) + +// Event 是过程事件帧;消费方(Core)负责映射为 SSE/持久化事件。 +type Event struct { + Kind EventKind `json:"kind"` + Text string `json:"text,omitempty"` // *_delta 文本 + Tool string `json:"tool,omitempty"` // tool_* 工具名 + Detail string `json:"detail,omitempty"` // 工具参数/结果摘要 +} + +// SideEffects 描述失败时的副作用状态,决定是否允许重试/fallback 重放。 +type SideEffects string + +const ( + // SideEffectNone:确认未产生任何外部副作用(未派发或纯读取后失败)。 + SideEffectNone SideEffects = "none" + // SideEffectPossible:可能已有副作用(文件已写、外部工具已调用、 + // 终态未知)。禁止无条件重放;恢复判断归调用方。 + SideEffectPossible SideEffects = "possible" +) + +// ErrorCode 稳定错误分类。未知代码按 Unknown 处理。 +type ErrorCode string + +const ( + ErrCanceled ErrorCode = "canceled" + ErrDeadlineExceeded ErrorCode = "deadline_exceeded" + ErrBudgetExceeded ErrorCode = "budget_exceeded" + ErrProviderUnavailable ErrorCode = "provider_unavailable" + ErrProviderRejected ErrorCode = "provider_rejected" + ErrToolRejected ErrorCode = "tool_rejected" + ErrPathOutsideWorkspace ErrorCode = "path_outside_workspace" + ErrMissingCapability ErrorCode = "missing_capability" + ErrMissingDeliverable ErrorCode = "missing_deliverable" + ErrEmptyOutput ErrorCode = "empty_output" + ErrUnknownOutcome ErrorCode = "unknown_outcome" + ErrInternal ErrorCode = "internal" +) + +// Error 是标准化执行错误。Retryable 仅表示传输级瞬时故障可由 adapter 有界 +// 重试或调用方作为新步骤重试;与 SideEffects 独立判定。 +type Error struct { + Code ErrorCode `json:"code"` + Reason string `json:"reason,omitempty"` + Retryable bool `json:"retryable,omitempty"` + SideEffects SideEffects `json:"side_effects"` +} + +func (e *Error) Error() string { return string(e.Code) + ": " + e.Reason } + +// Usage 报告 token 用量;实现报告不了时保持零值,不猜测。 +type Usage struct { + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` +} + +// Result 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。 +type Result struct { + Outcome Outcome `json:"outcome"` + // Text 为文本结果(CapText 任务必填)。 + Text string `json:"text,omitempty"` + // Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。 + Files map[string][]byte `json:"files,omitempty"` + // Logs 为执行日志/过程摘要(兼容现有 Result.Logs)。 + Logs string `json:"logs,omitempty"` + // Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。 + Err *Error `json:"err,omitempty"` + // Usage 为 token 用量。 + Usage Usage `json:"usage"` +} + +// BackendDescriptor 声明实现身份与可验证能力;前端只展示经授权的描述。 +type BackendDescriptor struct { + // Name 为规范后端名(如 agent-native、pi-agent)。规范名 agent-native; + // pi-native 为兼容别名,注册表负责别名解析与弃用周期。 + Name string `json:"name"` + // Capabilities 为该实现声明且经验证的能力集合。 + Capabilities []Capability `json:"capabilities"` + // AdapterVersion 标识实现版本(内核或适配器),用于收据与回退审计。 + AdapterVersion string `json:"adapter_version,omitempty"` +} + +// Has 报告描述是否声明指定能力。 +func (d BackendDescriptor) Has(c Capability) bool { + for _, x := range d.Capabilities { + if x == c { + return true + } + } + return false +} + +// Backend 是统一执行接口。实现必须: +// - 在 ctx 取消时尽快返回(Canceled 或 Unknown); +// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用); +// - 不自行扩大工具范围或读取 Workspace 之外的路径。 +type Backend interface { + Descriptor() BackendDescriptor + // Execute 执行一次授权调用。返回的 Result 永不为 nil;ctx 错误映射为 + // Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。 + Execute(ctx context.Context, req Request) (*Result, error) +} + +// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得 +// 回落到 Stub。 +type Resolver interface { + Resolve(name string, required []Capability) (Backend, error) +} diff --git a/contract/contract_test.go b/contract/contract_test.go new file mode 100644 index 0000000..88087ee --- /dev/null +++ b/contract/contract_test.go @@ -0,0 +1,43 @@ +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") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4de126e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module agent-native + +go 1.27