Files
byre/internal/codex/session.go
T

702 lines
16 KiB
Go

package codex
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"regexp"
"strconv"
"sync"
"sync/atomic"
"time"
"g.fc.run/misc/byre/internal/mcp"
)
type Options struct {
Cwd string
Model string
Resume string
ForkFrom string
ForkAt string
GitName string
GitEmail string
Env []string
Guidance string
Reminder string
MCPServers []*mcp.Server
StdioMCP []mcp.Stdio
Transcript io.Writer
OnBadLine func(line []byte, reason string)
}
type Event struct {
Method string
Params json.RawMessage
Read time.Time
}
func (e Event) Decode() (any, error) {
ctor, ok := notificationParams[e.Method]
if !ok {
return nil, fmt.Errorf("unknown notification %q", e.Method)
}
if ctor == nil {
return nil, nil
}
v := ctor()
err := json.Unmarshal(e.Params, v)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", e.Method, err)
}
return v, nil
}
type Image struct {
MediaType string
Data []byte
}
type RPCError struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
func (e *RPCError) Error() string {
return fmt.Sprintf("codex rpc error %d: %s", e.Code, e.Message)
}
type message struct {
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type rpcResult struct {
result json.RawMessage
err error
}
type Session struct {
cmd *exec.Cmd
stdin io.WriteCloser
events chan Event
done chan struct{}
writeMu sync.Mutex
recordMu sync.Mutex
transcript io.Writer
nextID atomic.Int64
pendingMu sync.Mutex
pending map[int64]chan rpcResult
stateMu sync.Mutex
threadID string
turnID string
model string
userAgent string
servers map[string]*mcp.Server
reminder string
onBadLine func(line []byte, reason string)
lastLine atomic.Int64
}
func Start(ctx context.Context, opts Options) (*Session, error) {
s, err := spawn(opts)
if err != nil {
return nil, err
}
err = s.open(ctx, opts)
if err != nil {
s.Close()
return nil, err
}
return s, nil
}
func ReadRateLimits(ctx context.Context, cwd string, env []string) (RateLimitSnapshot, error) {
s, err := spawn(Options{Cwd: cwd, Env: env})
if err != nil {
return RateLimitSnapshot{}, err
}
defer s.Close()
go func() {
for range s.events {
}
}()
err = s.initialize(ctx)
if err != nil {
return RateLimitSnapshot{}, err
}
resp := GetAccountRateLimitsResponse{}
err = s.Call(ctx, MethodAccountRateLimitsRead, GetAccountRateLimitsParams{ExcludeResetCreditDetails: true}, &resp)
if err != nil {
return RateLimitSnapshot{}, fmt.Errorf("read rate limits: %w", err)
}
return resp.RateLimits, nil
}
func spawn(opts Options) (*Session, error) {
cmd := exec.Command("codex", "app-server", "-c", "check_for_update_on_startup=false")
cmd.Dir = opts.Cwd
cmd.Stderr = os.Stderr
cmd.Env = buildEnv(opts)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
err = cmd.Start()
if err != nil {
return nil, fmt.Errorf("start codex: %w", err)
}
s := newSession(opts, stdin, stdout)
s.cmd = cmd
return s, nil
}
func newSession(opts Options, stdin io.WriteCloser, stdout io.Reader) *Session {
s := &Session{
stdin: stdin,
events: make(chan Event, 256),
done: make(chan struct{}),
transcript: opts.Transcript,
pending: map[int64]chan rpcResult{},
servers: map[string]*mcp.Server{},
reminder: opts.Reminder,
onBadLine: opts.OnBadLine,
}
for _, srv := range opts.MCPServers {
s.servers[srv.Name] = srv
}
s.lastLine.Store(time.Now().UnixNano())
go s.readLoop(stdout)
return s
}
func (s *Session) open(ctx context.Context, opts Options) error {
err := s.initialize(ctx)
if err != nil {
return err
}
if opts.Resume != "" {
resp := ThreadResumeResponse{}
err = s.Call(ctx, MethodThreadResume, ThreadResumeParams{
ThreadID: opts.Resume,
Cwd: opts.Cwd,
Model: opts.Model,
ApprovalPolicy: json.RawMessage(`"never"`),
Sandbox: SandboxModeDangerFullAccess,
DeveloperInstructions: opts.Guidance,
Config: stdioConfig(opts.StdioMCP),
}, &resp)
if err != nil {
return fmt.Errorf("resume thread: %w", err)
}
s.setThread(resp.Thread.ID)
return nil
}
if opts.ForkFrom != "" {
resp := ThreadForkResponse{}
err = s.Call(ctx, MethodThreadFork, ThreadForkParams{
ThreadID: opts.ForkFrom,
LastTurnID: opts.ForkAt,
Cwd: opts.Cwd,
Model: opts.Model,
ApprovalPolicy: json.RawMessage(`"never"`),
Sandbox: SandboxModeDangerFullAccess,
DeveloperInstructions: opts.Guidance,
Config: stdioConfig(opts.StdioMCP),
}, &resp)
if err != nil {
return fmt.Errorf("fork thread: %w", err)
}
s.setThread(resp.Thread.ID)
return nil
}
resp := ThreadStartResponse{}
err = s.Call(ctx, MethodThreadStart, ThreadStartParams{
Cwd: opts.Cwd,
Model: opts.Model,
ApprovalPolicy: json.RawMessage(`"never"`),
Sandbox: SandboxModeDangerFullAccess,
DeveloperInstructions: opts.Guidance,
DynamicTools: dynamicTools(opts.MCPServers),
Config: stdioConfig(opts.StdioMCP),
}, &resp)
if err != nil {
return fmt.Errorf("start thread: %w", err)
}
s.setThread(resp.Thread.ID)
return nil
}
func (s *Session) initialize(ctx context.Context) error {
init := InitializeResponse{}
err := s.Call(ctx, MethodInitialize, InitializeParams{
ClientInfo: ClientInfo{Name: "byre", Title: "byre", Version: "0.0.0"},
Capabilities: &InitializeCapabilities{ExperimentalApi: true},
}, &init)
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
s.stateMu.Lock()
s.userAgent = init.UserAgent
s.stateMu.Unlock()
return s.notify("initialized", nil)
}
func stdioConfig(servers []mcp.Stdio) map[string]any {
if len(servers) == 0 {
return nil
}
cfg := map[string]any{}
for _, srv := range servers {
cfg[srv.Name] = map[string]any{"command": srv.Command, "args": srv.Args}
}
return map[string]any{"mcp_servers": cfg}
}
func dynamicTools(servers []*mcp.Server) []DynamicToolSpec {
specs := []DynamicToolSpec{}
for _, srv := range servers {
tools := []DynamicToolNamespaceTool{}
for _, t := range srv.Tools {
tools = append(tools, DynamicToolNamespaceTool{
Type: DynamicToolSpecTypeFunction,
Name: t.Name,
Description: t.Description,
InputSchema: t.InputSchema,
})
}
specs = append(specs, DynamicToolSpec{
Type: DynamicToolSpecTypeNamespace,
Name: srv.Name,
Description: srv.Name + "'s tools",
Tools: tools,
})
}
return specs
}
func (s *Session) setThread(id string) {
s.stateMu.Lock()
s.threadID = id
s.stateMu.Unlock()
}
func buildEnv(opts Options) []string {
env := append(os.Environ(),
"GIT_AUTHOR_NAME="+opts.GitName,
"GIT_AUTHOR_EMAIL="+opts.GitEmail,
"GIT_COMMITTER_NAME="+opts.GitName,
"GIT_COMMITTER_EMAIL="+opts.GitEmail,
)
return append(env, opts.Env...)
}
func (s *Session) Events() <-chan Event {
return s.events
}
func (s *Session) Done() <-chan struct{} {
return s.done
}
func (s *Session) Pid() int {
return s.cmd.Process.Pid
}
func (s *Session) LastLine() time.Time {
return time.Unix(0, s.lastLine.Load())
}
func (s *Session) Exited() bool {
select {
case <-s.done:
return true
default:
return false
}
}
func (s *Session) ID() string {
s.stateMu.Lock()
defer s.stateMu.Unlock()
return s.threadID
}
func (s *Session) TurnID() string {
s.stateMu.Lock()
defer s.stateMu.Unlock()
return s.turnID
}
func (s *Session) UserAgent() string {
s.stateMu.Lock()
defer s.stateMu.Unlock()
return s.userAgent
}
func (s *Session) SetModel(model string) {
s.stateMu.Lock()
s.model = model
s.stateMu.Unlock()
}
func (s *Session) Send(ctx context.Context, text string, images ...Image) (Turn, error) {
input := []UserInput{}
for _, img := range images {
input = append(input, UserInput{
Type: "image",
URL: "data:" + img.MediaType + ";base64," + base64.StdEncoding.EncodeToString(img.Data),
})
}
if text != "" {
input = append(input, UserInput{Type: "text", Text: text})
}
s.stateMu.Lock()
params := TurnStartParams{ThreadID: s.threadID, Input: input, Model: s.model}
s.model = ""
s.stateMu.Unlock()
if s.reminder != "" {
params.AdditionalContext = map[string]AdditionalContextEntry{
"byre": {Kind: AdditionalContextKindApplication, Value: s.reminder},
}
}
resp := TurnStartResponse{}
err := s.Call(ctx, MethodTurnStart, params, &resp)
if err != nil {
return Turn{}, err
}
s.stateMu.Lock()
if s.turnID == "" {
s.turnID = resp.Turn.ID
}
s.stateMu.Unlock()
return resp.Turn, nil
}
func (s *Session) Interrupt(ctx context.Context) error {
s.stateMu.Lock()
params := TurnInterruptParams{ThreadID: s.threadID, TurnID: s.turnID}
s.stateMu.Unlock()
if params.TurnID == "" {
return errors.New("no turn in progress")
}
return s.Call(ctx, MethodTurnInterrupt, params, nil)
}
func (s *Session) Steer(ctx context.Context, text string) error {
s.stateMu.Lock()
params := TurnSteerParams{ThreadID: s.threadID, ExpectedTurnID: s.turnID, Input: []UserInput{{Type: UserInputTypeText, Text: text}}}
s.stateMu.Unlock()
if params.ExpectedTurnID == "" {
return errors.New("no turn in progress")
}
return s.Call(ctx, MethodTurnSteer, params, nil)
}
func (s *Session) Compact(ctx context.Context) error {
return s.Call(ctx, MethodThreadCompactStart, ThreadCompactStartParams{ThreadID: s.ID()}, nil)
}
func (s *Session) Call(ctx context.Context, method string, params any, result any) error {
id := s.nextID.Add(1)
ch := make(chan rpcResult, 1)
s.pendingMu.Lock()
s.pending[id] = ch
s.pendingMu.Unlock()
defer func() {
s.pendingMu.Lock()
delete(s.pending, id)
s.pendingMu.Unlock()
}()
raw, err := json.Marshal(params)
if err != nil {
return err
}
err = s.write(message{ID: json.RawMessage(strconv.FormatInt(id, 10)), Method: method, Params: raw})
if err != nil {
return err
}
select {
case res := <-ch:
if res.err != nil {
return res.err
}
if result == nil {
return nil
}
return json.Unmarshal(res.result, result)
case <-s.done:
return errors.New("codex exited")
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Session) notify(method string, params any) error {
m := message{Method: method}
if params != nil {
raw, err := json.Marshal(params)
if err != nil {
return err
}
m.Params = raw
}
return s.write(m)
}
func (s *Session) Close() error {
s.writeMu.Lock()
s.stdin.Close()
s.writeMu.Unlock()
select {
case <-s.done:
case <-time.After(5 * time.Second):
s.cmd.Process.Kill()
<-s.done
}
return s.cmd.Wait()
}
func (s *Session) Kill() {
s.cmd.Process.Kill()
<-s.done
s.cmd.Wait()
}
func (s *Session) write(m message) error {
b, err := json.Marshal(m)
if err != nil {
return err
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
s.record("send", b)
_, err = s.stdin.Write(append(b, '\n'))
return err
}
var emailField = regexp.MustCompile(`"email":"[^"]*"`)
func (s *Session) record(dir string, line []byte) {
if s.transcript == nil {
return
}
line = emailField.ReplaceAll(line, []byte(`"email":"[redacted]"`))
b, err := json.Marshal(map[string]any{"dir": dir, "msg": json.RawMessage(line)})
if err != nil {
slog.Error("record transcript", "err", err)
return
}
s.recordMu.Lock()
defer s.recordMu.Unlock()
s.transcript.Write(append(b, '\n'))
}
func (s *Session) readLoop(stdout io.Reader) {
defer close(s.done)
defer close(s.events)
r := bufio.NewReader(stdout)
for {
line, err := r.ReadBytes('\n')
line = bytes.TrimRight(line, "\r\n")
if len(line) > 0 {
s.lastLine.Store(time.Now().UnixNano())
}
if len(line) > 0 && line[0] == '{' {
s.handleLine(line)
} else if len(line) > 0 {
s.badLine(line, "not a JSON object")
}
if err != nil {
if err != io.EOF {
slog.Error("read codex stdout", "err", err)
}
return
}
}
}
func (s *Session) handleLine(line []byte) {
read := time.Now()
m := message{}
err := json.Unmarshal(line, &m)
if err != nil {
s.badLine(line, err.Error())
return
}
s.record("recv", line)
if m.Method == "" {
s.handleResponse(line, m)
return
}
if len(m.ID) > 0 {
go s.handleServerRequest(m)
return
}
if _, ok := notificationParams[m.Method]; !ok {
s.badLine(line, fmt.Sprintf("unrecognised method %q", m.Method))
}
s.trackTurn(m)
s.events <- Event{Method: m.Method, Params: append(json.RawMessage{}, m.Params...), Read: read}
}
func (s *Session) trackTurn(m message) {
switch m.Method {
case MethodTurnStarted:
n := TurnStartedNotification{}
if json.Unmarshal(m.Params, &n) == nil {
s.stateMu.Lock()
s.turnID = n.Turn.ID
s.stateMu.Unlock()
}
case MethodTurnCompleted:
n := TurnCompletedNotification{}
if json.Unmarshal(m.Params, &n) == nil {
s.stateMu.Lock()
if s.turnID == n.Turn.ID {
s.turnID = ""
}
s.stateMu.Unlock()
}
}
}
func (s *Session) handleResponse(line []byte, m message) {
id, err := strconv.ParseInt(string(m.ID), 10, 64)
if err != nil {
s.badLine(line, "response with a non-numeric id")
return
}
s.pendingMu.Lock()
ch := s.pending[id]
s.pendingMu.Unlock()
if ch == nil {
slog.Warn("codex response for unknown request", "id", id)
return
}
if m.Error != nil {
ch <- rpcResult{err: m.Error}
return
}
ch <- rpcResult{result: m.Result}
}
func (s *Session) handleServerRequest(m message) {
result, err := s.answerServerRequest(m)
if err != nil {
slog.Error("codex server request failed", "method", m.Method, "err", err)
s.write(message{ID: m.ID, Error: &RPCError{Code: -32601, Message: err.Error()}})
return
}
raw, err := json.Marshal(result)
if err != nil {
slog.Error("encode codex server response", "method", m.Method, "err", err)
return
}
s.write(message{ID: m.ID, Result: raw})
}
func (s *Session) answerServerRequest(m message) (any, error) {
switch m.Method {
case MethodItemCommandExecutionRequestApproval:
return CommandExecutionRequestApprovalResponse{Decision: json.RawMessage(`"accept"`)}, nil
case MethodItemFileChangeRequestApproval:
return FileChangeRequestApprovalResponse{Decision: "accept"}, nil
case MethodItemToolCall:
return s.callTool(m.Params)
}
return nil, fmt.Errorf("unsupported server request %s", m.Method)
}
func (s *Session) callTool(raw json.RawMessage) (any, error) {
p := DynamicToolCallParams{}
err := json.Unmarshal(raw, &p)
if err != nil {
return nil, err
}
srv := s.servers[p.Namespace]
if srv == nil {
return nil, fmt.Errorf("unknown tool namespace %q", p.Namespace)
}
t, ok := srv.Tool(p.Tool)
if !ok {
return nil, fmt.Errorf("unknown tool %s in %s", p.Tool, p.Namespace)
}
text, err := t.Handler(context.Background(), p.Arguments)
if err != nil {
return DynamicToolCallResponse{ContentItems: []DynamicToolCallOutputContentItem{{Type: DynamicToolCallOutputContentItemTypeInputText, Text: err.Error()}}}, nil
}
return DynamicToolCallResponse{Success: true, ContentItems: []DynamicToolCallOutputContentItem{{Type: DynamicToolCallOutputContentItemTypeInputText, Text: text}}}, nil
}
func (s *Session) badLine(line []byte, reason string) {
slog.Warn("bad codex line", "reason", reason, "line", string(line))
if s.onBadLine != nil {
s.onBadLine(line, reason)
}
}