Bold questions
This commit is contained in:
+157
-50
@@ -7,13 +7,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"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/report"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
@@ -37,8 +38,9 @@ var DefaultLimits = Limits{
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
limits Limits
|
||||
store *store.Store
|
||||
limits Limits
|
||||
worktreeRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
@@ -51,13 +53,19 @@ type Manager struct {
|
||||
|
||||
type live struct {
|
||||
sess *claude.Session
|
||||
gate *report.Gate
|
||||
id string
|
||||
dir string
|
||||
model string
|
||||
gitName string
|
||||
gitEmail string
|
||||
vendor api.AgentSessionVendor
|
||||
taskID string
|
||||
projectID string
|
||||
stopping bool
|
||||
interrupted bool
|
||||
asked bool
|
||||
bounced bool
|
||||
compacting bool
|
||||
rateLimited map[string]any
|
||||
|
||||
contextModel string
|
||||
@@ -65,16 +73,21 @@ type live struct {
|
||||
contextWindow int
|
||||
}
|
||||
|
||||
func New(st *store.Store, limits Limits) *Manager {
|
||||
func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
|
||||
return &Manager{
|
||||
store: st,
|
||||
limits: limits,
|
||||
live: map[string]*live{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
store: st,
|
||||
limits: limits,
|
||||
worktreeRoot: worktreeRoot,
|
||||
live: map[string]*live{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string) (api.AgentSession, error) {
|
||||
return m.create(ctx, task, creator, prompt, model, creator.Login)
|
||||
}
|
||||
|
||||
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string) (api.AgentSession, error) {
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return api.AgentSession{}, ErrNoGitIdentity
|
||||
}
|
||||
@@ -111,10 +124,10 @@ func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, p
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, creator.Login, prompt)
|
||||
return m.turn(ctx, sess, task, actor, prompt)
|
||||
}
|
||||
|
||||
func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.AgentSession, error) {
|
||||
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string) (api.AgentSession, error) {
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
@@ -124,6 +137,14 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.
|
||||
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.store.GetTask(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
@@ -175,26 +196,7 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
|
||||
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()
|
||||
}
|
||||
|
||||
if sess.State == api.AgentSessionStateQueued {
|
||||
err = m.store.DeleteQueuedTurn(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
||||
err = m.retire(ctx, sess, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -203,6 +205,28 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
|
||||
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 {
|
||||
@@ -325,6 +349,9 @@ func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Tas
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -341,6 +368,22 @@ func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Tas
|
||||
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 {
|
||||
@@ -425,17 +468,23 @@ func transition(ctx context.Context, st *store.Store, id string, from, to api.Ag
|
||||
}
|
||||
|
||||
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})
|
||||
head, err := git.Head(l.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.emit(ctx, l, actor, "user.message", map[string]any{"text": text, "head": head})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
l.interrupted = false
|
||||
l.asked = false
|
||||
l.bounced = false
|
||||
l.compacting = actor == "byre" && text == handoffPrompt
|
||||
l.rateLimited = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
l.gate.Reset()
|
||||
return l.sess.Send(text)
|
||||
}
|
||||
|
||||
@@ -457,24 +506,27 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l = &live{id: sess.Id, vendor: sess.Vendor, taskID: task.Id, projectID: task.ProjectId}
|
||||
l.gate = &report.Gate{OnReport: func(r report.Report) {
|
||||
payload := store.ToPayload(r)
|
||||
payload["inbox_item_id"] = m.fileReport(l, r)
|
||||
err := m.emit(context.Background(), l, agentActor, "agent.report", payload)
|
||||
if err != nil {
|
||||
slog.Error("record report", "session", l.id, "err", 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,
|
||||
}
|
||||
|
||||
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(), m.askTool(l)}}},
|
||||
Guidance: guidance,
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.createTaskTool(l)}}},
|
||||
Hooks: map[string][]claude.HookMatcher{
|
||||
"Stop": {{Func: l.gate.StopHook}},
|
||||
"UserPromptSubmit": {{Func: remindGuidance}},
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
},
|
||||
}
|
||||
started, err := m.store.SessionStarted(ctx, sess.Id)
|
||||
@@ -593,7 +645,31 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.emit(ctx, l, agentActor, "turn.ended", store.ToPayload(res))
|
||||
|
||||
m.mu.Lock()
|
||||
interrupted := l.interrupted
|
||||
rateLimited := l.rateLimited
|
||||
compacting := l.compacting
|
||||
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
|
||||
}
|
||||
switch {
|
||||
case handoff:
|
||||
payload["handoff"] = true
|
||||
case finished && !compacting:
|
||||
payload["inbox_item_id"] = m.fileSummary(l, res.Result)
|
||||
}
|
||||
err = m.emit(ctx, l, agentActor, "turn.ended", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -606,11 +682,6 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
interrupted := l.interrupted
|
||||
rateLimited := l.rateLimited
|
||||
m.mu.Unlock()
|
||||
|
||||
failure := fmt.Sprintf("The turn ended with an error (%s): %s", res.Subtype, res.Result)
|
||||
switch {
|
||||
case !res.IsError || interrupted:
|
||||
@@ -629,12 +700,31 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
if err != nil {
|
||||
slog.Error("start queued turns", "err", err)
|
||||
}
|
||||
if handoff {
|
||||
go m.continueFromHandoff(l.id, res.Result)
|
||||
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(api.Event{
|
||||
Kind: "agent.delta",
|
||||
ProjectId: &l.projectID,
|
||||
TaskId: &l.taskID,
|
||||
AgentSessionId: &l.id,
|
||||
Actor: agentActor,
|
||||
Payload: map[string]any{"text": text},
|
||||
})
|
||||
return nil
|
||||
|
||||
case "rate_limit_event":
|
||||
info := struct {
|
||||
RateLimitInfo map[string]any `json:"rate_limit_info"`
|
||||
@@ -665,6 +755,23 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user