Files
byre/internal/agents/manager.go
T

1008 lines
24 KiB
Go

package agents
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
)
var (
ErrConflict = errors.New("conflict")
ErrInvalid = errors.New("invalid")
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 Limits struct {
Global int
PerVendor map[api.AgentSessionVendor]int
}
var DefaultLimits = Limits{
Global: 8,
PerVendor: map[api.AgentSessionVendor]int{api.Claude: 6},
}
type Manager struct {
store *store.Store
limits Limits
worktreeRoot string
leadRoot string
mu sync.Mutex
live map[string]*live
closing bool
rateLimits map[api.AgentSessionVendor]map[string]any
leadAnswered map[string]bool
paneFocused map[string]bool
deliverMu sync.Mutex
capMu sync.Mutex
leadMu sync.Mutex
signals chan api.Event
snapMu sync.Mutex
snaps map[string]*snapState
bad *badLines
watchStop chan struct{}
}
type live struct {
sess *claude.Session
id string
dir string
model string
gitName string
gitEmail string
vendor api.AgentSessionVendor
taskID string
projectID string
owner string
stopping bool
interrupted bool
asked bool
askedBlock bool
bounced bool
compacting bool
wantCompact bool
rateLimited map[string]any
contextModel string
contextUsed int
contextWindow int
}
func New(st *store.Store, limits Limits, worktreeRoot, leadRoot string) *Manager {
m := &Manager{
store: st,
limits: limits,
worktreeRoot: worktreeRoot,
leadRoot: leadRoot,
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocused: map[string]bool{},
signals: make(chan api.Event, 1024),
snaps: map[string]*snapState{},
bad: newBadLines(),
watchStop: make(chan struct{}),
}
go m.signalLoop()
return m
}
func (m *Manager) taskFor(ctx context.Context, sess api.AgentSession) (api.Task, error) {
if sess.Owner != "" {
return api.Task{}, nil
}
return m.store.GetTask(ctx, sess.TaskId)
}
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string, images ...string) (api.AgentSession, error) {
return m.create(ctx, task, creator, prompt, model, creator.Login, images)
}
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []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, actor, prompt, images)
}
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string, images ...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)
}
if model != "" && model != sess.Model {
err = m.store.SetSessionModel(ctx, sess.Id, model, actor)
if err != nil {
return api.AgentSession{}, err
}
sess.Model = model
}
task, err := m.taskFor(ctx, sess)
if err != nil {
return api.AgentSession{}, err
}
return m.turn(ctx, sess, task, actor, text, images)
}
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
}
if sess.State == api.AgentSessionStateQueued {
return m.cancelQueued(ctx, sess)
}
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)
}
m.mu.Lock()
l.interrupted = true
m.mu.Unlock()
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
}
err = m.retire(ctx, sess, actor)
if err != nil {
return err
}
}
return m.drain(ctx)
}
func (m *Manager) retire(ctx context.Context, sess api.AgentSession, actor string) error {
m.mu.Lock()
l := m.live[sess.Id]
if l != nil {
l.stopping = true
}
m.mu.Unlock()
if l != nil {
l.sess.Kill()
}
if sess.State == api.AgentSessionStateQueued {
err := m.store.DeleteQueuedTurn(ctx, sess.Id)
if err != nil {
return err
}
}
return m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, 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.taskFor(ctx, sess)
if err != nil {
return err
}
l, err := m.ensureLive(ctx, sess, task)
if err != nil {
slog.Error("resume session", "session", sess.Id, "err", err)
m.logUndelivered(ctx, sess.Id, "resume failed")
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()})
if err != nil {
return err
}
if sess.Owner == "" {
m.takeMerge(task.Id)
}
continue
}
err = m.message(ctx, l, "byre", restartNotice, nil)
if err != nil {
return err
}
slog.Info("resumed session", "session", sess.Id, "task", task.Id, "owner", sess.Owner)
m.logUndelivered(ctx, sess.Id, "resumed with restart notice")
}
idle, err := m.store.SessionsInState(ctx, api.AgentSessionStateIdle)
if err != nil {
return err
}
for _, sess := range idle {
m.logUndelivered(ctx, sess.Id, "idle at startup")
}
go m.deliverAllLeads()
return m.drain(ctx)
}
func (m *Manager) logUndelivered(ctx context.Context, sessionID, reason string) {
pending, err := m.store.Undelivered(ctx, sessionID)
if err != nil {
slog.Error("list undelivered responses", "session", sessionID, "err", err)
return
}
if len(pending) == 0 {
return
}
slog.Info("undelivered responses", "session", sessionID, "reason", reason, "responses", responseIDs(pending))
}
func (m *Manager) Close() {
close(m.watchStop)
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()
}
err := m.parkLeads(context.Background())
if err != nil {
slog.Error("park lead sessions", "err", err)
}
}
func (m *Manager) LiveCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.live)
}
type Usage struct {
Running []api.TurnRef
Queued []api.TurnRef
Caps api.Caps
RateLimits map[string]map[string]any
}
func (m *Manager) Usage(ctx context.Context) (Usage, error) {
u := Usage{
Running: []api.TurnRef{},
Queued: []api.TurnRef{},
Caps: api.Caps{Global: m.limits.Global, PerVendor: map[string]int{}},
RateLimits: map[string]map[string]any{},
}
for vendor, n := range m.limits.PerVendor {
u.Caps.PerVendor[string(vendor)] = n
}
running, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
if err != nil {
return u, err
}
for _, sess := range running {
if sess.Owner != "" {
continue
}
u.Running = append(u.Running, api.TurnRef{SessionId: sess.Id, TaskId: sess.TaskId, Vendor: string(sess.Vendor)})
}
queued, err := m.store.QueuedTurns(ctx)
if err != nil {
return u, err
}
for _, q := range queued {
u.Queued = append(u.Queued, api.TurnRef{SessionId: q.SessionID, TaskId: q.TaskID, Vendor: string(q.Vendor)})
}
m.mu.Lock()
defer m.mu.Unlock()
for vendor, info := range m.rateLimits {
u.RateLimits[string(vendor)] = info
}
return u, nil
}
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
if sess.Owner != "" {
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
}
m.capMu.Lock()
defer m.capMu.Unlock()
room, err := m.hasRoom(ctx, sess.Vendor)
if err != nil {
return api.AgentSession{}, err
}
if room {
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
}
err = m.store.EnqueueTurn(ctx, store.QueuedTurn{
SessionID: sess.Id,
Vendor: sess.Vendor,
Actor: actor,
Text: text,
Images: images,
CreatedAt: time.Now().UTC(),
})
if err != nil {
return api.AgentSession{}, err
}
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateQueued, actor)
if err != nil {
derr := m.store.DeleteQueuedTurn(ctx, sess.Id)
if derr != nil {
slog.Error("remove queued turn", "session", sess.Id, "err", derr)
}
return api.AgentSession{}, err
}
return m.store.GetSession(ctx, sess.Id)
}
func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string, from api.AgentSessionState) (api.AgentSession, error) {
err := transition(ctx, m.store, sess.Id, from, api.AgentSessionStateRunning, actor)
if err != nil {
return api.AgentSession{}, err
}
l, err := m.ensureLive(ctx, sess, task)
if err == nil {
err = m.syncModel(ctx, l, sess.Model)
}
if err != nil {
serr := m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, actor, map[string]any{"error": err.Error()})
if serr != nil {
slog.Error("record failed start", "session", sess.Id, "err", serr)
}
return api.AgentSession{}, err
}
err = m.message(ctx, l, actor, text, images)
if err != nil {
return api.AgentSession{}, err
}
return m.store.GetSession(ctx, sess.Id)
}
func (m *Manager) syncModel(ctx context.Context, l *live, model string) error {
if model == l.model {
return nil
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err := l.sess.Control(cctx, map[string]any{"subtype": "set_model", "model": model})
if err != nil {
return fmt.Errorf("set model %s: %w", model, err)
}
l.model = model
return nil
}
func (m *Manager) hasRoom(ctx context.Context, vendor api.AgentSessionVendor) (bool, error) {
counts, err := m.store.CountRunning(ctx)
if err != nil {
return false, err
}
total := 0
for _, n := range counts {
total += n
}
return total < m.limits.Global && counts[vendor] < m.limits.PerVendor[vendor], nil
}
func (m *Manager) drain(ctx context.Context) error {
m.capMu.Lock()
defer m.capMu.Unlock()
queued, err := m.store.QueuedTurns(ctx)
if err != nil {
return err
}
for _, q := range queued {
room, err := m.hasRoom(ctx, q.Vendor)
if err != nil {
return err
}
if !room {
continue
}
sess, err := m.store.GetSession(ctx, q.SessionID)
if err != nil {
return err
}
task, err := m.store.GetTask(ctx, sess.TaskId)
if err != nil {
return err
}
err = m.store.DeleteQueuedTurn(ctx, q.SessionID)
if err != nil {
return err
}
_, err = m.start(ctx, sess, task, q.Actor, q.Text, q.Images, api.AgentSessionStateQueued)
if err != nil {
slog.Error("start queued turn", "session", q.SessionID, "err", err)
}
}
return nil
}
func (m *Manager) cancelQueued(ctx context.Context, sess api.AgentSession) (api.AgentSession, error) {
m.capMu.Lock()
defer m.capMu.Unlock()
queued, err := m.store.QueuedTurns(ctx)
if err != nil {
return api.AgentSession{}, err
}
for _, q := range queued {
if q.SessionID == sess.Id && strings.HasPrefix(q.Text, "Responses from your inbox:") {
slog.Info("cancelled queued turn carried inbox responses", "session", sess.Id, "text", q.Text)
}
}
err = m.store.DeleteQueuedTurn(ctx, sess.Id)
if errors.Is(err, store.ErrNotFound) {
return api.AgentSession{}, fmt.Errorf("%w: session %s has no queued turn", ErrConflict, sess.Id)
}
if err != nil {
return api.AgentSession{}, err
}
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateQueued, api.AgentSessionStateIdle, "byre")
if err != nil {
return api.AgentSession{}, err
}
return m.store.GetSession(ctx, sess.Id)
}
func transition(ctx context.Context, st *store.Store, id string, from, to api.AgentSessionState, actor string) error {
err := st.TransitionSession(ctx, id, from, to, actor)
if errors.Is(err, store.ErrStateChanged) {
return fmt.Errorf("%w: %w", ErrConflict, err)
}
return err
}
func (m *Manager) message(ctx context.Context, l *live, actor, text string, images []string) error {
imgs := []claude.Image{}
for _, id := range images {
b, data, err := m.store.ReadBlob(ctx, id)
if err != nil {
return err
}
imgs = append(imgs, claude.Image{MediaType: b.ContentType, Data: data})
}
payload := map[string]any{"text": text}
if l.owner == "" {
head, err := git.Head(l.dir)
if err != nil {
return err
}
payload["head"] = head
}
if len(images) > 0 {
payload["images"] = images
}
compacting := isHandoffPrompt(actor, text)
if compacting {
payload["handoff"] = true
}
err := m.emit(ctx, l, actor, "user.message", payload)
if err != nil {
return err
}
m.mu.Lock()
l.interrupted = false
l.asked = false
l.askedBlock = false
l.bounced = false
l.wantCompact = false
l.compacting = compacting
l.rateLimited = nil
m.mu.Unlock()
return l.sess.Send(text, imgs...)
}
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task) (*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,
dir: task.WorktreePath,
model: sess.Model,
gitName: creator.GitName,
gitEmail: creator.GitEmail,
vendor: sess.Vendor,
taskID: task.Id,
projectID: task.ProjectId,
owner: sess.Owner,
}
g := taskGuidance(task)
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}
hooks := map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance(g)}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.afterTool(l)}},
}
if l.owner != "" {
l.dir = filepath.Join(m.leadRoot, url.PathEscape(l.owner))
err = os.MkdirAll(l.dir, 0o755)
if err != nil {
return nil, err
}
g = leadGuidance
tools = m.leadTools(l)
hooks = map[string][]claude.HookMatcher{"UserPromptSubmit": {{Func: remindGuidance(g)}}}
}
opts := claude.Options{
Cwd: l.dir,
Model: sess.Model,
GitName: creator.GitName,
GitEmail: creator.GitEmail,
Guidance: g,
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: tools}},
Hooks: hooks,
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
}
started, err := m.store.SessionStarted(ctx, sess.Id)
if err != nil {
return nil, err
}
if started {
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)
if l.owner == "" {
go m.snapshotTimer(l)
}
return l, nil
}
func (m *Manager) pump(l *live) {
ctx := context.Background()
for ev := range l.sess.Events() {
start := time.Now()
if wait := start.Sub(ev.Read); wait > 200*time.Millisecond {
slog.Info("agent event waited", "session", l.id, "type", ev.Type, "wait", wait)
}
err := m.handle(ctx, l, ev)
if err != nil {
slog.Error("record agent event", "session", l.id, "type", ev.Type, "err", err)
}
if isDecodeError(err) {
m.recordBadLine(l, ev.Raw, fmt.Sprintf("decode %s: %s", ev.Type, err))
}
if took := time.Since(start); took > 200*time.Millisecond {
slog.Info("agent event slow", "session", l.id, "type", ev.Type, "took", took)
}
}
m.mu.Lock()
delete(m.live, l.id)
quiet := m.closing || l.stopping
m.mu.Unlock()
if quiet {
m.logUndelivered(ctx, l.id, "agent process ended while closing or stopping")
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
}
m.logUndelivered(ctx, l.id, "agent process exited mid-turn")
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)
}
if l.owner == "" {
m.takeMerge(l.taskID)
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
}
err = m.drain(ctx)
if err != nil {
slog.Error("start queued turns", "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
}
}
usage, err := ev.ContextUsage()
if err != nil {
return err
}
if usage.Tokens == 0 {
return nil
}
l.contextModel = usage.Model
return m.updateContext(ctx, l, usage.Tokens, l.contextWindow)
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
}
if l.owner != "" {
return m.leadTurnEnded(ctx, l, res)
}
m.mu.Lock()
interrupted := l.interrupted
rateLimited := l.rateLimited
compacting := l.compacting
askedBlock := l.askedBlock
m.mu.Unlock()
finished := !res.IsError && !interrupted && strings.TrimSpace(res.Result) != ""
handoff := compacting && finished
payload := store.ToPayload(res)
sha, err := git.CommitAll(l.dir, commitMessage(res), l.gitName, l.gitEmail)
if err != nil {
slog.Error("commit turn", "session", l.id, "err", err)
}
if sha != "" {
payload["commit"] = sha
}
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd)
switch {
case handoff:
payload["handoff"] = true
case finished && !compacting && !askedBlock:
payload["inbox_item_id"] = m.fileSummary(l, res.Result)
}
err = m.emit(ctx, l, agentActor, "turn.ended", payload)
if err != nil {
return err
}
window := res.ModelUsage[l.contextModel].ContextWindow
if window > 0 {
err = m.updateContext(ctx, l, l.contextUsed, window)
if err != nil {
return err
}
}
failure := fmt.Sprintf("The turn ended with an error (%s): %s", res.Subtype, res.Result)
switch {
case !res.IsError || interrupted:
case rateLimited != nil:
m.fileRateLimited(l, rateLimited, failure)
default:
m.fileError(l, failure)
}
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
if err != nil {
return err
}
err = m.drain(ctx)
if err != nil {
slog.Error("start queued turns", "err", err)
}
if handoff {
m.logUndelivered(ctx, l.id, "turn ended in handoff")
go m.continueFromHandoff(l.id, res.Result)
return nil
}
mergeBy := m.takeMerge(l.taskID)
if mergeBy != "" && finished {
m.logUndelivered(ctx, l.id, "turn ended in merge")
go m.finishMerge(l, mergeBy)
return nil
}
err = m.Deliver(ctx, l.id)
if err != nil {
slog.Error("deliver inbox responses", "session", l.id, "err", err)
}
return nil
case "stream_event":
text, err := ev.TextDelta()
if err != nil || text == "" {
return err
}
m.store.Broadcast(m.event(l, agentActor, "agent.delta", map[string]any{"text": text}))
return nil
case "rate_limit_event":
info := struct {
RateLimitInfo map[string]any `json:"rate_limit_info"`
}{}
err := json.Unmarshal(ev.Raw, &info)
if err != nil {
return err
}
m.mu.Lock()
m.rateLimits[l.vendor] = info.RateLimitInfo
if info.RateLimitInfo["status"] == "rejected" {
l.rateLimited = info.RateLimitInfo
}
m.mu.Unlock()
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 commitMessage(res claude.Result) string {
line, _, _ := strings.Cut(strings.TrimSpace(res.Result), "\n")
sentence, _, found := strings.Cut(line, ". ")
if found {
sentence += "."
}
sentence = strings.TrimSpace(strings.ReplaceAll(sentence, "**", ""))
if sentence == "" {
return fmt.Sprintf("Work from a turn that ended without a summary (%s)", res.Subtype)
}
runes := []rune(sentence)
if len(runes) > 100 {
return string(runes[:99]) + "…"
}
return sentence
}
func (m *Manager) updateContext(ctx context.Context, l *live, used, window int) error {
if used == l.contextUsed && window == l.contextWindow {
return nil
}
l.contextUsed = used
l.contextWindow = window
return m.emit(ctx, l, agentActor, "session.context", map[string]any{"model": l.contextModel, "used": used, "window": window})
}
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, m.event(l, actor, kind, payload))
}
func (m *Manager) event(l *live, actor, kind string, payload map[string]any) api.Event {
ev := api.Event{
Kind: kind,
AgentSessionId: &l.id,
Actor: actor,
Payload: payload,
}
if l.owner != "" {
ev.Owner = &l.owner
return ev
}
ev.ProjectId = &l.projectID
ev.TaskId = &l.taskID
return ev
}
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:])
}