Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108c194c8d | ||
|
|
6ea6929309 | ||
|
|
d9a168635e | ||
|
|
c9c2f23990 | ||
|
|
16e356a3a4 | ||
|
|
d333094f7e | ||
|
|
0d8edbea52 | ||
|
|
7f2c8b9ee1 | ||
|
|
c5b9dca446 | ||
|
|
948ca36ca2 | ||
|
|
c2befaf17e | ||
|
|
d5714e81ed | ||
|
|
9758a8d8d0 | ||
|
|
fc5a1930b5 | ||
|
|
16a164a594 | ||
|
|
3a110a728c | ||
|
|
ecca2d74b9 | ||
|
|
e4020d297c | ||
|
|
b6d6708416 | ||
|
|
fb2b3b796c | ||
|
|
ba90a7d9ae | ||
|
|
0a52d9f976 | ||
|
|
3851bfb8a7 | ||
|
|
6fd1d441a9 | ||
|
|
9335fd40e6 | ||
|
|
9f5751c205 | ||
|
|
e51f2e6d00 | ||
|
|
d84a67cd24 | ||
|
|
33573d0d75 | ||
|
|
4a10818535 | ||
|
|
d9b0f8f178 | ||
|
|
855fe41152 | ||
|
|
11d1a76bdd | ||
|
|
17f2a4ecfa | ||
|
|
b1f89bf969 | ||
|
|
56848f3cbd | ||
|
|
91c1556184 | ||
|
|
0773a25ab9 | ||
|
|
694b08cdbe | ||
|
|
7e0374ff40 | ||
|
|
e047d90985 | ||
|
|
c6d867cde7 | ||
|
|
1a999c344d | ||
|
|
a34689c78f | ||
|
|
e2815bbf94 | ||
|
|
35f65a3c49 | ||
|
|
886e86c6a8 | ||
|
|
6b7d2432b3 | ||
|
|
23b5e3f336 | ||
|
|
dfd0d23bf3 | ||
|
|
2dedc0d20c | ||
|
|
e7ab48e8ea | ||
|
|
6bc1b08540 | ||
|
|
ec731addef |
+38
-5
@@ -6,15 +6,48 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
jobs:
|
jobs:
|
||||||
vet-and-test:
|
vet-and-test:
|
||||||
runs-on: nightly
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GOPROXY: https://goproxy.cn,direct
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
- run: go vet ./... && go test -count=1 ./...
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git init -q
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q --detach FETCH_HEAD
|
||||||
|
if [ -z "${SUBMODULE_TOKEN}" ]; then
|
||||||
|
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||||
|
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||||
|
- name: Install Go 1.27.1
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||||
|
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||||
|
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||||
|
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||||
|
- name: Vet, test, and race
|
||||||
|
run: go vet ./... && go test -count=1 ./... && go test -count=1 -race ./...
|
||||||
docker-checkout-hostgateway:
|
docker-checkout-hostgateway:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
options: --add-host gitea:host-gateway
|
options: --add-host gitea:host-gateway
|
||||||
env:
|
env:
|
||||||
GITHUB_SERVER_URL: http://gitea:3000
|
GITHUB_SERVER_URL: http://gitea:3000
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout without GitHub actions
|
||||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git init -q
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q --detach FETCH_HEAD
|
||||||
|
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||||
|
|||||||
@@ -1,56 +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}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *gated) Name() string { return g.next.Name() }
|
|
||||||
|
|
||||||
func (g *gated) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
|
||||||
res, err := g.rec.Recognize(ctx, req.Prompt, g.snap)
|
|
||||||
if err != nil {
|
|
||||||
// 识别系统错误 fail-closed:不静默放行可能应拦截的内容,也不吞错。
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch res.Branch {
|
|
||||||
case intent.BranchGuardHit, intent.BranchMatched, intent.BranchExpectationHit:
|
|
||||||
default:
|
|
||||||
return g.next.Execute(ctx, req) // 无裁决:透传原后端
|
|
||||||
}
|
|
||||||
out, herr, handled := g.handler(ctx, req, res)
|
|
||||||
if !handled {
|
|
||||||
return g.next.Execute(ctx, req)
|
|
||||||
}
|
|
||||||
return out, herr
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package intentgate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
+149
-41
@@ -14,6 +14,9 @@ import (
|
|||||||
|
|
||||||
"agent-native/exec"
|
"agent-native/exec"
|
||||||
"agent-native/runtime"
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/checkpoint"
|
||||||
|
"agent-native/runtime/filestate"
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
"agent-native/runtime/llm"
|
"agent-native/runtime/llm"
|
||||||
"agent-native/runtime/mcp"
|
"agent-native/runtime/mcp"
|
||||||
"agent-native/runtime/promptx"
|
"agent-native/runtime/promptx"
|
||||||
@@ -25,39 +28,68 @@ 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> 注册进工作区。
|
SkillsRoots []string
|
||||||
// nil = 不注册(fail-closed:server 不可达时整个步骤失败,不静默跳过)。
|
Subagent subagent.Factory
|
||||||
MCP *mcp.Manager
|
HostTools []tools.Tool
|
||||||
// SkillsRoots 可选:全局技能根目录;执行时自动并入工作区 .agent/skills/。
|
Checkpoints *checkpoint.Log
|
||||||
SkillsRoots []string
|
CheckpointAnchor func() string
|
||||||
// Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。
|
// CodeOrchestration 为真时注册 run_tool_script。默认关。
|
||||||
Compact *CompactConfig
|
CodeOrchestration bool
|
||||||
// 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
|
||||||
|
// Trace 交给运行循环。nil 不采集。
|
||||||
|
Trace func(name string, attrs map[string]string) func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GateOptions 把直连已经使用的停止、提醒和工具钩子交给小队成员。零值不改变行为。
|
||||||
|
type GateOptions struct {
|
||||||
|
BeforeTool func(call llm.ToolCall) error
|
||||||
|
AfterTool func(call llm.ToolCall, res llm.Message)
|
||||||
|
Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision
|
||||||
|
Reminders func(turn int) []string
|
||||||
|
RepeatGuard bool
|
||||||
|
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 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
||||||
|
// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。
|
||||||
|
type Options struct {
|
||||||
|
ModelOptions
|
||||||
|
ToolOptions
|
||||||
|
SessionOptions
|
||||||
|
ObserveOptions
|
||||||
|
GateOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
|
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
|
||||||
@@ -122,6 +154,9 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
|
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
|
||||||
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
|
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
|
||||||
}
|
}
|
||||||
|
if b.opts.Ask != nil {
|
||||||
|
ctx = mcp.WithAsk(ctx, b.opts.Ask)
|
||||||
|
}
|
||||||
model := strings.TrimSpace(b.opts.Model)
|
model := strings.TrimSpace(b.opts.Model)
|
||||||
if strings.TrimSpace(req.Model) != "" {
|
if strings.TrimSpace(req.Model) != "" {
|
||||||
model = strings.TrimSpace(req.Model)
|
model = strings.TrimSpace(req.Model)
|
||||||
@@ -164,7 +199,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
|
|
||||||
var reg *tools.Registry
|
var reg *tools.Registry
|
||||||
if profile == exec.ToolProfileWorkspace {
|
if profile == exec.ToolProfileWorkspace {
|
||||||
reg = tools.WorkspaceTools(workDir)
|
reg = tools.WorkspaceToolsCheckpointed(workDir, filestate.New(), b.opts.Checkpoints, b.opts.CheckpointAnchor)
|
||||||
skills.RegisterLoader(reg, skillStore)
|
skills.RegisterLoader(reg, skillStore)
|
||||||
if b.opts.Search != nil {
|
if b.opts.Search != nil {
|
||||||
tools.RegisterSearch(reg, b.opts.Search)
|
tools.RegisterSearch(reg, b.opts.Search)
|
||||||
@@ -179,9 +214,19 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
}
|
}
|
||||||
tools.RegisterExecTools(reg, workDir, execCfg)
|
tools.RegisterExecTools(reg, workDir, execCfg)
|
||||||
}
|
}
|
||||||
|
if b.opts.CodeOrchestration {
|
||||||
|
tools.RegisterCodeTool(reg)
|
||||||
|
}
|
||||||
|
reg.SetActivationFile(filepath.Join(workDir, ".agentx", "activated-tools.json"))
|
||||||
|
_ = reg.RestoreActivated()
|
||||||
if b.opts.MCP != nil {
|
if b.opts.MCP != nil {
|
||||||
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
|
switch {
|
||||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
case len(req.ToolAllowlist) > 0 || b.opts.MCP.Ready():
|
||||||
|
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
|
||||||
|
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.opts.MCP.StartBackground(ctx, reg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subagent.Register(reg, b.opts.Subagent)
|
subagent.Register(reg, b.opts.Subagent)
|
||||||
@@ -276,9 +321,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
|
|
||||||
if runRes.Err != nil {
|
if runRes.Err != nil {
|
||||||
reason := runRes.Err.Error()
|
reason := runRes.Err.Error()
|
||||||
b.mu.Lock()
|
b.noteFailure(workDir, reason)
|
||||||
b.lastFailure[workDir] = reason
|
|
||||||
b.mu.Unlock()
|
|
||||||
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
|
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
|
||||||
Err: runRes.Err,
|
Err: runRes.Err,
|
||||||
WorkDir: workDir,
|
WorkDir: workDir,
|
||||||
@@ -293,9 +336,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
b.mu.Lock()
|
b.clearFailure(workDir)
|
||||||
delete(b.lastFailure, workDir)
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
|
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
|
||||||
if matErr != nil {
|
if matErr != nil {
|
||||||
@@ -319,7 +360,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
|||||||
if strings.TrimSpace(execID) == "" {
|
if strings.TrimSpace(execID) == "" {
|
||||||
execID = b.opts.ExecutionID
|
execID = b.opts.ExecutionID
|
||||||
}
|
}
|
||||||
runRes, err := runtime.Run(ctx, runtime.Config{
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
Model: model,
|
Model: model,
|
||||||
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
|
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
|
||||||
Tools: reg,
|
Tools: reg,
|
||||||
@@ -329,6 +370,11 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
|||||||
Log: b.logger(),
|
Log: b.logger(),
|
||||||
ExecutionID: execID,
|
ExecutionID: execID,
|
||||||
Budget: budget,
|
Budget: 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) {
|
OnEvent: func(ev runtime.Event) {
|
||||||
logs.WriteString(ev.Type)
|
logs.WriteString(ev.Type)
|
||||||
if ev.Tool != "" {
|
if ev.Tool != "" {
|
||||||
@@ -351,8 +397,8 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
|
})
|
||||||
return runRes, err
|
return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
|
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
|
||||||
@@ -419,6 +465,40 @@ func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func gateBus(g GateOptions) *hooks.Bus {
|
||||||
|
bus := g.Hooks
|
||||||
|
if g.BeforeTool == nil && g.AfterTool == nil && g.Stop == nil {
|
||||||
|
return bus
|
||||||
|
}
|
||||||
|
if bus == nil {
|
||||||
|
bus = hooks.New()
|
||||||
|
}
|
||||||
|
if g.BeforeTool != nil {
|
||||||
|
fn := g.BeforeTool
|
||||||
|
bus.On(hooks.AdaptBefore(func(name, args string) error { return fn(llm.ToolCall{Name: name, Arguments: args}) }))
|
||||||
|
}
|
||||||
|
if g.AfterTool != nil {
|
||||||
|
fn := g.AfterTool
|
||||||
|
bus.On(hooks.AdaptAfter(func(name, args, result string, isError bool) {
|
||||||
|
fn(llm.ToolCall{Name: name, Arguments: args}, llm.Message{Content: result, IsError: isError})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if g.Stop != nil {
|
||||||
|
fn := g.Stop
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event != hooks.Stop {
|
||||||
|
return hooks.Outcome{}
|
||||||
|
}
|
||||||
|
d := fn(llm.Message{Role: llm.RoleAssistant, Content: c.LastText}, runtime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
|
||||||
|
if d.Continue {
|
||||||
|
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
|
||||||
|
}
|
||||||
|
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return bus
|
||||||
|
}
|
||||||
|
|
||||||
type errString string
|
type errString string
|
||||||
|
|
||||||
func (e errString) Error() string { return string(e) }
|
func (e errString) Error() string { return string(e) }
|
||||||
@@ -433,9 +513,7 @@ var resumeUnsafeMarkers = []string{
|
|||||||
|
|
||||||
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
|
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
|
||||||
func (b *Backend) rotateIfPoisoned(workDir string) {
|
func (b *Backend) rotateIfPoisoned(workDir string) {
|
||||||
b.mu.Lock()
|
reason := strings.ToLower(b.failureReason(workDir))
|
||||||
reason := strings.ToLower(b.lastFailure[workDir])
|
|
||||||
b.mu.Unlock()
|
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -469,6 +547,36 @@ func (b *Backend) logger() *logsys.Logger {
|
|||||||
return log.With("execution_id", b.opts.ExecutionID)
|
return log.With("execution_id", b.opts.ExecutionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastFailureFile = ".native-last-failure"
|
||||||
|
|
||||||
|
func (b *Backend) noteFailure(workDir, reason string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.lastFailure[workDir] = reason
|
||||||
|
b.mu.Unlock()
|
||||||
|
_ = os.WriteFile(filepath.Join(workDir, lastFailureFile), []byte(reason), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) clearFailure(workDir string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.lastFailure, workDir)
|
||||||
|
b.mu.Unlock()
|
||||||
|
_ = os.Remove(filepath.Join(workDir, lastFailureFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) failureReason(workDir string) string {
|
||||||
|
b.mu.Lock()
|
||||||
|
reason := b.lastFailure[workDir]
|
||||||
|
b.mu.Unlock()
|
||||||
|
if reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(workDir, lastFailureFile))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
// systemPrompt 经 promptx 组装:角色指令 + 环境事实 + 技能索引 + 交付物约束。
|
// systemPrompt 经 promptx 组装:角色指令 + 环境事实 + 技能索引 + 交付物约束。
|
||||||
func (b *Backend) systemPrompt(req exec.Request, model, profile, workDir string, skillIndex []skills.Skill) string {
|
func (b *Backend) systemPrompt(req exec.Request, model, profile, workDir string, skillIndex []skills.Skill) string {
|
||||||
var index []promptx.IndexEntry
|
var index []promptx.IndexEntry
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package native
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -39,12 +40,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工具写入的交付物内容。","reason":"产出交付物"}`,
|
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 +87,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,14 +120,56 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBeforeToolReachesSquadMembers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
var seen string
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"nope"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "blocked", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
GateOptions: GateOptions{BeforeTool: func(call llm.ToolCall) error {
|
||||||
|
seen = call.Name
|
||||||
|
return errors.New("计划模式拒绝了写工具")
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen != "write_file" {
|
||||||
|
t.Fatalf("before tool saw %q", seen)
|
||||||
|
}
|
||||||
|
if res.Success {
|
||||||
|
t.Fatal("denied write still succeeded")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "output.md")); err == nil {
|
||||||
|
t.Fatal("write_file ran after deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"},
|
||||||
@@ -47,8 +51,11 @@ func TestBackendCompactBeforeRun(t *testing.T) {
|
|||||||
t.Fatal("compaction should have run before the loop")
|
t.Fatal("compaction should have run before the loop")
|
||||||
}
|
}
|
||||||
data, _ := os.ReadFile(transcript)
|
data, _ := os.ReadFile(transcript)
|
||||||
if !strings.Contains(string(data), "[conversation summary]") {
|
if !strings.Contains(string(data), "预置历史摘要") || !strings.Contains(string(data), `"type":"compaction"`) {
|
||||||
t.Fatalf("transcript should contain the summary marker, got:\n%.200s", data)
|
t.Fatalf("transcript should keep history and append a compaction entry, got:\n%.400s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"role":"system"`) {
|
||||||
|
t.Fatal("compaction must not rewrite the original transcript")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -180,6 +212,9 @@ func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer mgr.Close()
|
defer mgr.Close()
|
||||||
|
if err := mgr.RegisterInto(context.Background(), tools.NewRegistry(t.TempDir())); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
stream, c := captureStream(
|
stream, c := captureStream(
|
||||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
ID: "c1", Name: "mcp__fake__echo", Arguments: `{"text":"via-backend"}`,
|
ID: "c1", Name: "mcp__fake__echo", Arguments: `{"text":"via-backend"}`,
|
||||||
@@ -190,7 +225,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 +272,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 +308,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 +336,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 +375,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()
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
execution_id=cx session=
|
||||||
|
agent_start
|
||||||
|
turn_start
|
||||||
|
message_start
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
hi
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/rpc"
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventAndRPCFixtures(t *testing.T) {
|
||||||
|
var types []string
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
OnEvent: func(ev runtime.Event) { types = append(types, ev.Type) },
|
||||||
|
})
|
||||||
|
if _, err := agent.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(types)
|
||||||
|
for _, want := range []string{"message_start", "message_end", "agent_settled"} {
|
||||||
|
if !contains(types, want) {
|
||||||
|
t.Fatalf("fixture missing %s in %s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Open(dir + "/s.jsonl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &rpc.Server{Agent: agent, Store: store}
|
||||||
|
resp, err := srv.Handle(context.Background(), []byte(`{"type":"get_session_stats"}`))
|
||||||
|
if err != nil || !json.Valid(resp) {
|
||||||
|
t.Fatalf("rpc fixture: %s %v", resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiCrossCompareOptional(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("pi"); err != nil {
|
||||||
|
t.Skip("pi is not installed")
|
||||||
|
}
|
||||||
|
if os.Getenv("PI_CROSS") != "1" {
|
||||||
|
t.Skip("set PI_CROSS=1 to compare a live pi process")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, want string) bool {
|
||||||
|
for _, item := range list {
|
||||||
|
if item == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestR2PoisonMarkerSurvivesNewProcess(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".agentx"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
||||||
|
if err := os.WriteFile(transcript, []byte("{}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, lastFailureFile), []byte("context length exceeded"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := New(Options{})
|
||||||
|
b.rotateIfPoisoned(dir)
|
||||||
|
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("poisoned transcript still at the live path")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, ".agentx"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var renamed bool
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), "transcript.poisoned.") {
|
||||||
|
renamed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !renamed {
|
||||||
|
t.Fatal("expected archived transcript")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"agent-native/rpc"
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := flag.String("mode", "print", "print, json, or rpc")
|
||||||
|
model := flag.String("model", "", "provider/model[:thinking]")
|
||||||
|
prompt := flag.String("prompt", "", "user message for print and json modes")
|
||||||
|
sessionPath := flag.String("session", "", "transcript path")
|
||||||
|
noSession := flag.Bool("no-session", false, "do not read or write a transcript")
|
||||||
|
export := flag.String("export", "", "write the active session as HTML and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *sessionPath == "" && !*noSession {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
*sessionPath = filepath.Join(home, ".agent-native", "session.jsonl")
|
||||||
|
}
|
||||||
|
var store *session.Store
|
||||||
|
if !*noSession {
|
||||||
|
st, err := session.Open(*sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
store = st
|
||||||
|
}
|
||||||
|
if *export != "" {
|
||||||
|
if store == nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "export requires a session")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := store.ExportHTML(*export); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := llm.ParseModelRef(*model)
|
||||||
|
if *model != "" && err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: ref.Model,
|
||||||
|
ThinkingLevel: ref.Thinking,
|
||||||
|
Session: store,
|
||||||
|
Stream: liveStream(ref),
|
||||||
|
OnEvent: func(ev runtime.Event) {
|
||||||
|
if *mode != "json" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]any{"type": ev.Type, "text": ev.Text, "tool": ev.Tool})
|
||||||
|
fmt.Println(string(raw))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
srv := &rpc.Server{Agent: agent, Store: store, NoStore: *noSession}
|
||||||
|
ctx := context.Background()
|
||||||
|
switch *mode {
|
||||||
|
case "rpc":
|
||||||
|
in := bufio.NewScanner(os.Stdin)
|
||||||
|
in.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||||
|
for in.Scan() {
|
||||||
|
resp, err := srv.Handle(ctx, in.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Stdout.Write(resp)
|
||||||
|
}
|
||||||
|
case "print", "json":
|
||||||
|
if *prompt == "" {
|
||||||
|
*prompt = readAll(os.Stdin)
|
||||||
|
}
|
||||||
|
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: *prompt})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, res.Err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *mode == "print" {
|
||||||
|
fmt.Println(res.Text)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(os.Stderr, "unknown mode")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveStream(ref llm.ModelRef) llm.StreamFunc {
|
||||||
|
provider, ok := llm.Lookup(ref.Provider, nil)
|
||||||
|
if !ok {
|
||||||
|
provider = llm.Provider{Protocol: llm.ProtocolOpenAIChat, BaseURL: os.Getenv("OPENAI_BASE_URL"), APIKey: os.Getenv("OPENAI_API_KEY")}
|
||||||
|
}
|
||||||
|
client, err := llm.ClientFor(provider)
|
||||||
|
if err != nil {
|
||||||
|
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
ch <- llm.Event{Kind: llm.KindError, Err: err, Message: llm.Message{StopReason: llm.StopError}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return llm.WithRetry(client.Stream, 2, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAll(r io.Reader) string {
|
||||||
|
raw, _ := io.ReadAll(r)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
+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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 3674dc4bfd...f57645c645
Vendored
+1
-1
Submodule deps/error-code-system updated: dbeed2f1b6...a3ee67bba2
Vendored
+1
-1
Submodule deps/logging-system updated: 2c5716fdcf...49b5d8be60
@@ -89,3 +89,19 @@ baseline_fixed: true
|
|||||||
|
|
||||||
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
|
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
|
||||||
server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。
|
server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。
|
||||||
|
扩展进程、安装的包和外部 CLI 都以调用者的完整权限运行,同样不是沙箱。
|
||||||
|
|
||||||
|
## R16 无界面全量(pi-headless,2026-10-01)
|
||||||
|
|
||||||
|
对标口径改为 pi 的无界面能力。不做 TUI、主题、快捷键,也不做订阅制 OAuth。
|
||||||
|
|
||||||
|
| 域 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 会话树 | available:id/parentId、fork/clone、叶子切换、continue/resume、追加式压缩、旧线性文件仍可 Load、export_html |
|
||||||
|
| 供应商 | available:Anthropic、OpenAI Chat 与 Responses、Google、Azure、兼容预设、models.json、`provider/id:thinking`、图片、缓存 token、自动重试;上下文溢出不重试 |
|
||||||
|
| AgentSession | available:steer/follow-up、one 与 all、abort、clear_queue、中途换模型;事件含 message_start、tool_execution_update、queue_update、agent_settled |
|
||||||
|
| 资源 | available:pi 工具名与原名并存、上下文文件、提示词模板、`/skill:name`、项目信任 |
|
||||||
|
| 扩展与包 | available:stdio JSONL 子进程;install/remove/update/list/config |
|
||||||
|
| 模式 | available:`cmd/agent-native` 的 print/json/rpc,以及 Go `rpc.Client` |
|
||||||
|
| exec.Backend | available:`adapters/native` 的 Execute 经 AgentSession,请求/结果字段不变 |
|
||||||
|
|
||||||
|
|||||||
+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,13 +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
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the Go SDK for the JSONL RPC mode.
|
||||||
|
type Client struct {
|
||||||
|
in io.Writer
|
||||||
|
out *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient wraps a bidirectional pipe. Commands are one JSON object per line.
|
||||||
|
func NewClient(in io.Writer, out io.Reader) *Client {
|
||||||
|
return &Client{in: in, out: bufio.NewReader(out)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call sends one command and reads the matching response.
|
||||||
|
func (c *Client) Call(ctx context.Context, req Request) (Response, error) {
|
||||||
|
raw, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
if _, err := c.in.Write(append(raw, '\n')); err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
type result struct {
|
||||||
|
resp Response
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
line, err := c.out.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
ch <- result{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal(line, &resp); err != nil {
|
||||||
|
ch <- result{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- result{resp: resp}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Response{}, ctx.Err()
|
||||||
|
case got := <-ch:
|
||||||
|
return got.resp, got.err
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is one RPC command. Type matches the pi headless command set.
|
||||||
|
type Request struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinkingLevel,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Leaf string `json:"leaf,omitempty"`
|
||||||
|
Keep int `json:"keep,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is the synchronous result of one command.
|
||||||
|
type Response struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
// Status 是命令回执:started 已接手,queued 先排队,handled 已处理完。
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server serves RPC commands against one agent session and its transcript.
|
||||||
|
type Server struct {
|
||||||
|
Agent *runtime.AgentSession
|
||||||
|
Store *session.Store
|
||||||
|
NoStore bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle runs one command and returns the JSON response line.
|
||||||
|
func (s *Server) Handle(ctx context.Context, line []byte) ([]byte, error) {
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(line, &req); err != nil {
|
||||||
|
return encode(Response{Type: "response", OK: false, Error: err.Error()})
|
||||||
|
}
|
||||||
|
resp := s.dispatch(ctx, req)
|
||||||
|
resp.ID = req.ID
|
||||||
|
if resp.Type == "" {
|
||||||
|
resp.Type = "response"
|
||||||
|
}
|
||||||
|
return encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dispatch(ctx context.Context, req Request) Response {
|
||||||
|
switch req.Type {
|
||||||
|
case "prompt":
|
||||||
|
if s.Agent == nil {
|
||||||
|
return fail("no agent")
|
||||||
|
}
|
||||||
|
msg := llm.Message{Role: llm.RoleUser, Content: req.Message}
|
||||||
|
if s.Agent.Busy() {
|
||||||
|
s.Agent.FollowUp(msg)
|
||||||
|
return Response{OK: true, Status: "queued"}
|
||||||
|
}
|
||||||
|
res, err := s.Agent.Prompt(ctx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
return fail(res.Err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: res.Text, Status: "handled"}
|
||||||
|
case "steer":
|
||||||
|
if s.Agent == nil {
|
||||||
|
return fail("no agent")
|
||||||
|
}
|
||||||
|
s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message})
|
||||||
|
return Response{OK: true, Status: queueStatus(s.Agent.Busy())}
|
||||||
|
case "follow_up":
|
||||||
|
if s.Agent == nil {
|
||||||
|
return fail("no agent")
|
||||||
|
}
|
||||||
|
s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message})
|
||||||
|
return Response{OK: true, Status: queueStatus(s.Agent.Busy())}
|
||||||
|
case "abort":
|
||||||
|
s.Agent.Abort()
|
||||||
|
return Response{OK: true}
|
||||||
|
case "clear_queue":
|
||||||
|
s.Agent.ClearQueue()
|
||||||
|
return Response{OK: true}
|
||||||
|
case "set_model":
|
||||||
|
if err := s.Agent.SetModel(req.Model, req.ThinkingLevel); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true}
|
||||||
|
case "compact":
|
||||||
|
keep := req.Keep
|
||||||
|
if keep <= 0 {
|
||||||
|
keep = 4
|
||||||
|
}
|
||||||
|
done, err := s.Agent.Compact(keep, func(messages []llm.Message) (string, error) {
|
||||||
|
return fmt.Sprintf("%d messages", len(messages)), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: done}
|
||||||
|
case "get_state":
|
||||||
|
leaf, _ := s.Store.Leaf()
|
||||||
|
return Response{OK: true, Result: map[string]any{"leaf": leaf, "name": s.Store.Name(), "path": s.Store.Path()}}
|
||||||
|
case "get_tree":
|
||||||
|
entries, err := s.Store.Entries()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: entries}
|
||||||
|
case "get_entries":
|
||||||
|
entries, err := s.Store.Entries()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: entries}
|
||||||
|
case "get_last_assistant_text":
|
||||||
|
msgs, err := s.Store.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
text := ""
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleAssistant {
|
||||||
|
text = m.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: text}
|
||||||
|
case "set_session_name":
|
||||||
|
if err := s.Store.SetName(req.Name); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true}
|
||||||
|
case "fork":
|
||||||
|
dest := req.Path
|
||||||
|
if dest == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if _, err := s.Store.Fork(dest, req.Leaf); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: dest}
|
||||||
|
case "clone":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if _, err := s.Store.Clone(req.Path); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "switch_session":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
st, err := session.Open(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
s.Store = st
|
||||||
|
return Response{OK: true}
|
||||||
|
case "new_session":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
st, err := session.Open(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
s.Store = st
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "export_html":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if err := s.Store.ExportHTML(req.Path); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "get_session_stats":
|
||||||
|
st := s.Store.Stats()
|
||||||
|
return Response{OK: true, Result: st}
|
||||||
|
default:
|
||||||
|
return fail("unknown command " + req.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(msg string) Response { return Response{OK: false, Error: msg} }
|
||||||
|
|
||||||
|
func queueStatus(busy bool) string {
|
||||||
|
if busy {
|
||||||
|
return "queued"
|
||||||
|
}
|
||||||
|
return "started"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(resp Response) ([]byte, error) {
|
||||||
|
raw, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(raw, '\n'), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRPCSessionCommandsAndClient(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Append(llm.Message{Role: llm.RoleAssistant, Content: "ready"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "echo", StopReason: llm.StopEnd}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
})
|
||||||
|
srv := &Server{Agent: agent, Store: store}
|
||||||
|
clientIn, serverOut := io.Pipe()
|
||||||
|
serverIn, clientOut := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
defer serverOut.Close()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := serverIn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
resp, herr := srv.Handle(context.Background(), buf[:n])
|
||||||
|
if herr == nil {
|
||||||
|
_, _ = serverOut.Write(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
client := NewClient(clientOut, clientIn)
|
||||||
|
resp, err := client.Call(context.Background(), Request{ID: "1", Type: "get_last_assistant_text"})
|
||||||
|
if err != nil || !resp.OK || resp.Text != "ready" {
|
||||||
|
t.Fatalf("%+v %v", resp, err)
|
||||||
|
}
|
||||||
|
fork := filepath.Join(dir, "fork.jsonl")
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "fork", Path: fork})
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
t.Fatalf("fork %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "prompt", Message: "hi"})
|
||||||
|
if err != nil || !resp.OK || resp.Text != "echo" || resp.Status != "handled" {
|
||||||
|
t.Fatalf("prompt %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "steer", Message: "later"})
|
||||||
|
if err != nil || !resp.OK || resp.Status != "started" {
|
||||||
|
t.Fatalf("steer %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
htmlPath := filepath.Join(dir, "out.html")
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "export_html", Path: htmlPath})
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
t.Fatalf("export %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
_ = clientOut.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHookAdaptersMatchDirectCallbacks(t *testing.T) {
|
||||||
|
direct := runGate(t, false)
|
||||||
|
adapted := runGate(t, true)
|
||||||
|
if direct.stop != adapted.stop || direct.text != adapted.text {
|
||||||
|
t.Fatalf("stop/text direct=%s %q adapted=%s %q", direct.stop, direct.text, adapted.stop, adapted.text)
|
||||||
|
}
|
||||||
|
if direct.denies != adapted.denies || direct.reminders != adapted.reminders || direct.errors != adapted.errors {
|
||||||
|
t.Fatalf("counts direct=%+v adapted=%+v", direct, adapted)
|
||||||
|
}
|
||||||
|
if direct.denies != 1 || direct.reminders == 0 {
|
||||||
|
t.Fatalf("unexpected counts %+v", direct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type gateStats struct {
|
||||||
|
stop, text string
|
||||||
|
denies, reminders, errors int
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGate(t *testing.T, viaHooks bool) gateStats {
|
||||||
|
t.Helper()
|
||||||
|
var stats gateStats
|
||||||
|
before := func(call llm.ToolCall) error {
|
||||||
|
if call.Name == "read_file" {
|
||||||
|
stats.denies++
|
||||||
|
return errors.New("denied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reminders := func(int) []string {
|
||||||
|
stats.reminders++
|
||||||
|
return []string{"记一笔"}
|
||||||
|
}
|
||||||
|
stop := func(last llm.Message, info StopInfo) StopDecision {
|
||||||
|
if info.Continuations < 1 {
|
||||||
|
return StopDecision{Continue: true, Reason: "再看一眼"}
|
||||||
|
}
|
||||||
|
return StopDecision{}
|
||||||
|
}
|
||||||
|
cfg := Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: tools.WorkspaceTools(t.TempDir()),
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "read_file", Arguments: `{"path":"a.md"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
Reminders: reminders,
|
||||||
|
}
|
||||||
|
if viaHooks {
|
||||||
|
bus := hooks.New()
|
||||||
|
bus.On(hooks.AdaptBefore(func(name, args string) error {
|
||||||
|
return before(llm.ToolCall{Name: name, Arguments: args})
|
||||||
|
}))
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event != hooks.Stop {
|
||||||
|
return hooks.Outcome{}
|
||||||
|
}
|
||||||
|
d := stop(llm.Message{Content: c.LastText}, StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
|
||||||
|
if d.Continue {
|
||||||
|
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
|
||||||
|
}
|
||||||
|
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
|
||||||
|
})
|
||||||
|
cfg.Hooks = bus
|
||||||
|
} else {
|
||||||
|
cfg.BeforeTool = before
|
||||||
|
cfg.Stop = stop
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), cfg, llm.Message{Role: llm.RoleUser, Content: "读文件"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stats.stop = string(res.StopReason)
|
||||||
|
stats.text = res.Text
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
if m.Role == llm.RoleTool && m.IsError {
|
||||||
|
stats.errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
@@ -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,242 @@
|
|||||||
|
// Package checkpoint 记录写文件之前的内容,并按用户消息把文件退回去。
|
||||||
|
// 只追踪显式交给它的写入。shell 与 python 的改动不经过这里。
|
||||||
|
package checkpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record 是一次被追踪的写入。Before 是写入前的字节,After 是写入后的字节。
|
||||||
|
type Record struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Anchor string `json:"anchor,omitempty"`
|
||||||
|
Root string `json:"root"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Existed bool `json:"existed"`
|
||||||
|
Before []byte `json:"before,omitempty"`
|
||||||
|
After []byte `json:"after,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件。
|
||||||
|
type Result struct {
|
||||||
|
Restored []string
|
||||||
|
Skipped []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log 是追加写的检查点文件。
|
||||||
|
type Log struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Log, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
return &Log{path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Log) Path() string { return l.path }
|
||||||
|
|
||||||
|
// Record 追加一条检查点。rel 是相对 root 的路径。
|
||||||
|
func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, after []byte) error {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
all, err := l.read()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rec := Record{
|
||||||
|
Seq: len(all) + 1, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
|
||||||
|
Existed: existed, Before: append([]byte(nil), before...), After: append([]byte(nil), after...),
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(l.path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.Write(append(raw, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan 列出从 anchor 起(含)会被回滚的文件,不改磁盘。
|
||||||
|
// ordered 是用户消息 id 的时间顺序。
|
||||||
|
func (l *Log) Plan(anchor string, ordered []string) (Result, error) {
|
||||||
|
if l == nil {
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
all, err := l.read()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
return classify(all, anchor, ordered), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewind 把 anchor 及其后的被追踪写入退回写入前。
|
||||||
|
// 当前内容与最后一次记录的 After 不一致时跳过并列入 Skipped。
|
||||||
|
func (l *Log) Rewind(anchor string, ordered []string) (Result, error) {
|
||||||
|
if l == nil {
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
all, err := l.read()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
plan := classify(all, anchor, ordered)
|
||||||
|
for _, rel := range plan.Restored {
|
||||||
|
group := groupFor(selected(all, anchor, ordered), rel)
|
||||||
|
if err := restore(group); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Log) read() ([]Record, error) {
|
||||||
|
b, err := os.ReadFile(l.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Record
|
||||||
|
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec Record
|
||||||
|
if err := json.Unmarshal(line, &rec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selected(all []Record, anchor string, ordered []string) []Record {
|
||||||
|
keep := map[string]bool{}
|
||||||
|
on := false
|
||||||
|
for _, id := range ordered {
|
||||||
|
if id == anchor {
|
||||||
|
on = true
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
keep[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anchor != "" && len(ordered) == 0 {
|
||||||
|
keep[anchor] = true
|
||||||
|
}
|
||||||
|
var out []Record
|
||||||
|
for _, rec := range all {
|
||||||
|
if keep[rec.Anchor] {
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func classify(all []Record, anchor string, ordered []string) Result {
|
||||||
|
sel := selected(all, anchor, ordered)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var restored, skipped []string
|
||||||
|
for _, rec := range sel {
|
||||||
|
if seen[rec.Path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[rec.Path] = true
|
||||||
|
group := groupFor(sel, rec.Path)
|
||||||
|
switch fileState(group) {
|
||||||
|
case stateRestore:
|
||||||
|
restored = append(restored, rec.Path)
|
||||||
|
case stateSkip:
|
||||||
|
skipped = append(skipped, rec.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{Restored: restored, Skipped: skipped}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupFor(sel []Record, rel string) []Record {
|
||||||
|
var group []Record
|
||||||
|
for _, rec := range sel {
|
||||||
|
if rec.Path == rel {
|
||||||
|
group = append(group, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateAlready = iota
|
||||||
|
stateRestore
|
||||||
|
stateSkip
|
||||||
|
)
|
||||||
|
|
||||||
|
func fileState(group []Record) int {
|
||||||
|
if len(group) == 0 {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
first, last := group[0], group[len(group)-1]
|
||||||
|
abs := filepath.Join(last.Root, filepath.FromSlash(last.Path))
|
||||||
|
current, err := os.ReadFile(abs)
|
||||||
|
missing := os.IsNotExist(err)
|
||||||
|
if err != nil && !missing {
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
if !last.Existed && len(last.After) == 0 {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
if !first.Existed && bytes.Equal(nil, first.Before) && len(last.After) > 0 {
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
if bytes.Equal(current, last.After) {
|
||||||
|
if !first.Existed {
|
||||||
|
return stateRestore
|
||||||
|
}
|
||||||
|
if bytes.Equal(current, first.Before) {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
return stateRestore
|
||||||
|
}
|
||||||
|
if first.Existed && bytes.Equal(current, first.Before) {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(group []Record) error {
|
||||||
|
if len(group) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
first := group[0]
|
||||||
|
abs := filepath.Join(first.Root, filepath.FromSlash(first.Path))
|
||||||
|
if !first.Existed {
|
||||||
|
return os.Remove(abs)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(abs, first.Before, 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package checkpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRewindRestoresTrackedWriteAndSkipsExternalChange(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(t.TempDir(), "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("old"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "keep.txt", []byte("old"), true, []byte("new")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("new"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "side.txt"), []byte("from-shell"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "moved.txt", []byte("a"), true, []byte("b")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "moved.txt"), []byte("someone-else"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := log.Rewind("m1", []string{"m1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Restored) != 1 || got.Restored[0] != "keep.txt" {
|
||||||
|
t.Fatalf("restored %#v", got.Restored)
|
||||||
|
}
|
||||||
|
if len(got.Skipped) != 1 || got.Skipped[0] != "moved.txt" {
|
||||||
|
t.Fatalf("skipped %#v", got.Skipped)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(root, "keep.txt"))
|
||||||
|
if err != nil || string(body) != "old" {
|
||||||
|
t.Fatalf("keep.txt %q %v", body, err)
|
||||||
|
}
|
||||||
|
side, err := os.ReadFile(filepath.Join(root, "side.txt"))
|
||||||
|
if err != nil || string(side) != "from-shell" {
|
||||||
|
t.Fatalf("untracked file changed: %q %v", side, err)
|
||||||
|
}
|
||||||
|
moved, _ := os.ReadFile(filepath.Join(root, "moved.txt"))
|
||||||
|
if string(moved) != "someone-else" {
|
||||||
|
t.Fatalf("external edit was reverted: %q", moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewindRemovesCreatedFile(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(t.TempDir(), "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "new.txt"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m2", root, "new.txt", nil, false, []byte("hi")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := log.Rewind("m2", []string{"m1", "m2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Restored) != 1 {
|
||||||
|
t.Fatalf("restored %#v", got.Restored)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("created file still present: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""工作流脚本回调。用法:import agentwf as wf。
|
||||||
|
|
||||||
|
请求写到 fd 3,响应从 fd 4 按行读回。宿主一次只处理一条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
_seq = 0
|
||||||
|
_out = os.fdopen(3, "w", encoding="utf-8", buffering=1)
|
||||||
|
_inp = os.fdopen(4, "r", encoding="utf-8", buffering=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _rpc(op, **fields):
|
||||||
|
global _seq
|
||||||
|
_seq += 1
|
||||||
|
msg = {"id": str(_seq), "op": op}
|
||||||
|
msg.update(fields)
|
||||||
|
_out.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||||
|
_out.flush()
|
||||||
|
line = _inp.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("workflow host closed")
|
||||||
|
resp = json.loads(line)
|
||||||
|
if not resp.get("ok"):
|
||||||
|
raise RuntimeError(resp.get("error") or "workflow call failed")
|
||||||
|
return resp.get("output", "")
|
||||||
|
|
||||||
|
|
||||||
|
def tool(name, args=None):
|
||||||
|
return _rpc("tool.call", name=name, args=args or {})
|
||||||
|
|
||||||
|
|
||||||
|
def ask(question, choices=None):
|
||||||
|
return _rpc("hitl.ask", question=question, choices=choices or [])
|
||||||
|
|
||||||
|
|
||||||
|
def agent(name, task):
|
||||||
|
return _rpc("agent.spawn", agent=name, task=task)
|
||||||
|
|
||||||
|
|
||||||
|
def artifact(name, content):
|
||||||
|
return _rpc("artifact.register", name=name, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
def card(title, kind="result"):
|
||||||
|
return _rpc("card.create", title=title, kind=kind)
|
||||||
|
|
||||||
|
|
||||||
|
def step(step_id, title, status):
|
||||||
|
return _rpc("step", stepId=step_id, title=title, status=status)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package codeorch
|
||||||
|
|
||||||
|
// Forbidden 是脚本不能再调用的工具:解释器、自身、派发和工作流。
|
||||||
|
func Forbidden(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "run_python", "run_shell", "run_command", "run_tool_script",
|
||||||
|
"delegate_task", "squad_orchestrate", "run_workflow", "review_findings":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// Package codeorch 在会话目录里跑一段 Python,并按行把工具调用交回宿主。
|
||||||
|
// 工作流脚本和 run_tool_script 共用这一套进程与限额。
|
||||||
|
package codeorch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed agentwf.py
|
||||||
|
var agentwfSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTimeout = 120 * time.Second
|
||||||
|
defaultMaxWait = 600 * time.Second
|
||||||
|
defaultCalls = 50
|
||||||
|
defaultResult = 1 << 20
|
||||||
|
defaultModel = 16000
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
helperOnce sync.Once
|
||||||
|
helperDir string
|
||||||
|
helperErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call 是脚本发来的一行请求。Args 与 Choices 保持原始 JSON。
|
||||||
|
type Call struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args json.RawMessage `json:"args"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Choices json.RawMessage `json:"choices"`
|
||||||
|
Agent string `json:"agent"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StepID string `json:"stepId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits 控制一次脚本运行。零值使用工具路径的缺省:120 秒、最长 600 秒、50 次、1 MiB、16k 字符。
|
||||||
|
type Limits struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
MaxTimeout time.Duration
|
||||||
|
MaxCalls int
|
||||||
|
MaxResultBytes int
|
||||||
|
MaxModelChars int
|
||||||
|
FullDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome 是给模型的文本、已成功的调用,以及超限时的全文路径。
|
||||||
|
type Outcome struct {
|
||||||
|
Output string
|
||||||
|
FullPath string
|
||||||
|
Calls []string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starter 按工作目录和脚本路径准备命令。空则用 python3。
|
||||||
|
type Starter func(ctx context.Context, workDir, path string) (*exec.Cmd, error)
|
||||||
|
|
||||||
|
// Dispatch 处理一条回调。返回的错误会写回脚本并记入最终结果。
|
||||||
|
type Dispatch func(ctx context.Context, call Call) (string, error)
|
||||||
|
|
||||||
|
// ModelText 把截断后的输出、已执行调用和错误拼成给模型的文本。
|
||||||
|
func (o Outcome) ModelText() string {
|
||||||
|
text := o.Output
|
||||||
|
if o.Err != nil {
|
||||||
|
msg := o.Err.Error()
|
||||||
|
if len(o.Calls) > 0 {
|
||||||
|
msg = "已执行:" + strings.Join(o.Calls, "、") + "\n" + msg
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += msg
|
||||||
|
}
|
||||||
|
if o.FullPath != "" {
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += "全文在 " + o.FullPath
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunFile 执行已经落盘的脚本。
|
||||||
|
func RunFile(ctx context.Context, workDir, path string, lim Limits, start Starter, dispatch Dispatch) Outcome {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
timeout := lim.bounded()
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
helper, err := helperPath()
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
if start == nil {
|
||||||
|
start = defaultStart
|
||||||
|
}
|
||||||
|
cmd, err := start(runCtx, workDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
cmd.Env = prependPythonPath(cmd.Env, helper)
|
||||||
|
reqR, reqW, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
respR, respW, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
reqR.Close()
|
||||||
|
reqW.Close()
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
cmd.ExtraFiles = []*os.File{reqW, respR}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
reqR.Close()
|
||||||
|
reqW.Close()
|
||||||
|
respR.Close()
|
||||||
|
respW.Close()
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
reqW.Close()
|
||||||
|
respR.Close()
|
||||||
|
defer reqR.Close()
|
||||||
|
defer respW.Close()
|
||||||
|
calls, callErr := serve(runCtx, reqR, respW, lim, dispatch)
|
||||||
|
if callErr != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
out := Outcome{Output: clipJoined(&stdout, &stderr), Calls: calls}
|
||||||
|
out = clipModel(out, lim)
|
||||||
|
switch {
|
||||||
|
case callErr != nil:
|
||||||
|
out.Err = callErr
|
||||||
|
case runCtx.Err() == context.DeadlineExceeded:
|
||||||
|
out.Err = fmt.Errorf("脚本超时")
|
||||||
|
case runCtx.Err() == context.Canceled:
|
||||||
|
out.Err = fmt.Errorf("脚本已取消")
|
||||||
|
case waitErr != nil:
|
||||||
|
out.Err = fmt.Errorf("脚本退出:%v", waitErr)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 把脚本体写入工作目录后再执行。
|
||||||
|
func Run(ctx context.Context, workDir, body string, lim Limits, start Starter, dispatch Dispatch) Outcome {
|
||||||
|
dir := filepath.Join(workDir, ".agent")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("orch-%d.py", time.Now().UnixNano()))
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
return RunFile(ctx, workDir, path, lim, start, dispatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) bounded() time.Duration {
|
||||||
|
d := l.Timeout
|
||||||
|
if d <= 0 {
|
||||||
|
d = defaultTimeout
|
||||||
|
}
|
||||||
|
max := l.MaxTimeout
|
||||||
|
if max <= 0 {
|
||||||
|
max = defaultMaxWait
|
||||||
|
}
|
||||||
|
if d > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) calls() int {
|
||||||
|
if l.MaxCalls <= 0 {
|
||||||
|
return defaultCalls
|
||||||
|
}
|
||||||
|
return l.MaxCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) resultBytes() int {
|
||||||
|
if l.MaxResultBytes <= 0 {
|
||||||
|
return defaultResult
|
||||||
|
}
|
||||||
|
return l.MaxResultBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) modelChars() int {
|
||||||
|
if l.MaxModelChars <= 0 {
|
||||||
|
return defaultModel
|
||||||
|
}
|
||||||
|
return l.MaxModelChars
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(ctx context.Context, reqR, respW *os.File, lim Limits, dispatch Dispatch) ([]string, error) {
|
||||||
|
sc := bufio.NewScanner(reqR)
|
||||||
|
sc.Buffer(make([]byte, 64*1024), lim.resultBytes())
|
||||||
|
var calls []string
|
||||||
|
n := 0
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
var call Call
|
||||||
|
if err := json.Unmarshal([]byte(line), &call); err != nil {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", "请求不是合法 JSON")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n > lim.calls() {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", "脚本回调超过 50 次")
|
||||||
|
return calls, fmt.Errorf("脚本回调超过 50 次")
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return calls, ctx.Err()
|
||||||
|
}
|
||||||
|
if dispatch == nil {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", "没有回调处理")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output, err := dispatch(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", err.Error())
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return calls, ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if call.Op == "tool.call" && call.Name != "" {
|
||||||
|
calls = append(calls, call.Name)
|
||||||
|
}
|
||||||
|
if err := writeResp(respW, call.ID, true, clipBytes(output, lim.resultBytes()), ""); err != nil {
|
||||||
|
return calls, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
if err == bufio.ErrTooLong {
|
||||||
|
return calls, fmt.Errorf("单行请求超过 1MB")
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return calls, ctx.Err()
|
||||||
|
}
|
||||||
|
return calls, err
|
||||||
|
}
|
||||||
|
return calls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResp(w io.Writer, id string, ok bool, output, errText string) error {
|
||||||
|
raw, err := json.Marshal(struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}{ID: id, OK: ok, Output: output, Error: errText})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(append(raw, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipBytes(s string, n int) string {
|
||||||
|
if n <= 0 || len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := s[:n]
|
||||||
|
for !utf8.ValidString(cut) && len(cut) > 0 {
|
||||||
|
cut = cut[:len(cut)-1]
|
||||||
|
}
|
||||||
|
return cut
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipJoined(stdout, stderr *bytes.Buffer) string {
|
||||||
|
text := strings.TrimSpace(stdout.String())
|
||||||
|
errText := strings.TrimSpace(stderr.String())
|
||||||
|
if errText != "" {
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += errText
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipModel(out Outcome, lim Limits) Outcome {
|
||||||
|
limit := lim.modelChars()
|
||||||
|
if utf8.RuneCountInString(out.Output) <= limit {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
dir := lim.FullDir
|
||||||
|
if dir == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
out.Output = clipRunes(out.Output, limit)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("script-output-%d.txt", time.Now().UnixNano()))
|
||||||
|
if err := os.WriteFile(path, []byte(out.Output), 0o644); err != nil {
|
||||||
|
out.Output = clipRunes(out.Output, limit)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.FullPath = path
|
||||||
|
out.Output = clipRunes(out.Output, limit)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipRunes(s string, n int) string {
|
||||||
|
if utf8.RuneCountInString(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
i := 0
|
||||||
|
for count := 0; count < n && i < len(s); count++ {
|
||||||
|
_, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return s[:i] + "…(超长截断)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultStart(ctx context.Context, workDir, path string) (*exec.Cmd, error) {
|
||||||
|
bin, err := exec.LookPath("python3")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("找不到 python3: %w", err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, bin, path)
|
||||||
|
cmd.Dir = workDir
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func helperPath() (string, error) {
|
||||||
|
helperOnce.Do(func() {
|
||||||
|
dir, err := os.MkdirTemp("", "agentwf-*")
|
||||||
|
if err != nil {
|
||||||
|
helperErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "agentwf.py"), []byte(agentwfSource), 0o644); err != nil {
|
||||||
|
helperErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helperDir = dir
|
||||||
|
})
|
||||||
|
return helperDir, helperErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func prependPythonPath(env []string, dir string) []string {
|
||||||
|
if env == nil {
|
||||||
|
env = os.Environ()
|
||||||
|
}
|
||||||
|
for i, kv := range env {
|
||||||
|
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||||
|
env[i] = "PYTHONPATH=" + dir + string(os.PathListSeparator) + strings.TrimPrefix(kv, "PYTHONPATH=")
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(env, "PYTHONPATH="+dir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package codeorch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScriptCallsTwoToolsAndReportsTheOneThatRan(t *testing.T) {
|
||||||
|
body := "import agentwf as wf\nprint(wf.tool('alpha', {}))\nprint(wf.tool('beta', {}))\n"
|
||||||
|
out := Run(context.Background(), t.TempDir(), body, Limits{}, nil, func(_ context.Context, call Call) (string, error) {
|
||||||
|
if call.Name == "beta" {
|
||||||
|
return "", errors.New("beta 失败")
|
||||||
|
}
|
||||||
|
return "A", nil
|
||||||
|
})
|
||||||
|
text := out.ModelText()
|
||||||
|
if len(out.Calls) != 1 || out.Calls[0] != "alpha" {
|
||||||
|
t.Fatalf("calls %+v", out.Calls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "已执行:alpha") || !strings.Contains(text, "beta 失败") {
|
||||||
|
t.Fatalf("text %q err %v", text, out.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForbiddenToolIsRejectedBeforeDispatchRunsIt(t *testing.T) {
|
||||||
|
body := "import agentwf as wf\nprint(wf.tool('read_file', {}))\nprint(wf.tool('run_shell', {}))\n"
|
||||||
|
var ran []string
|
||||||
|
out := Run(context.Background(), t.TempDir(), body, Limits{}, nil, func(_ context.Context, call Call) (string, error) {
|
||||||
|
if Forbidden(call.Name) {
|
||||||
|
return "", errors.New("不能调用 " + call.Name)
|
||||||
|
}
|
||||||
|
ran = append(ran, call.Name)
|
||||||
|
return "ok", nil
|
||||||
|
})
|
||||||
|
if len(ran) != 1 || ran[0] != "read_file" {
|
||||||
|
t.Fatalf("ran %+v", ran)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.ModelText(), "不能调用 run_shell") {
|
||||||
|
t.Fatalf("text %q", out.ModelText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongOutputIsClippedAndStored(t *testing.T) {
|
||||||
|
body := "import agentwf as wf\nprint(wf.tool('big', {}))\n"
|
||||||
|
dir := t.TempDir()
|
||||||
|
out := Run(context.Background(), dir, body, Limits{MaxModelChars: 8, FullDir: dir}, nil, func(_ context.Context, call Call) (string, error) {
|
||||||
|
return strings.Repeat("测", 20), nil
|
||||||
|
})
|
||||||
|
if out.FullPath == "" {
|
||||||
|
t.Fatal("expected a full-text file")
|
||||||
|
}
|
||||||
|
full, err := os.ReadFile(out.FullPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if utf8.RuneCount(full) < 20 {
|
||||||
|
t.Fatalf("full text too short: %q", full)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(out.Output, "…(超长截断)") || utf8.RuneCountInString(strings.TrimSuffix(out.Output, "…(超长截断)")) != 8 {
|
||||||
|
t.Fatalf("model output not clipped: %q", out.Output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.ModelText(), out.FullPath) {
|
||||||
|
t.Fatalf("model text missing path: %q", out.ModelText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultGivenToScriptIsCapped(t *testing.T) {
|
||||||
|
got := clipBytes(strings.Repeat("x", 20), 8)
|
||||||
|
if got != "xxxxxxxx" {
|
||||||
|
t.Fatalf("clip %q", got)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(clipBytes("测测测", 4)) {
|
||||||
|
t.Fatal("clip broke utf-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeoutIsCapped(t *testing.T) {
|
||||||
|
if (Limits{Timeout: 900 * time.Second, MaxTimeout: 600 * time.Second}).bounded() != 600*time.Second {
|
||||||
|
t.Fatal("timeout must clamp to the max")
|
||||||
|
}
|
||||||
|
if (Limits{}).bounded() != defaultTimeout {
|
||||||
|
t.Fatal("empty limits must use the 120s default")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type replayTool struct {
|
||||||
|
countingTool
|
||||||
|
replay string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *replayTool) Replay() string {
|
||||||
|
if r.replay == "" {
|
||||||
|
return tools.ReplayNever
|
||||||
|
}
|
||||||
|
return r.replay
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingUnsafeToolIsNotRerun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "write_file", Arguments: `{}`}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "write_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tool := &countingTool{name: "write_file"}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(tool)
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 0 {
|
||||||
|
t.Fatalf("unsafe tool reran: %d", tool.calls)
|
||||||
|
}
|
||||||
|
msgs, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var paired bool
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == unknownSideEffect {
|
||||||
|
paired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !paired {
|
||||||
|
t.Fatalf("pair missing: %#v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingSafeToolRerunsOnce(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(tool)
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 1 {
|
||||||
|
t.Fatalf("calls %d", tool.calls)
|
||||||
|
}
|
||||||
|
msgs, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID == "c1" {
|
||||||
|
if m.Content != "ok" || strings.Contains(m.Content, unknownSideEffect) {
|
||||||
|
t.Fatalf("result %#v", m)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("missing tool result")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoredSafeCurrentNeverDoesNotRerun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplayNever}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(tool)
|
||||||
|
if err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 0 {
|
||||||
|
t.Fatalf("calls %d", tool.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFollowUpIsPersistedBeforeNextRequest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sent := false
|
||||||
|
var saw bool
|
||||||
|
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Content == "follow-please" {
|
||||||
|
loaded, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("load %v", err)
|
||||||
|
}
|
||||||
|
for _, got := range loaded {
|
||||||
|
if got.Content == "follow-please" {
|
||||||
|
saw = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, MaxTurns: 3, Stream: stream,
|
||||||
|
GetFollowUp: func() []llm.Message {
|
||||||
|
if sent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sent = true
|
||||||
|
return []llm.Message{{Role: llm.RoleUser, Content: "follow-please"}}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !saw {
|
||||||
|
t.Fatal("follow-up entered the request before it was stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
|
||||||
|
var names []string
|
||||||
|
var leaked bool
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(&countingTool{name: "read_file"})
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake", Tools: reg, MaxTurns: 2,
|
||||||
|
Trace: func(name string, attrs map[string]string) func() {
|
||||||
|
names = append(names, name)
|
||||||
|
if _, ok := attrs["prompt"]; ok || attrs["content"] != "" {
|
||||||
|
leaked = true
|
||||||
|
}
|
||||||
|
return func() {}
|
||||||
|
},
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "1", Name: "read_file", Arguments: `{}`}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "用户的秘密提示词"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
joined := strings.Join(names, ",")
|
||||||
|
for _, want := range []string{"invoke_agent", "chat", "execute_tool"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("names %s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if leaked {
|
||||||
|
t.Fatal("prompt leaked into span attrs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package pkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest is the agent-native.json discovered in a package.
|
||||||
|
type Manifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Extensions []string `json:"extensions,omitempty"`
|
||||||
|
Config map[string]string `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager installs packages under Dir.
|
||||||
|
type Manager struct {
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) root() string {
|
||||||
|
if m.Dir != "" {
|
||||||
|
return m.Dir
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".agent-native", "packages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install copies a local directory, clones a git URL, or runs npm pack
|
||||||
|
// when src starts with "npm:". The package name comes from its manifest.
|
||||||
|
func (m Manager) Install(src string) (Manifest, error) {
|
||||||
|
dir, err := os.MkdirTemp("", "agent-native-pkg-*")
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(src, "npm:"):
|
||||||
|
if _, err := exec.LookPath("npm"); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: npm is not installed")
|
||||||
|
}
|
||||||
|
cmd := exec.Command("npm", "pack", strings.TrimPrefix(src, "npm:"))
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: npm pack: %s", out)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(src, "git+") || strings.HasSuffix(src, ".git") || strings.Contains(src, "://"):
|
||||||
|
cmd := exec.Command("git", "clone", "--depth", "1", src, filepath.Join(dir, "src"))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: git clone: %s", out)
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "src")
|
||||||
|
default:
|
||||||
|
if err := copyDir(src, filepath.Join(dir, "src")); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "src")
|
||||||
|
}
|
||||||
|
manifest, err := ReadManifest(dir)
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(m.root(), manifest.Name)
|
||||||
|
if err := os.RemoveAll(dest); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
if err := copyDir(dir, dest); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes an installed package.
|
||||||
|
func (m Manager) Remove(name string) error {
|
||||||
|
return os.RemoveAll(filepath.Join(m.root(), name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update reinstalls from the recorded source when present, otherwise no-ops.
|
||||||
|
func (m Manager) Update(name string) (Manifest, error) {
|
||||||
|
manifest, err := ReadManifest(filepath.Join(m.root(), name))
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
src := manifest.Config["source"]
|
||||||
|
if src == "" {
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
return m.Install(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns installed manifests.
|
||||||
|
func (m Manager) List() ([]Manifest, error) {
|
||||||
|
entries, err := os.ReadDir(m.root())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Manifest
|
||||||
|
for _, ent := range entries {
|
||||||
|
if !ent.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
manifest, err := ReadManifest(filepath.Join(m.root(), ent.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, manifest)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config sets one package config key.
|
||||||
|
func (m Manager) Config(name, key, value string) error {
|
||||||
|
path := filepath.Join(m.root(), name)
|
||||||
|
manifest, err := ReadManifest(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if manifest.Config == nil {
|
||||||
|
manifest.Config = map[string]string{}
|
||||||
|
}
|
||||||
|
manifest.Config[key] = value
|
||||||
|
raw, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(path, "agent-native.json"), raw, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadManifest loads agent-native.json from a directory.
|
||||||
|
func ReadManifest(dir string) (Manifest, error) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "agent-native.json"))
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
if manifest.Name == "" {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: manifest name is empty")
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package ext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one JSONL frame on the extension pipe.
|
||||||
|
type Message struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
IsError bool `json:"isError,omitempty"`
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Block bool `json:"block,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
BaseURL string `json:"baseURL,omitempty"`
|
||||||
|
CustomType string `json:"customType,omitempty"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIRequest is a question an extension wants a human to answer.
|
||||||
|
type UIRequest struct {
|
||||||
|
ID string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process is one extension subprocess.
|
||||||
|
type Process struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
mu sync.Mutex
|
||||||
|
tools []registeredTool
|
||||||
|
commands []string
|
||||||
|
providers []llm.Provider
|
||||||
|
pending map[string]chan Message
|
||||||
|
UI chan UIRequest
|
||||||
|
OnCustom func(customType string, data json.RawMessage)
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type registeredTool struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
parameters json.RawMessage
|
||||||
|
proc *Process
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t registeredTool) Name() string { return t.name }
|
||||||
|
func (t registeredTool) Description() string { return t.description }
|
||||||
|
func (t registeredTool) Parameters() json.RawMessage { return t.parameters }
|
||||||
|
func (t registeredTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||||
|
return t.proc.callTool(ctx, t.name, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches command and reads its registration frames until it goes idle
|
||||||
|
// or the context ends. command is argv, not a shell line.
|
||||||
|
func Start(ctx context.Context, command []string) (*Process, error) {
|
||||||
|
if len(command) == 0 {
|
||||||
|
return nil, fmt.Errorf("ext: empty command")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := &Process{
|
||||||
|
cmd: cmd, stdin: stdin, pending: map[string]chan Message{},
|
||||||
|
UI: make(chan UIRequest, 4),
|
||||||
|
}
|
||||||
|
go p.read(stdout)
|
||||||
|
if err := p.send(Message{Type: "init"}); err != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools returns the tools the extension registered.
|
||||||
|
func (p *Process) Tools() []tools.Tool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := make([]tools.Tool, len(p.tools))
|
||||||
|
for i := range p.tools {
|
||||||
|
out[i] = p.tools[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands returns slash-command names.
|
||||||
|
func (p *Process) Commands() []string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return append([]string(nil), p.commands...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers returns endpoints the extension registered.
|
||||||
|
func (p *Process) Providers() []llm.Provider {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return append([]llm.Provider(nil), p.providers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook tells the extension about an event. A block result stops the host action.
|
||||||
|
func (p *Process) Hook(ctx context.Context, event, name string, args json.RawMessage) (bool, string) {
|
||||||
|
id := name + ":" + event
|
||||||
|
ch := make(chan Message, 1)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pending[id] = ch
|
||||||
|
p.mu.Unlock()
|
||||||
|
if err := p.send(Message{Type: "event", ID: id, Event: event, Name: name, Arguments: args}); err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err().Error()
|
||||||
|
case msg := <-ch:
|
||||||
|
return msg.Block, msg.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) callTool(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) {
|
||||||
|
id := name
|
||||||
|
ch := make(chan Message, 1)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pending[id] = ch
|
||||||
|
p.mu.Unlock()
|
||||||
|
if err := p.send(Message{Type: "tool_call", ID: id, Name: name, Arguments: args}); err != nil {
|
||||||
|
return tools.Result{IsError: true, Content: err.Error()}, err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return tools.Result{IsError: true, Content: ctx.Err().Error()}, ctx.Err()
|
||||||
|
case msg := <-ch:
|
||||||
|
return tools.Result{Content: msg.Content, IsError: msg.IsError}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) send(msg Message) error {
|
||||||
|
raw, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.closed {
|
||||||
|
return fmt.Errorf("ext: closed")
|
||||||
|
}
|
||||||
|
_, err = p.stdin.Write(append(raw, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) read(r io.Reader) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(sc.Bytes(), &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.dispatch(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) dispatch(msg Message) {
|
||||||
|
switch msg.Type {
|
||||||
|
case "register_tool":
|
||||||
|
p.mu.Lock()
|
||||||
|
p.tools = append(p.tools, registeredTool{
|
||||||
|
name: msg.Name, description: msg.Description, parameters: msg.Parameters, proc: p,
|
||||||
|
})
|
||||||
|
p.mu.Unlock()
|
||||||
|
case "register_command":
|
||||||
|
p.mu.Lock()
|
||||||
|
p.commands = append(p.commands, msg.Name)
|
||||||
|
p.mu.Unlock()
|
||||||
|
case "register_provider":
|
||||||
|
p.mu.Lock()
|
||||||
|
p.providers = append(p.providers, llm.Provider{
|
||||||
|
Name: msg.Name, Protocol: llm.Protocol(msg.Protocol), BaseURL: msg.BaseURL,
|
||||||
|
})
|
||||||
|
p.mu.Unlock()
|
||||||
|
case "tool_result", "hook_result":
|
||||||
|
p.mu.Lock()
|
||||||
|
ch := p.pending[msg.ID]
|
||||||
|
delete(p.pending, msg.ID)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- msg
|
||||||
|
}
|
||||||
|
case "ui_request":
|
||||||
|
select {
|
||||||
|
case p.UI <- UIRequest{ID: msg.ID, Message: msg.Message}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
case "custom_entry":
|
||||||
|
if p.OnCustom != nil {
|
||||||
|
p.OnCustom(msg.CustomType, msg.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the subprocess.
|
||||||
|
func (p *Process) Close() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.closed = true
|
||||||
|
_ = p.stdin.Close()
|
||||||
|
p.mu.Unlock()
|
||||||
|
if p.cmd.Process != nil {
|
||||||
|
_ = p.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return p.cmd.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package ext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/runtime/ext/pkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtensionRegistersAndHooks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
script := filepath.Join(dir, "ext.sh")
|
||||||
|
body := `#!/bin/sh
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
*'"type":"init"'*)
|
||||||
|
echo '{"type":"register_tool","name":"ping","description":"p","parameters":{"type":"object"}}'
|
||||||
|
echo '{"type":"register_command","name":"hi"}'
|
||||||
|
echo '{"type":"register_provider","name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9"}'
|
||||||
|
echo '{"type":"ui_request","id":"u1","message":"choose"}'
|
||||||
|
;;
|
||||||
|
*'"type":"tool_call"'*)
|
||||||
|
echo '{"type":"tool_result","id":"ping","content":"pong"}'
|
||||||
|
;;
|
||||||
|
*'"type":"event"'*)
|
||||||
|
echo '{"type":"hook_result","id":"ping:tool_call","block":true,"reason":"no"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
proc, err := Start(ctx, []string{"/bin/sh", script})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer proc.Close()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for len(proc.Tools()) == 0 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
tools := proc.Tools()
|
||||||
|
if len(tools) != 1 || tools[0].Name() != "ping" {
|
||||||
|
t.Fatalf("tools %+v", tools)
|
||||||
|
}
|
||||||
|
res, err := tools[0].Execute(ctx, []byte(`{}`))
|
||||||
|
if err != nil || res.Content != "pong" {
|
||||||
|
t.Fatalf("tool: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
block, reason := proc.Hook(ctx, "tool_call", "ping", []byte(`{}`))
|
||||||
|
if !block || reason != "no" {
|
||||||
|
t.Fatalf("hook %v %s", block, reason)
|
||||||
|
}
|
||||||
|
if len(proc.Commands()) != 1 || len(proc.Providers()) != 1 {
|
||||||
|
t.Fatalf("commands %v providers %v", proc.Commands(), proc.Providers())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ui := <-proc.UI:
|
||||||
|
if ui.Message != "choose" {
|
||||||
|
t.Fatal(ui)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("missing ui request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageInstallListConfigRemove(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "agent-native.json"), []byte(`{"name":"demo","extensions":["./ext.sh"]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mgr := pkg.Manager{Dir: t.TempDir()}
|
||||||
|
manifest, err := mgr.Install(src)
|
||||||
|
if err != nil || manifest.Name != "demo" {
|
||||||
|
t.Fatalf("%+v %v", manifest, err)
|
||||||
|
}
|
||||||
|
if err := mgr.Config("demo", "source", src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := mgr.List()
|
||||||
|
if err != nil || len(list) != 1 || list[0].Config["source"] != src {
|
||||||
|
t.Fatalf("list %+v %v", list, err)
|
||||||
|
}
|
||||||
|
if _, err := mgr.Update("demo"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mgr.Remove("demo"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = mgr.List()
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("still installed: %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package filestate 记录本轮读过的文件,并在改写前核对修改时间与大小。
|
||||||
|
package filestate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stamp struct {
|
||||||
|
mod time.Time
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracker 是一次 run 内的读取记录。nil 接收者表示不检查。
|
||||||
|
type Tracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]stamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Tracker {
|
||||||
|
return &Tracker{seen: map[string]stamp{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteRead 记下当前的修改时间与大小。
|
||||||
|
func (t *Tracker) NoteRead(path string) error {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
t.seen[path] = stamp{mod: info.ModTime(), size: info.Size()}
|
||||||
|
t.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckWrite 允许新建。已存在的文件必须在本轮读过,且修改时间与大小未变。
|
||||||
|
func (t *Tracker) CheckWrite(path string) error {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
got, ok := t.seen[path]
|
||||||
|
t.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("请先读取该文件再修改:%s", path)
|
||||||
|
}
|
||||||
|
if !got.mod.Equal(info.ModTime()) || got.size != info.Size() {
|
||||||
|
return fmt.Errorf("文件在读取后已变化,请重新读取:%s", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package filestate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckWriteRejectsStaleFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "note.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("v1"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr := New()
|
||||||
|
if err := tr.CheckWrite(path); err == nil || !strings.Contains(err.Error(), "请先读取") {
|
||||||
|
t.Fatalf("unread write: %v", err)
|
||||||
|
}
|
||||||
|
if err := tr.NoteRead(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tr.CheckWrite(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 改大小,并尽量推开修改时间。
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if err := os.WriteFile(path, []byte("v1-changed"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := tr.CheckWrite(path)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "请重新读取") {
|
||||||
|
t.Fatalf("stale write: %v", err)
|
||||||
|
}
|
||||||
|
missing := filepath.Join(dir, "new.txt")
|
||||||
|
if err := tr.CheckWrite(missing); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Package hooks 是进程内钩子总线。决策按 block/deny、ask、modify、allow 取更强的一条。
|
||||||
|
// 没有表态的处理函数不改变结果。
|
||||||
|
package hooks
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Event string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionStart Event = "SessionStart"
|
||||||
|
UserPromptSubmit Event = "UserPromptSubmit"
|
||||||
|
PreToolUse Event = "PreToolUse"
|
||||||
|
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
|
||||||
|
|
||||||
|
const (
|
||||||
|
Allow Decision = "allow"
|
||||||
|
Deny Decision = "deny"
|
||||||
|
Ask Decision = "ask"
|
||||||
|
Modify Decision = "modify"
|
||||||
|
Block Decision = "block"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call 是一次事件。工具字段只在工具事件上有意义。
|
||||||
|
type Call struct {
|
||||||
|
Event Event
|
||||||
|
Tool string
|
||||||
|
Args string
|
||||||
|
Result string
|
||||||
|
IsError bool
|
||||||
|
Turn int
|
||||||
|
LastText string
|
||||||
|
Continuations int
|
||||||
|
ToolCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome 是一个处理函数的表态。空 Decision 表示不插手。
|
||||||
|
type Outcome struct {
|
||||||
|
Decision Decision
|
||||||
|
Reason string
|
||||||
|
Args string
|
||||||
|
Notice string
|
||||||
|
Replacement string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler func(Call) Outcome
|
||||||
|
|
||||||
|
type Bus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
hs []Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Bus { return &Bus{} }
|
||||||
|
|
||||||
|
func (b *Bus) On(h Handler) {
|
||||||
|
if b == nil || h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.hs = append(b.hs, h)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit 按优先级合并表态。deny 与 block 同级,先登记的保留。
|
||||||
|
func (b *Bus) Emit(c Call) Outcome {
|
||||||
|
if b == nil {
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
hs := append([]Handler(nil), b.hs...)
|
||||||
|
b.mu.Unlock()
|
||||||
|
var merged Outcome
|
||||||
|
for _, h := range hs {
|
||||||
|
merged = prefer(merged, h(c))
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefer(cur, next Outcome) Outcome {
|
||||||
|
if rank(next.Decision) > rank(cur.Decision) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
func rank(d Decision) int {
|
||||||
|
switch d {
|
||||||
|
case Block, Deny:
|
||||||
|
return 4
|
||||||
|
case Ask:
|
||||||
|
return 3
|
||||||
|
case Modify:
|
||||||
|
return 2
|
||||||
|
case Allow:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptBefore 把「返回错误即拒绝」收成 PreToolUse 处理函数。
|
||||||
|
func AdaptBefore(fn func(name, args string) error) Handler {
|
||||||
|
return func(c Call) Outcome {
|
||||||
|
if c.Event != PreToolUse || fn == nil {
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
if err := fn(c.Tool, c.Args); err != nil {
|
||||||
|
return Outcome{Decision: Deny, Reason: err.Error()}
|
||||||
|
}
|
||||||
|
return Outcome{Decision: Allow}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptAfter 把工具结果观察收成 PostToolUse 或失败事件。它不改写结果。
|
||||||
|
func AdaptAfter(fn func(name, args, result string, isError bool)) Handler {
|
||||||
|
return func(c Call) Outcome {
|
||||||
|
if fn == nil {
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
switch c.Event {
|
||||||
|
case PostToolUse, PostToolUseFailure:
|
||||||
|
fn(c.Tool, c.Args, c.Result, c.IsError)
|
||||||
|
}
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package hooks
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDenyBeatsAllow(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
b.On(func(Call) Outcome { return Outcome{Decision: Allow} })
|
||||||
|
b.On(func(c Call) Outcome {
|
||||||
|
if c.Event == PreToolUse {
|
||||||
|
return Outcome{Decision: Deny, Reason: "no"}
|
||||||
|
}
|
||||||
|
return Outcome{}
|
||||||
|
})
|
||||||
|
out := b.Emit(Call{Event: PreToolUse, Tool: "write_file"})
|
||||||
|
if out.Decision != Deny || out.Reason != "no" {
|
||||||
|
t.Fatalf("%+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptBefore(t *testing.T) {
|
||||||
|
h := AdaptBefore(func(name, args string) error {
|
||||||
|
if name == "write_file" {
|
||||||
|
return errString("blocked")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if h(Call{Event: PreToolUse, Tool: "read_file"}).Decision != Allow {
|
||||||
|
t.Fatal("read")
|
||||||
|
}
|
||||||
|
if h(Call{Event: PreToolUse, Tool: "write_file"}).Decision != Deny {
|
||||||
|
t.Fatal("write")
|
||||||
|
}
|
||||||
|
if h(Call{Event: Stop}).Decision != "" {
|
||||||
|
t.Fatal("stop should be ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
+21
-3
@@ -42,6 +42,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
|||||||
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
|
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if opts.MessageShape != "" {
|
||||||
|
messages = PrepareMessages(messages, opts.MessageShape)
|
||||||
|
}
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": toProviderMessages(messages),
|
"messages": toProviderMessages(messages),
|
||||||
@@ -53,6 +56,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
|||||||
if opts.MaxTokens > 0 {
|
if opts.MaxTokens > 0 {
|
||||||
body["max_tokens"] = opts.MaxTokens
|
body["max_tokens"] = opts.MaxTokens
|
||||||
}
|
}
|
||||||
|
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
||||||
|
body["reasoning_effort"] = effort
|
||||||
|
}
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
body["tools"] = toProviderTools(tools)
|
body["tools"] = toProviderTools(tools)
|
||||||
body["tool_choice"] = "auto"
|
body["tool_choice"] = "auto"
|
||||||
@@ -86,8 +92,20 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
|
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
|
||||||
emitError(out, "", retryable, fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp))))
|
ev := Event{
|
||||||
|
Kind: KindError,
|
||||||
|
HTTPStatus: resp.StatusCode,
|
||||||
|
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
|
||||||
|
ErrorMessage: msg,
|
||||||
|
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
|
||||||
|
Message: Message{Role: RoleAssistant, StopReason: StopError},
|
||||||
|
}
|
||||||
|
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
|
||||||
|
ev.HasRetryAfter = true
|
||||||
|
ev.RetryAfter = d
|
||||||
|
}
|
||||||
|
out <- ev
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for ev := range ParseSSE(ctx, resp.Body) {
|
for ev := range ParseSSE(ctx, resp.Body) {
|
||||||
@@ -125,7 +143,7 @@ func toProviderMessages(messages []Message) []map[string]any {
|
|||||||
default:
|
default:
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"role": string(m.Role),
|
"role": string(m.Role),
|
||||||
"content": m.Content,
|
"content": messageContent(m),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// WithFallback 在尚未转发任何增量时,遇到 5xx 就改用链上下一个模型。
|
||||||
|
// fallbacks 为空时返回原来的 StreamFunc,不改变行为。
|
||||||
|
func WithFallback(base StreamFunc, fallbacks []string, onSwitch func(from, to string)) StreamFunc {
|
||||||
|
chain := nonEmpty(fallbacks)
|
||||||
|
if base == nil || len(chain) == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
out := make(chan Event)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
models := append([]string{model}, chain...)
|
||||||
|
for i, current := range models {
|
||||||
|
forwarded := false
|
||||||
|
switchNext := false
|
||||||
|
for ev := range base(ctx, current, messages, tools, opts) {
|
||||||
|
if isDelta(ev) {
|
||||||
|
forwarded = true
|
||||||
|
}
|
||||||
|
if ev.Kind == KindError && !forwarded && serverError(ev) && i+1 < len(models) {
|
||||||
|
switchNext = true
|
||||||
|
if onSwitch != nil {
|
||||||
|
onSwitch(current, models[i+1])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case out <- ev:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !switchNext {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmpty(in []string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range in {
|
||||||
|
s = trimModel(s)
|
||||||
|
if s == "" || seen[s] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s] = true
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimModel(s string) string {
|
||||||
|
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDelta(ev Event) bool {
|
||||||
|
return ev.Kind == KindTextDelta || ev.Kind == KindReasoningDelta || ev.Kind == KindToolCallDelta
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverError(ev Event) bool {
|
||||||
|
return ev.HTTPStatus >= 500 && ev.HTTPStatus <= 599
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFallbackSwitchesOn5xxBeforeOutput(t *testing.T) {
|
||||||
|
var switched string
|
||||||
|
base := func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
ch := make(chan Event, 2)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
if model == "primary" {
|
||||||
|
ch <- Event{Kind: KindError, HTTPStatus: 503, ErrorMessage: "provider status 503", Message: Message{Role: RoleAssistant, StopReason: StopError}}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- Event{Kind: KindTextDelta, Text: "ok"}
|
||||||
|
ch <- Event{Kind: KindMessageEnd, Message: Message{Role: RoleAssistant, Content: "ok", StopReason: StopEnd}}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
stream := WithFallback(base, []string{"backup"}, func(from, to string) {
|
||||||
|
switched = from + "->" + to
|
||||||
|
})
|
||||||
|
var text string
|
||||||
|
var sawError bool
|
||||||
|
for ev := range stream(context.Background(), "primary", nil, nil, StreamOptions{}) {
|
||||||
|
if ev.Kind == KindTextDelta {
|
||||||
|
text += ev.Text
|
||||||
|
}
|
||||||
|
if ev.Kind == KindError {
|
||||||
|
sawError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if switched != "primary->backup" || text != "ok" || sawError {
|
||||||
|
t.Fatalf("switched %q text %q error %v", switched, text, sawError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallbackStaysAfterFirstDelta(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
base := func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
calls++
|
||||||
|
ch := make(chan Event, 2)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
ch <- Event{Kind: KindTextDelta, Text: "partial"}
|
||||||
|
ch <- Event{Kind: KindError, HTTPStatus: 500, Message: Message{Role: RoleAssistant, StopReason: StopError}}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
stream := WithFallback(base, []string{"backup"}, func(from, to string) {
|
||||||
|
t.Fatal("switched after output")
|
||||||
|
})
|
||||||
|
n := 0
|
||||||
|
for range stream(context.Background(), "primary", nil, nil, StreamOptions{}) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if calls != 1 || n != 2 {
|
||||||
|
t.Fatalf("calls %d events %d", calls, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyFallbackIsIdentity(t *testing.T) {
|
||||||
|
base := func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
ch := make(chan Event)
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
if WithFallback(base, nil, nil) == nil {
|
||||||
|
t.Fatal("nil stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
// PrepareMessages 按 endpoint 整形消息。
|
||||||
|
// merge 合并相邻、且不携带工具调用的同角色消息。
|
||||||
|
// strict 在此之后把第一条之后的 system 收成 user,再合并,满足严格交替。
|
||||||
|
// 工具消息和带 tool_calls 的助手消息保持原样,避免拆开调用与结果。
|
||||||
|
func PrepareMessages(msgs []Message, shape string) []Message {
|
||||||
|
switch shape {
|
||||||
|
case "", "merge", "strict":
|
||||||
|
default:
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
out := mergeSameRole(msgs)
|
||||||
|
if shape == "strict" {
|
||||||
|
out = foldExtraSystems(out)
|
||||||
|
out = mergeSameRole(out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeable(m Message) bool {
|
||||||
|
return m.Role != RoleTool && len(m.ToolCalls) == 0 && m.ToolCallID == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeSameRole(msgs []Message) []Message {
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
out := make([]Message, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
if n := len(out); n > 0 && out[n-1].Role == m.Role && mergeable(out[n-1]) && mergeable(m) {
|
||||||
|
if out[n-1].Content != "" && m.Content != "" {
|
||||||
|
out[n-1].Content += "\n\n" + m.Content
|
||||||
|
} else {
|
||||||
|
out[n-1].Content += m.Content
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func foldExtraSystems(msgs []Message) []Message {
|
||||||
|
seenSystem := false
|
||||||
|
out := make([]Message, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == RoleSystem {
|
||||||
|
if seenSystem {
|
||||||
|
m.Role = RoleUser
|
||||||
|
if m.Content != "" {
|
||||||
|
m.Content = "[system]\n" + m.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenSystem = true
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPrepareMergesAdjacentSystems(t *testing.T) {
|
||||||
|
out := PrepareMessages([]Message{
|
||||||
|
{Role: RoleSystem, Content: "a"},
|
||||||
|
{Role: RoleSystem, Content: "b"},
|
||||||
|
{Role: RoleUser, Content: "hi"},
|
||||||
|
}, "merge")
|
||||||
|
if len(out) != 2 || out[0].Content != "a\n\nb" {
|
||||||
|
t.Fatalf("merged = %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareStrictFoldsLaterSystem(t *testing.T) {
|
||||||
|
out := PrepareMessages([]Message{
|
||||||
|
{Role: RoleSystem, Content: "base"},
|
||||||
|
{Role: RoleUser, Content: "hi"},
|
||||||
|
{Role: RoleSystem, Content: "note"},
|
||||||
|
}, "strict")
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("len = %d %#v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[1].Role != RoleUser || out[1].Content != "hi\n\n[system]\nnote" {
|
||||||
|
t.Fatalf("folded = %#v", out[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareKeepsToolPairs(t *testing.T) {
|
||||||
|
out := PrepareMessages([]Message{
|
||||||
|
{Role: RoleAssistant, Content: "call", ToolCalls: []ToolCall{{ID: "1", Name: "t"}}},
|
||||||
|
{Role: RoleTool, ToolCallID: "1", Content: "ok"},
|
||||||
|
}, "merge")
|
||||||
|
if len(out) != 2 || out[1].Role != RoleTool {
|
||||||
|
t.Fatalf("tools = %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Thinking levels, from off to max. Unknown values are rejected by NormalizeThinking.
|
||||||
|
var ThinkingLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"}
|
||||||
|
|
||||||
|
// NormalizeThinking returns the canonical level. Empty stays empty.
|
||||||
|
func NormalizeThinking(level string) (string, error) {
|
||||||
|
if level == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
level = strings.ToLower(strings.TrimSpace(level))
|
||||||
|
for _, known := range ThinkingLevels {
|
||||||
|
if level == known {
|
||||||
|
return level, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("llm: unknown thinking level %q", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIReasoningEffort maps a thinking level onto OpenAI reasoning_effort.
|
||||||
|
// off and empty produce no parameter.
|
||||||
|
func OpenAIReasoningEffort(level string) string {
|
||||||
|
switch level {
|
||||||
|
case "minimal":
|
||||||
|
return "minimal"
|
||||||
|
case "low":
|
||||||
|
return "low"
|
||||||
|
case "medium":
|
||||||
|
return "medium"
|
||||||
|
case "high", "xhigh", "max":
|
||||||
|
return "high"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicThinkingBudget maps a thinking level onto a token budget.
|
||||||
|
// off and empty disable thinking.
|
||||||
|
func AnthropicThinkingBudget(level string) int {
|
||||||
|
switch level {
|
||||||
|
case "minimal":
|
||||||
|
return 1024
|
||||||
|
case "low":
|
||||||
|
return 2048
|
||||||
|
case "medium":
|
||||||
|
return 4096
|
||||||
|
case "high":
|
||||||
|
return 8192
|
||||||
|
case "xhigh":
|
||||||
|
return 16384
|
||||||
|
case "max":
|
||||||
|
return 32768
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleThinkingBudget maps a thinking level onto Gemini thinkingBudget.
|
||||||
|
func GoogleThinkingBudget(level string) int {
|
||||||
|
switch level {
|
||||||
|
case "minimal", "low":
|
||||||
|
return 1024
|
||||||
|
case "medium":
|
||||||
|
return 4096
|
||||||
|
case "high":
|
||||||
|
return 8192
|
||||||
|
case "xhigh", "max":
|
||||||
|
return 24576
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelRef is provider/model with an optional :thinking suffix.
|
||||||
|
type ModelRef struct {
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
Thinking string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseModelRef accepts "model", "provider/model", and "provider/model:level".
|
||||||
|
func ParseModelRef(spec string) (ModelRef, error) {
|
||||||
|
spec = strings.TrimSpace(spec)
|
||||||
|
if spec == "" {
|
||||||
|
return ModelRef{}, fmt.Errorf("llm: empty model ref")
|
||||||
|
}
|
||||||
|
thinking := ""
|
||||||
|
if i := strings.LastIndex(spec, ":"); i >= 0 {
|
||||||
|
thinking = spec[i+1:]
|
||||||
|
spec = spec[:i]
|
||||||
|
norm, err := NormalizeThinking(thinking)
|
||||||
|
if err != nil {
|
||||||
|
return ModelRef{}, err
|
||||||
|
}
|
||||||
|
thinking = norm
|
||||||
|
}
|
||||||
|
provider, model := "", spec
|
||||||
|
if i := strings.Index(spec, "/"); i >= 0 {
|
||||||
|
provider = spec[:i]
|
||||||
|
model = spec[i+1:]
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
return ModelRef{}, fmt.Errorf("llm: model id missing in %q", spec)
|
||||||
|
}
|
||||||
|
return ModelRef{Provider: provider, Model: model, Thinking: thinking}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol names the wire format a provider speaks.
|
||||||
|
type Protocol string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProtocolOpenAIChat Protocol = "openai-chat"
|
||||||
|
ProtocolOpenAIResponses Protocol = "openai-responses"
|
||||||
|
ProtocolAnthropic Protocol = "anthropic"
|
||||||
|
ProtocolGoogle Protocol = "google"
|
||||||
|
ProtocolAzure Protocol = "azure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider is one configured endpoint.
|
||||||
|
type Provider struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol Protocol `json:"protocol"`
|
||||||
|
BaseURL string `json:"baseURL"`
|
||||||
|
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
||||||
|
APIKey string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets are OpenAI-compatible or native endpoints that only need an API key.
|
||||||
|
func Presets() []Provider {
|
||||||
|
return []Provider{
|
||||||
|
{Name: "anthropic", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.anthropic.com/v1", APIKeyEnv: "ANTHROPIC_API_KEY"},
|
||||||
|
{Name: "openai", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
|
||||||
|
{Name: "openai-responses", Protocol: ProtocolOpenAIResponses, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
|
||||||
|
{Name: "google", Protocol: ProtocolOpenAIChat, BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", APIKeyEnv: "GEMINI_API_KEY"},
|
||||||
|
{Name: "azure", Protocol: ProtocolAzure, BaseURL: "", APIKeyEnv: "AZURE_OPENAI_API_KEY"},
|
||||||
|
{Name: "deepseek", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.deepseek.com/v1", APIKeyEnv: "DEEPSEEK_API_KEY"},
|
||||||
|
{Name: "minimax", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.minimaxi.com/v1", APIKeyEnv: "MINIMAX_API_KEY"},
|
||||||
|
{Name: "openrouter", Protocol: ProtocolOpenAIChat, BaseURL: "https://openrouter.ai/api/v1", APIKeyEnv: "OPENROUTER_API_KEY"},
|
||||||
|
{Name: "groq", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsFile is the on-disk shape of models.json.
|
||||||
|
type modelsFile struct {
|
||||||
|
Providers []Provider `json:"providers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadModelsJSON reads custom providers. Missing file yields an empty list.
|
||||||
|
func LoadModelsJSON(path string) ([]Provider, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var file modelsFile
|
||||||
|
if err := json.Unmarshal(raw, &file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range file.Providers {
|
||||||
|
if file.Providers[i].APIKey == "" && file.Providers[i].APIKeyEnv != "" {
|
||||||
|
file.Providers[i].APIKey = os.Getenv(file.Providers[i].APIKeyEnv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file.Providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup finds a preset or a custom provider by name.
|
||||||
|
func Lookup(name string, custom []Provider) (Provider, bool) {
|
||||||
|
for _, p := range custom {
|
||||||
|
if p.Name == name {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range Presets() {
|
||||||
|
if p.Name == name {
|
||||||
|
if p.APIKey == "" {
|
||||||
|
p.APIKey = os.Getenv(p.APIKeyEnv)
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Provider{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientFor 返回会说该协议的客户端。responses、azure 以及原生 Anthropic/Google
|
||||||
|
// 协议当前没有接到客户端上,返回明确错误而不是发到错误的路径。
|
||||||
|
func ClientFor(p Provider) (*Client, error) {
|
||||||
|
switch p.Protocol {
|
||||||
|
case ProtocolOpenAIChat:
|
||||||
|
return &Client{BaseURL: p.BaseURL, APIKey: p.APIKey}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("llm: protocol %q is not implemented", p.Protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageContent(m Message) any {
|
||||||
|
if len(m.Images) == 0 {
|
||||||
|
return m.Content
|
||||||
|
}
|
||||||
|
parts := []map[string]any{}
|
||||||
|
if m.Content != "" {
|
||||||
|
parts = append(parts, map[string]any{"type": "text", "text": m.Content})
|
||||||
|
}
|
||||||
|
for _, img := range m.Images {
|
||||||
|
url := img.URL
|
||||||
|
if url == "" && img.Data != "" {
|
||||||
|
mime := img.MIME
|
||||||
|
if mime == "" {
|
||||||
|
mime = "image/png"
|
||||||
|
}
|
||||||
|
url = "data:" + mime + ";base64," + img.Data
|
||||||
|
}
|
||||||
|
parts = append(parts, map[string]any{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": map[string]any{"url": url},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildBody returns the JSON body for one native protocol.
|
||||||
|
func BuildBody(protocol Protocol, model string, messages []Message, tools []ToolDecl, opts StreamOptions) ([]byte, error) {
|
||||||
|
switch protocol {
|
||||||
|
case ProtocolOpenAIChat, ProtocolAzure:
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": toProviderMessages(messages),
|
||||||
|
"stream": true,
|
||||||
|
}
|
||||||
|
if opts.MaxTokens > 0 {
|
||||||
|
body["max_tokens"] = opts.MaxTokens
|
||||||
|
}
|
||||||
|
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
||||||
|
body["reasoning_effort"] = effort
|
||||||
|
}
|
||||||
|
if len(tools) > 0 {
|
||||||
|
body["tools"] = toProviderTools(tools)
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
case ProtocolOpenAIResponses:
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"input": responsesInput(messages),
|
||||||
|
"stream": true,
|
||||||
|
}
|
||||||
|
if opts.MaxTokens > 0 {
|
||||||
|
body["max_output_tokens"] = opts.MaxTokens
|
||||||
|
}
|
||||||
|
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
||||||
|
body["reasoning"] = map[string]any{"effort": effort}
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
// ProtocolAnthropic 与 ProtocolGoogle 只供离线组包。
|
||||||
|
// 当前 Client 只发 OpenAI chat completions,这两支没有接到客户端上。
|
||||||
|
// 原生协议是后续阶段的事;预设表里的 anthropic / google 已改走兼容端点。
|
||||||
|
case ProtocolAnthropic:
|
||||||
|
sys, msgs := anthropicMessages(messages)
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": msgs,
|
||||||
|
"max_tokens": opts.MaxTokens,
|
||||||
|
"stream": true,
|
||||||
|
}
|
||||||
|
// Anthropic 要求 max_tokens。0 表示调用方不限制:用足够大的默认值,
|
||||||
|
// 避免 4096 把长回复截断。真正的上限由调用方 StreamOptions.MaxTokens 决定。
|
||||||
|
if body["max_tokens"] == 0 {
|
||||||
|
body["max_tokens"] = 128000
|
||||||
|
}
|
||||||
|
if sys != "" {
|
||||||
|
body["system"] = sys
|
||||||
|
}
|
||||||
|
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
|
||||||
|
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
case ProtocolGoogle:
|
||||||
|
body := map[string]any{
|
||||||
|
"contents": googleContents(messages),
|
||||||
|
}
|
||||||
|
if budget := GoogleThinkingBudget(opts.ThinkingLevel); budget > 0 {
|
||||||
|
body["generationConfig"] = map[string]any{
|
||||||
|
"thinkingConfig": map[string]any{"thinkingBudget": budget},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("llm: unknown protocol %q", protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesInput(messages []Message) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
role := string(m.Role)
|
||||||
|
if role == "" {
|
||||||
|
role = "user"
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"role": role,
|
||||||
|
"content": messageContent(m),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func anthropicMessages(messages []Message) (string, []map[string]any) {
|
||||||
|
var system strings.Builder
|
||||||
|
var out []map[string]any
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == RoleSystem {
|
||||||
|
if system.Len() > 0 {
|
||||||
|
system.WriteString("\n")
|
||||||
|
}
|
||||||
|
system.WriteString(m.Content)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := "user"
|
||||||
|
if m.Role == RoleAssistant {
|
||||||
|
role = "assistant"
|
||||||
|
}
|
||||||
|
content := []map[string]any{}
|
||||||
|
if m.Content != "" {
|
||||||
|
content = append(content, map[string]any{"type": "text", "text": m.Content})
|
||||||
|
}
|
||||||
|
for _, img := range m.Images {
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "image",
|
||||||
|
"source": map[string]any{
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": firstNonEmpty(img.MIME, "image/png"),
|
||||||
|
"data": img.Data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"role": role, "content": content})
|
||||||
|
}
|
||||||
|
return system.String(), out
|
||||||
|
}
|
||||||
|
|
||||||
|
func googleContents(messages []Message) []map[string]any {
|
||||||
|
var out []map[string]any
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == RoleSystem {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := "user"
|
||||||
|
if m.Role == RoleAssistant {
|
||||||
|
role = "model"
|
||||||
|
}
|
||||||
|
parts := []map[string]any{}
|
||||||
|
if m.Content != "" {
|
||||||
|
parts = append(parts, map[string]any{"text": m.Content})
|
||||||
|
}
|
||||||
|
for _, img := range m.Images {
|
||||||
|
parts = append(parts, map[string]any{
|
||||||
|
"inlineData": map[string]any{
|
||||||
|
"mimeType": firstNonEmpty(img.MIME, "image/png"),
|
||||||
|
"data": img.Data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"role": role, "parts": parts})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(a, b string) string {
|
||||||
|
if a != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
retryBaseDelay = 200 * time.Millisecond
|
||||||
|
maxRetryAfter = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// overflowNeedles are substrings that mean the prompt does not fit.
|
||||||
|
// Matching is case-insensitive. A bare "context" is not enough.
|
||||||
|
var overflowNeedles = []string{
|
||||||
|
"status 413",
|
||||||
|
"payload too large",
|
||||||
|
"context_length_exceeded",
|
||||||
|
"context length",
|
||||||
|
"maximum context",
|
||||||
|
"context window",
|
||||||
|
"prompt is too long",
|
||||||
|
"exceeds the maximum",
|
||||||
|
"exceeds max",
|
||||||
|
"too many tokens",
|
||||||
|
"input is too long",
|
||||||
|
"input length",
|
||||||
|
"tokens exceed",
|
||||||
|
"上下文",
|
||||||
|
"超出最大",
|
||||||
|
"输入过长",
|
||||||
|
"提示过长",
|
||||||
|
"请求过长",
|
||||||
|
"请求体过大",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextOverflow reports provider errors that ask the caller to compact.
|
||||||
|
func ContextOverflow(message string) bool {
|
||||||
|
m := strings.ToLower(message)
|
||||||
|
for _, needle := range overflowNeedles {
|
||||||
|
if strings.Contains(m, strings.ToLower(needle)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapacityLimited reports an in-stream overload that is worth retrying
|
||||||
|
// before any delta has been forwarded.
|
||||||
|
func CapacityLimited(message string) bool {
|
||||||
|
m := strings.ToLower(message)
|
||||||
|
return strings.Contains(m, "overloaded") || strings.Contains(m, "at capacity") || strings.Contains(m, "too many requests") || strings.Contains(m, "rate limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusRetryable is true for timeouts, rate limits, server errors, and 529.
|
||||||
|
func StatusRetryable(code int) bool {
|
||||||
|
return code == http.StatusRequestTimeout || code == http.StatusTooManyRequests || code == 529 || code >= 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRetryAfter reads a delay in seconds or an HTTP date.
|
||||||
|
// The returned duration is capped. ok is false when the header is missing or garbage.
|
||||||
|
func ParseRetryAfter(raw string, now time.Time) (time.Duration, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if secs, err := strconv.Atoi(raw); err == nil {
|
||||||
|
if secs < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return capRetryAfter(time.Duration(secs) * time.Second), true
|
||||||
|
}
|
||||||
|
when, err := http.ParseTime(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
d := when.Sub(now)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
return capRetryAfter(d), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func capRetryAfter(d time.Duration) time.Duration {
|
||||||
|
if d > maxRetryAfter {
|
||||||
|
return maxRetryAfter
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryBackoff(extraTry int) time.Duration {
|
||||||
|
if extraTry < 1 {
|
||||||
|
extraTry = 1
|
||||||
|
}
|
||||||
|
shift := extraTry - 1
|
||||||
|
if shift > 4 {
|
||||||
|
shift = 4
|
||||||
|
}
|
||||||
|
delay := retryBaseDelay * time.Duration(1<<uint(shift))
|
||||||
|
jitter := time.Duration(rand.Int63n(int64(delay/2) + 1))
|
||||||
|
return delay + jitter
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardedDelta(ev Event) bool {
|
||||||
|
switch ev.Kind {
|
||||||
|
case KindTextDelta, KindReasoningDelta, KindToolCallDelta:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRetry re-invokes base on a retryable terminal error, up to attempts
|
||||||
|
// extra tries. Events are forwarded as they arrive. A retry happens only when
|
||||||
|
// no delta has been forwarded yet. onEvent receives auto_retry_start and
|
||||||
|
// auto_retry_end. Context-overflow errors are returned immediately.
|
||||||
|
// attempts <= 0 disables extra tries.
|
||||||
|
func WithRetry(base StreamFunc, attempts int, onEvent func(kind, detail string)) StreamFunc {
|
||||||
|
if attempts < 0 {
|
||||||
|
attempts = 0
|
||||||
|
}
|
||||||
|
return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
out := make(chan Event)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
headerWait := time.Duration(0)
|
||||||
|
useHeader := false
|
||||||
|
for try := 0; try <= attempts; try++ {
|
||||||
|
if try > 0 {
|
||||||
|
delay := retryBackoff(try)
|
||||||
|
if useHeader {
|
||||||
|
delay = headerWait
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("attempt %d", try+1)
|
||||||
|
if onEvent != nil {
|
||||||
|
onEvent("auto_retry_start", detail)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
emitAbort(out, "", ctx.Err())
|
||||||
|
return
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useHeader = false
|
||||||
|
forwarded := false
|
||||||
|
var terminal *Event
|
||||||
|
for ev := range base(ctx, model, messages, tools, opts) {
|
||||||
|
if terminal != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ev.Kind == KindError {
|
||||||
|
cp := ev
|
||||||
|
terminal = &cp
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if forwardedDelta(ev) {
|
||||||
|
forwarded = true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case out <- ev:
|
||||||
|
}
|
||||||
|
if ev.Kind == KindMessageEnd {
|
||||||
|
if try > 0 && onEvent != nil {
|
||||||
|
onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if terminal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overflow := ContextOverflow(terminal.ErrorMessage)
|
||||||
|
if forwarded || overflow || !terminal.Retryable || try == attempts {
|
||||||
|
if try > 0 && onEvent != nil {
|
||||||
|
onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case out <- *terminal:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if terminal.HasRetryAfter {
|
||||||
|
useHeader = true
|
||||||
|
headerWait = terminal.RetryAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseModelRefAndThinking(t *testing.T) {
|
||||||
|
ref, err := ParseModelRef("anthropic/claude-sonnet:high")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ref.Provider != "anthropic" || ref.Model != "claude-sonnet" || ref.Thinking != "high" {
|
||||||
|
t.Fatalf("%+v", ref)
|
||||||
|
}
|
||||||
|
if _, err := ParseModelRef("openai/gpt:nope"); err == nil {
|
||||||
|
t.Fatal("unknown thinking level must fail")
|
||||||
|
}
|
||||||
|
if AnthropicThinkingBudget("high") != 8192 || OpenAIReasoningEffort("off") != "" {
|
||||||
|
t.Fatal("thinking map")
|
||||||
|
}
|
||||||
|
p, ok := Lookup("groq", nil)
|
||||||
|
if !ok || !strings.Contains(p.BaseURL, "groq") || p.Protocol != ProtocolOpenAIChat {
|
||||||
|
t.Fatalf("preset: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelsJSONAndBodies(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "models.json")
|
||||||
|
raw := []byte(`{"providers":[{"name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9","apiKeyEnv":"UNUSED_KEY"}]}`)
|
||||||
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := LoadModelsJSON(path)
|
||||||
|
if err != nil || len(got) != 1 || got[0].Name != "local" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
msgs := []Message{{
|
||||||
|
Role: RoleUser, Content: "see",
|
||||||
|
Images: []Image{{MIME: "image/png", Data: "aaaa"}},
|
||||||
|
}}
|
||||||
|
body, err := BuildBody(ProtocolAnthropic, "claude", append([]Message{{Role: RoleSystem, Content: "sys"}}, msgs...), nil, StreamOptions{ThinkingLevel: "low"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "budget_tokens") || !strings.Contains(string(body), "base64") {
|
||||||
|
t.Fatalf("anthropic body: %s", body)
|
||||||
|
}
|
||||||
|
gbody, err := BuildBody(ProtocolGoogle, "gemini", msgs, nil, StreamOptions{ThinkingLevel: "max"})
|
||||||
|
if err != nil || !strings.Contains(string(gbody), "inlineData") || !strings.Contains(string(gbody), "thinkingBudget") {
|
||||||
|
t.Fatalf("google: %s %v", gbody, err)
|
||||||
|
}
|
||||||
|
rbody, err := BuildBody(ProtocolOpenAIResponses, "gpt", msgs, nil, StreamOptions{ThinkingLevel: "medium", MaxTokens: 20})
|
||||||
|
if err != nil || !strings.Contains(string(rbody), "max_output_tokens") || !strings.Contains(string(rbody), "image_url") {
|
||||||
|
t.Fatalf("responses: %s %v", rbody, err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(rbody, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetsAndClientForChatCompletions(t *testing.T) {
|
||||||
|
want := map[string]string{
|
||||||
|
"anthropic": "https://api.anthropic.com/v1",
|
||||||
|
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
}
|
||||||
|
for _, p := range Presets() {
|
||||||
|
base, ok := want[p.Name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Protocol != ProtocolOpenAIChat || p.BaseURL != base {
|
||||||
|
t.Fatalf("%s preset: %+v", p.Name, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"openai-responses", "azure"} {
|
||||||
|
p, ok := Lookup(name, nil)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal(name)
|
||||||
|
}
|
||||||
|
if _, err := ClientFor(p); err == nil {
|
||||||
|
t.Fatalf("%s should fail closed", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var path string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path = r.URL.Path
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
client, err := ClientFor(Provider{Protocol: ProtocolOpenAIChat, BaseURL: srv.URL, APIKey: "k"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for ev := range client.Stream(context.Background(), "m", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}) {
|
||||||
|
if ev.Kind == KindError {
|
||||||
|
t.Fatal(ev.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path != "/chat/completions" {
|
||||||
|
t.Fatalf("path %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryThenSuccess(t *testing.T) {
|
||||||
|
n := 0
|
||||||
|
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
out := make(chan Event, 2)
|
||||||
|
n++
|
||||||
|
if n == 1 {
|
||||||
|
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "429"}
|
||||||
|
} else {
|
||||||
|
out <- Event{Kind: KindTextDelta, Text: "ok"}
|
||||||
|
out <- Event{Kind: KindMessageEnd, Message: Message{Role: RoleAssistant, Content: "ok"}}
|
||||||
|
}
|
||||||
|
close(out)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
var events []string
|
||||||
|
wrapped := WithRetry(base, 2, func(kind, detail string) { events = append(events, kind) })
|
||||||
|
var texts []string
|
||||||
|
for ev := range wrapped(context.Background(), "m", nil, nil, StreamOptions{}) {
|
||||||
|
if ev.Kind == KindTextDelta {
|
||||||
|
texts = append(texts, ev.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(texts) != 1 || texts[0] != "ok" {
|
||||||
|
t.Fatalf("texts %v calls %d", texts, n)
|
||||||
|
}
|
||||||
|
if len(events) == 0 || events[0] != "auto_retry_start" {
|
||||||
|
t.Fatalf("events %v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextOverflowPatterns(t *testing.T) {
|
||||||
|
hits := []string{
|
||||||
|
"provider status 413: payload too large",
|
||||||
|
"context_length_exceeded",
|
||||||
|
"prompt is too long",
|
||||||
|
"input length exceeds the maximum",
|
||||||
|
"maximum context length exceeded",
|
||||||
|
"context length overflow",
|
||||||
|
"too many tokens",
|
||||||
|
"输入过长,请缩短上下文",
|
||||||
|
"请求过长",
|
||||||
|
"超出最大长度",
|
||||||
|
}
|
||||||
|
for _, msg := range hits {
|
||||||
|
if !ContextOverflow(msg) {
|
||||||
|
t.Fatalf("want overflow: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ContextOverflow("context cancelled") || ContextOverflow("temporary network error") {
|
||||||
|
t.Fatal("unrelated context text must not count as overflow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
if d, ok := ParseRetryAfter("2", now); !ok || d != 2*time.Second {
|
||||||
|
t.Fatalf("seconds: %v %v", d, ok)
|
||||||
|
}
|
||||||
|
if d, ok := ParseRetryAfter("not-a-date", now); ok || d != 0 {
|
||||||
|
t.Fatalf("garbage should fail: %v %v", d, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseRetryAfter("", now); ok {
|
||||||
|
t.Fatal("empty header")
|
||||||
|
}
|
||||||
|
far := now.Add(2 * time.Hour).Format(http.TimeFormat)
|
||||||
|
if d, ok := ParseRetryAfter(far, now); !ok || d != maxRetryAfter {
|
||||||
|
t.Fatalf("date cap: %v %v", d, ok)
|
||||||
|
}
|
||||||
|
past := now.Add(-time.Minute).Format(http.TimeFormat)
|
||||||
|
if d, ok := ParseRetryAfter(past, now); !ok || d != 0 {
|
||||||
|
t.Fatalf("past date: %v %v", d, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDoesNotRepeatDeltas(t *testing.T) {
|
||||||
|
n := 0
|
||||||
|
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
out := make(chan Event, 2)
|
||||||
|
n++
|
||||||
|
out <- Event{Kind: KindTextDelta, Text: "a"}
|
||||||
|
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "overloaded"}
|
||||||
|
close(out)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
var texts []string
|
||||||
|
var errors int
|
||||||
|
for ev := range WithRetry(base, 2, nil)(context.Background(), "m", nil, nil, StreamOptions{}) {
|
||||||
|
if ev.Kind == KindTextDelta {
|
||||||
|
texts = append(texts, ev.Text)
|
||||||
|
}
|
||||||
|
if ev.Kind == KindError {
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 || len(texts) != 1 || texts[0] != "a" || errors != 1 {
|
||||||
|
t.Fatalf("calls=%d texts=%v errors=%d", n, texts, errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextOverflowIsNotRetried(t *testing.T) {
|
||||||
|
if !ContextOverflow("maximum context length exceeded") {
|
||||||
|
t.Fatal("expected overflow")
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
||||||
|
out := make(chan Event, 1)
|
||||||
|
n++
|
||||||
|
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "context length overflow"}
|
||||||
|
close(out)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
for range WithRetry(base, 3, nil)(context.Background(), "m", nil, nil, StreamOptions{}) {
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("overflow must not retry, calls=%d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestR2SSEJoinsMultilineData(t *testing.T) {
|
||||||
|
body := strings.Join([]string{
|
||||||
|
`data: {"choices":[{"delta":{"content":"hi"}`,
|
||||||
|
`data: }]}`,
|
||||||
|
``,
|
||||||
|
`data: [DONE]`,
|
||||||
|
``,
|
||||||
|
}, "\n")
|
||||||
|
var got string
|
||||||
|
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||||
|
if ev.Kind == KindTextDelta {
|
||||||
|
got += ev.Text
|
||||||
|
}
|
||||||
|
if ev.Kind == KindError {
|
||||||
|
t.Fatal(ev.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got != "hi" {
|
||||||
|
t.Fatalf("joined content %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
-14
@@ -26,12 +26,19 @@ type sseChoice struct {
|
|||||||
|
|
||||||
type sseChunk struct {
|
type sseChunk struct {
|
||||||
Choices []sseChoice `json:"choices"`
|
Choices []sseChoice `json:"choices"`
|
||||||
Usage *Usage `json:"usage"`
|
Usage *usageWire `json:"usage"`
|
||||||
Error *struct {
|
Error *struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type usageWire struct {
|
||||||
|
Usage
|
||||||
|
Details struct {
|
||||||
|
Cached int `json:"cached_tokens"`
|
||||||
|
} `json:"prompt_tokens_details"`
|
||||||
|
}
|
||||||
|
|
||||||
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
|
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
|
||||||
// deltas plus one terminal message_end or error event.
|
// deltas plus one terminal message_end or error event.
|
||||||
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
|
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
|
||||||
@@ -49,22 +56,21 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
|||||||
order := []int{}
|
order := []int{}
|
||||||
var usage Usage
|
var usage Usage
|
||||||
finished := ""
|
finished := ""
|
||||||
|
sawDone := false
|
||||||
sc := bufio.NewScanner(r)
|
sc := bufio.NewScanner(r)
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
for sc.Scan() {
|
reader := &sseEventReader{sc: sc}
|
||||||
|
for {
|
||||||
|
data, ok := reader.next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
emitAbort(out, content.String(), ctx.Err())
|
emitAbort(out, content.String(), ctx.Err())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
line := strings.TrimSpace(sc.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, ":") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(line, "data:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
||||||
if data == "[DONE]" {
|
if data == "[DONE]" {
|
||||||
|
sawDone = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
var chunk sseChunk
|
var chunk sseChunk
|
||||||
@@ -73,11 +79,16 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if chunk.Error != nil && chunk.Error.Message != "" {
|
if chunk.Error != nil && chunk.Error.Message != "" {
|
||||||
emitError(out, content.String(), false, chunk.Error.Message)
|
msg := chunk.Error.Message
|
||||||
|
retryable := CapacityLimited(msg) && !ContextOverflow(msg)
|
||||||
|
emitError(out, content.String(), retryable, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if chunk.Usage != nil {
|
if chunk.Usage != nil {
|
||||||
usage = *chunk.Usage
|
usage = chunk.Usage.Usage
|
||||||
|
if chunk.Usage.Details.Cached > 0 {
|
||||||
|
usage.CacheReadTokens = chunk.Usage.Details.Cached
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(chunk.Choices) == 0 {
|
if len(chunk.Choices) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -144,6 +155,16 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
|||||||
emitError(out, content.String(), true, err.Error())
|
emitError(out, content.String(), true, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 取消必须压过正常结束。select 在两个分支都就绪时随机挑选,
|
||||||
|
// 竞态下会把 message_end 发给已经取消的调用方。
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
emitAbort(out, content.String(), ctx.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// length、空结束原因,或没有 [DONE] 的中断,都不把半截工具调用交给执行层。
|
||||||
|
if !toolFinishExecutable(finished, sawDone) {
|
||||||
|
order = nil
|
||||||
|
}
|
||||||
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
|
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
|
||||||
for _, idx := range order {
|
for _, idx := range order {
|
||||||
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
|
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
|
||||||
@@ -154,7 +175,75 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
emitAbort(out, content.String(), ctx.Err())
|
emitAbort(out, content.String(), ctx.Err())
|
||||||
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage}:
|
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished}:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseEventReader 按空行切事件,并把同一事件里的多行 data: 用换行拼回。
|
||||||
|
// 已经是完整 JSON 的 data 行单独成事件,这样单行流不用空行也能解析。
|
||||||
|
type sseEventReader struct {
|
||||||
|
sc *bufio.Scanner
|
||||||
|
carry string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sseEventReader) next() (string, bool) {
|
||||||
|
var parts []string
|
||||||
|
nextPiece := func() (string, bool) {
|
||||||
|
if r.carry != "" {
|
||||||
|
piece := r.carry
|
||||||
|
r.carry = ""
|
||||||
|
return piece, true
|
||||||
|
}
|
||||||
|
if !r.sc.Scan() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return r.sc.Text(), true
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
line, ok := nextPiece()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n"), true
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, ":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
piece := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||||
|
if len(parts) > 0 && json.Valid([]byte(strings.Join(parts, "\n"))) {
|
||||||
|
r.carry = line
|
||||||
|
return strings.Join(parts, "\n"), true
|
||||||
|
}
|
||||||
|
parts = append(parts, piece)
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return strings.Join(parts, "\n"), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolFinishExecutable 只在供应商明确以工具调用或正常 stop 结束时执行。
|
||||||
|
// length、缺失的结束原因,以及没有 [DONE] 的断开,都视为未完成。
|
||||||
|
func toolFinishExecutable(reason string, sawDone bool) bool {
|
||||||
|
switch reason {
|
||||||
|
case "tool_calls", "function_call":
|
||||||
|
return true
|
||||||
|
case "stop":
|
||||||
|
return true
|
||||||
|
case "length":
|
||||||
|
return false
|
||||||
|
case "":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return sawDone && reason != "error" && reason != "content_filter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +253,9 @@ func mapFinish(reason string, hasTools bool) StopReason {
|
|||||||
return StopToolCalls
|
return StopToolCalls
|
||||||
case "content_filter", "error":
|
case "content_filter", "error":
|
||||||
return StopError
|
return StopError
|
||||||
case "stop", "length", "":
|
case "length":
|
||||||
|
return StopEnd
|
||||||
|
case "stop", "":
|
||||||
if hasTools {
|
if hasTools {
|
||||||
return StopToolCalls
|
return StopToolCalls
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,29 @@ func TestParseSSETextAndUsage(t *testing.T) {
|
|||||||
if end.Usage.TotalTokens != 5 {
|
if end.Usage.TotalTokens != 5 {
|
||||||
t.Fatalf("usage %#v", end.Usage)
|
t.Fatalf("usage %#v", end.Usage)
|
||||||
}
|
}
|
||||||
|
if end.FinishReason != "stop" {
|
||||||
|
t.Fatalf("finish reason %q, want stop", end.FinishReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSEFinishReasonLengthKeepsStopEnd(t *testing.T) {
|
||||||
|
body := strings.Join([]string{
|
||||||
|
`data: {"choices":[{"delta":{"content":"半"},"finish_reason":"length"}]}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
var end Event
|
||||||
|
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||||
|
if ev.Kind == KindMessageEnd {
|
||||||
|
end = ev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end.FinishReason != "length" {
|
||||||
|
t.Fatalf("finish reason %q, want length", end.FinishReason)
|
||||||
|
}
|
||||||
|
if end.Message.StopReason != StopEnd {
|
||||||
|
t.Fatalf("stop reason %s, want end so existing callers stay unchanged", end.Message.StopReason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSSEToolCallIncrements(t *testing.T) {
|
func TestParseSSEToolCallIncrements(t *testing.T) {
|
||||||
@@ -70,6 +93,51 @@ func TestParseSSEToolCallIncrements(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSSELengthDropsUnfinishedToolCall(t *testing.T) {
|
||||||
|
body := strings.Join([]string{
|
||||||
|
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":"}}]}}]}`,
|
||||||
|
`data: {"choices":[{"delta":{},"finish_reason":"length"}]}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
var end Event
|
||||||
|
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||||
|
if ev.Kind == KindMessageEnd {
|
||||||
|
end = ev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end.Message.StopReason != StopEnd || len(end.Message.ToolCalls) != 0 {
|
||||||
|
t.Fatalf("truncated tool call must not be executable: %#v", end.Message)
|
||||||
|
}
|
||||||
|
if end.FinishReason != "length" {
|
||||||
|
t.Fatalf("finish %q", end.FinishReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSEUncleanEndDropsToolCall(t *testing.T) {
|
||||||
|
body := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":"}}]}}]}`
|
||||||
|
var end Event
|
||||||
|
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||||
|
if ev.Kind == KindMessageEnd {
|
||||||
|
end = ev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end.Kind != KindMessageEnd || end.Message.StopReason != StopEnd || len(end.Message.ToolCalls) != 0 {
|
||||||
|
t.Fatalf("unclean stream must not execute tools: %#v", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSECapacityErrorIsRetryable(t *testing.T) {
|
||||||
|
body := "data: {\"error\":{\"message\":\"server is at capacity\"}}\n\n"
|
||||||
|
var got Event
|
||||||
|
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||||
|
got = ev
|
||||||
|
}
|
||||||
|
if got.Kind != KindError || !got.Retryable || !CapacityLimited(got.ErrorMessage) {
|
||||||
|
t.Fatalf("event %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSSEMalformedIsError(t *testing.T) {
|
func TestParseSSEMalformedIsError(t *testing.T) {
|
||||||
body := "data: {not-json}\n\n"
|
body := "data: {not-json}\n\n"
|
||||||
var got Event
|
var got Event
|
||||||
|
|||||||
+38
-7
@@ -3,6 +3,7 @@ package llm
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Role is a transcript role carried into the provider request.
|
// Role is a transcript role carried into the provider request.
|
||||||
@@ -48,6 +49,7 @@ type Message struct {
|
|||||||
Role Role `json:"role"`
|
Role Role `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
Images []Image `json:"images,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
StopReason StopReason `json:"stop_reason,omitempty"`
|
StopReason StopReason `json:"stop_reason,omitempty"`
|
||||||
@@ -61,11 +63,26 @@ type ToolDecl struct {
|
|||||||
Parameters json.RawMessage `json:"parameters"`
|
Parameters json.RawMessage `json:"parameters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage counts tokens reported by the provider. Zero means the provider omitted usage.
|
// Image is one picture attached to a user message.
|
||||||
|
type Image struct {
|
||||||
|
MIME string `json:"mime,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage counts tokens. On one provider response, PromptTokens, CompletionTokens
|
||||||
|
// and TotalTokens are that request. On a loop Result they are the billing sum
|
||||||
|
// across requests; Last* is only the latest request, for context observation.
|
||||||
|
// Zero means the provider omitted usage.
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
TotalTokens int `json:"total_tokens"`
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
|
||||||
|
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
|
||||||
|
LastPromptTokens int `json:"last_prompt_tokens,omitempty"`
|
||||||
|
LastCompletionTokens int `json:"last_completion_tokens,omitempty"`
|
||||||
|
LastTotalTokens int `json:"last_total_tokens,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event is one streamed update. Failures are KindError plus a final assistant
|
// Event is one streamed update. Failures are KindError plus a final assistant
|
||||||
@@ -80,9 +97,18 @@ type Event struct {
|
|||||||
ArgumentsDelta string
|
ArgumentsDelta string
|
||||||
Message Message
|
Message Message
|
||||||
Usage Usage
|
Usage Usage
|
||||||
Err error
|
// FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。
|
||||||
Retryable bool
|
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
|
||||||
ErrorMessage string
|
FinishReason string
|
||||||
|
Err error
|
||||||
|
Retryable bool
|
||||||
|
ErrorMessage string
|
||||||
|
// HTTPStatus 是提供方的 HTTP 状态。0 表示这条错误不是 HTTP 响应。
|
||||||
|
HTTPStatus int
|
||||||
|
// RetryAfter is the provider's requested wait. HasRetryAfter distinguishes
|
||||||
|
// a real zero from a missing or unparseable header.
|
||||||
|
RetryAfter time.Duration
|
||||||
|
HasRetryAfter bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamOptions tunes one provider request.
|
// StreamOptions tunes one provider request.
|
||||||
@@ -90,6 +116,11 @@ type StreamOptions struct {
|
|||||||
MaxTokens int
|
MaxTokens int
|
||||||
APIBase string
|
APIBase string
|
||||||
APIKey string
|
APIKey string
|
||||||
|
// ThinkingLevel is off, minimal, low, medium, high, xhigh, or max.
|
||||||
|
// Empty leaves the provider default unchanged.
|
||||||
|
ThinkingLevel string
|
||||||
|
// MessageShape is merge, strict, or empty. Empty leaves the transcript unchanged.
|
||||||
|
MessageShape string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamFunc is the provider boundary. It must return a channel that is closed
|
// StreamFunc is the provider boundary. It must return a channel that is closed
|
||||||
|
|||||||
+567
-20
@@ -8,7 +8,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
"agent-native/runtime/llm"
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/reminders"
|
||||||
"agent-native/runtime/session"
|
"agent-native/runtime/session"
|
||||||
"agent-native/runtime/tools"
|
"agent-native/runtime/tools"
|
||||||
|
|
||||||
@@ -18,8 +20,15 @@ import (
|
|||||||
const (
|
const (
|
||||||
ExecSequential = "sequential"
|
ExecSequential = "sequential"
|
||||||
ExecParallel = "parallel"
|
ExecParallel = "parallel"
|
||||||
|
|
||||||
|
maxStopContinuations = 3
|
||||||
|
maxLengthContinuations = 3
|
||||||
|
maxRepeatReminders = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const repeatReminderText = "你已连续 3 次以相同参数调用同一工具。如果结果不符合预期,请更换参数或做法,不要再重复这一调用。"
|
||||||
|
const lengthContinuePrompt = "上一段回复因为达到长度上限被截断。请从断点接着写,不要重复已经写出的内容。"
|
||||||
|
|
||||||
// Event is a lifecycle notice for logs and UI.
|
// Event is a lifecycle notice for logs and UI.
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type string
|
Type string
|
||||||
@@ -29,23 +38,34 @@ type Event struct {
|
|||||||
// Detail carries tool arguments/results or reasoning deltas for
|
// Detail carries tool arguments/results or reasoning deltas for
|
||||||
// process-visualization consumers; log writers may ignore it.
|
// process-visualization consumers; log writers may ignore it.
|
||||||
Detail string
|
Detail string
|
||||||
// PromptTokens / CompletionTokens 只在 type=usage 时有意义。
|
// PromptTokens / CompletionTokens / TotalTokens 在 type=usage 或
|
||||||
|
// type=message_end 时有意义,供调用方累计用量。
|
||||||
PromptTokens int
|
PromptTokens int
|
||||||
CompletionTokens int
|
CompletionTokens int
|
||||||
|
TotalTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config drives one agent session.
|
// Config drives one agent session.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Model string
|
Model string
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
Tools *tools.Registry
|
Tools *tools.Registry
|
||||||
Stream llm.StreamFunc
|
Stream llm.StreamFunc
|
||||||
Session *session.Store
|
Session *session.Store
|
||||||
|
// Trace 开始一个 span,返回的函数结束它。nil 表示不采集。
|
||||||
|
// 属性里不要放提示词正文。
|
||||||
|
Trace func(name string, attrs map[string]string) func()
|
||||||
MaxTurns int
|
MaxTurns int
|
||||||
ToolExecution string
|
ToolExecution string
|
||||||
GetSteering func() []llm.Message
|
GetSteering func() []llm.Message
|
||||||
GetFollowUp func() []llm.Message
|
GetFollowUp func() []llm.Message
|
||||||
OnEvent func(Event)
|
// OnEvent 观察循环事件。同一次 Run 内不会并发调用
|
||||||
|
// (与 BeforeTool、AfterTool 共用一把锁串行)。
|
||||||
|
OnEvent func(Event)
|
||||||
|
// ThinkingLevel is forwarded on each provider request.
|
||||||
|
ThinkingLevel string
|
||||||
|
// Resolve, when set, overrides Model and ThinkingLevel at the start of each turn.
|
||||||
|
Resolve func() (model, thinking string)
|
||||||
// Log 可选。nil 时丢弃日志,不改变循环结果。
|
// Log 可选。nil 时丢弃日志,不改变循环结果。
|
||||||
Log *logsys.Logger
|
Log *logsys.Logger
|
||||||
// ExecutionID 只用于日志字段,不参与控制流。
|
// ExecutionID 只用于日志字段,不参与控制流。
|
||||||
@@ -53,6 +73,68 @@ type Config struct {
|
|||||||
// Budget 可选。任一上限命中即停止并返回 budget_exceeded 错误(StopError);
|
// Budget 可选。任一上限命中即停止并返回 budget_exceeded 错误(StopError);
|
||||||
// 零值字段不设限。截止时刻与外层 ctx 先到者生效。
|
// 零值字段不设限。截止时刻与外层 ctx 先到者生效。
|
||||||
Budget *Budget
|
Budget *Budget
|
||||||
|
// History 是会话转录为空时的初始转录。以 system 开头时,不再前置
|
||||||
|
// SystemPrompt 和工具清单。不回写进 Session。
|
||||||
|
History []llm.Message
|
||||||
|
// Stop 在模型不再调用工具、且本轮没有 follow-up 时询问是否续跑。
|
||||||
|
// nil 表示不询问。内核最多接受 3 次 Continue。
|
||||||
|
Stop func(last llm.Message, info StopInfo) StopDecision
|
||||||
|
// BeforeTool 返回错误时不执行工具,但仍写一条 IsError 的 tool 结果。
|
||||||
|
// 同一次 Run 内与 OnEvent、AfterTool 串行,不会并发调用。
|
||||||
|
BeforeTool func(call llm.ToolCall) error
|
||||||
|
// UnknownTool 处理注册表里没有的调用。nil 时仍返回 unknown tool。
|
||||||
|
UnknownTool func(call llm.ToolCall) tools.Result
|
||||||
|
// AfterTool 只观察工具结果,不能改写。
|
||||||
|
// 同一次 Run 内与 OnEvent、BeforeTool 串行,不会并发调用。
|
||||||
|
AfterTool func(call llm.ToolCall, res llm.Message)
|
||||||
|
// Reminders 在每次调用模型前提供提醒。正文包在 system-reminder 里,
|
||||||
|
// 并入最后一条工具结果或用户消息,只进入本次请求,不写进 Session。
|
||||||
|
Reminders func(turn int) []string
|
||||||
|
// RepeatGuard 为真时,同名且参数按键排序后相同的调用连续 3 次,注入一条提醒。
|
||||||
|
// 每个 run 最多 3 条,不中断循环。
|
||||||
|
RepeatGuard bool
|
||||||
|
// ContinueOnLength 为真时,FinishReason=length 且没有工具调用就续写。
|
||||||
|
// 续写提示不写进 Session。次数由 MaxLengthContinuations 决定。
|
||||||
|
ContinueOnLength bool
|
||||||
|
// MaxLengthContinuations 是 length 续写的次数上限。
|
||||||
|
// 0 表示默认 3 次;负数表示不限制。
|
||||||
|
MaxLengthContinuations int
|
||||||
|
// ContextWindow 是模型窗口的字符估算。0 表示用保守默认值。
|
||||||
|
// 超过窗口减去 OutputReserve 与 ContextBuffer 时,清理最近若干条之前的旧工具结果。
|
||||||
|
ContextWindow int
|
||||||
|
// OutputReserve 是留给模型输出的字符数。0 表示默认预留。
|
||||||
|
OutputReserve int
|
||||||
|
// ContextBuffer 是窗口估算的余量。0 表示默认余量。
|
||||||
|
ContextBuffer int
|
||||||
|
// KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。
|
||||||
|
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 进度。
|
||||||
|
type StopInfo struct {
|
||||||
|
Continuations int
|
||||||
|
ToolCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopDecision 决定模型停下之后是否再跑一轮。
|
||||||
|
type StopDecision struct {
|
||||||
|
Continue bool
|
||||||
|
Replacement string
|
||||||
|
Reason string
|
||||||
|
Notice string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budget 是循环层的执行预算;与 exec.Budget 字段同构,由适配层换算。
|
// Budget 是循环层的执行预算;与 exec.Budget 字段同构,由适配层换算。
|
||||||
@@ -69,6 +151,7 @@ type Result struct {
|
|||||||
Messages []llm.Message
|
Messages []llm.Message
|
||||||
StopReason llm.StopReason
|
StopReason llm.StopReason
|
||||||
Usage llm.Usage
|
Usage llm.Usage
|
||||||
|
Notice string
|
||||||
Err error
|
Err error
|
||||||
Retryable bool
|
Retryable bool
|
||||||
}
|
}
|
||||||
@@ -84,6 +167,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
|||||||
if cfg.ToolExecution == "" {
|
if cfg.ToolExecution == "" {
|
||||||
cfg.ToolExecution = ExecSequential
|
cfg.ToolExecution = ExecSequential
|
||||||
}
|
}
|
||||||
|
serializeHostCallbacks(&cfg)
|
||||||
|
endRun := startSpan(cfg, "invoke_agent", map[string]string{"model": cfg.Model})
|
||||||
|
defer endRun()
|
||||||
var messages []llm.Message
|
var messages []llm.Message
|
||||||
if cfg.Session != nil {
|
if cfg.Session != nil {
|
||||||
loaded, err := cfg.Session.Load()
|
loaded, err := cfg.Session.Load()
|
||||||
@@ -92,6 +178,19 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
|||||||
}
|
}
|
||||||
messages = loaded
|
messages = loaded
|
||||||
}
|
}
|
||||||
|
if err := recoverPending(ctx, cfg); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if cfg.Session != nil {
|
||||||
|
loaded, err := cfg.Session.Load()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
messages = loaded
|
||||||
|
}
|
||||||
|
if len(messages) == 0 && len(cfg.History) > 0 {
|
||||||
|
messages = append([]llm.Message{}, cfg.History...)
|
||||||
|
}
|
||||||
if len(messages) == 0 || messages[0].Role != llm.RoleSystem {
|
if len(messages) == 0 || messages[0].Role != llm.RoleSystem {
|
||||||
sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)}
|
sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)}
|
||||||
messages = append([]llm.Message{sys}, messages...)
|
messages = append([]llm.Message{sys}, messages...)
|
||||||
@@ -104,10 +203,39 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
|||||||
}
|
}
|
||||||
messages = append(messages, user)
|
messages = append(messages, user)
|
||||||
emit(cfg, Event{Type: "agent_start"})
|
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 last llm.Message
|
||||||
var usage llm.Usage
|
var usage llm.Usage
|
||||||
toolCalls := 0
|
toolCalls := 0
|
||||||
|
stopContinues := 0
|
||||||
|
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++ {
|
for turn := 0; turn < cfg.MaxTurns; turn++ {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
cfg.logger().Error("runtime turn aborted", ctx.Err())
|
cfg.logger().Error("runtime turn aborted", ctx.Err())
|
||||||
@@ -117,7 +245,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
|||||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Type: "turn_start"})
|
emit(cfg, Event{Type: "turn_start"})
|
||||||
end, errEv := consume(ctx, cfg, messages)
|
emit(cfg, Event{Type: "message_start"})
|
||||||
|
request := prepareRequest(cfg, messages, turn, &repeats, &lengthNudge)
|
||||||
|
end, errEv := consume(ctx, cfg, request)
|
||||||
if errEv != nil {
|
if errEv != nil {
|
||||||
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
|
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
|
||||||
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
|
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
|
||||||
@@ -126,18 +256,37 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
|||||||
usage.PromptTokens += end.Usage.PromptTokens
|
usage.PromptTokens += end.Usage.PromptTokens
|
||||||
usage.CompletionTokens += end.Usage.CompletionTokens
|
usage.CompletionTokens += end.Usage.CompletionTokens
|
||||||
usage.TotalTokens += end.Usage.TotalTokens
|
usage.TotalTokens += end.Usage.TotalTokens
|
||||||
|
usage.LastPromptTokens = end.Usage.PromptTokens
|
||||||
|
usage.LastCompletionTokens = end.Usage.CompletionTokens
|
||||||
|
usage.LastTotalTokens = end.Usage.TotalTokens
|
||||||
if err := persist(cfg, last); err != nil {
|
if err := persist(cfg, last); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
messages = append(messages, last)
|
messages = append(messages, last)
|
||||||
emit(cfg, Event{Type: "message_end", Message: last})
|
emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens})
|
||||||
|
|
||||||
if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 {
|
if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 {
|
||||||
|
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
|
||||||
|
var err error
|
||||||
|
messages, err = appendSkippedToolResults(cfg, last.ToolCalls, messages)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||||
|
}
|
||||||
if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls {
|
if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls {
|
||||||
reason := fmt.Sprintf("budget_exceeded: tool calls %d+%d > max %d", toolCalls, len(last.ToolCalls), cfg.Budget.MaxToolCalls)
|
reason := fmt.Sprintf("budget_exceeded: tool calls %d+%d > max %d", toolCalls, len(last.ToolCalls), cfg.Budget.MaxToolCalls)
|
||||||
|
var err error
|
||||||
|
messages, err = appendSkippedToolResults(cfg, last.ToolCalls, messages)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||||
}
|
}
|
||||||
toolCalls += len(last.ToolCalls)
|
toolCalls += len(last.ToolCalls)
|
||||||
|
if cfg.RepeatGuard {
|
||||||
|
repeats.observe(last.ToolCalls)
|
||||||
|
}
|
||||||
results := executeTools(ctx, cfg, last)
|
results := executeTools(ctx, cfg, last)
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
if err := persist(cfg, res); err != nil {
|
if err := persist(cfg, res); err != nil {
|
||||||
@@ -153,10 +302,25 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
|||||||
messages = append(messages, m)
|
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})
|
emit(cfg, Event{Type: "turn_end", Message: last})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Type: "turn_end", Message: last})
|
emit(cfg, Event{Type: "turn_end", Message: last})
|
||||||
|
if cfg.ContinueOnLength && end.FinishReason == "length" && len(last.ToolCalls) == 0 {
|
||||||
|
lengthCap := cfg.MaxLengthContinuations
|
||||||
|
if lengthCap == 0 {
|
||||||
|
lengthCap = maxLengthContinuations
|
||||||
|
}
|
||||||
|
if lengthCap > 0 && lengthContinues >= lengthCap {
|
||||||
|
errCap := errString("length continuation exceeded")
|
||||||
|
cfg.logger().Error("runtime length continuation exceeded", errCap)
|
||||||
|
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: errCap}, nil
|
||||||
|
}
|
||||||
|
lengthContinues++
|
||||||
|
lengthNudge = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if follow := drain(cfg.GetFollowUp); len(follow) > 0 {
|
if follow := drain(cfg.GetFollowUp); len(follow) > 0 {
|
||||||
for _, m := range follow {
|
for _, m := range follow {
|
||||||
if err := persist(cfg, m); err != nil {
|
if err := persist(cfg, m); err != nil {
|
||||||
@@ -166,9 +330,30 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Type: "agent_end", Message: last})
|
if decision, hooked := stopDecision(cfg, last, stopContinues, toolCalls); hooked {
|
||||||
emitUsage(cfg, usage)
|
if decision.Continue && stopContinues < maxStopContinuations {
|
||||||
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil
|
stopContinues++
|
||||||
|
if decision.Replacement != "" && len(messages) > 0 {
|
||||||
|
messages[len(messages)-1].Content = decision.Replacement
|
||||||
|
last.Content = decision.Replacement
|
||||||
|
note := llm.Message{Role: llm.RoleUser, Content: decision.Replacement}
|
||||||
|
if err := persist(cfg, note); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
messages = append(messages, note)
|
||||||
|
}
|
||||||
|
if decision.Reason != "" {
|
||||||
|
reasonMsg := llm.Message{Role: llm.RoleUser, Content: decision.Reason}
|
||||||
|
if err := persist(cfg, reasonMsg); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
messages = append(messages, reasonMsg)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return settle(cfg, last, messages, usage, decision.Notice), nil
|
||||||
|
}
|
||||||
|
return settle(cfg, last, messages, usage, ""), nil
|
||||||
}
|
}
|
||||||
errCap := errString("max turns exceeded")
|
errCap := errString("max turns exceeded")
|
||||||
cfg.logger().Error("runtime turn cap hit", errCap)
|
cfg.logger().Error("runtime turn cap hit", errCap)
|
||||||
@@ -216,15 +401,29 @@ func systemText(cfg Config) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event, error) {
|
func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event, error) {
|
||||||
|
endSpan := startSpan(cfg, "chat", map[string]string{"model": cfg.Model})
|
||||||
|
defer endSpan()
|
||||||
var decls []llm.ToolDecl
|
var decls []llm.ToolDecl
|
||||||
if cfg.Tools != nil {
|
if cfg.Tools != nil {
|
||||||
for _, d := range cfg.Tools.Decls() {
|
for _, d := range cfg.Tools.Decls() {
|
||||||
decls = append(decls, llm.ToolDecl{Name: d.Name, Description: d.Description, Parameters: d.Parameters})
|
decls = append(decls, llm.ToolDecl{Name: d.Name, Description: d.Description, Parameters: d.Parameters})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
model := cfg.Model
|
||||||
|
opts := llm.StreamOptions{ThinkingLevel: cfg.ThinkingLevel}
|
||||||
|
if cfg.Resolve != nil {
|
||||||
|
if m, th := cfg.Resolve(); m != "" || th != "" {
|
||||||
|
if m != "" {
|
||||||
|
model = m
|
||||||
|
}
|
||||||
|
if th != "" {
|
||||||
|
opts.ThinkingLevel = th
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var end llm.Event
|
var end llm.Event
|
||||||
sawEnd := false
|
sawEnd := false
|
||||||
for ev := range cfg.Stream(ctx, cfg.Model, messages, decls, llm.StreamOptions{}) {
|
for ev := range cfg.Stream(ctx, model, messages, decls, opts) {
|
||||||
switch ev.Kind {
|
switch ev.Kind {
|
||||||
case llm.KindTextDelta:
|
case llm.KindTextDelta:
|
||||||
emit(cfg, Event{Type: "text_delta", Text: ev.Text})
|
emit(cfg, Event{Type: "text_delta", Text: ev.Text})
|
||||||
@@ -245,12 +444,66 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
|
|||||||
return end, nil
|
return end, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolBudgetSkipContent = "not executed: tool budget exceeded"
|
||||||
|
|
||||||
|
// appendSkippedToolResults 为未执行的 tool_call 各写一条错误结果,保持调用与结果成对。
|
||||||
|
// 不发工具事件:这些调用没有进入执行。
|
||||||
|
func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.Message) ([]llm.Message, error) {
|
||||||
|
for _, call := range calls {
|
||||||
|
msg := llm.Message{
|
||||||
|
Role: llm.RoleTool,
|
||||||
|
Name: call.Name,
|
||||||
|
ToolCallID: call.ID,
|
||||||
|
Content: toolBudgetSkipContent,
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
if err := persist(cfg, msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
|
||||||
|
func serializeHostCallbacks(cfg *Config) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
if cfg.OnEvent != nil {
|
||||||
|
inner := cfg.OnEvent
|
||||||
|
cfg.OnEvent = func(ev Event) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
inner(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.BeforeTool != nil {
|
||||||
|
inner := cfg.BeforeTool
|
||||||
|
cfg.BeforeTool = func(call llm.ToolCall) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return inner(call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.AfterTool != nil {
|
||||||
|
inner := cfg.AfterTool
|
||||||
|
cfg.AfterTool = func(call llm.ToolCall, res llm.Message) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
inner(call, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
|
func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
|
||||||
if cfg.ToolExecution == ExecParallel && len(assistant.ToolCalls) > 1 {
|
if cfg.ToolExecution == ExecParallel && len(assistant.ToolCalls) > 1 {
|
||||||
return executeParallel(ctx, cfg, assistant)
|
return executeParallel(ctx, cfg, assistant)
|
||||||
}
|
}
|
||||||
out := make([]llm.Message, 0, len(assistant.ToolCalls))
|
out := make([]llm.Message, 0, len(assistant.ToolCalls))
|
||||||
for _, call := range assistant.ToolCalls {
|
for _, call := range assistant.ToolCalls {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
out = append(out, cancelledToolMessage(call, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, runOne(ctx, cfg, call))
|
out = append(out, runOne(ctx, cfg, call))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -270,20 +523,314 @@ func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []l
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cancelledToolMessage(call llm.ToolCall, err error) llm.Message {
|
||||||
|
return llm.Message{
|
||||||
|
Role: llm.RoleTool,
|
||||||
|
Name: call.Name,
|
||||||
|
ToolCallID: call.ID,
|
||||||
|
Content: "not executed: " + err.Error(),
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectIncompleteToolCall(call llm.ToolCall) string {
|
||||||
|
if strings.TrimSpace(call.Name) == "" {
|
||||||
|
return "工具调用未执行:名称不完整"
|
||||||
|
}
|
||||||
|
args := strings.TrimSpace(call.Arguments)
|
||||||
|
if !json.Valid([]byte(args)) || !strings.HasPrefix(args, "{") {
|
||||||
|
return "工具调用未执行:参数不是完整的 JSON 对象"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopDecision(cfg Config, last llm.Message, continuations, toolCalls int) (StopDecision, bool) {
|
||||||
|
if cfg.Hooks != nil {
|
||||||
|
out := cfg.Hooks.Emit(hooks.Call{
|
||||||
|
Event: hooks.Stop, LastText: last.Content, Continuations: continuations, ToolCalls: toolCalls,
|
||||||
|
})
|
||||||
|
if out.Decision != "" {
|
||||||
|
return StopDecision{
|
||||||
|
Continue: out.Decision == hooks.Block, Reason: out.Reason,
|
||||||
|
Replacement: out.Replacement, Notice: out.Notice,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Stop != nil {
|
||||||
|
return cfg.Stop(last, StopInfo{Continuations: continuations, ToolCalls: toolCalls}), true
|
||||||
|
}
|
||||||
|
return StopDecision{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Ask:
|
||||||
|
// ask 先问宿主;没有裁决端口(或宿主拒绝)时保持旧行为:按拒绝处理。
|
||||||
|
cfg.Hooks.Emit(hooks.Call{Event: hooks.PermissionRequest, Tool: call.Name, Args: call.Arguments})
|
||||||
|
why := out.Reason
|
||||||
|
if cfg.PermissionAsk != nil {
|
||||||
|
if verdict := cfg.PermissionAsk(ctx, call); verdict.Allow {
|
||||||
|
return call, llm.Message{}, false
|
||||||
|
} else if verdict.Reason != "" {
|
||||||
|
why = verdict.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
ev := hooks.PostToolUse
|
||||||
|
if msg.IsError {
|
||||||
|
ev = hooks.PostToolUseFailure
|
||||||
|
}
|
||||||
|
cfg.Hooks.Emit(hooks.Call{Event: ev, Tool: call.Name, Args: call.Arguments, Result: msg.Content, IsError: msg.IsError})
|
||||||
|
}
|
||||||
|
|
||||||
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name})
|
if err := ctx.Err(); err != nil {
|
||||||
|
return cancelledToolMessage(call, err)
|
||||||
|
}
|
||||||
|
if why := rejectIncompleteToolCall(call); why != "" {
|
||||||
|
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
|
||||||
|
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
|
||||||
|
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: why})
|
||||||
|
if cfg.AfterTool != nil {
|
||||||
|
cfg.AfterTool(call, msg)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
|
||||||
|
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 {
|
||||||
|
cfg.AfterTool(call, msg)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
} else {
|
||||||
|
call = gated
|
||||||
|
}
|
||||||
|
if cfg.BeforeTool != nil {
|
||||||
|
if err := cfg.BeforeTool(call); err != nil {
|
||||||
|
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||||
|
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
|
||||||
|
noteToolHook(cfg, call, msg)
|
||||||
|
if cfg.AfterTool != nil {
|
||||||
|
cfg.AfterTool(call, msg)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
|
||||||
|
if err := commitToolIntent(cfg, call); err != nil {
|
||||||
|
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||||
|
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
endSpan := startSpan(cfg, "execute_tool", map[string]string{"tool": call.Name})
|
||||||
|
defer endSpan()
|
||||||
res := tools.Result{Content: "no tools configured", IsError: true}
|
res := tools.Result{Content: "no tools configured", IsError: true}
|
||||||
if cfg.Tools != nil {
|
known := cfg.Tools != nil && cfg.Tools.Has(call.Name)
|
||||||
|
switch {
|
||||||
|
case known:
|
||||||
|
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
|
||||||
|
case cfg.UnknownTool != nil:
|
||||||
|
res = cfg.UnknownTool(call)
|
||||||
|
case cfg.Tools != nil:
|
||||||
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
|
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
|
msg := llm.Message{
|
||||||
return llm.Message{
|
|
||||||
Role: llm.RoleTool,
|
Role: llm.RoleTool,
|
||||||
Name: call.Name,
|
Name: call.Name,
|
||||||
ToolCallID: call.ID,
|
ToolCallID: call.ID,
|
||||||
Content: res.Content,
|
Content: res.Content,
|
||||||
IsError: res.IsError,
|
IsError: res.IsError,
|
||||||
}
|
}
|
||||||
|
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
|
||||||
|
noteToolHook(cfg, call, msg)
|
||||||
|
if cfg.AfterTool != nil {
|
||||||
|
cfg.AfterTool(call, msg)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, notice string) Result {
|
||||||
|
emit(cfg, Event{Type: "agent_end", Message: last})
|
||||||
|
emit(cfg, Event{Type: "agent_settled", Message: last})
|
||||||
|
emitUsage(cfg, usage)
|
||||||
|
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
|
||||||
|
request := clearOldResults(cfg, persistedProjection(cfg, messages))
|
||||||
|
return reminders.Attach(request, turnReminderTexts(cfg, turn, repeats, lengthNudge))
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistedProjection 在有会话存储时只采用已经落盘的消息。
|
||||||
|
// steer 与 follow-up 必须先写入存储,下一轮请求才会看见它们。
|
||||||
|
func persistedProjection(cfg Config, messages []llm.Message) []llm.Message {
|
||||||
|
if cfg.Session == nil {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
loaded, err := cfg.Session.Load()
|
||||||
|
if err != nil {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownSideEffect = "副作用未知,未重试"
|
||||||
|
|
||||||
|
func recoverPending(ctx context.Context, cfg Config) error {
|
||||||
|
if cfg.Session == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pending, err := cfg.Session.PendingIntents()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, in := range pending {
|
||||||
|
current := tools.ReplayNever
|
||||||
|
if cfg.Tools != nil {
|
||||||
|
current = cfg.Tools.Replay(in.Name)
|
||||||
|
}
|
||||||
|
msg := llm.Message{
|
||||||
|
Role: llm.RoleTool, Name: in.Name, ToolCallID: in.CallID,
|
||||||
|
Content: unknownSideEffect, IsError: true,
|
||||||
|
}
|
||||||
|
if in.Replay == tools.ReplaySafe && current == tools.ReplaySafe && cfg.Tools != nil {
|
||||||
|
res := cfg.Tools.Execute(ctx, in.Name, json.RawMessage(in.Arguments))
|
||||||
|
msg.Content = res.Content
|
||||||
|
msg.IsError = res.IsError
|
||||||
|
}
|
||||||
|
if err := persist(cfg, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitToolIntent(cfg Config, call llm.ToolCall) error {
|
||||||
|
if cfg.Session == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
replay := tools.ReplayNever
|
||||||
|
if cfg.Tools != nil {
|
||||||
|
replay = cfg.Tools.Replay(call.Name)
|
||||||
|
}
|
||||||
|
return cfg.Session.AppendToolIntent(session.ToolIntent{
|
||||||
|
CallID: call.ID, Name: call.Name, Arguments: call.Arguments, Replay: replay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSpan(cfg Config, name string, attrs map[string]string) func() {
|
||||||
|
if cfg.Trace == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
end := cfg.Trace(name, attrs)
|
||||||
|
if end == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
|
||||||
|
window := cfg.ContextWindow
|
||||||
|
if window <= 0 {
|
||||||
|
window = session.DefaultContextWindow
|
||||||
|
}
|
||||||
|
reserve := cfg.OutputReserve
|
||||||
|
if reserve <= 0 {
|
||||||
|
reserve = session.DefaultOutputReserve
|
||||||
|
}
|
||||||
|
buffer := cfg.ContextBuffer
|
||||||
|
if buffer <= 0 {
|
||||||
|
buffer = session.DefaultContextBuffer
|
||||||
|
}
|
||||||
|
if !session.NeedsClear(session.EstimateChars(messages), window, reserve, buffer) {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
return session.ClearOldToolResults(messages, cfg.KeepToolResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func turnReminderTexts(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []string {
|
||||||
|
var extra []string
|
||||||
|
if cfg.Reminders != nil {
|
||||||
|
for _, text := range cfg.Reminders(turn) {
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extra = append(extra, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lengthNudge != nil && *lengthNudge {
|
||||||
|
extra = append(extra, lengthContinuePrompt)
|
||||||
|
*lengthNudge = false
|
||||||
|
}
|
||||||
|
if cfg.RepeatGuard && repeats != nil {
|
||||||
|
due := repeats.streak/3 - repeats.reminders
|
||||||
|
room := maxRepeatReminders - repeats.reminders
|
||||||
|
if due > room {
|
||||||
|
due = room
|
||||||
|
}
|
||||||
|
for i := 0; i < due; i++ {
|
||||||
|
extra = append(extra, repeatReminderText)
|
||||||
|
repeats.reminders++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extra
|
||||||
|
}
|
||||||
|
|
||||||
|
type repeatState struct {
|
||||||
|
sig string
|
||||||
|
streak int
|
||||||
|
reminders int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repeatState) observe(calls []llm.ToolCall) {
|
||||||
|
for _, call := range calls {
|
||||||
|
sig := call.Name + "\n" + canonicalArgs(call.Arguments)
|
||||||
|
if sig == s.sig {
|
||||||
|
s.streak++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.sig = sig
|
||||||
|
s.streak = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalArgs(raw string) string {
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func persist(cfg Config, msg llm.Message) error {
|
func persist(cfg Config, msg llm.Message) error {
|
||||||
@@ -301,7 +848,7 @@ func drain(fn func() []llm.Message) []llm.Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func emitUsage(cfg Config, usage llm.Usage) {
|
func emitUsage(cfg Config, usage llm.Usage) {
|
||||||
emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens})
|
emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens})
|
||||||
}
|
}
|
||||||
|
|
||||||
func emit(cfg Config, ev Event) {
|
func emit(cfg Config, ev Event) {
|
||||||
@@ -319,8 +866,8 @@ func budgetExceeded(b *Budget, usage llm.Usage, toolCalls int, now time.Time) st
|
|||||||
if !b.Deadline.IsZero() && now.After(b.Deadline) {
|
if !b.Deadline.IsZero() && now.After(b.Deadline) {
|
||||||
return fmt.Sprintf("budget_exceeded: deadline %s passed", b.Deadline.Format(time.RFC3339))
|
return fmt.Sprintf("budget_exceeded: deadline %s passed", b.Deadline.Format(time.RFC3339))
|
||||||
}
|
}
|
||||||
if b.MaxPromptTokens > 0 && usage.PromptTokens > b.MaxPromptTokens {
|
if b.MaxPromptTokens > 0 && usage.LastPromptTokens > b.MaxPromptTokens {
|
||||||
return fmt.Sprintf("budget_exceeded: prompt tokens %d > max %d", usage.PromptTokens, b.MaxPromptTokens)
|
return fmt.Sprintf("budget_exceeded: prompt tokens %d > max %d", usage.LastPromptTokens, b.MaxPromptTokens)
|
||||||
}
|
}
|
||||||
if b.MaxCompletionTokens > 0 && usage.CompletionTokens > b.MaxCompletionTokens {
|
if b.MaxCompletionTokens > 0 && usage.CompletionTokens > b.MaxCompletionTokens {
|
||||||
return fmt.Sprintf("budget_exceeded: completion tokens %d > max %d", usage.CompletionTokens, b.MaxCompletionTokens)
|
return fmt.Sprintf("budget_exceeded: completion tokens %d > max %d", usage.CompletionTokens, b.MaxCompletionTokens)
|
||||||
|
|||||||
+150
-5
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -66,15 +67,108 @@ func TestLoopBudgetToolCallCap(t *testing.T) {
|
|||||||
if !sawBudgetEvent {
|
if !sawBudgetEvent {
|
||||||
t.Fatal("expected budget_exceeded event")
|
t.Fatal("expected budget_exceeded event")
|
||||||
}
|
}
|
||||||
// 第二批工具调用不该被执行(预算在执行前拦截):transcript 只含一次 tool 结果。
|
// 第一批执行 1 次;第二批超限,两条合成错误结果,不执行。
|
||||||
toolResults := 0
|
toolResults := 0
|
||||||
|
skipped := 0
|
||||||
for _, m := range res.Messages {
|
for _, m := range res.Messages {
|
||||||
if m.Role == llm.RoleTool {
|
if m.Role != llm.RoleTool {
|
||||||
toolResults++
|
continue
|
||||||
|
}
|
||||||
|
toolResults++
|
||||||
|
if m.IsError && m.Content == toolBudgetSkipContent {
|
||||||
|
skipped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if toolResults != 1 {
|
if toolResults != 3 || skipped != 2 {
|
||||||
t.Fatalf("expected exactly 1 tool result, got %d", toolResults)
|
t.Fatalf("expected 3 tool results including 2 skipped, got results=%d skipped=%d", toolResults, skipped)
|
||||||
|
}
|
||||||
|
assertToolCallsPaired(t, res.Messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertToolCallsPaired(t *testing.T, messages []llm.Message) {
|
||||||
|
t.Helper()
|
||||||
|
pending := map[string]string{}
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == llm.RoleAssistant {
|
||||||
|
for _, call := range m.ToolCalls {
|
||||||
|
if _, ok := pending[call.ID]; ok {
|
||||||
|
t.Fatalf("duplicate tool call id %s", call.ID)
|
||||||
|
}
|
||||||
|
pending[call.ID] = call.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Role == llm.RoleTool {
|
||||||
|
name, ok := pending[m.ToolCallID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("tool result %s has no matching call", m.ToolCallID)
|
||||||
|
}
|
||||||
|
if name != m.Name {
|
||||||
|
t.Fatalf("tool result %s name %q != call %q", m.ToolCallID, m.Name, name)
|
||||||
|
}
|
||||||
|
delete(pending, m.ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pending) != 0 {
|
||||||
|
t.Fatalf("unpaired tool calls: %v", pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pauseTool struct{}
|
||||||
|
|
||||||
|
func (pauseTool) Name() string { return "pause" }
|
||||||
|
func (pauseTool) Description() string { return "slow tool" }
|
||||||
|
func (pauseTool) Parameters() json.RawMessage {
|
||||||
|
return json.RawMessage(`{"type":"object","properties":{}}`)
|
||||||
|
}
|
||||||
|
func (pauseTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
return tools.Result{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelHostCallbacksAreSerial(t *testing.T) {
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(pauseTool{})
|
||||||
|
var mu sync.Mutex
|
||||||
|
inFlight, maxInFlight := 0, 0
|
||||||
|
enter := func() {
|
||||||
|
mu.Lock()
|
||||||
|
inFlight++
|
||||||
|
if inFlight > maxInFlight {
|
||||||
|
maxInFlight = inFlight
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
mu.Lock()
|
||||||
|
inFlight--
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
ToolExecution: ExecParallel,
|
||||||
|
OnEvent: func(Event) {
|
||||||
|
enter()
|
||||||
|
},
|
||||||
|
BeforeTool: func(llm.ToolCall) error {
|
||||||
|
enter()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
AfterTool: func(llm.ToolCall, llm.Message) {
|
||||||
|
enter()
|
||||||
|
},
|
||||||
|
Stream: usageScript(llm.Usage{},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "a", Name: "pause", Arguments: `{}`},
|
||||||
|
{ID: "b", Name: "pause", 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 maxInFlight != 1 {
|
||||||
|
t.Fatalf("host callbacks overlapped: max in flight %d", maxInFlight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +194,57 @@ func TestLoopBudgetPromptTokens(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoopUsageSplitsLastRequestFromCumulative(t *testing.T) {
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: tools.WorkspaceTools(t.TempDir()),
|
||||||
|
Budget: &Budget{MaxPromptTokens: 10},
|
||||||
|
Stream: usageScript(llm.Usage{PromptTokens: 6, CompletionTokens: 1, TotalTokens: 7},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "list_dir", Arguments: `{}`,
|
||||||
|
}}},
|
||||||
|
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.Err != nil || res.Text != "ok" {
|
||||||
|
t.Fatalf("last request stays under the window: err=%v text=%q", res.Err, res.Text)
|
||||||
|
}
|
||||||
|
if res.Usage.PromptTokens != 12 || res.Usage.LastPromptTokens != 6 {
|
||||||
|
t.Fatalf("billing sum %d last %d", res.Usage.PromptTokens, res.Usage.LastPromptTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopRejectsIncompleteToolCall(t *testing.T) {
|
||||||
|
tool := &countingTool{name: "list_dir"}
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(tool)
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "list_dir", Arguments: `{"path":`,
|
||||||
|
}}}, llm.Message{Role: llm.RoleAssistant, Content: "stopped", StopReason: llm.StopEnd}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 0 {
|
||||||
|
t.Fatalf("incomplete call executed %d times", tool.calls)
|
||||||
|
}
|
||||||
|
var paired bool
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError {
|
||||||
|
paired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !paired {
|
||||||
|
t.Fatal("expected a paired error result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoopBudgetDeadline(t *testing.T) {
|
func TestLoopBudgetDeadline(t *testing.T) {
|
||||||
res, err := Run(context.Background(), Config{
|
res, err := Run(context.Background(), Config{
|
||||||
Model: "fake",
|
Model: "fake",
|
||||||
|
|||||||
@@ -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,372 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoopStopContinuesUntilCap(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
MaxTurns: 8,
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
Stop: func(last llm.Message, info StopInfo) StopDecision {
|
||||||
|
calls++
|
||||||
|
if info.Continuations != calls-1 {
|
||||||
|
t.Errorf("continuations = %d, want %d", info.Continuations, calls-1)
|
||||||
|
}
|
||||||
|
return StopDecision{Continue: true, Replacement: "已作废", Reason: "再试", Notice: "到顶了"}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 4 {
|
||||||
|
t.Fatalf("stop calls = %d, want 4", calls)
|
||||||
|
}
|
||||||
|
if res.Err != nil || res.Notice != "到顶了" {
|
||||||
|
t.Fatalf("notice %q err %v", res.Notice, res.Err)
|
||||||
|
}
|
||||||
|
replaced, forged := 0, 0
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
if m.Role != llm.RoleAssistant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Content == "已作废" {
|
||||||
|
replaced++
|
||||||
|
}
|
||||||
|
if m.Content == "forged-claim" {
|
||||||
|
forged++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if replaced != 3 || forged != 1 {
|
||||||
|
t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages))
|
||||||
|
}
|
||||||
|
if strings.Count(transcriptText(res.Messages), "再试") != 3 {
|
||||||
|
t.Fatalf("reasons = %s", transcriptText(res.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopBeforeToolRejectPairsResult(t *testing.T) {
|
||||||
|
hits := 0
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(hitTool{hits: &hits})
|
||||||
|
var seen llm.Message
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
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},
|
||||||
|
),
|
||||||
|
BeforeTool: func(call llm.ToolCall) error {
|
||||||
|
return errors.New("不允许")
|
||||||
|
},
|
||||||
|
AfterTool: func(call llm.ToolCall, msg llm.Message) {
|
||||||
|
seen = msg
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits != 0 {
|
||||||
|
t.Fatalf("tool executed %d times", hits)
|
||||||
|
}
|
||||||
|
if !seen.IsError || seen.Content != "不允许" || seen.ToolCallID != "c1" {
|
||||||
|
t.Fatalf("after = %#v", seen)
|
||||||
|
}
|
||||||
|
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 tool result: %s", transcriptText(res.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopRemindersStayOutOfSession(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "transcript.jsonl")
|
||||||
|
st, err := session.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var saw bool
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Session: st,
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
for _, m := range messages {
|
||||||
|
if strings.Contains(m.Content, "<system-reminder>") && strings.Contains(m.Content, "先核对交付物") {
|
||||||
|
saw = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
},
|
||||||
|
Reminders: func(turn int) []string {
|
||||||
|
if turn != 0 {
|
||||||
|
t.Errorf("turn %d", turn)
|
||||||
|
}
|
||||||
|
return []string{"先核对交付物"}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !saw {
|
||||||
|
t.Fatal("reminder missing from the provider request")
|
||||||
|
}
|
||||||
|
loaded, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(transcriptText(loaded), "先核对交付物") {
|
||||||
|
t.Fatalf("reminder persisted: %#v", loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopRepeatGuardFiresAndCaps(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var requests []string
|
||||||
|
turns := make([]llm.Message, 0, 13)
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
args := `{"b":1,"a":2}`
|
||||||
|
if i%2 == 1 {
|
||||||
|
args = `{"a":2,"b":1}`
|
||||||
|
}
|
||||||
|
turns = append(turns, llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c", Name: "echo", Arguments: args,
|
||||||
|
}}})
|
||||||
|
}
|
||||||
|
turns = append(turns, llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd})
|
||||||
|
var i int
|
||||||
|
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
mu.Lock()
|
||||||
|
requests = append(requests, transcriptText(messages))
|
||||||
|
n := i
|
||||||
|
i++
|
||||||
|
mu.Unlock()
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
msg := turns[len(turns)-1]
|
||||||
|
if n < len(turns) {
|
||||||
|
msg = turns[n]
|
||||||
|
}
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
MaxTurns: 20,
|
||||||
|
RepeatGuard: true,
|
||||||
|
Stream: stream,
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Text != "done" {
|
||||||
|
t.Fatalf("text %q", res.Text)
|
||||||
|
}
|
||||||
|
reminders := 0
|
||||||
|
for _, req := range requests {
|
||||||
|
reminders += strings.Count(req, repeatReminderText)
|
||||||
|
}
|
||||||
|
if reminders != 3 {
|
||||||
|
t.Fatalf("reminders = %d, want 3; requests=%d", reminders, len(requests))
|
||||||
|
}
|
||||||
|
if strings.Contains(transcriptText(res.Messages), repeatReminderText) {
|
||||||
|
t.Fatal("repeat reminder was written into the transcript")
|
||||||
|
}
|
||||||
|
if !strings.Contains(requests[3], repeatReminderText) {
|
||||||
|
t.Fatal("first reminder should arrive on the request after the third identical call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopContinueOnLengthCaps(t *testing.T) {
|
||||||
|
var requests []string
|
||||||
|
var n int
|
||||||
|
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
requests = append(requests, transcriptText(messages))
|
||||||
|
n++
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
ch <- llm.Event{
|
||||||
|
Kind: llm.KindMessageEnd,
|
||||||
|
FinishReason: "length",
|
||||||
|
Message: llm.Message{Role: llm.RoleAssistant, Content: "part", StopReason: llm.StopEnd},
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
MaxTurns: 8,
|
||||||
|
ContinueOnLength: true,
|
||||||
|
Stream: stream,
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Err == nil || !strings.Contains(res.Err.Error(), "length continuation exceeded") {
|
||||||
|
t.Fatalf("err = %v", res.Err)
|
||||||
|
}
|
||||||
|
if n != 4 {
|
||||||
|
t.Fatalf("model calls = %d, want 4", n)
|
||||||
|
}
|
||||||
|
if len(res.Messages) == 0 || !strings.Contains(transcriptText(res.Messages), "part") {
|
||||||
|
t.Fatal("truncated transcript was dropped")
|
||||||
|
}
|
||||||
|
if strings.Contains(transcriptText(res.Messages), lengthContinuePrompt) {
|
||||||
|
t.Fatal("continuation prompt was persisted")
|
||||||
|
}
|
||||||
|
prompts := 0
|
||||||
|
for _, req := range requests {
|
||||||
|
prompts += strings.Count(req, lengthContinuePrompt)
|
||||||
|
}
|
||||||
|
if prompts != 3 {
|
||||||
|
t.Fatalf("continuation prompts = %d, want 3", prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopContinueOnLengthUnlimited(t *testing.T) {
|
||||||
|
var n int
|
||||||
|
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
n++
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
finish := "length"
|
||||||
|
text := "part"
|
||||||
|
if n >= 5 {
|
||||||
|
finish = "stop"
|
||||||
|
text = "done"
|
||||||
|
}
|
||||||
|
ch <- llm.Event{
|
||||||
|
Kind: llm.KindMessageEnd,
|
||||||
|
FinishReason: finish,
|
||||||
|
Message: llm.Message{Role: llm.RoleAssistant, Content: text, StopReason: llm.StopEnd},
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
MaxTurns: 8,
|
||||||
|
ContinueOnLength: true,
|
||||||
|
MaxLengthContinuations: -1,
|
||||||
|
Stream: stream,
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
t.Fatalf("err = %v", res.Err)
|
||||||
|
}
|
||||||
|
if n != 5 {
|
||||||
|
t.Fatalf("model calls = %d, want 5", n)
|
||||||
|
}
|
||||||
|
if res.Text != "done" {
|
||||||
|
t.Fatalf("text = %q", res.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopHistorySkipsGeneratedSystem(t *testing.T) {
|
||||||
|
var first string
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
SystemPrompt: "LEAK",
|
||||||
|
History: []llm.Message{
|
||||||
|
{Role: llm.RoleSystem, Content: "FROM_HISTORY"},
|
||||||
|
{Role: llm.RoleUser, Content: "earlier"},
|
||||||
|
},
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
if len(messages) > 0 {
|
||||||
|
first = messages[0].Content
|
||||||
|
}
|
||||||
|
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: "now"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first != "FROM_HISTORY" || strings.Contains(first, "LEAK") {
|
||||||
|
t.Fatalf("system = %q", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopUnknownToolFallsThrough(t *testing.T) {
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
UnknownTool: func(call llm.ToolCall) tools.Result {
|
||||||
|
return tools.Result{Content: "via " + call.Name}
|
||||||
|
},
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "make_report", Arguments: `{}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
if m.Role == llm.RoleTool && m.Content == "via make_report" && !m.IsError {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("unknown tool was not delegated: %s", transcriptText(res.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopTurnCapKeepsTranscript(t *testing.T) {
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
MaxTurns: 1,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "missing", Arguments: `{}`,
|
||||||
|
}}}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Err == nil || !strings.Contains(res.Err.Error(), "max turns exceeded") {
|
||||||
|
t.Fatalf("err = %v", res.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(transcriptText(res.Messages), "ping") {
|
||||||
|
t.Fatalf("transcript dropped: %s", transcriptText(res.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type hitTool struct{ hits *int }
|
||||||
|
|
||||||
|
func (h hitTool) Name() string { return "echo" }
|
||||||
|
func (h hitTool) Description() string { return "echo" }
|
||||||
|
func (h hitTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (h hitTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||||
|
*h.hits++
|
||||||
|
return tools.Result{Content: "ran"}, nil
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type askKey struct{}
|
||||||
|
|
||||||
|
// WithAsk 把当前回合的 AskUser 放进上下文。MCP 返回 input_required 时使用它。
|
||||||
|
func WithAsk(ctx context.Context, fn func(context.Context, string) (string, error)) context.Context {
|
||||||
|
return context.WithValue(ctx, askKey{}, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func askFrom(ctx context.Context) func(context.Context, string) (string, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fn, _ := ctx.Value(askKey{}).(func(context.Context, string) (string, error))
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputQuestion(res callToolResult) (string, bool) {
|
||||||
|
for _, blk := range res.Content {
|
||||||
|
if blk.Type == "input_required" {
|
||||||
|
return blk.Text, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunFakeServer 在 stdin/stdout 上运行最小 MCP server(echo / boom / 未知方法),
|
// RunFakeServer 在 stdin/stdout 上运行最小 MCP server(echo / boom / 未知方法),
|
||||||
@@ -67,6 +68,9 @@ func fakeServerHandle(req rpcRequest) rpcResponse {
|
|||||||
Content: []contentBlock{{Type: "text", Text: "boom failed"}},
|
Content: []contentBlock{{Type: "text", Text: "boom failed"}},
|
||||||
IsError: true,
|
IsError: true,
|
||||||
})
|
})
|
||||||
|
case "slow":
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
return respond(callToolResult{Content: []contentBlock{{Type: "text", Text: "slow done"}}})
|
||||||
default:
|
default:
|
||||||
return fail(-32602, "unknown tool "+p.Name)
|
return fail(-32602, "unknown tool "+p.Name)
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,11 @@ func fakeServerTools() []toolInfo {
|
|||||||
Description: "Always fails",
|
Description: "Always fails",
|
||||||
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "slow",
|
||||||
|
Description: "Responds after a delay",
|
||||||
|
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -92,7 +92,31 @@ func (c *httpClient) CallTool(ctx context.Context, name string, args json.RawMes
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *httpClient) Close() error { return nil }
|
func (c *httpClient) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
sid := c.sessionID
|
||||||
|
url := c.cfg.URL
|
||||||
|
client := c.client
|
||||||
|
c.mu.Unlock()
|
||||||
|
if sid == "" || url == "" || client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Mcp-Session-Id", sid)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048))
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("mcp session terminate: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *httpClient) roundTrip(ctx context.Context, method string, params any, out any) error {
|
func (c *httpClient) roundTrip(ctx context.Context, method string, params any, out any) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
+162
-47
@@ -19,6 +19,8 @@ const (
|
|||||||
maxToolNameBytes = 128
|
maxToolNameBytes = 128
|
||||||
callTimeoutDefault = 30 * time.Second
|
callTimeoutDefault = 30 * time.Second
|
||||||
registerTimeoutFixed = 30 * time.Second
|
registerTimeoutFixed = 30 * time.Second
|
||||||
|
// 工具数超过这个值且没有显式暴露级别时,整台服务器改为延迟暴露。
|
||||||
|
deferredToolThreshold = 8
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager 维护一组 MCP server 连接,并把它们的工具注册进 tools.Registry。
|
// Manager 维护一组 MCP server 连接,并把它们的工具注册进 tools.Registry。
|
||||||
@@ -72,49 +74,138 @@ func (m *Manager) client(ctx context.Context, cfg ServerConfig) (Client, error)
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterInto 连接全部 server、枚举工具并以 mcp__<server>__<tool> 命名注册。
|
// ApplyOverrides 用项目配置覆盖启用状态和暴露级别。空字符串不改。
|
||||||
// 任一 server 不可达即返回错误(fail-closed,不静默跳过)。
|
func (m *Manager) ApplyOverrides(raw string) error {
|
||||||
func (m *Manager) RegisterInto(ctx context.Context, reg *tools.Registry) error {
|
if m == nil || strings.TrimSpace(raw) == "" {
|
||||||
for _, cfg := range m.configs {
|
return nil
|
||||||
c, err := m.client(ctx, cfg)
|
}
|
||||||
if err != nil {
|
var over map[string]struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Exposure string `json:"exposure"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &over); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, cfg := range m.configs {
|
||||||
|
o, ok := over[cfg.Name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if o.Enabled != nil {
|
||||||
|
m.configs[i].Enabled = o.Enabled
|
||||||
|
}
|
||||||
|
if o.Exposure != "" {
|
||||||
|
m.configs[i].Exposure = o.Exposure
|
||||||
|
}
|
||||||
|
if err := m.configs[i].Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
listCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
|
|
||||||
list, err := c.ListTools(listCtx)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("mcp server %q tools/list failed: %w", cfg.Name, err)
|
|
||||||
}
|
|
||||||
if len(list) > maxToolsPerServer {
|
|
||||||
return fmt.Errorf("mcp server %q exposes %d tools (max %d)", cfg.Name, len(list), maxToolsPerServer)
|
|
||||||
}
|
|
||||||
for _, ti := range list {
|
|
||||||
name := ToolName(cfg.Name, ti.Name)
|
|
||||||
if len(name) > maxToolNameBytes {
|
|
||||||
return fmt.Errorf("mcp tool name too long: %s", name)
|
|
||||||
}
|
|
||||||
desc := ti.Description
|
|
||||||
if len(desc) > maxDescriptionBytes {
|
|
||||||
desc = desc[:maxDescriptionBytes] + "…[truncated]"
|
|
||||||
}
|
|
||||||
schema := ti.InputSchema
|
|
||||||
if len(schema) == 0 {
|
|
||||||
schema = json.RawMessage(`{"type":"object","properties":{}}`)
|
|
||||||
}
|
|
||||||
reg.Register(&bridgeTool{
|
|
||||||
client: c,
|
|
||||||
serverCfg: cfg,
|
|
||||||
toolName: ti.Name,
|
|
||||||
declName: name,
|
|
||||||
description: desc,
|
|
||||||
schema: schema,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ready 报告已启用的服务器是否都已经连上。已连上时注册只是枚举工具,不再握手。
|
||||||
|
func (m *Manager) Ready() bool {
|
||||||
|
if m == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, cfg := range m.configs {
|
||||||
|
if !cfg.IsEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := m.clients[cfg.Name]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func (m *Manager) StartBackground(ctx context.Context, reg *tools.Registry) {
|
||||||
|
if m == nil || reg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, cfg := range m.configs {
|
||||||
|
cfg := cfg
|
||||||
|
if !cfg.IsEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func() { _ = m.registerServer(ctx, reg, cfg) }()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterInto 连接全部已启用的 server 并注册工具。未启用的跳过。
|
||||||
|
func (m *Manager) RegisterInto(ctx context.Context, reg *tools.Registry) error {
|
||||||
|
for _, cfg := range m.configs {
|
||||||
|
if !cfg.IsEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.registerServer(ctx, reg, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) registerServer(ctx context.Context, reg *tools.Registry, cfg ServerConfig) error {
|
||||||
|
c, err := m.client(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
|
||||||
|
list, err := c.ListTools(listCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mcp server %q tools/list failed: %w", cfg.Name, err)
|
||||||
|
}
|
||||||
|
if len(list) > maxToolsPerServer {
|
||||||
|
return fmt.Errorf("mcp server %q exposes %d tools (max %d)", cfg.Name, len(list), maxToolsPerServer)
|
||||||
|
}
|
||||||
|
exposure := chooseExposure(cfg.Exposure, len(list))
|
||||||
|
for _, ti := range list {
|
||||||
|
name := ToolName(cfg.Name, ti.Name)
|
||||||
|
if len(name) > maxToolNameBytes {
|
||||||
|
return fmt.Errorf("mcp tool name too long: %s", name)
|
||||||
|
}
|
||||||
|
desc := ti.Description
|
||||||
|
if len(desc) > maxDescriptionBytes {
|
||||||
|
desc = tools.CutUTF8(desc, maxDescriptionBytes) + "…[truncated]"
|
||||||
|
}
|
||||||
|
schema := ti.InputSchema
|
||||||
|
if len(schema) == 0 {
|
||||||
|
schema = json.RawMessage(`{"type":"object","properties":{}}`)
|
||||||
|
}
|
||||||
|
reg.Register(&bridgeTool{
|
||||||
|
client: c,
|
||||||
|
serverCfg: cfg,
|
||||||
|
toolName: ti.Name,
|
||||||
|
declName: name,
|
||||||
|
description: desc,
|
||||||
|
schema: schema,
|
||||||
|
exposure: exposure,
|
||||||
|
redial: func(ctx context.Context) (Client, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.clients, cfg.Name)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return m.client(ctx, cfg)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseExposure(explicit string, n int) string {
|
||||||
|
switch explicit {
|
||||||
|
case tools.ExposureDirect, tools.ExposureDeferred:
|
||||||
|
return explicit
|
||||||
|
default:
|
||||||
|
if n > deferredToolThreshold {
|
||||||
|
return tools.ExposureDeferred
|
||||||
|
}
|
||||||
|
return tools.ExposureDirect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ToolName 生成桥接工具名(命名空间隔离)。
|
// ToolName 生成桥接工具名(命名空间隔离)。
|
||||||
func ToolName(server, tool string) string {
|
func ToolName(server, tool string) string {
|
||||||
return "mcp__" + server + "__" + tool
|
return "mcp__" + server + "__" + tool
|
||||||
@@ -130,7 +221,7 @@ func (m *Manager) Close() {
|
|||||||
m.clients = map[string]Client{}
|
m.clients = map[string]Client{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bridgeTool 把一个 MCP 工具适配为 tools.Tool;调用失败重连一次后重试。
|
// bridgeTool 把一个 MCP 工具适配为 tools.Tool。CallTool 失败时重连一次再试。
|
||||||
type bridgeTool struct {
|
type bridgeTool struct {
|
||||||
client Client
|
client Client
|
||||||
serverCfg ServerConfig
|
serverCfg ServerConfig
|
||||||
@@ -138,21 +229,39 @@ type bridgeTool struct {
|
|||||||
declName string
|
declName string
|
||||||
description string
|
description string
|
||||||
schema json.RawMessage
|
schema json.RawMessage
|
||||||
|
exposure string
|
||||||
|
redial func(ctx context.Context) (Client, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *bridgeTool) Name() string { return t.declName }
|
func (t *bridgeTool) Name() string { return t.declName }
|
||||||
func (t *bridgeTool) Description() string { return t.description }
|
func (t *bridgeTool) Description() string { return t.description }
|
||||||
func (t *bridgeTool) Parameters() json.RawMessage {
|
func (t *bridgeTool) Parameters() json.RawMessage { return t.schema }
|
||||||
return t.schema
|
func (t *bridgeTool) Exposure() string { return t.exposure }
|
||||||
}
|
|
||||||
|
|
||||||
func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||||
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
|
res, err := t.call(ctx, args)
|
||||||
defer cancel()
|
if err != nil && t.redial != nil {
|
||||||
res, err := t.client.CallTool(callCtx, t.toolName, args)
|
redial := t.redial
|
||||||
|
t.redial = nil
|
||||||
|
if c, rerr := redial(ctx); rerr == nil {
|
||||||
|
t.client = c
|
||||||
|
res, err = t.call(ctx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
|
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
|
||||||
}
|
}
|
||||||
|
if question, ok := inputQuestion(res); ok {
|
||||||
|
ask := askFrom(ctx)
|
||||||
|
if ask == nil {
|
||||||
|
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
|
||||||
|
}
|
||||||
|
answer, aerr := ask(ctx, question)
|
||||||
|
if aerr != nil {
|
||||||
|
return tools.Result{Content: aerr.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
return tools.Result{Content: answer}, nil
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, blk := range res.Content {
|
for _, blk := range res.Content {
|
||||||
if blk.Type == "text" && blk.Text != "" {
|
if blk.Type == "text" && blk.Text != "" {
|
||||||
@@ -162,10 +271,16 @@ func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.R
|
|||||||
}
|
}
|
||||||
out := b.String()
|
out := b.String()
|
||||||
if len(out) > maxTextResultBytes {
|
if len(out) > maxTextResultBytes {
|
||||||
out = out[:maxTextResultBytes] + "\n[truncated]"
|
out = tools.CutUTF8(out, maxTextResultBytes) + "\n[truncated]"
|
||||||
}
|
}
|
||||||
if res.IsError {
|
if res.IsError {
|
||||||
return tools.Result{Content: out, IsError: true}, nil
|
return tools.Result{Content: out, IsError: true}, nil
|
||||||
}
|
}
|
||||||
return tools.Result{Content: out}, nil
|
return tools.Result{Content: out}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *bridgeTool) call(ctx context.Context, args json.RawMessage) (callToolResult, error) {
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
|
||||||
|
defer cancel()
|
||||||
|
return t.client.CallTool(callCtx, t.toolName, args)
|
||||||
|
}
|
||||||
|
|||||||
+142
-3
@@ -45,7 +45,7 @@ func TestStdioClientHandshakeListAndCall(t *testing.T) {
|
|||||||
defer c.Close()
|
defer c.Close()
|
||||||
|
|
||||||
list, err := c.ListTools(ctx)
|
list, err := c.ListTools(ctx)
|
||||||
if err != nil || len(list) != 2 || list[0].Name != "echo" {
|
if err != nil || len(list) != 3 || list[0].Name != "echo" {
|
||||||
t.Fatalf("list failed: %v %+v", err, list)
|
t.Fatalf("list failed: %v %+v", err, list)
|
||||||
}
|
}
|
||||||
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"hi"}`))
|
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"hi"}`))
|
||||||
@@ -75,6 +75,35 @@ func TestStdioClientServerRejectsBadSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStdioSlowCancelThenEcho(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
c, err := DialStdio(ctx, fakeStdioConfig(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
slowCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err = c.CallTool(slowCtx, "slow", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("slow call should time out")
|
||||||
|
}
|
||||||
|
|
||||||
|
echoCtx, echoCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer echoCancel()
|
||||||
|
res, err := c.CallTool(echoCtx, "echo", json.RawMessage(`{"text":"after-slow"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Content) != 1 || res.Content[0].Text != "echo: after-slow" {
|
||||||
|
t.Fatalf("echo must receive its own result, got %+v", res)
|
||||||
|
}
|
||||||
|
if c.Readers() != 1 {
|
||||||
|
t.Fatalf("cancel must leave a single reader, got %d", c.Readers())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fakeHTTPHandler(w http.ResponseWriter, r *http.Request) {
|
func fakeHTTPHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var req rpcRequest
|
var req rpcRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -104,7 +133,7 @@ func TestHTTPClientJSONMode(t *testing.T) {
|
|||||||
t.Fatalf("session id must be captured, got %q", c.sessionID)
|
t.Fatalf("session id must be captured, got %q", c.sessionID)
|
||||||
}
|
}
|
||||||
list, err := c.ListTools(ctx)
|
list, err := c.ListTools(ctx)
|
||||||
if err != nil || len(list) != 2 {
|
if err != nil || len(list) != 3 {
|
||||||
t.Fatalf("list failed: %v %+v", err, list)
|
t.Fatalf("list failed: %v %+v", err, list)
|
||||||
}
|
}
|
||||||
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"net"}`))
|
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"net"}`))
|
||||||
@@ -130,7 +159,7 @@ func TestHTTPClientSSEMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
list, err := c.ListTools(ctx)
|
list, err := c.ListTools(ctx)
|
||||||
if err != nil || len(list) != 2 {
|
if err != nil || len(list) != 3 {
|
||||||
t.Fatalf("sse list failed: %v %+v", err, list)
|
t.Fatalf("sse list failed: %v %+v", err, list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,3 +268,113 @@ func TestManagerRegisterTimeoutBounded(t *testing.T) {
|
|||||||
t.Fatalf("timeout must bound the dial, took %s", elapsed)
|
t.Fatalf("timeout must bound the dial, took %s", elapsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackgroundConnectDoesNotBlockTheCaller(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
default:
|
||||||
|
close(started)
|
||||||
|
}
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
fakeHTTPHandler(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
mgr, err := NewManager([]ServerConfig{{Name: "late", URL: srv.URL}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer mgr.Close()
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
t0 := time.Now()
|
||||||
|
mgr.StartBackground(context.Background(), reg)
|
||||||
|
if time.Since(t0) > 150*time.Millisecond {
|
||||||
|
t.Fatalf("background connect blocked the caller for %s", time.Since(t0))
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if reg.Has("mcp__late__echo") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("tools did not appear after the server became ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeferredMCPToolsUseSearchAndCall(t *testing.T) {
|
||||||
|
mgr, err := NewManager([]ServerConfig{{Name: "fake", Command: fakeStdioConfig(t).Command, Args: fakeStdioConfig(t).Args, Env: fakeStdioConfig(t).Env, Exposure: "deferred"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer mgr.Close()
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, d := range reg.Decls() {
|
||||||
|
if strings.HasPrefix(d.Name, "mcp__fake__") {
|
||||||
|
t.Fatalf("deferred mcp tool listed: %s", d.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(reg.Search("echo"), "mcp__fake__echo") {
|
||||||
|
t.Fatalf("search %q", reg.Search("echo"))
|
||||||
|
}
|
||||||
|
res := reg.Execute(context.Background(), "call_tool", json.RawMessage(`{"name":"mcp__fake__echo","arguments":{"text":"via"}}`))
|
||||||
|
if res.IsError || !strings.Contains(res.Content, "echo: via") {
|
||||||
|
t.Fatalf("call %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledServerIsSkipped(t *testing.T) {
|
||||||
|
off := false
|
||||||
|
mgr, err := NewManager([]ServerConfig{{Name: "fake", Command: "x", Enabled: &off}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reg.Has("mcp__fake__echo") {
|
||||||
|
t.Fatal("disabled server registered a tool")
|
||||||
|
}
|
||||||
|
if err := mgr.ApplyOverrides(`{"fake":{"enabled":true,"exposure":"direct"}}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !mgr.Configs()[0].IsEnabled() || mgr.Configs()[0].Exposure != "direct" {
|
||||||
|
t.Fatalf("override %+v", mgr.Configs()[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManyToolsDefaultToDeferred(t *testing.T) {
|
||||||
|
if chooseExposure("", 9) != tools.ExposureDeferred {
|
||||||
|
t.Fatal("many tools should default to deferred")
|
||||||
|
}
|
||||||
|
if chooseExposure("direct", 9) != tools.ExposureDirect {
|
||||||
|
t.Fatal("explicit direct must win")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type askClient struct{}
|
||||||
|
|
||||||
|
func (askClient) Initialize(context.Context) (initializeResult, error) {
|
||||||
|
return initializeResult{}, nil
|
||||||
|
}
|
||||||
|
func (askClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
|
||||||
|
func (askClient) Close() error { return nil }
|
||||||
|
func (askClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
|
||||||
|
return callToolResult{Content: []contentBlock{{Type: "input_required", Text: "选一个颜色"}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputRequiredUsesAsk(t *testing.T) {
|
||||||
|
tool := &bridgeTool{
|
||||||
|
client: askClient{}, declName: "mcp__x__ask", toolName: "ask",
|
||||||
|
schema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||||
|
}
|
||||||
|
ctx := WithAsk(context.Background(), func(context.Context, string) (string, error) { return "蓝色", nil })
|
||||||
|
res, err := tool.Execute(ctx, json.RawMessage(`{}`))
|
||||||
|
if err != nil || res.IsError || res.Content != "蓝色" {
|
||||||
|
t.Fatalf("ask result %+v %v", res, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ type ServerConfig struct {
|
|||||||
Headers map[string]string `json:"headers,omitempty"`
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
// Timeout 单次请求上限(默认 30s)。
|
// Timeout 单次请求上限(默认 30s)。
|
||||||
Timeout time.Duration `json:"timeout,omitempty"`
|
Timeout time.Duration `json:"timeout,omitempty"`
|
||||||
|
// Enabled 为 false 时跳过这台服务器。nil 表示启用。
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
// Exposure 为 direct 或 deferred。空则工具多时延迟暴露。
|
||||||
|
Exposure string `json:"exposure,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate 校验配置完整性(fail-closed:命名不规范或双传输/无传输都拒绝)。
|
// Validate 校验配置完整性(fail-closed:命名不规范或双传输/无传输都拒绝)。
|
||||||
@@ -37,9 +41,19 @@ func (c ServerConfig) Validate() error {
|
|||||||
if hasCmd == hasURL {
|
if hasCmd == hasURL {
|
||||||
return fmt.Errorf("mcp server %q: exactly one of command or url is required", c.Name)
|
return fmt.Errorf("mcp server %q: exactly one of command or url is required", c.Name)
|
||||||
}
|
}
|
||||||
|
switch c.Exposure {
|
||||||
|
case "", "direct", "deferred":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("mcp server %q: exposure must be direct or deferred", c.Name)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsEnabled 报告这台服务器是否要连接。
|
||||||
|
func (c ServerConfig) IsEnabled() bool {
|
||||||
|
return c.Enabled == nil || *c.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
func validName(s string) bool {
|
func validName(s string) bool {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type flakyClient struct {
|
||||||
|
fail bool
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flakyClient) Initialize(context.Context) (initializeResult, error) {
|
||||||
|
return initializeResult{}, nil
|
||||||
|
}
|
||||||
|
func (f *flakyClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
|
||||||
|
func (f *flakyClient) Close() error { return nil }
|
||||||
|
func (f *flakyClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.fail {
|
||||||
|
return callToolResult{}, fmt.Errorf("down")
|
||||||
|
}
|
||||||
|
return callToolResult{Content: []contentBlock{{Type: "text", Text: "back"}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2MCPRetriesOnceAfterReconnect(t *testing.T) {
|
||||||
|
first := &flakyClient{fail: true}
|
||||||
|
second := &flakyClient{}
|
||||||
|
tool := &bridgeTool{
|
||||||
|
client: first,
|
||||||
|
toolName: "echo",
|
||||||
|
declName: "mcp__s__echo",
|
||||||
|
redial: func(context.Context) (Client, error) {
|
||||||
|
return second, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := tool.Execute(context.Background(), []byte(`{}`))
|
||||||
|
if err != nil || res.IsError {
|
||||||
|
t.Fatalf("retry: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
if first.calls != 1 || second.calls != 1 || !strings.Contains(res.Content, "back") {
|
||||||
|
t.Fatalf("calls first=%d second=%d content=%q", first.calls, second.calls, res.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2HTTPCloseTerminatesSession(t *testing.T) {
|
||||||
|
var method, sid string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
method = r.Method
|
||||||
|
sid = r.Header.Get("Mcp-Session-Id")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := &httpClient{cfg: ServerConfig{URL: srv.URL}, client: &http.Client{}, sessionID: "sess-1"}
|
||||||
|
if err := c.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if method != http.MethodDelete || sid != "sess-1" {
|
||||||
|
t.Fatalf("terminate %s session %q", method, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-46
@@ -12,14 +12,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// stdioClient 通过「子进程 stdin/stdout 按行分隔的 JSON-RPC」与 server 通信。
|
// stdioClient 通过「子进程 stdin/stdout 按行分隔的 JSON-RPC」与 server 通信。
|
||||||
// 同一时刻只允许一个在途请求(串行锁),匹配简单且足以支撑智能体工具调用。
|
// 一个长驻读循环按 id 把响应分发给各请求;写 stdin 仍串行。
|
||||||
|
// 取消会摘掉等待项,迟到的回复直接丢弃。读循环退出时,在等的请求全部失败,客户端标记为已死。
|
||||||
type stdioClient struct {
|
type stdioClient struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
stdout *bufio.Reader
|
stdout *bufio.Reader
|
||||||
mu sync.Mutex
|
writeMu sync.Mutex
|
||||||
nextID int64
|
mu sync.Mutex
|
||||||
closed bool
|
pending map[int64]chan stdioResult
|
||||||
|
nextID int64
|
||||||
|
readers atomic.Int32
|
||||||
|
closed bool
|
||||||
|
readerOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
type stdioResult struct {
|
||||||
|
resp rpcResponse
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// DialStdio 启动 server 子进程并完成 initialize 握手。
|
// DialStdio 启动 server 子进程并完成 initialize 握手。
|
||||||
@@ -43,7 +53,13 @@ func DialStdio(ctx context.Context, cfg ServerConfig) (*stdioClient, error) {
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("mcp server %q: start %s: %w", cfg.Name, cfg.Command, err)
|
return nil, fmt.Errorf("mcp server %q: start %s: %w", cfg.Name, cfg.Command, err)
|
||||||
}
|
}
|
||||||
c := &stdioClient{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 1<<20)}
|
c := &stdioClient{
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: bufio.NewReaderSize(stdout, 1<<20),
|
||||||
|
pending: map[int64]chan stdioResult{},
|
||||||
|
}
|
||||||
|
c.startReader()
|
||||||
if err := c.handshake(ctx); err != nil {
|
if err := c.handshake(ctx); err != nil {
|
||||||
_ = c.Close()
|
_ = c.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -104,11 +120,12 @@ func (c *stdioClient) CallTool(ctx context.Context, name string, args json.RawMe
|
|||||||
|
|
||||||
func (c *stdioClient) Close() error {
|
func (c *stdioClient) Close() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.closed {
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
_ = c.stdin.Close()
|
_ = c.stdin.Close()
|
||||||
if c.cmd.Process != nil {
|
if c.cmd.Process != nil {
|
||||||
_ = c.cmd.Process.Kill()
|
_ = c.cmd.Process.Kill()
|
||||||
@@ -117,29 +134,43 @@ func (c *stdioClient) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *stdioClient) roundTrip(ctx context.Context, method string, params any, out any) error {
|
func (c *stdioClient) roundTrip(ctx context.Context, method string, params any, out any) error {
|
||||||
|
c.startReader()
|
||||||
|
id := atomic.AddInt64(&c.nextID, 1)
|
||||||
|
ch := make(chan stdioResult, 1)
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.closed {
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
return fmt.Errorf("mcp client closed")
|
return fmt.Errorf("mcp client closed")
|
||||||
}
|
}
|
||||||
id := atomic.AddInt64(&c.nextID, 1)
|
c.pending[id] = ch
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
|
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.dropPending(id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body = append(body, '\n')
|
body = append(body, '\n')
|
||||||
if _, err := c.stdin.Write(body); err != nil {
|
c.writeMu.Lock()
|
||||||
|
_, err = c.stdin.Write(body)
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
c.dropPending(id)
|
||||||
return fmt.Errorf("mcp write %s: %w", method, err)
|
return fmt.Errorf("mcp write %s: %w", method, err)
|
||||||
}
|
}
|
||||||
line, err := c.readResponseLine(ctx)
|
|
||||||
if err != nil {
|
var got stdioResult
|
||||||
return fmt.Errorf("mcp read %s: %w", method, err)
|
select {
|
||||||
|
case got = <-ch:
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.dropPending(id)
|
||||||
|
return fmt.Errorf("mcp read %s: %w", method, ctx.Err())
|
||||||
}
|
}
|
||||||
var resp rpcResponse
|
if got.err != nil {
|
||||||
if err := json.Unmarshal(line, &resp); err != nil {
|
return fmt.Errorf("mcp read %s: %w", method, got.err)
|
||||||
return fmt.Errorf("mcp %s: bad response frame: %w", method, err)
|
|
||||||
}
|
}
|
||||||
|
resp := got.resp
|
||||||
if resp.ID == nil || *resp.ID != id {
|
if resp.ID == nil || *resp.ID != id {
|
||||||
return fmt.Errorf("mcp %s: response id mismatch", method)
|
return fmt.Errorf("mcp %s: response id mismatch", method)
|
||||||
}
|
}
|
||||||
@@ -152,40 +183,60 @@ func (c *stdioClient) roundTrip(ctx context.Context, method string, params any,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readResponseLine 逐行读取,跳过通知帧(无 id),带 ctx 取消感知。
|
func (c *stdioClient) dropPending(id int64) {
|
||||||
func (c *stdioClient) readResponseLine(ctx context.Context) ([]byte, error) {
|
c.mu.Lock()
|
||||||
type result struct {
|
delete(c.pending, id)
|
||||||
line []byte
|
c.mu.Unlock()
|
||||||
err error
|
}
|
||||||
}
|
|
||||||
done := make(chan result, 1)
|
func (c *stdioClient) startReader() {
|
||||||
go func() {
|
c.readerOnce.Do(func() {
|
||||||
for {
|
go c.readLoop()
|
||||||
line, err := c.stdout.ReadBytes('\n')
|
})
|
||||||
if err != nil {
|
}
|
||||||
done <- result{nil, err}
|
|
||||||
return
|
func (c *stdioClient) readLoop() {
|
||||||
}
|
c.readers.Add(1)
|
||||||
var probe rpcResponse
|
defer c.readers.Add(-1)
|
||||||
if json.Unmarshal(line, &probe) == nil && probe.ID != nil {
|
var fatal error
|
||||||
done <- result{line, nil}
|
for {
|
||||||
return
|
line, err := c.stdout.ReadBytes('\n')
|
||||||
}
|
if err != nil {
|
||||||
// 通知或日志行:忽略。
|
fatal = err
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}()
|
var resp rpcResponse
|
||||||
select {
|
if json.Unmarshal(line, &resp) != nil || resp.ID == nil {
|
||||||
case r := <-done:
|
continue
|
||||||
return r.line, r.err
|
}
|
||||||
case <-ctx.Done():
|
c.mu.Lock()
|
||||||
return nil, ctx.Err()
|
ch := c.pending[*resp.ID]
|
||||||
|
if ch != nil {
|
||||||
|
delete(c.pending, *resp.ID)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch <- stdioResult{resp: resp}
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.closed = true
|
||||||
|
pending := c.pending
|
||||||
|
c.pending = map[int64]chan stdioResult{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
for _, ch := range pending {
|
||||||
|
ch <- stdioResult{err: fatal}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *stdioClient) Readers() int { return int(c.readers.Load()) }
|
||||||
|
|
||||||
func (c *stdioClient) notify(method string) error {
|
func (c *stdioClient) notify(method string) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
closed := c.closed
|
||||||
if c.closed {
|
c.mu.Unlock()
|
||||||
|
if closed {
|
||||||
return fmt.Errorf("mcp client closed")
|
return fmt.Errorf("mcp client closed")
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(rpcNotification{JSONRPC: "2.0", Method: method})
|
body, err := json.Marshal(rpcNotification{JSONRPC: "2.0", Method: method})
|
||||||
@@ -193,6 +244,8 @@ func (c *stdioClient) notify(method string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body = append(body, '\n')
|
body = append(body, '\n')
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
_, err = c.stdin.Write(body)
|
_, err = c.stdin.Write(body)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Package policy 决定一次工具调用在当前权限模式下是拒绝、询问还是放行。
|
||||||
|
// 默认模式不额外表态,调用方继续用原有画像策略。
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeDefault Mode = "default"
|
||||||
|
ModePlan Mode = "plan"
|
||||||
|
ModeAutoEdit Mode = "auto_edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Effect string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EffectNone Effect = ""
|
||||||
|
EffectDeny Effect = "deny"
|
||||||
|
EffectAsk Effect = "ask"
|
||||||
|
EffectAllow Effect = "allow"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Traits struct {
|
||||||
|
ReadOnly bool
|
||||||
|
Edits bool
|
||||||
|
Risk string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule struct {
|
||||||
|
Effect Effect
|
||||||
|
Tool string
|
||||||
|
ArgGlob string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Mode Mode
|
||||||
|
Name string
|
||||||
|
Args string
|
||||||
|
Traits Traits
|
||||||
|
SessionAllow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Normalize(mode string) Mode {
|
||||||
|
switch Mode(mode) {
|
||||||
|
case ModePlan, ModeAutoEdit, ModeDefault:
|
||||||
|
return Mode(mode)
|
||||||
|
default:
|
||||||
|
return ModeDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide 的优先级是拒绝规则、计划模式禁写、本会话放行、询问规则、自动改文件、高风险命令。
|
||||||
|
// 都没有命中时返回空,表示沿用画像的拒绝、确认和放行。
|
||||||
|
func Decide(req Request, rules []Rule) Effect {
|
||||||
|
var sawAsk, sawAllow bool
|
||||||
|
for _, rule := range rules {
|
||||||
|
if !matchTool(rule.Tool, req.Name) || !glob(rule.ArgGlob, req.Args) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch rule.Effect {
|
||||||
|
case EffectDeny:
|
||||||
|
return EffectDeny
|
||||||
|
case EffectAsk:
|
||||||
|
sawAsk = true
|
||||||
|
case EffectAllow:
|
||||||
|
sawAllow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traits := mergeTraits(req.Name, req.Traits)
|
||||||
|
if commandRisk(req.Name, req.Args) == "high" {
|
||||||
|
traits.Risk = "high"
|
||||||
|
}
|
||||||
|
if Normalize(string(req.Mode)) == ModePlan && !planVisible(req.Name, traits.ReadOnly) {
|
||||||
|
return EffectDeny
|
||||||
|
}
|
||||||
|
if req.SessionAllow {
|
||||||
|
return EffectAllow
|
||||||
|
}
|
||||||
|
if sawAsk {
|
||||||
|
return EffectAsk
|
||||||
|
}
|
||||||
|
if Normalize(string(req.Mode)) == ModeAutoEdit && traits.Edits && traits.Risk != "high" {
|
||||||
|
return EffectAllow
|
||||||
|
}
|
||||||
|
if sawAllow {
|
||||||
|
return EffectAllow
|
||||||
|
}
|
||||||
|
if traits.Risk == "high" && (req.Name == "run_shell" || req.Name == "run_command") {
|
||||||
|
return EffectAsk
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return planVisible(name, readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planVisible(name string, readOnly bool) bool {
|
||||||
|
switch name {
|
||||||
|
case "update_plan", "update_todos", "ask_user", "exit_plan_mode":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return readOnly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraitsFor 是内置工具的只读、改文件和风险声明。未知工具为零值。
|
||||||
|
func TraitsFor(name string) Traits {
|
||||||
|
switch name {
|
||||||
|
case "read_file", "list_dir", "grep", "glob", "load_skill",
|
||||||
|
"fs_read_file", "fs_list_dir", "time_now", "knowledge_search",
|
||||||
|
"list_conversations", "read_conversation", "quality_review",
|
||||||
|
"web_search", "tavily_search", "fetch_url", "ask_user", "exit_plan_mode",
|
||||||
|
"update_plan", "update_todos", "tool_search":
|
||||||
|
return Traits{ReadOnly: true, Risk: "low"}
|
||||||
|
case "write_file", "edit_file", "multi_edit", "fs_write_file", "write_artifact", "register_artifact":
|
||||||
|
return Traits{Edits: true, Risk: "medium"}
|
||||||
|
case "run_shell", "run_command", "run_python", "run_tool_script", "call_tool":
|
||||||
|
return Traits{Risk: "medium"}
|
||||||
|
default:
|
||||||
|
return Traits{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeTraits(name string, given Traits) Traits {
|
||||||
|
base := TraitsFor(name)
|
||||||
|
if given.ReadOnly {
|
||||||
|
base.ReadOnly = true
|
||||||
|
}
|
||||||
|
if given.Edits {
|
||||||
|
base.Edits = true
|
||||||
|
}
|
||||||
|
if given.Risk != "" {
|
||||||
|
base.Risk = given.Risk
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyCommand 按命令文本分级。高风险会在默认模式下改为询问。
|
||||||
|
func ClassifyCommand(cmd string) string {
|
||||||
|
c := strings.ToLower(cmd)
|
||||||
|
for _, n := range []string{"rm -rf", "rm -fr", "sudo ", "mkfs", "shutdown", "reboot", "> /dev/", "| sh", "|sh", "chmod -r 777"} {
|
||||||
|
if strings.Contains(c, n) {
|
||||||
|
return "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []string{"rm ", "mv ", "chmod ", "chown ", "kill "} {
|
||||||
|
if strings.Contains(c, n) {
|
||||||
|
return "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandRisk(name, args string) string {
|
||||||
|
if name != "run_shell" && name != "run_command" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ClassifyCommand(extractCommand(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCommand(args string) string {
|
||||||
|
for _, key := range []string{`"command"`, `"cmd"`} {
|
||||||
|
i := strings.Index(args, key)
|
||||||
|
if i < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := args[i+len(key):]
|
||||||
|
rest = strings.TrimLeft(rest, " \t\n:")
|
||||||
|
if !strings.HasPrefix(rest, `"`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = rest[1:]
|
||||||
|
if j := strings.Index(rest, `"`); j >= 0 {
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchTool(pattern, name string) bool {
|
||||||
|
if pattern == "" || pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return glob(pattern, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func glob(pattern, s string) bool {
|
||||||
|
if pattern == "" || pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
if !strings.HasPrefix(pattern, "*") {
|
||||||
|
if !strings.HasPrefix(s, parts[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(pattern, "*") {
|
||||||
|
last := parts[len(parts)-1]
|
||||||
|
if !strings.HasSuffix(s, last) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx := 0
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := strings.Index(s[idx:], part)
|
||||||
|
if j < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
idx += j + len(part)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPlanModeDeniesWrites(t *testing.T) {
|
||||||
|
effect := Decide(Request{Mode: ModePlan, Name: "write_file", Args: `{"path":"a.md"}`}, nil)
|
||||||
|
if effect != EffectDeny {
|
||||||
|
t.Fatalf("effect %q", effect)
|
||||||
|
}
|
||||||
|
if !Visible(string(ModePlan), "update_plan", false) {
|
||||||
|
t.Fatal("plan tool hidden")
|
||||||
|
}
|
||||||
|
if Visible(string(ModePlan), "write_file", false) {
|
||||||
|
t.Fatal("write visible in plan mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultModeStaysQuiet(t *testing.T) {
|
||||||
|
if got := Decide(Request{Mode: ModeDefault, Name: "write_file"}, nil); got != EffectNone {
|
||||||
|
t.Fatalf("default write %q", got)
|
||||||
|
}
|
||||||
|
if got := Decide(Request{Mode: ModeDefault, Name: "run_shell", Args: `{"command":"rm -rf /"}`}, nil); got != EffectAsk {
|
||||||
|
t.Fatalf("high shell %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRulePrecedenceAndSessionAllow(t *testing.T) {
|
||||||
|
rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}}
|
||||||
|
if got := Decide(Request{Name: "write_file", Args: `{"path":"secret.txt"}`}, rules); got != EffectDeny {
|
||||||
|
t.Fatalf("deny lost: %q", got)
|
||||||
|
}
|
||||||
|
if got := Decide(Request{Name: "http.fetch", SessionAllow: true}, []Rule{{Effect: EffectAsk, Tool: "http.*"}}); got != EffectAllow {
|
||||||
|
t.Fatalf("session allow %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoEditAllowsWorkspaceWrites(t *testing.T) {
|
||||||
|
if got := Decide(Request{Mode: ModeAutoEdit, Name: "fs_write_file"}, nil); got != EffectAllow {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type countingTool struct {
|
||||||
|
name string
|
||||||
|
calls int
|
||||||
|
hook func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingTool) Name() string { return c.name }
|
||||||
|
func (c *countingTool) Description() string { return c.name }
|
||||||
|
func (c *countingTool) Parameters() json.RawMessage {
|
||||||
|
return json.RawMessage(`{"type":"object","properties":{}}`)
|
||||||
|
}
|
||||||
|
func (c *countingTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||||
|
c.calls++
|
||||||
|
if c.hook != nil {
|
||||||
|
c.hook()
|
||||||
|
}
|
||||||
|
return tools.Result{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2TokenBudgetSkipsTools(t *testing.T) {
|
||||||
|
tool := &countingTool{name: "list_dir"}
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(tool)
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
Budget: &Budget{MaxPromptTokens: 10},
|
||||||
|
Stream: usageScript(llm.Usage{PromptTokens: 500},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "list_dir", Arguments: `{}`,
|
||||||
|
}}},
|
||||||
|
),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 0 {
|
||||||
|
t.Fatalf("tool ran after token budget: %d", tool.calls)
|
||||||
|
}
|
||||||
|
if res.Err == nil || !strings.Contains(res.Err.Error(), "prompt tokens") {
|
||||||
|
t.Fatalf("budget error: %v", res.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2StopReplacementIsPersisted(t *testing.T) {
|
||||||
|
store, err := session.Open(filepath.Join(t.TempDir(), "t.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
MaxTurns: 3,
|
||||||
|
Session: store,
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "draft", StopReason: llm.StopEnd},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "final", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
Stop: func(last llm.Message, info StopInfo) StopDecision {
|
||||||
|
if info.Continuations == 0 {
|
||||||
|
return StopDecision{Continue: true, Replacement: "corrected-claim"}
|
||||||
|
}
|
||||||
|
return StopDecision{}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msgs, err := store.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var saw bool
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Content == "corrected-claim" {
|
||||||
|
saw = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !saw {
|
||||||
|
t.Fatalf("replacement missing from session: %+v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2SequentialToolsStopAfterCancel(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
first := &countingTool{name: "first", hook: cancel}
|
||||||
|
second := &countingTool{name: "second"}
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(first)
|
||||||
|
reg.Register(second)
|
||||||
|
res, err := Run(ctx, Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "1", Name: "first", Arguments: `{}`},
|
||||||
|
{ID: "2", Name: "second", Arguments: `{}`},
|
||||||
|
}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "after", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.calls != 1 || second.calls != 0 {
|
||||||
|
t.Fatalf("calls first=%d second=%d", first.calls, second.calls)
|
||||||
|
}
|
||||||
|
var paired int
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
if m.Role == llm.RoleTool {
|
||||||
|
paired++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if paired != 2 {
|
||||||
|
t.Fatalf("tool results = %d, want a pair for each call", paired)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Package reminders 收集一轮里要带给模型的提醒,并做节流。
|
||||||
|
// 提醒并入最后一条工具结果或用户消息,不另起一条 user 消息。
|
||||||
|
package reminders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindTodo Kind = "todo"
|
||||||
|
KindPlan Kind = "plan"
|
||||||
|
KindBackground Kind = "background"
|
||||||
|
KindDeliverable Kind = "deliverable"
|
||||||
|
KindBudget Kind = "budget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note 是一条待投递的提醒。Key 相同的提醒在本轮只投递一次。
|
||||||
|
type Note struct {
|
||||||
|
Kind Kind
|
||||||
|
Key string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bus 按最小间隔、每 run 上限和去重决定是否投递。
|
||||||
|
type Bus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
minGap time.Duration
|
||||||
|
cap int
|
||||||
|
sent int
|
||||||
|
last time.Time
|
||||||
|
seen map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(minGap time.Duration, cap int) *Bus {
|
||||||
|
if cap <= 0 {
|
||||||
|
cap = 3
|
||||||
|
}
|
||||||
|
return &Bus{minGap: minGap, cap: cap, seen: map[string]struct{}{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admit 在通过节流时返回真。同一 Kind+Key 只通过一次。
|
||||||
|
func (b *Bus) Admit(n Note, now time.Time) bool {
|
||||||
|
if b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.sent >= b.cap {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if b.minGap > 0 && !b.last.IsZero() && now.Sub(b.last) < b.minGap {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key := string(n.Kind) + "\n" + n.Key
|
||||||
|
if _, ok := b.seen[key]; ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.seen[key] = struct{}{}
|
||||||
|
b.last = now
|
||||||
|
b.sent++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach 把提醒正文并入最后一条工具结果或用户消息。
|
||||||
|
// 找不到这两种消息时原样返回,不追加 user 消息。
|
||||||
|
func Attach(messages []llm.Message, texts []string) []llm.Message {
|
||||||
|
var lines []string
|
||||||
|
for _, text := range texts {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text != "" {
|
||||||
|
lines = append(lines, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 || len(messages) == 0 {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
block := "<system-reminder>\n" + strings.Join(lines, "\n") + "\n</system-reminder>"
|
||||||
|
out := append([]llm.Message{}, messages...)
|
||||||
|
for i := len(out) - 1; i >= 0; i-- {
|
||||||
|
if out[i].Role != llm.RoleTool && out[i].Role != llm.RoleUser {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out[i].Content != "" {
|
||||||
|
out[i].Content += "\n" + block
|
||||||
|
} else {
|
||||||
|
out[i].Content = block
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package reminders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAttachDoesNotAddUserAfterTool(t *testing.T) {
|
||||||
|
msgs := []llm.Message{
|
||||||
|
{Role: llm.RoleUser, Content: "请改文件"},
|
||||||
|
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file"}}},
|
||||||
|
{Role: llm.RoleTool, Name: "read_file", ToolCallID: "c1", Content: "原文"},
|
||||||
|
}
|
||||||
|
out := Attach(msgs, []string{"计划和待办已过期"})
|
||||||
|
if len(out) != len(msgs) {
|
||||||
|
t.Fatalf("len %d, want %d", len(out), len(msgs))
|
||||||
|
}
|
||||||
|
for i := range msgs {
|
||||||
|
if out[i].Role != msgs[i].Role {
|
||||||
|
t.Fatalf("role changed at %d: %s", i, out[i].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last := out[len(out)-1]
|
||||||
|
if last.Role != llm.RoleTool || !strings.Contains(last.Content, "原文") || !strings.Contains(last.Content, "system-reminder") {
|
||||||
|
t.Fatalf("reminder not folded into tool result: %+v", last)
|
||||||
|
}
|
||||||
|
if strings.Contains(out[0].Content, "system-reminder") {
|
||||||
|
t.Fatal("reminder leaked onto the earlier user message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdmitThrottles(t *testing.T) {
|
||||||
|
bus := New(time.Minute, 2)
|
||||||
|
now := time.Now()
|
||||||
|
if !bus.Admit(Note{Kind: KindBudget, Key: "near", Text: "预算将尽"}, now) {
|
||||||
|
t.Fatal("first admit")
|
||||||
|
}
|
||||||
|
if bus.Admit(Note{Kind: KindBudget, Key: "near", Text: "预算将尽"}, now.Add(time.Second)) {
|
||||||
|
t.Fatal("duplicate key admitted")
|
||||||
|
}
|
||||||
|
if bus.Admit(Note{Kind: KindBackground, Key: "job", Text: "后台结束"}, now.Add(time.Second)) {
|
||||||
|
t.Fatal("inside min gap admitted")
|
||||||
|
}
|
||||||
|
if !bus.Admit(Note{Kind: KindBackground, Key: "job", Text: "后台结束"}, now.Add(time.Minute)) {
|
||||||
|
t.Fatal("after gap")
|
||||||
|
}
|
||||||
|
if bus.Admit(Note{Kind: KindDeliverable, Key: "doc", Text: "交付物待确认"}, now.Add(2*time.Minute)) {
|
||||||
|
t.Fatal("over cap")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextFiles walks from dir toward the filesystem root and concatenates
|
||||||
|
// AGENTS.md and CLAUDE.md (nearest first). SYSTEM.md and APPEND_SYSTEM.md
|
||||||
|
// are read from dir only.
|
||||||
|
func ContextFiles(dir string) (string, error) {
|
||||||
|
var parts []string
|
||||||
|
cur := dir
|
||||||
|
for {
|
||||||
|
for _, name := range []string{"AGENTS.md", "CLAUDE.md"} {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(cur, name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, "# "+name+" ("+cur+")\n"+string(raw))
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
for _, name := range []string{"SYSTEM.md", "APPEND_SYSTEM.md"} {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, "# "+name+"\n"+string(raw))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n\n"), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expand replaces {{var}} placeholders and /name includes.
|
||||||
|
// An include reads <dir>/<name>.md. Unknown variables are left intact.
|
||||||
|
func Expand(text, dir string, vars map[string]string) (string, error) {
|
||||||
|
out := text
|
||||||
|
for key, value := range vars {
|
||||||
|
out = strings.ReplaceAll(out, "{{"+key+"}}", value)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
i := strings.Index(out, "/")
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rest := out[i+1:]
|
||||||
|
j := 0
|
||||||
|
for j < len(rest) && isName(rest[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j == 0 || (i > 0 && out[i-1] != ' ' && out[i-1] != '\n') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := rest[:j]
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, name+".md"))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = out[:i] + string(raw) + out[i+1+j:]
|
||||||
|
}
|
||||||
|
for key, value := range vars {
|
||||||
|
out = strings.ReplaceAll(out, "{{"+key+"}}", value)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isName(b byte) bool {
|
||||||
|
return b == '-' || b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/skills"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContextAndPromptAndTrust(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
proj := filepath.Join(root, "proj")
|
||||||
|
if err := os.MkdirAll(proj, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("root-agents"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(proj, "SYSTEM.md"), []byte("sys"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text, err := ContextFiles(proj)
|
||||||
|
if err != nil || !strings.Contains(text, "root-agents") || !strings.Contains(text, "sys") {
|
||||||
|
t.Fatalf("context: %s %v", text, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(proj, "greet.md"), []byte("hello {{name}}"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Expand("say /greet", proj, map[string]string{"name": "Ada"})
|
||||||
|
if err != nil || got != "say hello Ada" {
|
||||||
|
t.Fatalf("expand: %q %v", got, err)
|
||||||
|
}
|
||||||
|
if ProjectTrusted(TrustAsk, false, false) {
|
||||||
|
t.Fatal("ask is not trusted without approve")
|
||||||
|
}
|
||||||
|
if !ProjectTrusted(TrustNever, true, false) {
|
||||||
|
t.Fatal("approve overrides never")
|
||||||
|
}
|
||||||
|
if ProjectTrusted(TrustAlways, false, true) {
|
||||||
|
t.Fatal("no-approve wins")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiAliasesFilterAndSkillCommand(t *testing.T) {
|
||||||
|
reg := tools.WorkspaceTools(t.TempDir())
|
||||||
|
reg.RegisterPiAliases()
|
||||||
|
if !reg.Has("read") || !reg.Has("ls") || !reg.Has("read_file") {
|
||||||
|
t.Fatal("aliases missing")
|
||||||
|
}
|
||||||
|
if err := reg.Filter(tools.FilterOptions{Exclude: []string{"read"}, NoBuiltin: false}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reg.Has("read") || !reg.Has("read_file") {
|
||||||
|
t.Fatal("exclude should drop only the alias")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
skillDir := filepath.Join(dir, "review")
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := skills.NewStore(dir)
|
||||||
|
out, err := store.ExpandCommands("use /skill:review now")
|
||||||
|
if err != nil || !strings.Contains(out, "be thorough") {
|
||||||
|
t.Fatalf("skill expand: %q %v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trust is how a project directory is treated in non-interactive runs.
|
||||||
|
type Trust string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TrustAsk Trust = "ask"
|
||||||
|
TrustAlways Trust = "always"
|
||||||
|
TrustNever Trust = "never"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings is the global plus project configuration.
|
||||||
|
type Settings struct {
|
||||||
|
Trust Trust `json:"trust,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Thinking string `json:"thinking,omitempty"`
|
||||||
|
Tools []string `json:"tools,omitempty"`
|
||||||
|
Exclude []string `json:"exclude,omitempty"`
|
||||||
|
Vars map[string]string `json:"vars,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSettings reads global (optional) then project settings.
|
||||||
|
// Project keys override global keys. A missing file is an empty settings.
|
||||||
|
func LoadSettings(globalPath, projectPath string) (Settings, error) {
|
||||||
|
global, err := readSettings(globalPath)
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
project, err := readSettings(projectPath)
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
return mergeSettings(global, project), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectTrusted reports whether project resources may be loaded.
|
||||||
|
// ask and never refuse in non-interactive mode. approve forces trust.
|
||||||
|
func ProjectTrusted(trust Trust, approve, noApprove bool) bool {
|
||||||
|
if noApprove {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if approve {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return trust == TrustAlways
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSettings(path string) (Settings, error) {
|
||||||
|
if path == "" {
|
||||||
|
return Settings{}, nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return Settings{}, nil
|
||||||
|
}
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
var s Settings
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeSettings(base, over Settings) Settings {
|
||||||
|
if over.Trust != "" {
|
||||||
|
base.Trust = over.Trust
|
||||||
|
}
|
||||||
|
if over.Model != "" {
|
||||||
|
base.Model = over.Model
|
||||||
|
}
|
||||||
|
if over.Thinking != "" {
|
||||||
|
base.Thinking = over.Thinking
|
||||||
|
}
|
||||||
|
if over.Tools != nil {
|
||||||
|
base.Tools = over.Tools
|
||||||
|
}
|
||||||
|
if over.Exclude != nil {
|
||||||
|
base.Exclude = over.Exclude
|
||||||
|
}
|
||||||
|
if len(over.Vars) > 0 {
|
||||||
|
if base.Vars == nil {
|
||||||
|
base.Vars = map[string]string{}
|
||||||
|
}
|
||||||
|
for k, v := range over.Vars {
|
||||||
|
base.Vars[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPaths returns the global and project settings paths for a cwd.
|
||||||
|
func DefaultPaths(home, cwd string) (string, string) {
|
||||||
|
return filepath.Join(home, ".agent-native", "settings.json"), filepath.Join(cwd, ".agent-native", "settings.json")
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info describes one session file in a directory.
|
||||||
|
type Info struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
CWD string
|
||||||
|
Leaf string
|
||||||
|
Updated string
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns session files in dir, newest first.
|
||||||
|
func List(dir string) ([]Info, error) {
|
||||||
|
ents, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Info
|
||||||
|
for _, ent := range ents {
|
||||||
|
name := ent.Name()
|
||||||
|
if ent.IsDir() || !strings.HasSuffix(name, ".jsonl") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
st, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := st.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Updated > out[j].Updated })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue opens the newest session whose cwd matches. A second return
|
||||||
|
// value of false means there is nothing to continue.
|
||||||
|
func Continue(dir, cwd string) (*Store, bool, error) {
|
||||||
|
list, err := List(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
for _, info := range list {
|
||||||
|
if cwd == "" || info.CWD == cwd {
|
||||||
|
st, err := Open(info.Path)
|
||||||
|
return st, err == nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume opens a session by id (filename stem) or by name.
|
||||||
|
func Resume(dir, idOrName string) (*Store, error) {
|
||||||
|
list, err := List(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, info := range list {
|
||||||
|
if info.ID == idOrName || info.Name == idOrName {
|
||||||
|
return Open(info.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCWD records the working directory used by Continue.
|
||||||
|
func (s *Store) SetCWD(cwd string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.writeMetaKeyLocked("cwd", cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info reads the catalog fields for this file.
|
||||||
|
func (s *Store) Info() (Info, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entries, err := s.readEntriesLocked()
|
||||||
|
if err != nil {
|
||||||
|
return Info{}, err
|
||||||
|
}
|
||||||
|
meta := s.readMetaLocked()
|
||||||
|
updated := ""
|
||||||
|
if len(entries) > 0 {
|
||||||
|
updated = entries[len(entries)-1].Timestamp
|
||||||
|
}
|
||||||
|
id := strings.TrimSuffix(filepath.Base(s.path), ".jsonl")
|
||||||
|
return Info{
|
||||||
|
ID: id, Path: s.path, Name: meta["name"], CWD: meta["cwd"],
|
||||||
|
Leaf: s.leafLocked(entries), Updated: updated,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+63
-47
@@ -3,6 +3,7 @@ package session
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"agent-native/runtime/llm"
|
"agent-native/runtime/llm"
|
||||||
@@ -66,73 +67,88 @@ func (s *Store) ReadMeta() map[string]string {
|
|||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact 把转录改写为「系统消息 + 摘要消息 + 最近 keepLastN 条」。
|
const maxCompactAttempts = 3
|
||||||
// 摘要由 summarize 生成(由适配层注入,通常是一次无工具模型调用);
|
|
||||||
// 改写原子落盘(临时文件 + rename)。
|
// Compact appends a compaction entry. The file keeps every earlier record.
|
||||||
// 返回是否发生了压缩(消息数不足/已是紧凑形态时返回 false 且不动文件)。
|
// Load hides the summarized prefix behind the summary plus the last
|
||||||
|
// keepLastN non-system messages. Returns false when there is nothing to fold.
|
||||||
|
// A session change during summary is retried from the new transcript.
|
||||||
|
// Three consecutive summary failures or conflicts trip the breaker and
|
||||||
|
// leave the file unchanged.
|
||||||
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
|
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
|
||||||
messages, err := s.Load()
|
var last error
|
||||||
|
for attempt := 0; attempt < maxCompactAttempts; attempt++ {
|
||||||
|
done, err, retry := s.compactOnce(keepLastN, summarize)
|
||||||
|
if !retry {
|
||||||
|
return done, err
|
||||||
|
}
|
||||||
|
last = err
|
||||||
|
}
|
||||||
|
if last == nil {
|
||||||
|
last = fmt.Errorf("compaction conflict: session changed during summary")
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("compaction circuit open: %w", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) compactOnce(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
entries, err := s.readEntriesLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
s.mu.Unlock()
|
||||||
|
return false, err, false
|
||||||
}
|
}
|
||||||
if len(messages) <= keepLastN+1 { // 系统消息 + 尾部,无可压缩头部
|
leaf := s.leafLocked(entries)
|
||||||
return false, nil
|
generation := len(entries)
|
||||||
|
s.mu.Unlock()
|
||||||
|
path := pathTo(entries, leaf)
|
||||||
|
var messages []llm.Message
|
||||||
|
var msgEntries []Entry
|
||||||
|
for _, e := range path {
|
||||||
|
if e.Type == TypeMessage && e.Message != nil {
|
||||||
|
messages = append(messages, *e.Message)
|
||||||
|
msgEntries = append(msgEntries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(messages) <= keepLastN+1 {
|
||||||
|
return false, nil, false
|
||||||
}
|
}
|
||||||
var system *llm.Message
|
|
||||||
rest := messages
|
rest := messages
|
||||||
|
restEntries := msgEntries
|
||||||
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
|
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
|
||||||
copyOf := rest[0]
|
|
||||||
system = ©Of
|
|
||||||
rest = rest[1:]
|
rest = rest[1:]
|
||||||
|
restEntries = restEntries[1:]
|
||||||
}
|
}
|
||||||
tailStart := len(rest) - keepLastN
|
tailStart := len(rest) - keepLastN
|
||||||
if tailStart <= 0 {
|
if tailStart <= 0 {
|
||||||
return false, nil
|
return false, nil, false
|
||||||
}
|
}
|
||||||
|
tailStart = alignToolTail(rest, tailStart)
|
||||||
head := rest[:tailStart]
|
head := rest[:tailStart]
|
||||||
tail := rest[tailStart:]
|
|
||||||
summary, err := summarize(head)
|
summary, err := summarize(head)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err, true
|
||||||
}
|
}
|
||||||
summaryMsg := llm.Message{
|
var tailIDs []string
|
||||||
Role: llm.RoleUser,
|
for _, e := range restEntries[tailStart:] {
|
||||||
Content: "[conversation summary]\n" + summary + "\n[recent messages follow]",
|
tailIDs = append(tailIDs, e.ID)
|
||||||
}
|
}
|
||||||
var rebuilt []llm.Message
|
|
||||||
if system != nil {
|
|
||||||
rebuilt = append(rebuilt, *system)
|
|
||||||
}
|
|
||||||
rebuilt = append(rebuilt, summaryMsg)
|
|
||||||
rebuilt = append(rebuilt, tail...)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
tmp := s.path + ".compact.tmp"
|
now, err := s.readEntriesLocked()
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err, false
|
||||||
}
|
}
|
||||||
w := bufio.NewWriter(f)
|
if len(now) != generation {
|
||||||
for _, m := range rebuilt {
|
return false, fmt.Errorf("compaction conflict: session changed during summary"), true
|
||||||
raw, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
w.Write(raw)
|
|
||||||
w.WriteByte('\n')
|
|
||||||
}
|
}
|
||||||
if err := w.Flush(); err != nil {
|
_, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs})
|
||||||
_ = f.Close()
|
return err == nil, err, false
|
||||||
_ = os.Remove(tmp)
|
}
|
||||||
return false, err
|
|
||||||
|
// alignToolTail 让保留尾巴从一条非 tool 消息开始,避免把 tool_calls 和结果拆开。
|
||||||
|
func alignToolTail(messages []llm.Message, tailStart int) int {
|
||||||
|
for tailStart > 0 && messages[tailStart].Role == llm.RoleTool {
|
||||||
|
tailStart--
|
||||||
}
|
}
|
||||||
_ = f.Close()
|
return tailStart
|
||||||
if err := os.Rename(tmp, s.path); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,9 +114,12 @@ func TestCompactRewritesTranscript(t *testing.T) {
|
|||||||
if msgs[2].Content != "reply" || msgs[3].Content != "earlyj" || msgs[4].Content != "reply" {
|
if msgs[2].Content != "reply" || msgs[3].Content != "earlyj" || msgs[4].Content != "reply" {
|
||||||
t.Fatalf("tail order wrong: %+v", msgs[2:])
|
t.Fatalf("tail order wrong: %+v", msgs[2:])
|
||||||
}
|
}
|
||||||
// 无 tmp 残留。
|
entries, err := s.Entries()
|
||||||
if _, err := os.Stat(s.Path() + ".compact.tmp"); !os.IsNotExist(err) {
|
if err != nil {
|
||||||
t.Fatal("compact tmp file must not remain")
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) <= 5 {
|
||||||
|
t.Fatalf("compaction must append, not rewrite; got %d entries", len(entries))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportHTML writes the active branch as a standalone HTML document.
|
||||||
|
func (s *Store) ExportHTML(path string) error {
|
||||||
|
msgs, err := s.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>")
|
||||||
|
title := s.Name()
|
||||||
|
if title == "" {
|
||||||
|
title = "session"
|
||||||
|
}
|
||||||
|
b.WriteString(html.EscapeString(title))
|
||||||
|
b.WriteString("</title></head><body>\n")
|
||||||
|
for _, m := range msgs {
|
||||||
|
fmt.Fprintf(&b, "<article data-role=\"%s\"><h2>%s</h2><pre>%s</pre></article>\n",
|
||||||
|
html.EscapeString(string(m.Role)),
|
||||||
|
html.EscapeString(string(m.Role)),
|
||||||
|
html.EscapeString(m.Content))
|
||||||
|
}
|
||||||
|
b.WriteString("</body></html>\n")
|
||||||
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolIntent 是一次尚未配对结果的工具调用声明。
|
||||||
|
type ToolIntent struct {
|
||||||
|
CallID string `json:"callId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments,omitempty"`
|
||||||
|
Replay string `json:"replay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendToolIntent 先落下「即将执行」。结果另写一条 tool 消息。
|
||||||
|
func (s *Store) AppendToolIntent(in ToolIntent) error {
|
||||||
|
raw, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.AppendEntry(Entry{Type: TypeToolIntent, Data: raw})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingIntents 返回活动分支上还没有对应 tool 结果的意图。
|
||||||
|
func (s *Store) PendingIntents() ([]ToolIntent, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entries, err := s.readEntriesLocked()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
answered := map[string]bool{}
|
||||||
|
var intents []ToolIntent
|
||||||
|
for _, e := range pathTo(entries, s.leafLocked(entries)) {
|
||||||
|
if e.Type == TypeMessage && e.Message != nil && e.Message.Role == llm.RoleTool && e.Message.ToolCallID != "" {
|
||||||
|
answered[e.Message.ToolCallID] = true
|
||||||
|
}
|
||||||
|
if e.Type != TypeToolIntent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var in ToolIntent
|
||||||
|
if err := json.Unmarshal(e.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
intents = append(intents, in)
|
||||||
|
}
|
||||||
|
var pending []ToolIntent
|
||||||
|
for _, in := range intents {
|
||||||
|
if !answered[in.CallID] {
|
||||||
|
pending = append(pending, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending, nil
|
||||||
|
}
|
||||||
+12
-44
@@ -1,17 +1,16 @@
|
|||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"agent-native/runtime/llm"
|
"agent-native/runtime/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store is an append-only JSONL transcript. Each line is one llm.Message.
|
// Store is an append-only JSONL transcript. New sessions are a tree of
|
||||||
|
// entries (id / parentId). Load still returns the model-visible message
|
||||||
|
// path, and files written as one llm.Message per line remain readable.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -31,56 +30,25 @@ func Open(path string) (*Store, error) {
|
|||||||
|
|
||||||
func (s *Store) Path() string { return s.path }
|
func (s *Store) Path() string { return s.path }
|
||||||
|
|
||||||
|
// Append records one message on the active branch.
|
||||||
func (s *Store) Append(msg llm.Message) error {
|
func (s *Store) Append(msg llm.Message) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
raw, err := json.Marshal(msg)
|
_, err := s.appendEntryLocked(Entry{Type: TypeMessage, Message: &msg})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
raw = append(raw, '\n')
|
|
||||||
_, err = f.Write(raw)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 全量读取转录。崩溃残留容错:Append 恒以换行收尾,因此无换行结尾的
|
// Load returns the model-visible messages on the active branch.
|
||||||
// 尾部若不是完整 JSON 即为崩溃半行,丢弃而不是让整个会话不可用;
|
// A compaction entry replaces earlier turns with its summary plus the
|
||||||
// 行中间的坏行仍报错(那是文件损坏,不是崩溃残留)。
|
// retained tail; the file itself is never rewritten.
|
||||||
|
// Crash residue: a trailing line without a newline that is not valid JSON
|
||||||
|
// is dropped. A bad line in the middle is still an error.
|
||||||
func (s *Store) Load() ([]llm.Message, error) {
|
func (s *Store) Load() ([]llm.Message, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
raw, err := os.ReadFile(s.path)
|
entries, err := s.readEntriesLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
text := string(raw)
|
return visibleMessages(entries, s.leafLocked(entries)), nil
|
||||||
if text != "" && !strings.HasSuffix(text, "\n") {
|
|
||||||
if idx := strings.LastIndexByte(text, '\n'); idx >= 0 {
|
|
||||||
if !json.Valid([]byte(text[idx+1:])) {
|
|
||||||
text = text[:idx+1]
|
|
||||||
}
|
|
||||||
} else if !json.Valid([]byte(text)) {
|
|
||||||
text = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var out []llm.Message
|
|
||||||
sc := bufio.NewScanner(strings.NewReader(text))
|
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
|
||||||
for sc.Scan() {
|
|
||||||
line := sc.Bytes()
|
|
||||||
if len(line) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var msg llm.Message
|
|
||||||
if err := json.Unmarshal(line, &msg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, msg)
|
|
||||||
}
|
|
||||||
return out, sc.Err()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import "agent-native/runtime/llm"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultKeepToolResults 是运行内清理时保留的最近工具结果条数。
|
||||||
|
DefaultKeepToolResults = 5
|
||||||
|
// DefaultContextWindow 是未配置模型窗口时的保守字符数。
|
||||||
|
DefaultContextWindow = 32000
|
||||||
|
// DefaultOutputReserve 是留给模型输出的字符预留。
|
||||||
|
DefaultOutputReserve = 4096
|
||||||
|
// DefaultContextBuffer 是窗口估算的余量,避免贴着上限才清理。
|
||||||
|
DefaultContextBuffer = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// EstimateChars 用字符数近似这次请求的体积。
|
||||||
|
func EstimateChars(msgs []llm.Message) int {
|
||||||
|
n := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
n += len([]rune(m.Content))
|
||||||
|
for _, c := range m.ToolCalls {
|
||||||
|
n += len([]rune(c.Name)) + len([]rune(c.Arguments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsClear 在估算超过「窗口 − 输出预留 − 缓冲」时返回真。
|
||||||
|
// window ≤ 0 时用保守默认值。
|
||||||
|
func NeedsClear(estimate, window, outputReserve, buffer int) bool {
|
||||||
|
if window <= 0 {
|
||||||
|
window = DefaultContextWindow
|
||||||
|
}
|
||||||
|
room := window - outputReserve - buffer
|
||||||
|
if room < 1 {
|
||||||
|
room = 1
|
||||||
|
}
|
||||||
|
return estimate > room
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearOldToolResults 把最近 keep 条之前的工具结果换成占位。
|
||||||
|
// 消息条数、顺序和 tool_call 配对都不变;占位写明完整结果仍在会话记录里。
|
||||||
|
// keep ≤ 0 时保留默认的 5 条。返回的切片不与输入共享被改写的消息。
|
||||||
|
func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
|
||||||
|
if keep <= 0 {
|
||||||
|
keep = DefaultKeepToolResults
|
||||||
|
}
|
||||||
|
out := append([]llm.Message{}, msgs...)
|
||||||
|
var toolIdx []int
|
||||||
|
for i, m := range out {
|
||||||
|
if m.Role == llm.RoleTool {
|
||||||
|
toolIdx = append(toolIdx, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toolIdx) <= keep {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, i := range toolIdx[:len(toolIdx)-keep] {
|
||||||
|
name := out[i].Name
|
||||||
|
if name == "" {
|
||||||
|
name = "工具"
|
||||||
|
}
|
||||||
|
loc := "会话记录"
|
||||||
|
if out[i].ToolCallID != "" {
|
||||||
|
loc = "会话记录中的调用 " + out[i].ToolCallID
|
||||||
|
}
|
||||||
|
out[i].Content = "旧工具结果已清理。完整内容见" + loc + "(" + name + ")。"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClearOldToolResultsKeepsLastFive(t *testing.T) {
|
||||||
|
var msgs []llm.Message
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
id := string(rune('a' + i))
|
||||||
|
msgs = append(msgs,
|
||||||
|
llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read_file"}}},
|
||||||
|
llm.Message{Role: llm.RoleTool, Name: "read_file", ToolCallID: id, Content: "正文-" + id},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out := ClearOldToolResults(msgs, 5)
|
||||||
|
if len(out) != len(msgs) {
|
||||||
|
t.Fatalf("len %d, want %d", len(out), len(msgs))
|
||||||
|
}
|
||||||
|
cleared := 0
|
||||||
|
for i, m := range out {
|
||||||
|
if m.Role != llm.RoleTool {
|
||||||
|
if m.Role != msgs[i].Role || len(m.ToolCalls) != len(msgs[i].ToolCalls) {
|
||||||
|
t.Fatalf("pairing changed at %d", i)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.ToolCallID != msgs[i].ToolCallID {
|
||||||
|
t.Fatalf("tool id changed at %d", i)
|
||||||
|
}
|
||||||
|
if strings.Contains(m.Content, "旧工具结果已清理") {
|
||||||
|
cleared++
|
||||||
|
if !strings.Contains(m.Content, m.ToolCallID) {
|
||||||
|
t.Fatalf("placeholder missing call id: %s", m.Content)
|
||||||
|
}
|
||||||
|
} else if m.Content != msgs[i].Content {
|
||||||
|
t.Fatalf("recent result changed: %s", m.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cleared != 3 {
|
||||||
|
t.Fatalf("cleared %d, want 3", cleared)
|
||||||
|
}
|
||||||
|
for _, m := range out[len(out)-10:] {
|
||||||
|
if m.Role == llm.RoleTool && strings.Contains(m.Content, "旧工具结果已清理") {
|
||||||
|
t.Fatalf("last five were cleared: %s", m.ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsClearUsesConservativeWindow(t *testing.T) {
|
||||||
|
if NeedsClear(10, 0, 0, 0) {
|
||||||
|
t.Fatal("small estimate must fit the default window")
|
||||||
|
}
|
||||||
|
if !NeedsClear(DefaultContextWindow, 0, DefaultOutputReserve, DefaultContextBuffer) {
|
||||||
|
t.Fatal("estimate at the default window must clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user