Compare commits

..
Author SHA1 Message Date
weichenandCursor a34689c78f Delegate unregistered tool calls when a fallback is set
ci / vet-and-test (pull_request) Failing after 2m48s
ci / docker-checkout-hostgateway (pull_request) Failing after 31s
Why: the direct chat path still executes model-emitted calls that are not in the profile allow-list, while squad keeps the unknown-tool error when the fallback is unset.
Evidence: go test ./runtime/ passed, including TestLoopUnknownToolFallsThrough and the existing unknown-tool cases.
Impact: Config.UnknownTool is optional. Nil preserves today's registry error.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:27:36 +00:00
weichenandCursor e2815bbf94 Report token usage on each message end
Why: the direct-path adapter accounts usage per turn, including runs that stop on a budget error before the final usage event.
Evidence: go test ./runtime/ ./runtime/llm/ after the field was added.
Impact: message_end and usage events carry TotalTokens. Callers that ignore the new field are unchanged.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:25:58 +00:00
weichenandCursor 35f65a3c49 Add optional stop, reminder, and continuation hooks to the turn loop
Why: the direct chat path needs one shared loop for deliverable checks, tool pairing, reminders, and truncated replies, while squad callers keep today's behavior when the new fields stay unset.
Evidence: go test ./... in agent-native passed, including Stop cap, BeforeTool pairing, reminders absent from the session, repeat-guard cap, length continuation, and zero-value loop tests.
Impact: runtime.Config and llm.Event grow optional fields; AgentSession.Prompt copies them through. Squad sessions that leave them zero are unchanged.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:23:07 +00:00
weichen 886e86c6a8 Add the headless agent session, providers, and RPC
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 10:30:41 +00:00
weichenandCursor 6b7d2432b3 Serve print, JSON, and RPC modes on AgentSession
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: The library had no process entry, and the product backend still called the loop directly.
Evidence: go test -count=1 ./...
Impact: exec.Backend keeps the same request and result. Execute now goes through AgentSession. Extensions and packages are not a sandbox.
Refs: feature/pi-headless N6 N7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:16:00 +00:00
weichenandCursor 23b5e3f336 Add an extension process protocol and package manager
Why: Tools, hooks, and providers need a way in without a Go rebuild, and packages need install, update, and removal.
Evidence: go test -count=1 ./runtime/ext/
Impact: Extensions run as subprocesses with the caller's privileges. They are not a sandbox.
Refs: feature/pi-headless N5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:13:48 +00:00
weichenandCursor dfd0d23bf3 Add pi tool aliases, context files, and project trust
Why: Headless runs need the pi tool names, walked context files, prompt includes, and a trust gate before project settings are loaded.
Evidence: go test -count=1 ./runtime/tools/ ./runtime/resources/ ./runtime/skills/
Impact: Existing tool names stay registered. Aliases are added only when the underlying tool exists.
Refs: feature/pi-headless N4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:12:26 +00:00
weichenandCursor 2dedc0d20c Add an AgentSession with steer and follow-up queues
Why: Steering hooks existed on the loop but nothing owned the queues, abort, or a model change during a run.
Evidence: go test -count=1 ./runtime/
Impact: Run still emits the existing event names. message_start, tool_execution_update, and agent_settled are added beside them.
Refs: feature/pi-headless N3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:11:19 +00:00
weichenandCursor e7ab48e8ea Add native provider protocols and retry
Why: The runtime could only speak OpenAI chat completions, so thinking levels, images, and the other vendor wires had nowhere to go.
Evidence: go test -count=1 ./runtime/llm/
Impact: Existing chat-completions calls stay the same unless a thinking level or image is set. Overflow errors are not retried.
Refs: feature/pi-headless N2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:10:02 +00:00
weichenandCursor 6bc1b08540 Record sessions as an append-only entry tree
Why: Linear transcripts cannot fork, and compaction rewrote history, so resume and branch navigation had nothing to point at.
Evidence: go test -count=1 ./runtime/session/
Impact: Load still returns the model-visible path, including old one-message-per-line files. Compaction appends a summary entry instead of truncating the file.
Refs: feature/pi-headless N1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:08:04 +00:00
weichen ec731addef Let hosts register tools and observe token usage
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 09:35:54 +00:00
34 changed files with 3577 additions and 118 deletions
+3 -3
View File
@@ -319,7 +319,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
runRes, err := runtime.Run(ctx, runtime.Config{
agent := runtime.NewAgentSession(runtime.Config{
Model: model,
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
Tools: reg,
@@ -351,8 +351,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 在转录超过阈值时压缩头部历史。压缩是优化性质:
+5 -2
View File
@@ -47,8 +47,11 @@ func TestBackendCompactBeforeRun(t *testing.T) {
t.Fatal("compaction should have run before the loop")
}
data, _ := os.ReadFile(transcript)
if !strings.Contains(string(data), "[conversation summary]") {
t.Fatalf("transcript should contain the summary marker, got:\n%.200s", data)
if !strings.Contains(string(data), "预置历史摘要") || !strings.Contains(string(data), `"type":"compaction"`) {
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")
}
}
+65
View File
@@ -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
}
+119
View File
@@ -0,0 +1,119 @@
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 := &llm.Client{BaseURL: provider.BaseURL, APIKey: provider.APIKey}
return llm.WithRetry(client.Stream, 2, nil)
}
func readAll(r io.Reader) string {
raw, _ := io.ReadAll(r)
return string(raw)
}
+16
View File
@@ -89,3 +89,19 @@ baseline_fixed: true
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
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,请求/结果字段不变 |
+54
View File
@@ -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
}
}
+193
View File
@@ -0,0 +1,193 @@
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"`
}
// 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")
}
res, err := s.Agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Message})
if err != nil {
return fail(err.Error())
}
if res.Err != nil {
return fail(res.Err.Error())
}
return Response{OK: true, Text: res.Text}
case "steer":
s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message})
return Response{OK: true}
case "follow_up":
s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message})
return Response{OK: true}
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 encode(resp Response) ([]byte, error) {
raw, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return append(raw, '\n'), nil
}
+71
View File
@@ -0,0 +1,71 @@
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" {
t.Fatalf("prompt %+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()
}
+181
View File
@@ -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
})
}
+235
View File
@@ -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()
}
+100
View File
@@ -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)
}
}
+4 -1
View File
@@ -53,6 +53,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
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)
body["tool_choice"] = "auto"
@@ -125,7 +128,7 @@ func toProviderMessages(messages []Message) []map[string]any {
default:
out = append(out, map[string]any{
"role": string(m.Role),
"content": m.Content,
"content": messageContent(m),
})
}
}
+431
View File
@@ -0,0 +1,431 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"os"
"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: ProtocolAnthropic, BaseURL: "https://api.anthropic.com", 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: ProtocolGoogle, BaseURL: "https://generativelanguage.googleapis.com/v1beta", 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
}
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)
case ProtocolAnthropic:
sys, msgs := anthropicMessages(messages)
body := map[string]any{
"model": model,
"messages": msgs,
"max_tokens": opts.MaxTokens,
"stream": true,
}
if body["max_tokens"] == 0 {
body["max_tokens"] = 4096
}
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
}
// ContextOverflow reports provider errors that ask the caller to compact.
func ContextOverflow(message string) bool {
m := strings.ToLower(message)
return strings.Contains(m, "context") && (strings.Contains(m, "overflow") || strings.Contains(m, "too long") || strings.Contains(m, "length"))
}
// WithRetry re-invokes base on a retryable terminal error, up to attempts
// extra tries. onEvent receives auto_retry_start and auto_retry_end.
// Context-overflow errors are returned immediately so the caller can compact.
func WithRetry(base StreamFunc, attempts int, onEvent func(kind, detail string)) StreamFunc {
if attempts < 1 {
attempts = 1
}
return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
var last []Event
for try := 0; try <= attempts; try++ {
if try > 0 {
detail := fmt.Sprintf("attempt %d", try+1)
if onEvent != nil {
onEvent("auto_retry_start", detail)
}
delay := time.Duration(200*(1<<uint(try-1))) * time.Millisecond
select {
case <-ctx.Done():
emitAbort(out, "", ctx.Err())
return
case <-time.After(delay):
}
}
last = nil
for ev := range base(ctx, model, messages, tools, opts) {
last = append(last, ev)
}
overflow, retryable := false, false
for _, ev := range last {
if ev.Kind == KindError && ContextOverflow(ev.ErrorMessage) {
overflow = true
}
if ev.Kind == KindError && ev.Retryable {
retryable = true
}
}
if overflow || !retryable || try == attempts {
if try > 0 && onEvent != nil {
onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1))
}
for _, ev := range last {
select {
case <-ctx.Done():
return
case out <- ev:
}
}
return
}
}
}()
return out
}
}
+115
View File
@@ -0,0 +1,115 @@
package llm
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
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 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 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)
}
}
+13 -3
View File
@@ -26,12 +26,19 @@ type sseChoice struct {
type sseChunk struct {
Choices []sseChoice `json:"choices"`
Usage *Usage `json:"usage"`
Usage *usageWire `json:"usage"`
Error *struct {
Message string `json:"message"`
} `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
// deltas plus one terminal message_end or error event.
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
@@ -77,7 +84,10 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
return
}
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 {
continue
@@ -154,7 +164,7 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
select {
case <-ctx.Done():
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}:
}
}
+23
View File
@@ -38,6 +38,29 @@ func TestParseSSETextAndUsage(t *testing.T) {
if end.Usage.TotalTokens != 5 {
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) {
+19 -3
View File
@@ -48,6 +48,7 @@ type Message struct {
Role Role `json:"role"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
Images []Image `json:"images,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
StopReason StopReason `json:"stop_reason,omitempty"`
@@ -61,11 +62,20 @@ type ToolDecl struct {
Parameters json.RawMessage `json:"parameters"`
}
// 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 reported by the provider. Zero means the provider omitted usage.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
}
// Event is one streamed update. Failures are KindError plus a final assistant
@@ -80,9 +90,12 @@ type Event struct {
ArgumentsDelta string
Message Message
Usage Usage
Err error
Retryable bool
ErrorMessage string
// FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
FinishReason string
Err error
Retryable bool
ErrorMessage string
}
// StreamOptions tunes one provider request.
@@ -90,6 +103,9 @@ type StreamOptions struct {
MaxTokens int
APIBase string
APIKey string
// ThinkingLevel is off, minimal, low, medium, high, xhigh, or max.
// Empty leaves the provider default unchanged.
ThinkingLevel string
}
// StreamFunc is the provider boundary. It must return a channel that is closed
+212 -12
View File
@@ -18,8 +18,15 @@ import (
const (
ExecSequential = "sequential"
ExecParallel = "parallel"
maxStopContinuations = 3
maxLengthContinuations = 3
maxRepeatReminders = 3
)
const repeatReminderText = "你已连续 3 次以相同参数调用同一工具。如果结果不符合预期,请更换参数或做法,不要再重复这一调用。"
const lengthContinuePrompt = "上一段回复因为达到长度上限被截断。请从断点接着写,不要重复已经写出的内容。"
// Event is a lifecycle notice for logs and UI.
type Event struct {
Type string
@@ -29,9 +36,11 @@ type Event struct {
// Detail carries tool arguments/results or reasoning deltas for
// process-visualization consumers; log writers may ignore it.
Detail string
// PromptTokens / CompletionTokens 只在 type=usage 时有意义。
// PromptTokens / CompletionTokens / TotalTokens 在 type=usage 或
// type=message_end 时有意义,供调用方累计用量。
PromptTokens int
CompletionTokens int
TotalTokens int
}
// Config drives one agent session.
@@ -46,6 +55,10 @@ type Config struct {
GetSteering func() []llm.Message
GetFollowUp func() []llm.Message
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 *logsys.Logger
// ExecutionID 只用于日志字段,不参与控制流。
@@ -53,6 +66,41 @@ type Config struct {
// Budget 可选。任一上限命中即停止并返回 budget_exceeded 错误(StopError);
// 零值字段不设限。截止时刻与外层 ctx 先到者生效。
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 结果。
BeforeTool func(call llm.ToolCall) error
// UnknownTool 处理注册表里没有的调用。nil 时仍返回 unknown tool。
UnknownTool func(call llm.ToolCall) tools.Result
// AfterTool 只观察工具结果,不能改写。
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 且没有工具调用就续写,最多 3 次。
// 续写提示不写进 Session。第 4 次截断返回错误。
ContinueOnLength bool
}
// 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 字段同构,由适配层换算。
@@ -69,6 +117,7 @@ type Result struct {
Messages []llm.Message
StopReason llm.StopReason
Usage llm.Usage
Notice string
Err error
Retryable bool
}
@@ -92,6 +141,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
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 {
sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)}
messages = append([]llm.Message{sys}, messages...)
@@ -108,6 +160,10 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
var last llm.Message
var usage llm.Usage
toolCalls := 0
stopContinues := 0
lengthContinues := 0
lengthNudge := false
var repeats repeatState
for turn := 0; turn < cfg.MaxTurns; turn++ {
if ctx.Err() != nil {
cfg.logger().Error("runtime turn aborted", ctx.Err())
@@ -117,7 +173,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
return budgetStop(cfg, last, messages, usage, reason), nil
}
emit(cfg, Event{Type: "turn_start"})
end, errEv := consume(ctx, cfg, messages)
emit(cfg, Event{Type: "message_start"})
request := withEphemeral(messages, turnReminders(cfg, turn, &repeats, &lengthNudge))
end, errEv := consume(ctx, cfg, request)
if errEv != nil {
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
@@ -130,7 +188,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
return Result{}, err
}
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 cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls {
@@ -138,6 +196,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
return budgetStop(cfg, last, messages, usage, reason), nil
}
toolCalls += len(last.ToolCalls)
if cfg.RepeatGuard {
repeats.observe(last.ToolCalls)
}
results := executeTools(ctx, cfg, last)
for _, res := range results {
if err := persist(cfg, res); err != nil {
@@ -157,6 +218,16 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
continue
}
emit(cfg, Event{Type: "turn_end", Message: last})
if cfg.ContinueOnLength && end.FinishReason == "length" && len(last.ToolCalls) == 0 {
if lengthContinues >= maxLengthContinuations {
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 {
for _, m := range follow {
if err := persist(cfg, m); err != nil {
@@ -166,9 +237,26 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
continue
}
emit(cfg, Event{Type: "agent_end", Message: last})
emitUsage(cfg, usage)
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil
if cfg.Stop != nil {
decision := cfg.Stop(last, StopInfo{Continuations: stopContinues, ToolCalls: toolCalls})
if decision.Continue && stopContinues < maxStopContinuations {
stopContinues++
if decision.Replacement != "" && len(messages) > 0 {
messages[len(messages)-1].Content = decision.Replacement
last.Content = decision.Replacement
}
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")
cfg.logger().Error("runtime turn cap hit", errCap)
@@ -222,9 +310,21 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
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
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 {
case llm.KindTextDelta:
emit(cfg, Event{Type: "text_delta", Text: ev.Text})
@@ -271,19 +371,119 @@ func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []l
}
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name})
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
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})
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
}
}
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
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))
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
return llm.Message{
msg := llm.Message{
Role: llm.RoleTool,
Name: call.Name,
ToolCallID: call.ID,
Content: res.Content,
IsError: res.IsError,
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
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 reminderMessage(text string) llm.Message {
return llm.Message{Role: llm.RoleUser, Content: "<system-reminder>\n" + text + "\n</system-reminder>"}
}
func turnReminders(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
var extra []llm.Message
if cfg.Reminders != nil {
for _, text := range cfg.Reminders(turn) {
if strings.TrimSpace(text) == "" {
continue
}
extra = append(extra, reminderMessage(text))
}
}
if lengthNudge != nil && *lengthNudge {
extra = append(extra, llm.Message{Role: llm.RoleUser, Content: 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, reminderMessage(repeatReminderText))
repeats.reminders++
}
}
return extra
}
func withEphemeral(messages, extra []llm.Message) []llm.Message {
if len(extra) == 0 {
return messages
}
out := make([]llm.Message, 0, len(messages)+len(extra))
out = append(out, messages...)
out = append(out, extra...)
return out
}
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 {
@@ -301,7 +501,7 @@ func drain(fn func() []llm.Message) []llm.Message {
}
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) {
+332
View File
@@ -0,0 +1,332 @@
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 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
}
+37
View File
@@ -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
}
+44
View File
@@ -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')
}
+72
View File
@@ -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)
}
}
+102
View File
@@ -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")
}
+105
View File
@@ -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
}
+27 -47
View File
@@ -66,73 +66,53 @@ func (s *Store) ReadMeta() map[string]string {
return meta
}
// Compact 把转录改写为「系统消息 + 摘要消息 + 最近 keepLastN 条」。
// 摘要由 summarize 生成(由适配层注入,通常是一次无工具模型调用);
// 改写原子落盘(临时文件 + rename)。
// 返回是否发生了压缩(消息数不足/已是紧凑形态时返回 false 且不动文件)。
// Compact appends a compaction entry. The file keeps every earlier record.
// Load hides the summarized prefix behind the summary plus the last
// keepLastN non-system messages. Returns false when there is nothing to fold.
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
messages, err := s.Load()
s.mu.Lock()
entries, err := s.readEntriesLocked()
s.mu.Unlock()
if err != nil {
return false, err
}
if len(messages) <= keepLastN+1 { // 系统消息 + 尾部,无可压缩头部
leaf := ""
s.mu.Lock()
leaf = s.leafLocked(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
}
var system *llm.Message
rest := messages
restEntries := msgEntries
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
copyOf := rest[0]
system = &copyOf
rest = rest[1:]
restEntries = restEntries[1:]
}
tailStart := len(rest) - keepLastN
if tailStart <= 0 {
return false, nil
}
head := rest[:tailStart]
tail := rest[tailStart:]
summary, err := summarize(head)
if err != nil {
return false, err
}
summaryMsg := llm.Message{
Role: llm.RoleUser,
Content: "[conversation summary]\n" + summary + "\n[recent messages follow]",
var tailIDs []string
for _, e := range restEntries[tailStart:] {
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()
defer s.mu.Unlock()
tmp := s.path + ".compact.tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
w := bufio.NewWriter(f)
for _, m := range rebuilt {
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 {
_ = f.Close()
_ = os.Remove(tmp)
return false, err
}
_ = f.Close()
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return false, err
}
return true, nil
_, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs})
return err == nil, err
}
+6 -3
View File
@@ -114,9 +114,12 @@ func TestCompactRewritesTranscript(t *testing.T) {
if msgs[2].Content != "reply" || msgs[3].Content != "earlyj" || msgs[4].Content != "reply" {
t.Fatalf("tail order wrong: %+v", msgs[2:])
}
// 无 tmp 残留。
if _, err := os.Stat(s.Path() + ".compact.tmp"); !os.IsNotExist(err) {
t.Fatal("compact tmp file must not remain")
entries, err := s.Entries()
if err != nil {
t.Fatal(err)
}
if len(entries) <= 5 {
t.Fatalf("compaction must append, not rewrite; got %d entries", len(entries))
}
}
+32
View File
@@ -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)
}
+12 -44
View File
@@ -1,17 +1,16 @@
package session
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"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 {
path string
mu sync.Mutex
@@ -31,56 +30,25 @@ func Open(path string) (*Store, error) {
func (s *Store) Path() string { return s.path }
// Append records one message on the active branch.
func (s *Store) Append(msg llm.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
raw, err := json.Marshal(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)
_, err := s.appendEntryLocked(Entry{Type: TypeMessage, Message: &msg})
return err
}
// Load 全量读取转录。崩溃残留容错:Append 恒以换行收尾,因此无换行结尾的
// 尾部若不是完整 JSON 即为崩溃半行,丢弃而不是让整个会话不可用;
// 行中间的坏行仍报错(那是文件损坏,不是崩溃残留)。
// Load returns the model-visible messages on the active branch.
// 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) {
s.mu.Lock()
defer s.mu.Unlock()
raw, err := os.ReadFile(s.path)
entries, err := s.readEntriesLocked()
if err != nil {
return nil, err
}
text := string(raw)
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()
return visibleMessages(entries, s.leafLocked(entries)), nil
}
+397
View File
@@ -0,0 +1,397 @@
package session
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"agent-native/runtime/llm"
)
// EntryType is the kind of one JSONL record.
type EntryType string
const (
TypeHeader EntryType = "header"
TypeMessage EntryType = "message"
TypeModelChange EntryType = "model_change"
TypeThinkingLevelChange EntryType = "thinking_level_change"
TypeCompaction EntryType = "compaction"
TypeLabel EntryType = "label"
TypeCustom EntryType = "custom"
TypeSessionInfo EntryType = "session_info"
)
// Entry is one node in the session tree. Files written before the tree
// format (a bare llm.Message per line) are loaded as message entries.
type Entry struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Timestamp string `json:"timestamp"`
Type EntryType `json:"type"`
Message *llm.Message `json:"message,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty"`
Label string `json:"label,omitempty"`
Name string `json:"name,omitempty"`
CustomType string `json:"customType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Summary string `json:"summary,omitempty"`
TailIDs []string `json:"tailIds,omitempty"`
CWD string `json:"cwd,omitempty"`
}
// Entries returns every record in file order, including branches that are
// not on the active path.
func (s *Store) Entries() ([]Entry, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.readEntriesLocked()
}
// Leaf is the id of the current branch tip. Empty when the file has no entries.
func (s *Store) Leaf() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return "", err
}
return s.leafLocked(entries), nil
}
// SetLeaf moves the active branch to an existing entry.
func (s *Store) SetLeaf(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return err
}
if !hasID(entries, id) {
return fmt.Errorf("session: unknown entry %s", id)
}
return s.writeMetaKeyLocked("leaf", id)
}
// AppendEntry adds a non-message record (label, model change, custom, …)
// as a child of the current leaf and moves the leaf to it.
func (s *Store) AppendEntry(e Entry) (Entry, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.appendEntryLocked(e)
}
// Fork copies the tree into a new file and sets that file's leaf to atID.
// The source file is not modified. atID empty means the current leaf.
func (s *Store) Fork(dest, atID string) (*Store, error) {
return s.copyTo(dest, atID, true)
}
// Clone copies the tree and keeps the same leaf.
func (s *Store) Clone(dest string) (*Store, error) {
return s.copyTo(dest, "", false)
}
func (s *Store) copyTo(dest, atID string, moveLeaf bool) (*Store, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return nil, err
}
if err := copyFile(s.path, dest); err != nil {
return nil, err
}
out, err := Open(dest)
if err != nil {
return nil, err
}
meta := s.readMetaLocked()
if meta == nil {
meta = map[string]string{}
}
leaf := s.leafLocked(entries)
if moveLeaf && atID != "" {
if !hasID(entries, atID) {
return nil, fmt.Errorf("session: unknown entry %s", atID)
}
leaf = atID
}
if leaf != "" {
meta["leaf"] = leaf
}
if err := out.SaveMeta(meta); err != nil {
return nil, err
}
return out, nil
}
// SetName records a human name for continue/resume.
func (s *Store) SetName(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.writeMetaKeyLocked("name", name); err != nil {
return err
}
_, err := s.appendEntryLocked(Entry{Type: TypeSessionInfo, Name: name})
return err
}
// Name returns the session name, or empty.
func (s *Store) Name() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.readMetaLocked()["name"]
}
func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
entries, err := s.readEntriesLocked()
if err != nil {
return Entry{}, err
}
if e.ID == "" {
e.ID = newID()
}
if e.Timestamp == "" {
e.Timestamp = time.Now().UTC().Format(time.RFC3339Nano)
}
if e.Type == "" {
e.Type = TypeCustom
}
if e.ParentID == "" {
e.ParentID = s.leafLocked(entries)
}
raw, err := json.Marshal(e)
if err != nil {
return Entry{}, err
}
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return Entry{}, err
}
defer f.Close()
if _, err := f.Write(append(raw, '\n')); err != nil {
return Entry{}, err
}
if err := s.writeMetaKeyLocked("leaf", e.ID); err != nil {
return Entry{}, err
}
return e, nil
}
func (s *Store) readEntriesLocked() ([]Entry, error) {
raw, err := os.ReadFile(s.path)
if err != nil {
return nil, err
}
text := string(raw)
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 []Entry
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
legacyParent := ""
n := 0
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(line, &probe); err != nil {
return nil, err
}
if _, ok := probe["type"]; ok {
var e Entry
if err := json.Unmarshal(line, &e); err != nil {
return nil, err
}
out = append(out, e)
legacyParent = e.ID
n++
continue
}
var msg llm.Message
if err := json.Unmarshal(line, &msg); err != nil {
return nil, err
}
id := fmt.Sprintf("legacy-%d", n)
out = append(out, Entry{
ID: id, ParentID: legacyParent, Type: TypeMessage, Message: &msg,
})
legacyParent = id
n++
}
return out, sc.Err()
}
func (s *Store) leafLocked(entries []Entry) string {
if leaf := s.readMetaLocked()["leaf"]; leaf != "" && hasID(entries, leaf) {
return leaf
}
if len(entries) == 0 {
return ""
}
return entries[len(entries)-1].ID
}
func visibleMessages(entries []Entry, leaf string) []llm.Message {
path := pathTo(entries, leaf)
var last *Entry
for i := range path {
if path[i].Type == TypeCompaction {
last = &path[i]
}
}
if last == nil {
var msgs []llm.Message
for _, e := range path {
if e.Type == TypeMessage && e.Message != nil {
msgs = append(msgs, *e.Message)
}
}
return msgs
}
byID := map[string]Entry{}
for _, e := range entries {
byID[e.ID] = e
}
var msgs []llm.Message
for _, e := range path {
if e.ID == last.ID {
break
}
if e.Type == TypeMessage && e.Message != nil && e.Message.Role == llm.RoleSystem {
msgs = append(msgs, *e.Message)
}
}
msgs = append(msgs, llm.Message{
Role: llm.RoleUser,
Content: "[conversation summary]\n" + last.Summary + "\n[recent messages follow]",
})
for _, id := range last.TailIDs {
e, ok := byID[id]
if ok && e.Message != nil {
msgs = append(msgs, *e.Message)
}
}
after := false
for _, e := range path {
if e.ID == last.ID {
after = true
continue
}
if after && e.Type == TypeMessage && e.Message != nil {
msgs = append(msgs, *e.Message)
}
}
return msgs
}
func pathTo(entries []Entry, leaf string) []Entry {
byID := map[string]Entry{}
for _, e := range entries {
byID[e.ID] = e
}
var rev []Entry
seen := map[string]bool{}
for id := leaf; id != "" && !seen[id]; {
e, ok := byID[id]
if !ok {
break
}
seen[id] = true
rev = append(rev, e)
id = e.ParentID
}
for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 {
rev[i], rev[j] = rev[j], rev[i]
}
return rev
}
func hasID(entries []Entry, id string) bool {
for _, e := range entries {
if e.ID == id {
return true
}
}
return false
}
func (s *Store) readMetaLocked() map[string]string {
raw, err := os.ReadFile(s.path + ".meta.json")
if err != nil {
return nil
}
var meta map[string]string
if err := json.Unmarshal(raw, &meta); err != nil {
return nil
}
return meta
}
func (s *Store) writeMetaKeyLocked(key, value string) error {
meta := s.readMetaLocked()
if meta == nil {
meta = map[string]string{}
}
meta[key] = value
raw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
tmp := s.path + ".meta.json.tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path+".meta.json")
}
func newID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(dirOf(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func dirOf(path string) string {
i := strings.LastIndex(path, "/")
if i < 0 {
return "."
}
return path[:i]
}
+128
View File
@@ -0,0 +1,128 @@
package session
import (
"os"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/llm"
)
func TestTreeForkCloneAndLeaf(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "a.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "one"}); err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "two"}); err != nil {
t.Fatal(err)
}
entries, err := s.Entries()
if err != nil || len(entries) != 2 || entries[1].ParentID != entries[0].ID {
t.Fatalf("tree links: %+v %v", entries, err)
}
fork, err := s.Fork(filepath.Join(dir, "fork.jsonl"), entries[0].ID)
if err != nil {
t.Fatal(err)
}
if err := fork.Append(llm.Message{Role: llm.RoleUser, Content: "branch"}); err != nil {
t.Fatal(err)
}
msgs, err := fork.Load()
if err != nil || len(msgs) != 2 || msgs[1].Content != "branch" {
t.Fatalf("fork path: %+v %v", msgs, err)
}
orig, _ := s.Load()
if len(orig) != 2 || orig[1].Content != "two" {
t.Fatalf("source branch changed: %+v", orig)
}
clone, err := s.Clone(filepath.Join(dir, "clone.jsonl"))
if err != nil {
t.Fatal(err)
}
cmsgs, _ := clone.Load()
if len(cmsgs) != 2 || cmsgs[1].Content != "two" {
t.Fatalf("clone: %+v", cmsgs)
}
if err := s.SetLeaf(entries[0].ID); err != nil {
t.Fatal(err)
}
switched, _ := s.Load()
if len(switched) != 1 || switched[0].Content != "one" {
t.Fatalf("set leaf: %+v", switched)
}
}
func TestLegacyTranscriptStillLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "old.jsonl")
body := "{\"role\":\"system\",\"content\":\"sys\"}\n{\"role\":\"user\",\"content\":\"hi\"}\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
msgs, err := s.Load()
if err != nil || len(msgs) != 2 || msgs[1].Content != "hi" {
t.Fatalf("legacy: %+v %v", msgs, err)
}
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "next"}); err != nil {
t.Fatal(err)
}
msgs, err = s.Load()
if err != nil || len(msgs) != 3 || msgs[2].Content != "next" {
t.Fatalf("legacy append: %+v %v", msgs, err)
}
}
func TestCatalogContinueResumeAndHTML(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "sess.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := s.SetCWD("/work"); err != nil {
t.Fatal(err)
}
if err := s.SetName("notes"); err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "hello"}); err != nil {
t.Fatal(err)
}
got, ok, err := Continue(dir, "/work")
if err != nil || !ok || got.Path() != s.Path() {
t.Fatalf("continue: %v %v %v", got, ok, err)
}
resumed, err := Resume(dir, "notes")
if err != nil || resumed.Name() != "notes" {
t.Fatalf("resume: %v %v", resumed, err)
}
htmlPath := filepath.Join(dir, "out.html")
if err := s.ExportHTML(htmlPath); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(htmlPath)
if !strings.Contains(string(raw), "hello") || !strings.Contains(string(raw), "notes") {
t.Fatalf("html: %s", raw)
}
if _, err := s.AppendEntry(Entry{Type: TypeLabel, Label: "checkpoint"}); err != nil {
t.Fatal(err)
}
entries, _ := s.Entries()
var labels int
for _, e := range entries {
if e.Type == TypeLabel && e.Label == "checkpoint" {
labels++
}
}
if labels != 1 {
t.Fatalf("label missing: %+v", entries)
}
}
+200
View File
@@ -0,0 +1,200 @@
package runtime
import (
"context"
"sync"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
// Delivery selects how a queued steer or follow-up is handed to the loop.
type Delivery string
const (
DeliverOne Delivery = "one"
DeliverAll Delivery = "all"
)
// AgentSession is the headless session object: queues, abort, and
// mid-run model changes around Run.
type AgentSession struct {
cfg Config
mu sync.Mutex
steer []llm.Message
follow []llm.Message
steerMode Delivery
followMode Delivery
model string
thinking string
cancel context.CancelFunc
runID int
}
// NewAgentSession copies cfg. Queue modes default to one-at-a-time.
func NewAgentSession(cfg Config) *AgentSession {
if cfg.Model == "" {
cfg.Model = "default"
}
return &AgentSession{
cfg: cfg,
steerMode: DeliverOne,
followMode: DeliverOne,
model: cfg.Model,
thinking: cfg.ThinkingLevel,
}
}
// SetQueueMode sets the delivery mode for steer and follow-up.
func (a *AgentSession) SetQueueMode(steer, follow Delivery) {
a.mu.Lock()
defer a.mu.Unlock()
if steer != "" {
a.steerMode = steer
}
if follow != "" {
a.followMode = follow
}
}
// Steer queues a message to be delivered after the current tool batch.
func (a *AgentSession) Steer(msg llm.Message) {
a.mu.Lock()
a.steer = append(a.steer, msg)
a.mu.Unlock()
a.emitQueue("steer")
}
// FollowUp queues a message for after the assistant stops.
func (a *AgentSession) FollowUp(msg llm.Message) {
a.mu.Lock()
a.follow = append(a.follow, msg)
a.mu.Unlock()
a.emitQueue("follow_up")
}
// ClearQueue drops both queues.
func (a *AgentSession) ClearQueue() {
a.mu.Lock()
a.steer = nil
a.follow = nil
a.mu.Unlock()
a.emitQueue("clear")
}
// Abort cancels the in-flight Prompt.
func (a *AgentSession) Abort() {
a.mu.Lock()
cancel := a.cancel
a.mu.Unlock()
if cancel != nil {
cancel()
}
}
// SetModel changes the model and thinking level used on the next turn.
// A session store, when present, records the change.
func (a *AgentSession) SetModel(model, thinking string) error {
if thinking != "" {
norm, err := llm.NormalizeThinking(thinking)
if err != nil {
return err
}
thinking = norm
}
a.mu.Lock()
if model != "" {
a.model = model
}
if thinking != "" {
a.thinking = thinking
}
store := a.cfg.Session
a.mu.Unlock()
if store == nil {
return nil
}
if model != "" {
if _, err := store.AppendEntry(session.Entry{Type: session.TypeModelChange, Model: model}); err != nil {
return err
}
}
if thinking != "" {
if _, err := store.AppendEntry(session.Entry{Type: session.TypeThinkingLevelChange, ThinkingLevel: thinking}); err != nil {
return err
}
}
return nil
}
// Prompt runs one user message until the agent settles.
func (a *AgentSession) Prompt(ctx context.Context, user llm.Message) (Result, error) {
ctx, cancel := context.WithCancel(ctx)
a.mu.Lock()
a.runID++
id := a.runID
a.cancel = cancel
cfg := a.cfg
a.mu.Unlock()
defer func() {
cancel()
a.mu.Lock()
if a.runID == id {
a.cancel = nil
}
a.mu.Unlock()
}()
cfg.GetSteering = func() []llm.Message { return a.take("steer") }
cfg.GetFollowUp = func() []llm.Message { return a.take("follow") }
cfg.Resolve = func() (string, string) {
a.mu.Lock()
defer a.mu.Unlock()
return a.model, a.thinking
}
return Run(ctx, cfg, user)
}
// Compact appends a compaction entry and emits compaction events.
func (a *AgentSession) Compact(keep int, summarize func([]llm.Message) (string, error)) (bool, error) {
if a.cfg.Session == nil {
return false, nil
}
emit(a.cfg, Event{Type: "compaction_start"})
done, err := a.cfg.Session.Compact(keep, summarize)
text := "noop"
if done {
text = "compacted"
}
if err != nil {
text = err.Error()
}
emit(a.cfg, Event{Type: "compaction_end", Text: text})
return done, err
}
func (a *AgentSession) take(kind string) []llm.Message {
a.mu.Lock()
defer a.mu.Unlock()
q := &a.steer
mode := a.steerMode
if kind == "follow" {
q = &a.follow
mode = a.followMode
}
if len(*q) == 0 {
return nil
}
if mode == DeliverAll {
out := append([]llm.Message(nil), (*q)...)
*q = nil
return out
}
out := []llm.Message{(*q)[0]}
*q = (*q)[1:]
return out
}
func (a *AgentSession) emitQueue(text string) {
emit(a.cfg, Event{Type: "queue_update", Text: text})
}
+112
View File
@@ -0,0 +1,112 @@
package runtime
import (
"context"
"sync"
"testing"
"agent-native/runtime/llm"
)
func TestAgentSessionPromptForwardsStop(t *testing.T) {
called := false
sess := NewAgentSession(Config{
Model: "m",
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: "hi", StopReason: llm.StopEnd}}
close(ch)
return ch
},
Stop: func(last llm.Message, info StopInfo) StopDecision {
called = true
return StopDecision{}
},
})
res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if !called || res.Text != "hi" {
t.Fatalf("called=%v text=%q", called, res.Text)
}
}
func TestAgentSessionSteerFollowAndAbort(t *testing.T) {
var mu sync.Mutex
step := 0
stream := llm.StreamFunc(func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 2)
go func() {
defer close(ch)
mu.Lock()
n := step
step++
mu.Unlock()
if opts.ThinkingLevel != "low" && n == 0 {
t.Errorf("thinking level not forwarded: %s", opts.ThinkingLevel)
}
if n == 0 {
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "1", Name: "missing", Arguments: "{}"}},
}}
return
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd,
}}
}()
return ch
})
var types []string
sess := NewAgentSession(Config{
Model: "m",
ThinkingLevel: "low",
Stream: stream,
MaxTurns: 6,
OnEvent: func(ev Event) {
types = append(types, ev.Type)
},
})
sess.SetQueueMode(DeliverOne, DeliverAll)
sess.Steer(llm.Message{Role: llm.RoleUser, Content: "steer-me"})
sess.FollowUp(llm.Message{Role: llm.RoleUser, Content: "later"})
res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.StopReason != llm.StopEnd {
t.Fatalf("stop %s err %v", res.StopReason, res.Err)
}
found := map[string]bool{}
for _, typ := range types {
found[typ] = true
}
for _, want := range []string{"message_start", "queue_update", "tool_execution_update", "agent_settled"} {
if !found[want] {
t.Fatalf("missing %s in %v", want, types)
}
}
}
func TestClearQueueAndSetModel(t *testing.T) {
sess := NewAgentSession(Config{Model: "old", 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: model, StopReason: llm.StopEnd}}
close(ch)
return ch
}})
sess.Steer(llm.Message{Role: llm.RoleUser, Content: "drop"})
sess.ClearQueue()
if err := sess.SetModel("new-model", "high"); err != nil {
t.Fatal(err)
}
res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"})
if err != nil {
t.Fatal(err)
}
if res.Text != "new-model" {
t.Fatalf("model switch did not apply: %q", res.Text)
}
}
+29
View File
@@ -107,6 +107,35 @@ func (s *Store) Load(name string) (Skill, error) {
return Skill{}, fmt.Errorf("unknown skill %q (not installed in any configured root)", name)
}
// ExpandCommands replaces /skill:name with that skill's body.
// An unknown name returns an error and leaves the text unchanged.
func (s *Store) ExpandCommands(text string) (string, error) {
const marker = "/skill:"
var b strings.Builder
rest := text
for {
i := strings.Index(rest, marker)
if i < 0 {
b.WriteString(rest)
break
}
b.WriteString(rest[:i])
name := rest[i+len(marker):]
end := 0
for end < len(name) && name[end] != ' ' && name[end] != '\n' && name[end] != '\t' {
end++
}
skillName := name[:end]
skill, err := s.Load(skillName)
if err != nil {
return text, err
}
b.WriteString(skill.Body)
rest = name[end:]
}
return b.String(), nil
}
// parse 解析一份 SKILL.md:目录名权威,frontmatter 可选。
func parse(dirName, path string, data []byte) (Skill, error) {
if !validSkillName(dirName) {
+83
View File
@@ -0,0 +1,83 @@
package tools
import (
"context"
"encoding/json"
)
// aliasTool exposes an existing tool under a pi-style name.
type aliasTool struct {
name string
inner Tool
}
func (a aliasTool) Name() string { return a.name }
func (a aliasTool) Description() string { return a.inner.Description() }
func (a aliasTool) Parameters() json.RawMessage { return a.inner.Parameters() }
func (a aliasTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
return a.inner.Execute(ctx, args)
}
// piAliases maps the headless pi tool name onto the name already registered.
var piAliases = map[string]string{
"read": "read_file",
"write": "write_file",
"edit": "edit_file",
"bash": "run_command",
"grep": "grep",
"find": "glob",
"ls": "list_dir",
}
// RegisterPiAliases adds pi tool names beside the existing ones.
// A name is skipped when the underlying tool is not registered.
func (r *Registry) RegisterPiAliases() {
for alias, inner := range piAliases {
if alias == inner || r.Has(alias) || !r.Has(inner) {
continue
}
r.Register(aliasTool{name: alias, inner: r.tools[inner]})
}
}
// FilterOptions selects which tools stay visible.
type FilterOptions struct {
Tools []string
Exclude []string
NoBuiltin bool
}
// Filter applies an allow list, then exclusions. NoBuiltin drops the
// workspace tool names and their pi aliases.
func (r *Registry) Filter(opts FilterOptions) error {
if len(opts.Tools) > 0 {
if err := r.Restrict(opts.Tools); err != nil {
return err
}
}
drop := map[string]bool{}
for _, name := range opts.Exclude {
drop[name] = true
}
if opts.NoBuiltin {
for _, name := range []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "run_command", "run_python"} {
drop[name] = true
}
for alias := range piAliases {
drop[alias] = true
}
}
if len(drop) == 0 {
return nil
}
var order []string
for _, name := range r.order {
if drop[name] {
delete(r.tools, name)
continue
}
order = append(order, name)
}
r.order = order
return nil
}