Files
byre/internal/agents/manager.go
T

452 lines
10 KiB
Go
Raw Normal View History

package agents
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/report"
"g.fc.run/misc/byre/internal/store"
)
var (
ErrConflict = errors.New("conflict")
ErrNoGitIdentity = errors.New("set your git name and email (PUT /api/me) before starting agents")
)
const agentActor = "agent"
const restartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Re-check the working tree (git status and the files you were editing) before continuing where you left off."
type Manager struct {
store *store.Store
mu sync.Mutex
live map[string]*live
closing bool
}
type live struct {
sess *claude.Session
gate *report.Gate
id string
taskID string
projectID string
stopping bool
}
func New(st *store.Store) *Manager {
return &Manager{store: st, live: map[string]*live{}}
}
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string) (api.AgentSession, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return api.AgentSession{}, ErrNoGitIdentity
}
if task.Status != api.TaskStatusActive {
return api.AgentSession{}, fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.Status)
}
existing, err := m.store.ListSessions(ctx, task.Id)
if err != nil {
return api.AgentSession{}, err
}
for _, s := range existing {
if s.State != api.AgentSessionStateCompleted {
return api.AgentSession{}, fmt.Errorf("%w: task %s already has session %s", ErrConflict, task.Id, s.Id)
}
}
id := newUUID()
sess := api.AgentSession{
Id: id,
TaskId: task.Id,
Vendor: api.Claude,
Model: model,
VendorSessionId: id,
State: api.AgentSessionStateIdle,
Role: api.Implementer,
CreatedBy: creator.Login,
CreatedAt: time.Now().UTC(),
}
err = m.store.CreateSession(ctx, sess, task.ProjectId)
if err != nil {
return api.AgentSession{}, err
}
return m.turn(ctx, sess, task, creator.Login, prompt, false)
}
func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.AgentSession, error) {
sess, err := m.store.GetSession(ctx, sessionID)
if err != nil {
return api.AgentSession{}, err
}
if sess.State != api.AgentSessionStateIdle {
return api.AgentSession{}, fmt.Errorf("%w: session %s is %s", ErrConflict, sess.Id, sess.State)
}
task, err := m.store.GetTask(ctx, sess.TaskId)
if err != nil {
return api.AgentSession{}, err
}
return m.turn(ctx, sess, task, actor, text, true)
}
func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSession, error) {
sess, err := m.store.GetSession(ctx, sessionID)
if err != nil {
return api.AgentSession{}, err
}
m.mu.Lock()
l := m.live[sessionID]
m.mu.Unlock()
if sess.State != api.AgentSessionStateRunning || l == nil {
return api.AgentSession{}, fmt.Errorf("%w: session %s has no turn running", ErrConflict, sess.Id)
}
ictx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err = l.sess.Interrupt(ictx)
if err != nil {
return api.AgentSession{}, err
}
return sess, nil
}
func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
sessions, err := m.store.ListSessions(ctx, taskID)
if err != nil {
return err
}
for _, sess := range sessions {
if sess.State == api.AgentSessionStateCompleted {
continue
}
m.mu.Lock()
l := m.live[sess.Id]
if l != nil {
l.stopping = true
}
m.mu.Unlock()
if l != nil {
l.sess.Kill()
}
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
if err != nil {
return err
}
}
return nil
}
func (m *Manager) Recover(ctx context.Context) error {
sessions, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
if err != nil {
return err
}
for _, sess := range sessions {
task, err := m.store.GetTask(ctx, sess.TaskId)
if err != nil {
return err
}
l, err := m.ensureLive(ctx, sess, task, true)
if err != nil {
slog.Error("resume session", "session", sess.Id, "err", err)
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()})
if err != nil {
return err
}
continue
}
err = m.message(ctx, l, "byre", restartNotice)
if err != nil {
return err
}
slog.Info("resumed session", "session", sess.Id, "task", task.Id)
}
return nil
}
func (m *Manager) Close() {
m.mu.Lock()
m.closing = true
all := []*live{}
for _, l := range m.live {
all = append(all, l)
}
m.mu.Unlock()
for _, l := range all {
l.sess.Kill()
}
}
func (m *Manager) LiveCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.live)
}
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, resume bool) (api.AgentSession, error) {
l, err := m.ensureLive(ctx, sess, task, resume)
if err != nil {
return api.AgentSession{}, err
}
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateRunning, actor, nil)
if err != nil {
return api.AgentSession{}, err
}
err = m.message(ctx, l, actor, text)
if err != nil {
return api.AgentSession{}, err
}
return m.store.GetSession(ctx, sess.Id)
}
func (m *Manager) message(ctx context.Context, l *live, actor, text string) error {
err := m.emit(ctx, l, actor, "user.message", map[string]any{"text": text})
if err != nil {
return err
}
l.gate.Reset()
return l.sess.Send(text)
}
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task, resume bool) (*live, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closing {
return nil, errors.New("shutting down")
}
l := m.live[sess.Id]
if l != nil {
return l, nil
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return nil, err
}
l = &live{id: sess.Id, taskID: task.Id, projectID: task.ProjectId}
l.gate = &report.Gate{OnReport: func(r report.Report) {
err := m.emit(context.Background(), l, agentActor, "agent.report", store.ToPayload(r))
if err != nil {
slog.Error("record report", "session", l.id, "err", err)
}
}}
opts := claude.Options{
Cwd: task.WorktreePath,
Model: sess.Model,
GitName: creator.GitName,
GitEmail: creator.GitEmail,
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{l.gate.Tool()}}},
Hooks: map[string][]claude.HookMatcher{
"Stop": {{Func: l.gate.StopHook}},
},
}
if resume {
opts.Resume = sess.VendorSessionId
} else {
opts.SessionID = sess.VendorSessionId
}
sctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
l.sess, err = claude.Start(sctx, opts)
if err != nil {
return nil, fmt.Errorf("start claude: %w", err)
}
m.live[sess.Id] = l
go m.pump(l)
return l, nil
}
func (m *Manager) pump(l *live) {
ctx := context.Background()
for ev := range l.sess.Events() {
err := m.handle(ctx, l, ev)
if err != nil {
slog.Error("record agent event", "session", l.id, "type", ev.Type, "err", err)
}
}
m.mu.Lock()
delete(m.live, l.id)
quiet := m.closing || l.stopping
m.mu.Unlock()
if quiet {
return
}
sess, err := m.store.GetSession(ctx, l.id)
if err != nil {
slog.Error("load exited session", "session", l.id, "err", err)
return
}
if sess.State != api.AgentSessionStateRunning {
return
}
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, map[string]any{"error": "agent process exited mid-turn"})
if err != nil {
slog.Error("record session exit", "session", l.id, "err", err)
}
}
func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
switch ev.Type {
case "assistant":
blocks, err := ev.Blocks()
if err != nil {
return err
}
for _, b := range blocks {
err = m.handleAssistantBlock(ctx, l, b)
if err != nil {
return err
}
}
return nil
case "user":
blocks, err := ev.Blocks()
if err != nil {
return err
}
for _, b := range blocks {
if b.Type != "tool_result" {
continue
}
content, err := decode(b.Content)
if err != nil {
return err
}
err = m.emit(ctx, l, agentActor, "agent.tool_result", map[string]any{
"tool_use_id": b.ToolUseID,
"is_error": b.IsError,
"content": content,
})
if err != nil {
return err
}
}
return nil
case "result":
res, err := ev.Result()
if err != nil {
return err
}
err = m.emit(ctx, l, agentActor, "turn.ended", store.ToPayload(res))
if err != nil {
return err
}
return m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
case "system":
if ev.Subtype == "init" {
return nil
}
}
raw := map[string]any{}
err := json.Unmarshal(ev.Raw, &raw)
if err != nil {
return err
}
return m.emit(ctx, l, agentActor, "agent.vendor", raw)
}
func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Block) error {
switch b.Type {
case "text":
return m.emit(ctx, l, agentActor, "agent.message", map[string]any{"text": b.Text})
case "thinking":
if b.Thinking == "" {
return nil
}
return m.emit(ctx, l, agentActor, "agent.thinking", map[string]any{"text": b.Thinking})
case "tool_use":
input, err := decode(b.Input)
if err != nil {
return err
}
return m.emit(ctx, l, agentActor, "agent.tool_call", map[string]any{
"id": b.ID,
"name": b.Name,
"input": input,
})
}
return m.emit(ctx, l, agentActor, "agent.vendor", store.ToPayload(b))
}
func (m *Manager) emit(ctx context.Context, l *live, actor, kind string, payload map[string]any) error {
return m.store.AppendEvent(ctx, api.Event{
Kind: kind,
ProjectId: &l.projectID,
TaskId: &l.taskID,
AgentSessionId: &l.id,
Actor: actor,
Payload: payload,
})
}
func decode(raw json.RawMessage) (any, error) {
if len(raw) == 0 {
return nil, nil
}
var v any
err := json.Unmarshal(raw, &v)
return v, err
}
func newUUID() string {
b := make([]byte, 16)
rand.Read(b)
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}