Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108c194c8d | ||
|
|
6ea6929309 | ||
|
|
d9a168635e | ||
|
|
c9c2f23990 | ||
|
|
16e356a3a4 | ||
|
|
d333094f7e | ||
|
|
0d8edbea52 | ||
|
|
7f2c8b9ee1 | ||
|
|
c5b9dca446 | ||
|
|
948ca36ca2 | ||
|
|
c2befaf17e | ||
|
|
d5714e81ed | ||
|
|
9758a8d8d0 | ||
|
|
fc5a1930b5 |
@@ -0,0 +1,26 @@
|
||||
{"id":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.521547827Z","type":"message","message":{"role":"system","content":"## Environment\ndate: 2026-10-07(周三)\nmodel: fake\nworkspace: /home/weichen/projects/Agent-System-Dev-wt-feature-agent-stack-n1/deps/agent-native/adapters/native\ntool profile: workspace\n\nDeliverable files: output.md. Use workspace tools to write those files. Do not invent paths outside the workspace.\n\nTools available in this workspace:\n- read_file: Read a UTF-8 text file inside the workspace. Without offset/limit returns the whole file (truncated at 200 KiB); with offset/limit returns numbered lines from a 1-based offset.\n- write_file: Create or replace a UTF-8 text file inside the workspace.\n- edit_file: Replace one unique exact text span in a workspace file.\n- multi_edit: Apply several unique-span replacements to one workspace file atomically: either all edits land or the file is left untouched.\n- list_dir: List names in a workspace directory.\n- grep: Search workspace text files. Literal substring by default; regex=true switches to Go regexp, include filters file names (e.g. *.go), ignore_case folds case. Caps at 50 hits.\n- glob: Find workspace files matching a glob pattern (** crosses directories, * within one segment). Returns up to 500 relative paths."}}
|
||||
{"id":"46a70655ee65ddc3","parentId":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.524296265Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"ab242da6bd626cae","parentId":"46a70655ee65ddc3","timestamp":"2026-10-07T06:11:15.524748291Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"f637d2872ab9f34b","parentId":"ab242da6bd626cae","timestamp":"2026-10-07T06:11:15.525362933Z","type":"message","message":{"role":"user"}}
|
||||
{"id":"c1bada8c14ef7ec0","parentId":"f637d2872ab9f34b","timestamp":"2026-10-07T06:11:15.525828249Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"a01a79f9d2d2c219","parentId":"c1bada8c14ef7ec0","timestamp":"2026-10-07T06:11:15.52644899Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"37ce369360ed5bc3","parentId":"a01a79f9d2d2c219","timestamp":"2026-10-07T06:11:22.666535507Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"64a2479c0d206bf1","parentId":"37ce369360ed5bc3","timestamp":"2026-10-07T06:11:22.668999313Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"61593f441df28345","parentId":"64a2479c0d206bf1","timestamp":"2026-10-07T06:11:22.669756111Z","type":"message","message":{"role":"user"}}
|
||||
{"id":"c5aa1aebdaa45000","parentId":"61593f441df28345","timestamp":"2026-10-07T06:11:22.670242386Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"3a9f8790c99f9e94","parentId":"c5aa1aebdaa45000","timestamp":"2026-10-07T06:11:22.670926846Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"064604f3b2f1ad65","parentId":"3a9f8790c99f9e94","timestamp":"2026-10-07T06:13:48.448571493Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"9d3b5edce163fdbe","parentId":"064604f3b2f1ad65","timestamp":"2026-10-07T06:13:48.449399929Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"f280fb470f6e90f5","parentId":"9d3b5edce163fdbe","timestamp":"2026-10-07T06:13:48.450112717Z","type":"message","message":{"role":"user"}}
|
||||
{"id":"258b37ad6c79c52d","parentId":"f280fb470f6e90f5","timestamp":"2026-10-07T06:13:48.450611112Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"b6462f3dbbd35ce9","parentId":"258b37ad6c79c52d","timestamp":"2026-10-07T06:13:48.451303652Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"1f742b0f9b7c92a1","parentId":"b6462f3dbbd35ce9","timestamp":"2026-10-07T06:13:48.971444939Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"f472774cc9cb019d","parentId":"1f742b0f9b7c92a1","timestamp":"2026-10-07T06:13:48.973639624Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"27e484d323f079d3","parentId":"f472774cc9cb019d","timestamp":"2026-10-07T06:13:48.97478444Z","type":"message","message":{"role":"user"}}
|
||||
{"id":"9a97876386e882d4","parentId":"27e484d323f079d3","timestamp":"2026-10-07T06:13:48.97545809Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"a8a4c88afce69517","parentId":"9a97876386e882d4","timestamp":"2026-10-07T06:13:48.976690973Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"c2865055fe240f43","parentId":"a8a4c88afce69517","timestamp":"2026-10-07T06:14:02.592947456Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
{"id":"a02f22b1760c8363","parentId":"c2865055fe240f43","timestamp":"2026-10-07T06:14:02.595218749Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"c3321f8e2d467eda","parentId":"a02f22b1760c8363","timestamp":"2026-10-07T06:14:02.59619084Z","type":"message","message":{"role":"user"}}
|
||||
{"id":"7d0eb0b5d39283b2","parentId":"c3321f8e2d467eda","timestamp":"2026-10-07T06:14:02.596897779Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||
{"id":"f99d01430932d356","parentId":"7d0eb0b5d39283b2","timestamp":"2026-10-07T06:14:02.598248098Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"exec_id": "cx",
|
||||
"leaf": "f99d01430932d356",
|
||||
"model": "fake",
|
||||
"scope": "",
|
||||
"updated": "2026-10-07T06:14:02Z"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
stream ended without a message
|
||||
@@ -0,0 +1 @@
|
||||
hi
|
||||
@@ -77,6 +77,9 @@ type GateOptions struct {
|
||||
Hooks *hooks.Bus
|
||||
// Ask 在 MCP 工具要求用户输入时调用。nil 则把问题作为错误返回。
|
||||
Ask func(ctx context.Context, question string) (string, error)
|
||||
// PermissionAsk 在内核钩子对工具表态 ask 时向宿主请求裁决。
|
||||
// nil 时内核保持旧行为:ask 与 deny 一样按拒绝处理。
|
||||
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
|
||||
}
|
||||
|
||||
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
||||
@@ -370,6 +373,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
||||
Reminders: b.opts.Reminders,
|
||||
RepeatGuard: b.opts.RepeatGuard,
|
||||
Hooks: gateBus(b.opts.GateOptions),
|
||||
PermissionAsk: b.opts.PermissionAsk,
|
||||
Trace: b.opts.Trace,
|
||||
OnEvent: func(ev runtime.Event) {
|
||||
logs.WriteString(ev.Type)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// contractBackend 把 Backend 适配为 exec.ContractBackend。
|
||||
// 独立类型的原因:旧门面 Execute(ctx, exec.Request) 与契约的
|
||||
// Execute(ctx, exec.ContractRequest) 签名冲突,同名方法不能并存;
|
||||
// 包装让现有 squad 消费(旧门面)与契约消费(conformance/Core)互不影响。
|
||||
type contractBackend struct{ b *Backend }
|
||||
|
||||
// Contract 返回该后端的契约形态(N1 WP6;旧门面保持不变)。
|
||||
func (b *Backend) Contract() exec.ContractBackend {
|
||||
return contractBackend{b: b}
|
||||
}
|
||||
|
||||
func (c contractBackend) Descriptor() exec.BackendDescriptor {
|
||||
return exec.BackendDescriptor{
|
||||
Name: c.b.Name(),
|
||||
Capabilities: []exec.Capability{exec.CapText, exec.CapStream, exec.CapTools},
|
||||
}
|
||||
}
|
||||
|
||||
func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest) (*exec.ContractResult, error) {
|
||||
var onEvent func(ev exec.ProcessEvent)
|
||||
if req.OnEvent != nil {
|
||||
next := req.OnEvent
|
||||
onEvent = func(ev exec.ProcessEvent) {
|
||||
next(exec.Event{Kind: exec.EventKind(ev.Type), Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail})
|
||||
}
|
||||
}
|
||||
// 契约语义:空 ToolPolicy.Profile 等同 reasoning(无工具纯文本任务)。
|
||||
profile := strings.TrimSpace(req.ToolPolicy.Profile)
|
||||
if profile == "" {
|
||||
profile = exec.ToolProfileReasoning
|
||||
}
|
||||
old := exec.Request{
|
||||
Prompt: req.Prompt,
|
||||
Instructions: req.Instructions,
|
||||
ExecutionID: req.ExecutionID,
|
||||
Targets: append([]string(nil), req.Targets...),
|
||||
ToolProfile: profile,
|
||||
ToolAllowlist: append([]string(nil), req.ToolPolicy.Allowlist...),
|
||||
WorkDir: req.Workspace.Root,
|
||||
OnEvent: onEvent,
|
||||
}
|
||||
if b := req.ContractBudget; b.MaxPromptTokens > 0 || b.MaxCompletionTokens > 0 || !b.Deadline.IsZero() {
|
||||
old.Budget = &exec.Budget{
|
||||
MaxPromptTokens: b.MaxPromptTokens,
|
||||
MaxCompletionTokens: b.MaxCompletionTokens,
|
||||
Deadline: b.Deadline,
|
||||
}
|
||||
}
|
||||
res, err := c.b.Execute(ctx, old)
|
||||
if err != nil {
|
||||
// 传输/实现级错误:按契约映射为 Unknown,不经 Go error 返回业务终态。
|
||||
return &exec.ContractResult{
|
||||
Outcome: exec.OutcomeUnknown,
|
||||
Err: &exec.Error{Code: exec.ErrProviderUnavailable, Reason: err.Error(), SideEffects: exec.SideEffectPossible},
|
||||
}, nil
|
||||
}
|
||||
out := &exec.ContractResult{
|
||||
Logs: res.Logs,
|
||||
Usage: exec.Usage{
|
||||
PromptTokens: res.PromptTokens,
|
||||
CompletionTokens: res.CompletionTokens,
|
||||
},
|
||||
}
|
||||
switch {
|
||||
case res.Success:
|
||||
out.Outcome = exec.OutcomeSucceeded
|
||||
out.Files = res.Patches
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
out.Outcome = exec.OutcomeCanceled
|
||||
out.Err = &exec.Error{Code: exec.ErrCanceled, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
out.Outcome = exec.OutcomeFailed
|
||||
out.Err = &exec.Error{Code: exec.ErrDeadlineExceeded, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
|
||||
default:
|
||||
out.Outcome = exec.OutcomeFailed
|
||||
out.Err = &exec.Error{Code: exec.ErrProviderRejected, Reason: res.ErrorReason, Retryable: res.Retryable, SideEffects: exec.SideEffectPossible}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/conformance"
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// TestContractBackendConformance 把 native 后端的契约形态挂上最小契约套件
|
||||
// (N1 WP6:conformance 首次获得真实实现调用方)。
|
||||
func TestContractBackendConformance(t *testing.T) {
|
||||
conformance.Suite(t, func() exec.ContractBackend {
|
||||
return New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
}).Contract()
|
||||
})
|
||||
}
|
||||
|
||||
// TestContractExecuteCancel 取消路径:流阻塞到 ctx 结束,Outcome=Canceled 且
|
||||
// SideEffects 显式。
|
||||
func TestContractExecuteCancel(t *testing.T) {
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
},
|
||||
},
|
||||
}).Contract()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
res, err := b.Execute(ctx, exec.ContractRequest{ExecutionID: "cx", Prompt: "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
if res.Outcome != exec.OutcomeCanceled && res.Outcome != exec.OutcomeFailed {
|
||||
t.Fatalf("outcome = %q", res.Outcome)
|
||||
}
|
||||
if res.Err == nil || res.Err.SideEffects == "" {
|
||||
t.Fatalf("err = %+v", res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContractDescriptor 描述的身份与能力。
|
||||
func TestContractDescriptor(t *testing.T) {
|
||||
d := New(Options{}).Contract().Descriptor()
|
||||
if d.Name != "agent-native" {
|
||||
t.Fatalf("name = %q", d.Name)
|
||||
}
|
||||
if !d.Has(exec.CapText) || !d.Has(exec.CapStream) || !d.Has(exec.CapTools) {
|
||||
t.Fatalf("capabilities = %v", d.Capabilities)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
execution_id=cx session=
|
||||
agent_start
|
||||
turn_start
|
||||
message_start
|
||||
@@ -0,0 +1 @@
|
||||
hi
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 65aaebca1e...f57645c645
@@ -0,0 +1,216 @@
|
||||
// Package agentspec 定义子代理画像(AgentSpec)与 agent-*.md 文件形态。
|
||||
// 宿主与内置画像可声明全部字段;插件来源的画像只允许身份与工具面,
|
||||
// 不得声明钩子、MCP 与权限模式(fail-closed,错误定位到字段)。
|
||||
package agentspec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentSpec 是一个子代理画像。字段对齐公开子代理 frontmatter 约定,
|
||||
// 另加 outputSchema。空字段表示沿用宿主内置。
|
||||
type AgentSpec struct {
|
||||
Name string
|
||||
Description string
|
||||
Prompt string // 正文(frontmatter 之后的内容)
|
||||
Tools []string
|
||||
DisallowedTools []string
|
||||
Model string
|
||||
PermissionMode string
|
||||
Skills []string
|
||||
MaxTurns int
|
||||
MemoryScope string
|
||||
Effort string
|
||||
Background bool
|
||||
WorktreeIsolation bool
|
||||
OutputSchema string
|
||||
}
|
||||
|
||||
// pluginBannedKeys 是插件来源画像禁带的键:钩子、MCP 与权限模式归宿主与基座。
|
||||
var pluginBannedKeys = map[string]bool{
|
||||
"hooks": true,
|
||||
"mcp": true,
|
||||
"permissionMode": true,
|
||||
}
|
||||
|
||||
// Parse 解析宿主/内置画像文件:frontmatter(--- 包裹的 key: value 行,
|
||||
// 列表值用逗号分隔)+ 正文。未知键报错;name 必填且须小写连字符。
|
||||
func Parse(content string) (AgentSpec, error) {
|
||||
return parse(content, nil)
|
||||
}
|
||||
|
||||
// ParsePlugin 解析插件来源画像文件:在 Parse 之上禁带 hooks/mcp/permissionMode 键。
|
||||
func ParsePlugin(content string) (AgentSpec, error) {
|
||||
return parse(content, pluginBannedKeys)
|
||||
}
|
||||
|
||||
func parse(content string, banned map[string]bool) (AgentSpec, error) {
|
||||
fm, body, err := splitFrontmatter(content)
|
||||
if err != nil {
|
||||
return AgentSpec{}, err
|
||||
}
|
||||
var spec AgentSpec
|
||||
for key, value := range fm {
|
||||
if banned[key] {
|
||||
return AgentSpec{}, fmt.Errorf("agentspec: 插件画像不得声明 %s", key)
|
||||
}
|
||||
if err := assign(&spec, key, value); err != nil {
|
||||
return AgentSpec{}, err
|
||||
}
|
||||
}
|
||||
spec.Prompt = strings.TrimSpace(body)
|
||||
if spec.Name == "" {
|
||||
return AgentSpec{}, fmt.Errorf("agentspec: name required")
|
||||
}
|
||||
if !validName(spec.Name) {
|
||||
return AgentSpec{}, fmt.Errorf("agentspec: name %q 须为小写连字符(如 doc-reviewer)", spec.Name)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// LoadDir 整批加载目录下的 *.md 画像:任一文件解析失败则全批拒绝;
|
||||
// 重名拒绝。parser 按来源选 Parse 或 ParsePlugin。返回按 Name 索引。
|
||||
func LoadDir(dir string, parser func(string) (AgentSpec, error)) (map[string]AgentSpec, error) {
|
||||
if parser == nil {
|
||||
parser = Parse
|
||||
}
|
||||
names, err := filepath.Glob(filepath.Join(dir, "*.md"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agentspec: glob %s: %w", dir, err)
|
||||
}
|
||||
out := map[string]AgentSpec{}
|
||||
for _, name := range names {
|
||||
raw, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err)
|
||||
}
|
||||
spec, err := parser(string(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err)
|
||||
}
|
||||
if _, dup := out[spec.Name]; dup {
|
||||
return nil, fmt.Errorf("agentspec: duplicate name %q in %s", spec.Name, dir)
|
||||
}
|
||||
out[spec.Name] = spec
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitFrontmatter(content string) (map[string]string, string, error) {
|
||||
text := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return nil, "", fmt.Errorf("agentspec: missing frontmatter(须以 --- 开头)")
|
||||
}
|
||||
rest := text[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return nil, "", fmt.Errorf("agentspec: frontmatter 未闭合(缺 --- 结尾)")
|
||||
}
|
||||
fm := map[string]string{}
|
||||
for _, line := range strings.Split(rest[:end], "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(line, ":")
|
||||
if i <= 0 {
|
||||
return nil, "", fmt.Errorf("agentspec: 无法解析的行 %q(须为 key: value)", line)
|
||||
}
|
||||
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
body := rest[end+len("\n---"):]
|
||||
body = strings.TrimLeft(body, "\n")
|
||||
return fm, body, nil
|
||||
}
|
||||
|
||||
func assign(spec *AgentSpec, key, value string) error {
|
||||
switch key {
|
||||
case "name":
|
||||
spec.Name = value
|
||||
case "description":
|
||||
spec.Description = value
|
||||
case "tools":
|
||||
spec.Tools = splitList(value)
|
||||
case "disallowedTools":
|
||||
spec.DisallowedTools = splitList(value)
|
||||
case "model":
|
||||
spec.Model = value
|
||||
case "permissionMode":
|
||||
spec.PermissionMode = value
|
||||
case "skills":
|
||||
spec.Skills = splitList(value)
|
||||
case "maxTurns":
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 0 {
|
||||
return fmt.Errorf("agentspec: maxTurns %q 须为非负整数", value)
|
||||
}
|
||||
spec.MaxTurns = n
|
||||
case "memoryScope":
|
||||
spec.MemoryScope = value
|
||||
case "effort":
|
||||
spec.Effort = value
|
||||
case "background":
|
||||
b, err := parseBool(key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Background = b
|
||||
case "worktreeIsolation":
|
||||
b, err := parseBool(key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.WorktreeIsolation = b
|
||||
case "outputSchema":
|
||||
spec.OutputSchema = value
|
||||
default:
|
||||
return fmt.Errorf("agentspec: 未知键 %q", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(key, value string) (bool, error) {
|
||||
switch value {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("agentspec: %s %q 须为 true/false", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func splitList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validName(name string) bool {
|
||||
if name == "" || strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
|
||||
return false
|
||||
}
|
||||
for _, seg := range strings.Split(name, "-") {
|
||||
if seg == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range seg {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agentspec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const goodSpec = `---
|
||||
name: doc-reviewer
|
||||
description: 评审体系文档章节
|
||||
tools: read_file, grep, write_file
|
||||
disallowedTools: run_shell
|
||||
model: lightweight
|
||||
skills: house-style, rdm-review
|
||||
maxTurns: 8
|
||||
memoryScope: project
|
||||
effort: high
|
||||
background: true
|
||||
worktreeIsolation: false
|
||||
outputSchema: review-result
|
||||
---
|
||||
你是章节评审代理。逐条核对来源标注。
|
||||
`
|
||||
|
||||
func TestParseFullFields(t *testing.T) {
|
||||
spec, err := Parse(goodSpec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Name != "doc-reviewer" || spec.Description == "" {
|
||||
t.Fatalf("identity = %+v", spec)
|
||||
}
|
||||
if len(spec.Tools) != 3 || spec.Tools[2] != "write_file" {
|
||||
t.Fatalf("tools = %v", spec.Tools)
|
||||
}
|
||||
if len(spec.DisallowedTools) != 1 || spec.DisallowedTools[0] != "run_shell" {
|
||||
t.Fatalf("disallowed = %v", spec.DisallowedTools)
|
||||
}
|
||||
if spec.Model != "lightweight" || spec.MaxTurns != 8 || spec.MemoryScope != "project" || spec.Effort != "high" {
|
||||
t.Fatalf("scalars = %+v", spec)
|
||||
}
|
||||
if !spec.Background || spec.WorktreeIsolation {
|
||||
t.Fatalf("bools = %+v", spec)
|
||||
}
|
||||
if len(spec.Skills) != 2 || spec.OutputSchema != "review-result" {
|
||||
t.Fatalf("skills/schema = %+v", spec)
|
||||
}
|
||||
if spec.Prompt == "" {
|
||||
t.Fatal("prompt body empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaultsAndMinimal(t *testing.T) {
|
||||
spec, err := Parse("---\nname: helper\n---\n做杂事。\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.MaxTurns != 0 || spec.Background || len(spec.Tools) != 0 || spec.PermissionMode != "" {
|
||||
t.Fatalf("defaults = %+v", spec)
|
||||
}
|
||||
if spec.Prompt != "做杂事。" {
|
||||
t.Fatalf("prompt = %q", spec.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsBadInputs(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"missing frontmatter": "name: x\n---\n正文\n",
|
||||
"unclosed frontmatter": "---\nname: x\n正文\n",
|
||||
"unknown key": "---\nname: x\nbogus: 1\n---\n正文\n",
|
||||
"missing name": "---\ndescription: x\n---\n正文\n",
|
||||
"bad name uppercase": "---\nname: Bad\n---\n正文\n",
|
||||
"bad name underscore": "---\nname: has_under\n---\n正文\n",
|
||||
"bad maxTurns": "---\nname: x\nmaxTurns: many\n---\n正文\n",
|
||||
"bad bool": "---\nname: x\nbackground: yes\n---\n正文\n",
|
||||
"malformed line": "---\nname: x\nno-colon-here\n---\n正文\n",
|
||||
}
|
||||
for label, content := range cases {
|
||||
if _, err := Parse(content); err == nil {
|
||||
t.Fatalf("%s: expected error", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginBansHostOnlyKeys(t *testing.T) {
|
||||
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
|
||||
content := "---\nname: plug-agent\n" + key + ": anything\n---\n正文\n"
|
||||
if _, err := ParsePlugin(content); err == nil {
|
||||
t.Fatalf("plugin key %s: expected rejection", key)
|
||||
} else if got := err.Error(); !strings.Contains(got, "不得声明 "+key) {
|
||||
t.Fatalf("plugin key %s: error %q 未定位到字段", key, got)
|
||||
}
|
||||
}
|
||||
// 宿主来源不受此限:permissionMode 在 Parse 下可用。
|
||||
if _, err := Parse("---\nname: host-agent\npermissionMode: plan\n---\n正文\n"); err != nil {
|
||||
t.Fatalf("host parse with permissionMode: %v", err)
|
||||
}
|
||||
// 插件的合法字段照常解析。
|
||||
spec, err := ParsePlugin("---\nname: plug-agent\ntools: read_file\n---\n正文\n")
|
||||
if err != nil || len(spec.Tools) != 1 {
|
||||
t.Fatalf("plugin good spec: %+v err=%v", spec, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirAtomicAndDuplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, content string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("a.md", "---\nname: alpha\n---\nA\n")
|
||||
write("b.md", "---\nname: beta\n---\nB\n")
|
||||
got, err := LoadDir(dir, nil)
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("load = %d err=%v", len(got), err)
|
||||
}
|
||||
|
||||
write("c.md", "---\nname: alpha\n---\nC\n")
|
||||
if _, err := LoadDir(dir, nil); err == nil {
|
||||
t.Fatal("duplicate name accepted")
|
||||
}
|
||||
os.Remove(filepath.Join(dir, "c.md"))
|
||||
|
||||
write("bad.md", "---\nname: x\nbogus: 1\n---\n")
|
||||
if _, err := LoadDir(dir, nil); err == nil {
|
||||
t.Fatal("bad file did not fail the batch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Package gates 提供终轮门禁与进度提醒的组合机制(自产品下沉)。
|
||||
// 产品依赖——工件存取、质量评分、验证器模型、提示词、进度快照——全部端口化;
|
||||
// 判定逻辑(正则族)、续跑上限与节流策略由本包持有。
|
||||
// 端口为 nil 时语义与「未启用该检查」一致,不改变既有行为。
|
||||
package gates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 续跑与节流上限。与产品现网取值一致;消费方引用本包常量,不再各自硬编码。
|
||||
const (
|
||||
// MaxDeliverableGuards 是「声称交付但无工件」的纠正轮次上限。
|
||||
MaxDeliverableGuards = 2
|
||||
// MaxVerifyContinuations 是终轮检查的续跑上限(与内核 Stop 的 3 次上限对齐)。
|
||||
MaxVerifyContinuations = 3
|
||||
// ReminderToolGap 是连续这么多个工具轮没有更新计划或待办就提醒一次。
|
||||
ReminderToolGap = 3
|
||||
// MaxChecklistReminders 是每个 run 的进度提醒次数上限。
|
||||
MaxChecklistReminders = 3
|
||||
)
|
||||
|
||||
// ArtifactLookup 按 ID 取工件:名称、内容与是否存在。
|
||||
type ArtifactLookup func(id string) (name string, content []byte, ok bool)
|
||||
|
||||
// QualityScorer 对文本类工件评分;nil 表示跳过评分(视为通过)。
|
||||
// why 在未通过时给出可读原因。
|
||||
type QualityScorer func(name string, content []byte) (passed bool, why string)
|
||||
|
||||
// ArtifactRef 是一次工件引用(库内最小形态;宿主负责投影自有类型)。
|
||||
type ArtifactRef struct {
|
||||
ID string
|
||||
Name string
|
||||
Kind string
|
||||
}
|
||||
|
||||
var (
|
||||
// ArtifactIDPattern 是工件编号的文本形态(门禁与宿主的引用提取共用)。
|
||||
ArtifactIDPattern = regexp.MustCompile(`art_[0-9a-f]{12}`)
|
||||
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
|
||||
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
|
||||
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
|
||||
)
|
||||
|
||||
// ClaimGate 判断终轮正文是否在没有对应工件的情况下声称交付。
|
||||
// 引用本轮已产出、或确实存在的工件不算谎称;伪造调用标记始终拦截。
|
||||
// lookup 为 nil 时,任何引用未产出工件的 art_ 编号都视为不存在(拦截)。
|
||||
func ClaimGate(body string, produced []ArtifactRef, lookup ArtifactLookup) (bool, string) {
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return false, ""
|
||||
}
|
||||
if forgedCallRe.MatchString(body) {
|
||||
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
|
||||
}
|
||||
producedIDs := map[string]bool{}
|
||||
for _, ref := range produced {
|
||||
if ref.ID != "" {
|
||||
producedIDs[ref.ID] = true
|
||||
}
|
||||
}
|
||||
knownExisting := false
|
||||
for _, id := range ArtifactIDPattern.FindAllString(body, -1) {
|
||||
if producedIDs[id] {
|
||||
continue
|
||||
}
|
||||
if lookup != nil {
|
||||
if _, _, ok := lookup(id); ok {
|
||||
knownExisting = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true, "回复引用了不存在的工件"
|
||||
}
|
||||
if len(producedIDs) > 0 || knownExisting {
|
||||
return false, ""
|
||||
}
|
||||
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
|
||||
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// CheckArtifacts 确认登记的工件存在且非空。
|
||||
// md、txt、docx 还要过 scorer;其他类型只检查存在与非空。
|
||||
func CheckArtifacts(refs []ArtifactRef, lookup ArtifactLookup, scorer QualityScorer) []string {
|
||||
var reasons []string
|
||||
for _, ref := range refs {
|
||||
if ref.ID == "" {
|
||||
reasons = append(reasons, "工件引用缺少 ID")
|
||||
continue
|
||||
}
|
||||
name := ref.Name
|
||||
if name == "" {
|
||||
name = ref.ID
|
||||
}
|
||||
var (
|
||||
content []byte
|
||||
ok bool
|
||||
artName string
|
||||
)
|
||||
if lookup != nil {
|
||||
artName, content, ok = lookup(ref.ID)
|
||||
}
|
||||
if !ok {
|
||||
reasons = append(reasons, name+" 不存在")
|
||||
continue
|
||||
}
|
||||
if artName != "" {
|
||||
name = artName
|
||||
}
|
||||
if len(content) == 0 {
|
||||
reasons = append(reasons, name+" 内容为空")
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".md", ".txt", ".docx":
|
||||
if scorer == nil {
|
||||
continue
|
||||
}
|
||||
passed, why := scorer(name, content)
|
||||
if !passed {
|
||||
if why == "" {
|
||||
why = "评分未通过"
|
||||
}
|
||||
reasons = append(reasons, name+":"+why)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
// Verdict 是独立验证器的裁决。
|
||||
type Verdict struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
NextAction string `json:"nextAction"`
|
||||
}
|
||||
|
||||
// ParseVerdict 解析验证器输出:剥围栏、取最外层 JSON、必须有 passed 键。
|
||||
func ParseVerdict(raw string) (Verdict, error) {
|
||||
s := stripFence(raw)
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end < start {
|
||||
return Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
s = s[start : end+1]
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &probe); err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
if _, ok := probe["passed"]; !ok {
|
||||
return Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
var verdict Verdict
|
||||
if err := json.Unmarshal([]byte(s), &verdict); err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
func stripFence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package gates
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func lookupWith(ids map[string][]byte) ArtifactLookup {
|
||||
return func(id string) (string, []byte, bool) {
|
||||
c, ok := ids[id]
|
||||
return id + ".md", c, ok
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimGateForgedMarkerAlwaysBlocks(t *testing.T) {
|
||||
block, why := ClaimGate("结果如下 [调用工具 write_file]", nil, nil)
|
||||
if !block || why == "" {
|
||||
t.Fatalf("block=%v why=%q", block, why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimGateProducedAndExistingDoNotBlock(t *testing.T) {
|
||||
produced := []ArtifactRef{{ID: "art_aaaabbbbcccc"}}
|
||||
if block, _ := ClaimGate("已生成 art_aaaabbbbcccc 请查收", produced, nil); block {
|
||||
t.Fatal("produced reference blocked")
|
||||
}
|
||||
lookup := lookupWith(map[string][]byte{"art_dddd11112222": []byte("x")})
|
||||
if block, _ := ClaimGate("之前的 art_dddd11112222 里有结论", nil, lookup); block {
|
||||
t.Fatal("existing artifact reference blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimGateUnknownArtifactBlocks(t *testing.T) {
|
||||
block, why := ClaimGate("见附件 art_000011112222", nil, lookupWith(nil))
|
||||
if !block || !strings.Contains(why, "不存在") {
|
||||
t.Fatalf("block=%v why=%q", block, why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimGateBareClaimWithoutArtifactsBlocks(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"已生成报告.md,请查收",
|
||||
"文件已生成好了",
|
||||
} {
|
||||
if block, _ := ClaimGate(body, nil, nil); !block {
|
||||
t.Fatalf("claim not blocked: %q", body)
|
||||
}
|
||||
}
|
||||
// 如实说明没有生成则不拦。
|
||||
if block, _ := ClaimGate("这一轮没有生成文件", nil, nil); block {
|
||||
t.Fatal("honest note blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckArtifactsReasons(t *testing.T) {
|
||||
lookup := func(id string) (string, []byte, bool) {
|
||||
switch id {
|
||||
case "ok":
|
||||
return "ok.md", []byte("# 正常"), true
|
||||
case "empty":
|
||||
return "empty.md", nil, true
|
||||
default:
|
||||
return "", nil, false
|
||||
}
|
||||
}
|
||||
scorer := func(name string, content []byte) (bool, string) { return false, "标题缺失" }
|
||||
refs := []ArtifactRef{
|
||||
{ID: ""},
|
||||
{ID: "missing", Name: "缺失.md"},
|
||||
{ID: "empty"},
|
||||
{ID: "ok"},
|
||||
}
|
||||
reasons := CheckArtifacts(refs, lookup, scorer)
|
||||
joined := strings.Join(reasons, ";")
|
||||
for _, want := range []string{"缺少 ID", "缺失.md 不存在", "empty.md 内容为空", "ok.md:标题缺失"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("reasons %q 缺 %q", joined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckArtifactsNilScorerSkipsQuality(t *testing.T) {
|
||||
lookup := lookupWith(map[string][]byte{"a": []byte("x")})
|
||||
if reasons := CheckArtifacts([]ArtifactRef{{ID: "a"}}, lookup, nil); len(reasons) != 0 {
|
||||
t.Fatalf("reasons = %v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVerdictForms(t *testing.T) {
|
||||
v, err := ParseVerdict("```json\n{\"passed\":true,\"reason\":\"ok\"}\n```")
|
||||
if err != nil || !v.Passed {
|
||||
t.Fatalf("v=%+v err=%v", v, err)
|
||||
}
|
||||
if _, err := ParseVerdict("没有 JSON"); err == nil {
|
||||
t.Fatal("garbage accepted")
|
||||
}
|
||||
if _, err := ParseVerdict("{\"reason\":\"no passed key\"}"); err == nil {
|
||||
t.Fatal("missing passed accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReminderThrottleAndFormat(t *testing.T) {
|
||||
r := &ProgressReminder{}
|
||||
now := time.Now()
|
||||
first := r.Remind([]string{"写第三章"}, []string{"评审"}, now)
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("first = %v", first)
|
||||
}
|
||||
text := first[0]
|
||||
for _, want := range []string{"已连续 3 个工具轮", "- 待办:写第三章", "- 计划:评审", "请更新计划或待办"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("text 缺 %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// 内容相同且计数不同:key 不同,应继续发出直到封顶。
|
||||
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
|
||||
t.Fatalf("second = %v", got)
|
||||
}
|
||||
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
|
||||
t.Fatalf("third = %v", got)
|
||||
}
|
||||
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); got != nil {
|
||||
t.Fatalf("fourth should be capped: %v", got)
|
||||
}
|
||||
if r.Sent() != 3 {
|
||||
t.Fatalf("sent = %d", r.Sent())
|
||||
}
|
||||
// 无内容不发。
|
||||
if got := (&ProgressReminder{}).Remind(nil, nil, now); got != nil {
|
||||
t.Fatalf("empty = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package gates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/reminders"
|
||||
)
|
||||
|
||||
// ProgressReminder 是进度提醒的节流状态机与格式化机制。
|
||||
// 计数(第几个工具轮)由调用方按自己的事件流维护;本机制负责
|
||||
// 「达到阈值后的去重、封顶与文案」。
|
||||
type ProgressReminder struct {
|
||||
Gap int // 连续多少个工具轮未更新即提醒;0 = ReminderToolGap
|
||||
Cap int // 每 run 提醒上限;0 = MaxChecklistReminders
|
||||
notes *reminders.Bus
|
||||
sent int
|
||||
}
|
||||
|
||||
func (r *ProgressReminder) gap() int {
|
||||
if r.Gap <= 0 {
|
||||
return ReminderToolGap
|
||||
}
|
||||
return r.Gap
|
||||
}
|
||||
|
||||
func (r *ProgressReminder) cap() int {
|
||||
if r.Cap <= 0 {
|
||||
return MaxChecklistReminders
|
||||
}
|
||||
return r.Cap
|
||||
}
|
||||
|
||||
// Sent 报告已发出的提醒条数(调用方判定封顶用)。
|
||||
func (r *ProgressReminder) Sent() int { return r.sent }
|
||||
|
||||
// Remind 在给定未完成项时发一条提醒;节流或没有内容时返回 nil。
|
||||
// 文案与产品现网一致:「计划和待办已连续 N 个工具轮没有更新」。
|
||||
func (r *ProgressReminder) Remind(todos, plans []string, now time.Time) []string {
|
||||
if r.sent >= r.cap() {
|
||||
return nil
|
||||
}
|
||||
text := FormatUnfinished(r.gap(), todos, plans)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if r.notes == nil {
|
||||
r.notes = reminders.New(0, r.cap())
|
||||
}
|
||||
key := fmt.Sprintf("%d:%s", r.sent, text)
|
||||
if !r.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, now) {
|
||||
return nil
|
||||
}
|
||||
r.sent++
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
// FormatUnfinished 渲染未完成项提醒;无内容时返回空串。
|
||||
func FormatUnfinished(gap int, todos, plans []string) string {
|
||||
var lines []string
|
||||
for _, item := range todos {
|
||||
if text := strings.TrimSpace(item); text != "" {
|
||||
lines = append(lines, "- 待办:"+text)
|
||||
}
|
||||
}
|
||||
for _, step := range plans {
|
||||
if text := strings.TrimSpace(step); text != "" {
|
||||
lines = append(lines, "- 计划:"+text)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("计划和待办已连续 %d 个工具轮没有更新。未完成项:\n", gap) +
|
||||
strings.Join(lines, "\n") +
|
||||
"\n请更新计划或待办,或在回复里说明为什么先不做。"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package guardrails 提供三类语义化护栏端口:输入(只作用于入口智能体)、
|
||||
// 输出(只作用于给出最终结果的智能体)、工具(每次工具调用)。
|
||||
// 护栏以钩子总线注册:它是类型化语义层,钩子仍是通用事件层。
|
||||
// 当前形态护栏拿不到运行 ctx(事件载荷不含它);需要取消语义时直接写钩子。
|
||||
package guardrails
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
)
|
||||
|
||||
// Verdict 是一次护栏表态。空 Decision 表示不插手。
|
||||
// Deny 拒绝(Output 层表现为要求模型改进后继续);Modify 改写载荷
|
||||
// (Input 改用户输入、Output 改最终文本、Tool 改参数);Reason 附说明。
|
||||
type Verdict struct {
|
||||
Decision hooks.Decision
|
||||
Reason string
|
||||
Replacement string
|
||||
}
|
||||
|
||||
type InputGuardrail func(ctx context.Context, text string) Verdict
|
||||
type OutputGuardrail func(ctx context.Context, text string) Verdict
|
||||
type ToolGuardrail func(ctx context.Context, name, args string) Verdict
|
||||
|
||||
// Set 是一组护栏;nil 字段表示该层不启用。
|
||||
type Set struct {
|
||||
Input InputGuardrail
|
||||
Output OutputGuardrail
|
||||
Tool ToolGuardrail
|
||||
}
|
||||
|
||||
// Scope 标注本 run 的角色:Entry=本 run 处理入口用户输入;
|
||||
// Final=本 run 给出最终答案。子代理 run 两者都为 false,
|
||||
// Input/Output 护栏不会作用于它;Tool 护栏作用于一切工具调用。
|
||||
type Scope struct {
|
||||
Entry bool
|
||||
Final bool
|
||||
}
|
||||
|
||||
// Register 把护栏挂到总线。语义:
|
||||
// - Input(仅 scope.Entry):挂 UserPromptSubmit。deny → 循环不启动,
|
||||
// 以理由直接收尾;modify → 改写后的输入追加为新的用户消息(原文留痕)。
|
||||
// - Output(仅 scope.Final):挂 Stop。deny → 视同续跑,理由交给模型改进
|
||||
// (计入内核续跑上限,达上限后收尾并附 Notice);modify → 替换最终文本并续跑。
|
||||
// - Tool:挂 PreToolUse,与其他钩子按既有优先级合并
|
||||
// (deny/block > ask > modify > allow);modify 的 Replacement 改写工具参数。
|
||||
func Register(bus *hooks.Bus, set Set, scope Scope) {
|
||||
if bus == nil {
|
||||
return
|
||||
}
|
||||
if set.Input != nil && scope.Entry {
|
||||
g := set.Input
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.UserPromptSubmit {
|
||||
return hooks.Outcome{}
|
||||
}
|
||||
v := g(context.Background(), c.LastText)
|
||||
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason, Replacement: v.Replacement}
|
||||
})
|
||||
}
|
||||
if set.Output != nil && scope.Final {
|
||||
g := set.Output
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.Stop {
|
||||
return hooks.Outcome{}
|
||||
}
|
||||
v := g(context.Background(), c.LastText)
|
||||
switch v.Decision {
|
||||
case hooks.Deny, hooks.Block:
|
||||
// 对 Stop 事件而言「拒绝最终答案」= 要求模型改进后再来一轮。
|
||||
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason}
|
||||
case hooks.Modify:
|
||||
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason, Replacement: v.Replacement}
|
||||
default:
|
||||
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
|
||||
}
|
||||
})
|
||||
}
|
||||
if set.Tool != nil {
|
||||
g := set.Tool
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.PreToolUse {
|
||||
return hooks.Outcome{}
|
||||
}
|
||||
v := g(context.Background(), c.Tool, c.Args)
|
||||
out := hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
|
||||
if v.Decision == hooks.Modify {
|
||||
out.Args = v.Replacement
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package guardrails
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
)
|
||||
|
||||
func TestToolGuardrailMergesOnPreToolUse(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
Register(bus, Set{
|
||||
Tool: func(ctx context.Context, name, args string) Verdict {
|
||||
if name == "run_shell" {
|
||||
return Verdict{Decision: hooks.Deny, Reason: "shell 被护栏禁用"}
|
||||
}
|
||||
return Verdict{}
|
||||
},
|
||||
}, Scope{})
|
||||
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "run_shell"})
|
||||
if out.Decision != hooks.Deny || out.Reason == "" {
|
||||
t.Fatalf("out = %+v", out)
|
||||
}
|
||||
out = bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "read_file"})
|
||||
if out.Decision != "" {
|
||||
t.Fatalf("read_file out = %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardrailModifyRewritesArgs(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
Register(bus, Set{
|
||||
Tool: func(ctx context.Context, name, args string) Verdict {
|
||||
return Verdict{Decision: hooks.Modify, Replacement: `{"path":"safe.txt"}`}
|
||||
},
|
||||
}, Scope{})
|
||||
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "write_file"})
|
||||
if out.Decision != hooks.Modify || out.Args != `{"path":"safe.txt"}` {
|
||||
t.Fatalf("out = %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputGuardrailDenyBecomesContinuation(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
Register(bus, Set{
|
||||
Output: func(ctx context.Context, text string) Verdict {
|
||||
return Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
|
||||
},
|
||||
}, Scope{Final: true})
|
||||
out := bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "结论"})
|
||||
// Output deny 必须表达为续跑(Block),而不是把 run 掐死。
|
||||
if out.Decision != hooks.Block || out.Reason != "缺少来源标注" {
|
||||
t.Fatalf("out = %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeGatesInputAndOutput(t *testing.T) {
|
||||
fired := map[string]bool{}
|
||||
mk := func() Set {
|
||||
return Set{
|
||||
Input: func(ctx context.Context, text string) Verdict { fired["input"] = true; return Verdict{} },
|
||||
Output: func(ctx context.Context, text string) Verdict { fired["output"] = true; return Verdict{} },
|
||||
}
|
||||
}
|
||||
// 子代理 run(Entry/Final 都为 false):Input/Output 不注册。
|
||||
bus := hooks.New()
|
||||
Register(bus, mk(), Scope{})
|
||||
bus.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||||
bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||||
if fired["input"] || fired["output"] {
|
||||
t.Fatalf("sub-run scope fired: %v", fired)
|
||||
}
|
||||
// 入口且最终:两层都触发。
|
||||
bus2 := hooks.New()
|
||||
Register(bus2, mk(), Scope{Entry: true, Final: true})
|
||||
bus2.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||||
bus2.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||||
if !fired["input"] || !fired["output"] {
|
||||
t.Fatalf("entry/final scope did not fire: %v", fired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilBusIsNoop(t *testing.T) {
|
||||
Register(nil, Set{
|
||||
Input: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||
Output: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||
Tool: func(ctx context.Context, name, args string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||
}, Scope{Entry: true, Final: true})
|
||||
}
|
||||
@@ -13,10 +13,12 @@ const (
|
||||
PermissionRequest Event = "PermissionRequest"
|
||||
PostToolUse Event = "PostToolUse"
|
||||
PostToolUseFailure Event = "PostToolUseFailure"
|
||||
PostToolBatch Event = "PostToolBatch"
|
||||
Stop Event = "Stop"
|
||||
SubagentStart Event = "SubagentStart"
|
||||
SubagentStop Event = "SubagentStop"
|
||||
PreCompact Event = "PreCompact"
|
||||
SessionEnd Event = "SessionEnd"
|
||||
)
|
||||
|
||||
type Decision string
|
||||
|
||||
+58
-7
@@ -110,6 +110,17 @@ type Config struct {
|
||||
KeepToolResults int
|
||||
// Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。
|
||||
Hooks *hooks.Bus
|
||||
// PermissionAsk 在钩子对一次工具调用表态 ask 时向宿主请求裁决。
|
||||
// nil 时保持旧行为:ask 与 deny 一样按拒绝处理。
|
||||
// 返回 Allow 时工具照常执行;宿主需要「本会话始终允许」时自行记录,
|
||||
// 内核不替宿主保存放行状态。
|
||||
PermissionAsk func(ctx context.Context, call llm.ToolCall) AskVerdict
|
||||
}
|
||||
|
||||
// AskVerdict 是宿主对一次 ask 决策的裁决。
|
||||
type AskVerdict struct {
|
||||
Allow bool
|
||||
Reason string // 拒绝时写进配对 tool 结果的理由;放行时忽略
|
||||
}
|
||||
|
||||
// StopInfo 是 Stop 钩子看到的本 run 进度。
|
||||
@@ -192,6 +203,28 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
}
|
||||
messages = append(messages, user)
|
||||
emit(cfg, Event{Type: "agent_start"})
|
||||
cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionStart})
|
||||
if out := cfg.Hooks.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: user.Content}); out.Decision != "" {
|
||||
switch out.Decision {
|
||||
case hooks.Deny, hooks.Block:
|
||||
// 输入护栏拒绝:不进入循环,以理由直接收尾(原文已持久化留痕)。
|
||||
why := out.Reason
|
||||
if why == "" {
|
||||
why = "输入被护栏拒绝"
|
||||
}
|
||||
return Result{Text: why, Messages: messages, StopReason: llm.StopEnd, Notice: why}, nil
|
||||
case hooks.Modify:
|
||||
// 改写后的输入追加为新的用户消息(原文保留,可审计)。
|
||||
if out.Replacement != "" {
|
||||
note := llm.Message{Role: llm.RoleUser, Content: out.Replacement}
|
||||
if err := persist(cfg, note); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
messages = append(messages, note)
|
||||
user = note
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var last llm.Message
|
||||
var usage llm.Usage
|
||||
@@ -200,6 +233,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
lengthContinues := 0
|
||||
lengthNudge := false
|
||||
var repeats repeatState
|
||||
defer func() {
|
||||
cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionEnd, LastText: last.Content, ToolCalls: toolCalls})
|
||||
}()
|
||||
for turn := 0; turn < cfg.MaxTurns; turn++ {
|
||||
if ctx.Err() != nil {
|
||||
cfg.logger().Error("runtime turn aborted", ctx.Err())
|
||||
@@ -266,6 +302,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
}
|
||||
cfg.Hooks.Emit(hooks.Call{Event: hooks.PostToolBatch, Turn: turn, ToolCalls: len(last.ToolCalls)})
|
||||
emit(cfg, Event{Type: "turn_end", Message: last})
|
||||
continue
|
||||
}
|
||||
@@ -525,19 +562,26 @@ func stopDecision(cfg Config, last llm.Message, continuations, toolCalls int) (S
|
||||
return StopDecision{}, false
|
||||
}
|
||||
|
||||
func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
|
||||
func gateTool(ctx context.Context, cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
|
||||
if cfg.Hooks == nil {
|
||||
return call, llm.Message{}, false
|
||||
}
|
||||
out := cfg.Hooks.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: call.Name, Args: call.Arguments})
|
||||
switch out.Decision {
|
||||
case hooks.Deny, hooks.Block, hooks.Ask:
|
||||
case hooks.Ask:
|
||||
// ask 先问宿主;没有裁决端口(或宿主拒绝)时保持旧行为:按拒绝处理。
|
||||
cfg.Hooks.Emit(hooks.Call{Event: hooks.PermissionRequest, Tool: call.Name, Args: call.Arguments})
|
||||
why := out.Reason
|
||||
if why == "" {
|
||||
why = "工具调用被拒绝"
|
||||
if cfg.PermissionAsk != nil {
|
||||
if verdict := cfg.PermissionAsk(ctx, call); verdict.Allow {
|
||||
return call, llm.Message{}, false
|
||||
} else if verdict.Reason != "" {
|
||||
why = verdict.Reason
|
||||
}
|
||||
}
|
||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
|
||||
return call, msg, true
|
||||
return call, rejectToolMessage(call, why), true
|
||||
case hooks.Deny, hooks.Block:
|
||||
return call, rejectToolMessage(call, out.Reason), true
|
||||
case hooks.Modify:
|
||||
if out.Args != "" {
|
||||
call.Arguments = out.Args
|
||||
@@ -546,6 +590,13 @@ func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
|
||||
return call, llm.Message{}, false
|
||||
}
|
||||
|
||||
func rejectToolMessage(call llm.ToolCall, why string) llm.Message {
|
||||
if why == "" {
|
||||
why = "工具调用被拒绝"
|
||||
}
|
||||
return llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
|
||||
}
|
||||
|
||||
func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) {
|
||||
if cfg.Hooks == nil {
|
||||
return
|
||||
@@ -571,7 +622,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
return msg
|
||||
}
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
|
||||
if gated, msg, blocked := gateTool(cfg, call); blocked {
|
||||
if gated, msg, blocked := gateTool(ctx, cfg, call); blocked {
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/guardrails"
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// TestInputGuardrailDenySkipsLoop:输入护栏 deny 时不进循环、不调模型,
|
||||
// 以理由直接收尾;原文已进转录(留痕)。
|
||||
func TestInputGuardrailDenySkipsLoop(t *testing.T) {
|
||||
calls := 0
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Input: func(ctx context.Context, text string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Deny, Reason: "含敏感指令"}
|
||||
},
|
||||
}, guardrails.Scope{Entry: true})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Hooks: bus,
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
calls++
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||
close(ch)
|
||||
return ch
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "bad input"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("model called %d times after input deny", calls)
|
||||
}
|
||||
if res.Text != "含敏感指令" {
|
||||
t.Fatalf("result text = %q", res.Text)
|
||||
}
|
||||
if !strings.Contains(transcriptText(res.Messages), "bad input") {
|
||||
t.Fatal("original input missing from transcript")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputGuardrailModifyAppendsRewrite:modify 时改写内容追加为新的用户消息,
|
||||
// 模型最终看到的是改写后的输入;原文保留在转录里。
|
||||
func TestInputGuardrailModifyAppendsRewrite(t *testing.T) {
|
||||
var saw string
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Input: func(ctx context.Context, text string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Modify, Replacement: "改写后的输入"}
|
||||
},
|
||||
}, guardrails.Scope{Entry: true})
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Hooks: bus,
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
saw = transcriptText(messages)
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||
close(ch)
|
||||
return ch
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "原始输入"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(saw, "原始输入") || !strings.Contains(saw, "改写后的输入") {
|
||||
t.Fatalf("request messages = %q", saw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutputGuardrailDenyContinuesThenCaps:输出护栏 deny 视同续跑,
|
||||
// 理由进转录;达内核续跑上限后收尾。
|
||||
func TestOutputGuardrailDenyContinuesThenCaps(t *testing.T) {
|
||||
calls := 0
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Output: func(ctx context.Context, text string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
|
||||
},
|
||||
}, guardrails.Scope{Final: true})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
MaxTurns: 8,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v1", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v2", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v3", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v4", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = calls
|
||||
if strings.Count(transcriptText(res.Messages), "缺少来源标注") != 3 {
|
||||
t.Fatalf("reason count = %s", transcriptText(res.Messages))
|
||||
}
|
||||
if res.Text != "v4" {
|
||||
t.Fatalf("final text = %q", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolGuardrailDenyPairsResult:工具护栏 deny 写配对错误结果且不执行工具。
|
||||
func TestToolGuardrailDenyPairsResult(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Tool: func(ctx context.Context, name, args string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Deny, Reason: "护栏禁用"}
|
||||
},
|
||||
}, guardrails.Scope{})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("tool executed %d times", hits)
|
||||
}
|
||||
paired := false
|
||||
for _, m := range res.Messages {
|
||||
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "护栏禁用" {
|
||||
paired = true
|
||||
}
|
||||
}
|
||||
if !paired {
|
||||
t.Fatalf("missing paired result: %s", transcriptText(res.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubRunScopeSkipsInputOutput:子代理 run(Entry/Final=false)注册同名护栏也不触发。
|
||||
func TestSubRunScopeSkipsInputOutput(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Input: func(ctx context.Context, text string) guardrails.Verdict { return guardrails.Verdict{Decision: hooks.Deny, Reason: "x"} },
|
||||
Output: func(ctx context.Context, text string) guardrails.Verdict { return guardrails.Verdict{Decision: hooks.Deny, Reason: "y"} },
|
||||
}, guardrails.Scope{})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Hooks: bus,
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Text != "ok" {
|
||||
t.Fatalf("sub-scope run text = %q", res.Text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
type eventRecorder struct {
|
||||
seen []hooks.Event
|
||||
}
|
||||
|
||||
func (r *eventRecorder) handler(c hooks.Call) hooks.Outcome {
|
||||
r.seen = append(r.seen, c.Event)
|
||||
return hooks.Outcome{}
|
||||
}
|
||||
|
||||
func (r *eventRecorder) has(ev hooks.Event) bool {
|
||||
for _, e := range r.seen {
|
||||
if e == ev {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestHookLifecycleOrder 断言一次带工具调用的 run 的事件顺序为
|
||||
// SessionStart → UserPromptSubmit → PreToolUse → PostToolUse →
|
||||
// PostToolBatch → Stop → SessionEnd。
|
||||
func TestHookLifecycleOrder(t *testing.T) {
|
||||
rec := &eventRecorder{}
|
||||
bus := hooks.New()
|
||||
bus.On(rec.handler)
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []hooks.Event{hooks.SessionStart, hooks.UserPromptSubmit, hooks.PreToolUse,
|
||||
hooks.PostToolUse, hooks.PostToolBatch, hooks.Stop, hooks.SessionEnd}
|
||||
if len(rec.seen) != len(want) {
|
||||
t.Fatalf("events = %v, want %v", rec.seen, want)
|
||||
}
|
||||
for i := range want {
|
||||
if rec.seen[i] != want[i] {
|
||||
t.Fatalf("events = %v, want %v", rec.seen, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookUserPromptSubmitCarriesText 断言 UserPromptSubmit 事件带用户输入正文。
|
||||
func TestHookUserPromptSubmitCarriesText(t *testing.T) {
|
||||
var got string
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.UserPromptSubmit {
|
||||
got = c.LastText
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Hooks: bus,
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "ping" {
|
||||
t.Fatalf("UserPromptSubmit text = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAskPortAllowProceeds:ask 决策经宿主裁决放行后工具照常执行。
|
||||
func TestAskPortAllowProceeds(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.PreToolUse {
|
||||
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
sawRequest := false
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.PermissionRequest {
|
||||
sawRequest = true
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict {
|
||||
return AskVerdict{Allow: true}
|
||||
},
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("tool executed %d times, want 1", hits)
|
||||
}
|
||||
if !sawRequest {
|
||||
t.Fatal("PermissionRequest event missing")
|
||||
}
|
||||
if !strings.Contains(transcriptText(res.Messages), "ran") {
|
||||
t.Fatalf("tool result missing: %s", transcriptText(res.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAskPortDenyPairsResult:宿主拒绝时写配对错误结果,理由取宿主理由。
|
||||
func TestAskPortDenyPairsResult(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.PreToolUse {
|
||||
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict {
|
||||
return AskVerdict{Allow: false, Reason: "用户拒绝"}
|
||||
},
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("tool executed %d times, want 0", hits)
|
||||
}
|
||||
paired := false
|
||||
for _, m := range res.Messages {
|
||||
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "用户拒绝" {
|
||||
paired = true
|
||||
}
|
||||
}
|
||||
if !paired {
|
||||
t.Fatalf("missing paired deny result: %s", transcriptText(res.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAskWithoutPortKeepsLegacyReject:没有裁决端口时 ask 仍按拒绝处理(旧行为)。
|
||||
func TestAskWithoutPortKeepsLegacyReject(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.PreToolUse {
|
||||
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("tool executed %d times, want 0", hits)
|
||||
}
|
||||
paired := false
|
||||
for _, m := range res.Messages {
|
||||
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "需要确认" {
|
||||
paired = true
|
||||
}
|
||||
}
|
||||
if !paired {
|
||||
t.Fatalf("missing paired ask-reject result: %s", transcriptText(res.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopContinuationFlagVisible:续跑后 Stop 事件的 Continuations 计数对下游可见。
|
||||
func TestStopContinuationFlagVisible(t *testing.T) {
|
||||
var counts []int
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.Stop {
|
||||
counts = append(counts, c.Continuations)
|
||||
return hooks.Outcome{Decision: hooks.Block, Reason: "再试"}
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
MaxTurns: 8,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(counts) != 4 {
|
||||
t.Fatalf("stop events = %v", counts)
|
||||
}
|
||||
for i, c := range counts {
|
||||
if c != i {
|
||||
t.Fatalf("stop continuations = %v, want 0,1,2,3", counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
// 默认模式不额外表态,调用方继续用原有画像策略。
|
||||
package policy
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
@@ -92,6 +95,57 @@ func Decide(req Request, rules []Rule) Effect {
|
||||
return EffectNone
|
||||
}
|
||||
|
||||
// SessionAllows 记录并查询「本会话始终允许」的决定。键的口径由实现方定义;
|
||||
// 宿主可以用自己的模式匹配(如参数通配)实现同一接口。
|
||||
type SessionAllows interface {
|
||||
Remember(name, args string)
|
||||
Allowed(name, args string) bool
|
||||
}
|
||||
|
||||
// MemoryAllows 是进程内的会话放行存储,按「工具名 + 原样参数串」精确匹配。
|
||||
type MemoryAllows struct {
|
||||
mu sync.Mutex
|
||||
allowed map[string]bool
|
||||
}
|
||||
|
||||
func NewMemoryAllows() *MemoryAllows {
|
||||
return &MemoryAllows{allowed: make(map[string]bool)}
|
||||
}
|
||||
|
||||
func (m *MemoryAllows) Remember(name, args string) {
|
||||
m.mu.Lock()
|
||||
m.allowed[name+"\n"+args] = true
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *MemoryAllows) Allowed(name, args string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.allowed[name+"\n"+args]
|
||||
}
|
||||
|
||||
// Policy 是一次会话的权限配置:模式、规则与会话放行存在同一个对象上,
|
||||
// Decide/Visible 语义与同名函数完全一致。
|
||||
type Policy struct {
|
||||
Mode Mode
|
||||
Rules []Rule
|
||||
SessionAllows SessionAllows // 可为 nil:等价于 SessionAllow 恒为 false
|
||||
}
|
||||
|
||||
// Decide 按 Policy 的模式、规则与会话放行判定一次工具调用。
|
||||
func (p Policy) Decide(name, args string, traits Traits) Effect {
|
||||
var allowed bool
|
||||
if p.SessionAllows != nil {
|
||||
allowed = p.SessionAllows.Allowed(name, args)
|
||||
}
|
||||
return Decide(Request{Mode: p.Mode, Name: name, Args: args, Traits: traits, SessionAllow: allowed}, p.Rules)
|
||||
}
|
||||
|
||||
// Visible 报告该模式下这个工具能否出现在清单里。
|
||||
func (p Policy) Visible(name string) bool {
|
||||
return Visible(string(p.Mode), name, mergeTraits(name, Traits{}).ReadOnly)
|
||||
}
|
||||
|
||||
// Visible 报告计划模式下这个工具能否出现在清单里。其他模式一律可见。
|
||||
func Visible(mode string, name string, readOnly bool) bool {
|
||||
if Normalize(mode) != ModePlan {
|
||||
|
||||
@@ -39,3 +39,52 @@ func TestAutoEditAllowsWorkspaceWrites(t *testing.T) {
|
||||
t.Fatalf("auto edit %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyDecideMatchesFunctionForm:Policy 结构体形态与函数形态判定一致。
|
||||
func TestPolicyDecideMatchesFunctionForm(t *testing.T) {
|
||||
rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}}
|
||||
cases := []struct {
|
||||
p Policy
|
||||
name string
|
||||
args string
|
||||
want Effect
|
||||
}{
|
||||
{Policy{Mode: ModePlan}, "write_file", `{"path":"a.md"}`, EffectDeny},
|
||||
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"a.md"}`, EffectAllow},
|
||||
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"secret.txt"}`, EffectDeny},
|
||||
{Policy{Mode: ModeDefault}, "run_shell", `{"command":"rm -rf /"}`, EffectAsk},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if got := c.p.Decide(c.name, c.args, Traits{}); got != c.want {
|
||||
t.Fatalf("case %d: effect %q, want %q", i, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicySessionAllowsStore:会话放行经存储短路 ask,且跨调用存活。
|
||||
func TestPolicySessionAllowsStore(t *testing.T) {
|
||||
store := NewMemoryAllows()
|
||||
p := Policy{Mode: ModeDefault, Rules: []Rule{{Effect: EffectAsk, Tool: "run_shell"}}, SessionAllows: store}
|
||||
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAsk {
|
||||
t.Fatalf("before remember: %q, want ask", got)
|
||||
}
|
||||
store.Remember("run_shell", `{"command":"ls"}`)
|
||||
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAllow {
|
||||
t.Fatalf("after remember: %q, want allow", got)
|
||||
}
|
||||
if got := p.Decide("run_shell", `{"command":"rm -rf /"}`, Traits{}); got != EffectAsk {
|
||||
t.Fatalf("other args: %q, want ask(精确匹配不放行)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyNilStoreAndVisible:nil 存储等价于 SessionAllow 恒 false;Visible 方法可用。
|
||||
func TestPolicyNilStoreAndVisible(t *testing.T) {
|
||||
p := Policy{Mode: ModePlan}
|
||||
if got := p.Decide("write_file", `{}`, Traits{}); got != EffectDeny {
|
||||
t.Fatalf("plan deny: %q", got)
|
||||
}
|
||||
if !p.Visible("update_plan") || p.Visible("write_file") {
|
||||
t.Fatal("policy visible mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/session"
|
||||
)
|
||||
@@ -167,6 +168,7 @@ func (a *AgentSession) Compact(keep int, summarize func([]llm.Message) (string,
|
||||
if a.cfg.Session == nil {
|
||||
return false, nil
|
||||
}
|
||||
a.cfg.Hooks.Emit(hooks.Call{Event: hooks.PreCompact})
|
||||
emit(a.cfg, Event{Type: "compaction_start"})
|
||||
done, err := a.cfg.Session.Compact(keep, summarize)
|
||||
text := "noop"
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
@@ -42,14 +43,23 @@ type Result struct {
|
||||
// Factory 由宿主注入;ctx 取消必须传播到子执行。
|
||||
type Factory func(ctx context.Context, req Request) (Result, error)
|
||||
|
||||
type delegateTool struct{ factory Factory }
|
||||
type delegateTool struct {
|
||||
factory Factory
|
||||
bus *hooks.Bus
|
||||
}
|
||||
|
||||
// Register 把 delegate_task 工具注册进注册表(factory 为 nil 时不注册)。
|
||||
func Register(r *tools.Registry, factory Factory) {
|
||||
RegisterWithHooks(r, factory, nil)
|
||||
}
|
||||
|
||||
// RegisterWithHooks 同 Register;bus 非空时,每次委派执行前后分别发出
|
||||
// SubagentStart / SubagentStop 事件(Start 带目标摘要,Stop 带成败标记)。
|
||||
func RegisterWithHooks(r *tools.Registry, factory Factory, bus *hooks.Bus) {
|
||||
if factory == nil {
|
||||
return
|
||||
}
|
||||
r.Register(&delegateTool{factory: factory})
|
||||
r.Register(&delegateTool{factory: factory, bus: bus})
|
||||
}
|
||||
|
||||
func (t *delegateTool) Name() string { return "delegate_task" }
|
||||
@@ -78,7 +88,18 @@ func (t *delegateTool) Execute(ctx context.Context, args json.RawMessage) (tools
|
||||
if ctx.Err() != nil {
|
||||
return tools.Result{Content: "delegation canceled before start: " + ctx.Err().Error(), IsError: true}, nil
|
||||
}
|
||||
if t.bus != nil {
|
||||
goal := req.Goal
|
||||
if len(goal) > 200 {
|
||||
goal = goal[:200] + "…"
|
||||
}
|
||||
t.bus.Emit(hooks.Call{Event: hooks.SubagentStart, Tool: "delegate_task", LastText: goal})
|
||||
}
|
||||
res, err := t.factory(ctx, req)
|
||||
if t.bus != nil {
|
||||
failed := err != nil || !res.Success
|
||||
t.bus.Emit(hooks.Call{Event: hooks.SubagentStop, Tool: "delegate_task", IsError: failed})
|
||||
}
|
||||
if err != nil {
|
||||
return tools.Result{Content: fmt.Sprintf("sub-agent failed: %v", err), IsError: true}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package subagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
func TestDelegateEmitsStartAndStop(t *testing.T) {
|
||||
var seen []hooks.Event
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
seen = append(seen, c.Event)
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
RegisterWithHooks(reg, func(ctx context.Context, req Request) (Result, error) {
|
||||
return Result{Success: true, Text: "done"}, nil
|
||||
}, bus)
|
||||
res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"核对第三章来源","done_criteria":"逐条标注"}`))
|
||||
if res.IsError {
|
||||
t.Fatalf("delegate result: %s", res.Content)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != hooks.SubagentStart || seen[1] != hooks.SubagentStop {
|
||||
t.Fatalf("events = %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateStopMarksFailure(t *testing.T) {
|
||||
failed := false
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.SubagentStop {
|
||||
failed = c.IsError
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
RegisterWithHooks(reg, func(ctx context.Context, req Request) (Result, error) {
|
||||
return Result{Success: false, Text: "nope"}, nil
|
||||
}, bus)
|
||||
reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"x","done_criteria":"y"}`))
|
||||
if !failed {
|
||||
t.Fatal("stop event did not mark the failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWithoutBusKeepsSilent(t *testing.T) {
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
Register(reg, func(ctx context.Context, req Request) (Result, error) {
|
||||
return Result{Success: true, Text: "ok"}, nil
|
||||
})
|
||||
res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"x","done_criteria":"y"}`))
|
||||
if res.IsError {
|
||||
t.Fatalf("result: %s", res.Content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user