diff --git a/adapters/native/backend.go b/adapters/native/backend.go index 62704bf..0c2e42a 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -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 在转录超过阈值时压缩头部历史。压缩是优化性质: diff --git a/adapters/native/compact_test.go b/adapters/native/compact_test.go index 9b3683d..22881bb 100644 --- a/adapters/native/compact_test.go +++ b/adapters/native/compact_test.go @@ -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") } } diff --git a/adapters/native/pi_conformance_test.go b/adapters/native/pi_conformance_test.go new file mode 100644 index 0000000..6dd1295 --- /dev/null +++ b/adapters/native/pi_conformance_test.go @@ -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 +} diff --git a/cmd/agent-native/main.go b/cmd/agent-native/main.go new file mode 100644 index 0000000..51748ea --- /dev/null +++ b/cmd/agent-native/main.go @@ -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) +} diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 280c510..3c92d5c 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -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,请求/结果字段不变 | + diff --git a/rpc/client.go b/rpc/client.go new file mode 100644 index 0000000..6e2d290 --- /dev/null +++ b/rpc/client.go @@ -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 + } +} diff --git a/rpc/server.go b/rpc/server.go new file mode 100644 index 0000000..be097f7 --- /dev/null +++ b/rpc/server.go @@ -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 +} diff --git a/rpc/server_test.go b/rpc/server_test.go new file mode 100644 index 0000000..f332292 --- /dev/null +++ b/rpc/server_test.go @@ -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() +}