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>
This commit is contained in:
2026-10-01 10:16:00 +00:00
co-authored by Cursor
parent 23b5e3f336
commit 6b7d2432b3
8 changed files with 526 additions and 5 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()
}