2026-10-15 18:33:18 -07:00
|
|
|
package agents
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"strings"
|
2026-10-16 12:56:53 -07:00
|
|
|
"sync"
|
2026-10-15 18:33:18 -07:00
|
|
|
|
2026-10-18 15:32:07 -07:00
|
|
|
"g.fc.run/ai/byre/internal/api"
|
|
|
|
|
"g.fc.run/ai/byre/internal/store"
|
2026-10-15 18:33:18 -07:00
|
|
|
)
|
|
|
|
|
|
2026-10-16 12:56:53 -07:00
|
|
|
type sessionLock struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
refs int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) lockDelivery(sessionID string) func() {
|
2026-10-15 18:33:18 -07:00
|
|
|
m.deliverMu.Lock()
|
2026-10-16 12:56:53 -07:00
|
|
|
sl := m.deliverLocks[sessionID]
|
|
|
|
|
if sl == nil {
|
|
|
|
|
sl = &sessionLock{}
|
|
|
|
|
m.deliverLocks[sessionID] = sl
|
|
|
|
|
}
|
|
|
|
|
sl.refs++
|
|
|
|
|
m.deliverMu.Unlock()
|
|
|
|
|
|
|
|
|
|
sl.mu.Lock()
|
|
|
|
|
return func() {
|
|
|
|
|
sl.mu.Unlock()
|
|
|
|
|
m.deliverMu.Lock()
|
|
|
|
|
sl.refs--
|
|
|
|
|
if sl.refs == 0 {
|
|
|
|
|
delete(m.deliverLocks, sessionID)
|
|
|
|
|
}
|
|
|
|
|
m.deliverMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 00:12:42 -07:00
|
|
|
func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, bool, error) {
|
2026-10-16 12:56:53 -07:00
|
|
|
unlock := m.lockDelivery(sessionID)
|
|
|
|
|
defer unlock()
|
2026-10-15 18:33:18 -07:00
|
|
|
|
|
|
|
|
sess, err := m.store.GetSession(ctx, sessionID)
|
|
|
|
|
if err != nil {
|
2026-10-18 00:12:42 -07:00
|
|
|
return api.AgentSession{}, false, err
|
2026-10-15 18:33:18 -07:00
|
|
|
}
|
2026-10-19 14:08:56 -07:00
|
|
|
held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && automatedActor(actor) && m.interrupted(sess.Id)
|
|
|
|
|
if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) {
|
2026-10-18 00:12:42 -07:00
|
|
|
sess, err = m.Send(ctx, sessionID, actor, text, model, images...)
|
|
|
|
|
return sess, false, err
|
2026-10-15 18:33:18 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if model != "" && model != sess.Model {
|
|
|
|
|
err = checkModel(sess, model)
|
|
|
|
|
if err != nil {
|
2026-10-18 00:12:42 -07:00
|
|
|
return api.AgentSession{}, false, err
|
2026-10-15 18:33:18 -07:00
|
|
|
}
|
|
|
|
|
err = m.store.SetSessionModel(ctx, sess.Id, model, actor)
|
|
|
|
|
if err != nil {
|
2026-10-18 00:12:42 -07:00
|
|
|
return api.AgentSession{}, false, err
|
2026-10-15 18:33:18 -07:00
|
|
|
}
|
|
|
|
|
sess.Model = model
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 15:33:59 -07:00
|
|
|
task, err := m.store.GetTask(ctx, sess.TaskId)
|
|
|
|
|
if err != nil {
|
2026-10-18 00:12:42 -07:00
|
|
|
return api.AgentSession{}, false, err
|
2026-10-17 15:33:59 -07:00
|
|
|
}
|
2026-10-18 08:48:57 -07:00
|
|
|
err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Member: sess.Member, Actor: actor, Text: text, Images: images}, task.ProjectId, sess.Id)
|
2026-10-15 18:33:18 -07:00
|
|
|
if err != nil {
|
2026-10-18 00:12:42 -07:00
|
|
|
return api.AgentSession{}, false, err
|
2026-10-15 18:33:18 -07:00
|
|
|
}
|
2026-10-19 14:08:56 -07:00
|
|
|
slog.Info("message queued", "session", sess.Id, "task", sess.TaskId, "actor", actor, "interrupted", held)
|
2026-10-18 00:12:42 -07:00
|
|
|
return sess, true, nil
|
2026-10-15 18:33:18 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-19 14:08:56 -07:00
|
|
|
func automatedActor(actor string) bool {
|
|
|
|
|
return actor == "byre" || actor == agentActor || actor == boardActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) interrupted(sessionID string) bool {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
l := m.live[sessionID]
|
|
|
|
|
return l != nil && l.interrupted
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 15:45:27 -07:00
|
|
|
func (m *Manager) Withdraw(ctx context.Context, taskID string, inputID int64, actor string) error {
|
|
|
|
|
task, err := m.store.GetTask(ctx, taskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unlock := m.lockDelivery(queueLockKey(taskID))
|
|
|
|
|
defer unlock()
|
|
|
|
|
|
2026-10-18 08:48:57 -07:00
|
|
|
sessionID, err := m.store.WithdrawTaskInput(ctx, taskID, inputID, task.ProjectId, actor)
|
2026-10-17 15:45:27 -07:00
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
return fmt.Errorf("%w: message %d is not queued", ErrConflict, inputID)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-10-18 08:48:57 -07:00
|
|
|
slog.Info("queued message withdrawn", "session", sessionID, "task", taskID, "input", inputID, "actor", actor)
|
2026-10-17 15:45:27 -07:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func queueLockKey(taskID string) string {
|
|
|
|
|
return "task:" + taskID
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 18:33:18 -07:00
|
|
|
func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (bool, error) {
|
2026-10-17 15:45:27 -07:00
|
|
|
unlock := m.lockDelivery(queueLockKey(sess.TaskId))
|
|
|
|
|
defer unlock()
|
|
|
|
|
|
2026-10-18 08:48:57 -07:00
|
|
|
inputs, err := m.store.TaskInputs(ctx, sess.TaskId, sess.Member)
|
2026-10-15 18:33:18 -07:00
|
|
|
if err != nil || len(inputs) == 0 {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
2026-10-18 08:48:57 -07:00
|
|
|
if sess.Member != "" {
|
|
|
|
|
started, err := m.store.SessionStarted(ctx, sess.Id)
|
|
|
|
|
if err != nil || !started {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-15 18:33:18 -07:00
|
|
|
|
|
|
|
|
task, err := m.store.GetTask(ctx, sess.TaskId)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
in := inputs[0]
|
|
|
|
|
_, err = m.Send(ctx, sess.Id, in.Actor, in.Text, "", in.Images...)
|
|
|
|
|
if errors.Is(err, ErrConflict) {
|
|
|
|
|
slog.Info("queued message held: session changed state", "session", sess.Id, "task", task.Id, "err", err)
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
slog.Info("queued message sent", "session", sess.Id, "task", task.Id, "input", in.ID)
|
|
|
|
|
return true, m.store.DeleteTaskInputs(ctx, []int64{in.ID})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) deliverAllQueued(ctx context.Context) {
|
|
|
|
|
idle, err := m.store.SessionsInState(ctx, api.AgentSessionStateIdle)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("list idle sessions", "err", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for _, sess := range idle {
|
|
|
|
|
if sess.Role != api.Implementer {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-16 12:56:53 -07:00
|
|
|
unlock := m.lockDelivery(sess.Id)
|
2026-10-15 18:33:18 -07:00
|
|
|
_, err = m.deliverQueued(ctx, sess)
|
2026-10-16 12:56:53 -07:00
|
|
|
unlock()
|
2026-10-15 18:33:18 -07:00
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("deliver queued message", "session", sess.Id, "err", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type queued struct {
|
|
|
|
|
actor string
|
|
|
|
|
text string
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:18:40 -07:00
|
|
|
func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskInput {
|
2026-10-15 18:33:18 -07:00
|
|
|
m.mu.Lock()
|
|
|
|
|
compacting := l.compacting
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
if compacting {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 15:45:27 -07:00
|
|
|
unlock := m.lockDelivery(queueLockKey(l.taskID))
|
|
|
|
|
defer unlock()
|
|
|
|
|
|
2026-10-18 08:48:57 -07:00
|
|
|
inputs, err := m.store.TaskInputs(ctx, l.taskID, l.member)
|
2026-10-15 18:33:18 -07:00
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("list queued messages", "session", l.id, "err", err)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-10-16 14:18:40 -07:00
|
|
|
out := []store.TaskInput{}
|
2026-10-15 18:33:18 -07:00
|
|
|
ids := []int64{}
|
|
|
|
|
for _, in := range inputs {
|
|
|
|
|
if len(in.Images) > 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-10-16 14:18:40 -07:00
|
|
|
out = append(out, in)
|
2026-10-15 18:33:18 -07:00
|
|
|
ids = append(ids, in.ID)
|
|
|
|
|
}
|
|
|
|
|
if len(ids) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
err = m.store.DeleteTaskInputs(ctx, ids)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("delete queued messages", "session", l.id, "err", err)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:18:40 -07:00
|
|
|
func (m *Manager) takeLeadQueuedMidTurn(ctx context.Context, l *live) []store.LeadInput {
|
2026-10-15 18:33:18 -07:00
|
|
|
m.mu.Lock()
|
|
|
|
|
compacting := l.compacting
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
if compacting {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m.leadMu.Lock()
|
|
|
|
|
defer m.leadMu.Unlock()
|
|
|
|
|
|
|
|
|
|
inputs, err := m.store.LeadInputs(ctx, l.owner)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("list held lead messages", "session", l.id, "err", err)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-10-16 14:18:40 -07:00
|
|
|
out := []store.LeadInput{}
|
2026-10-15 18:33:18 -07:00
|
|
|
ids := []int64{}
|
|
|
|
|
for _, in := range inputs {
|
|
|
|
|
if in.Kind != store.LeadMessage {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if len(in.Images) > 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-10-16 14:18:40 -07:00
|
|
|
out = append(out, in)
|
2026-10-15 18:33:18 -07:00
|
|
|
ids = append(ids, in.ID)
|
|
|
|
|
}
|
|
|
|
|
if len(ids) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
err = m.store.DeleteLeadInputs(ctx, ids)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("delete held lead messages", "session", l.id, "err", err)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) string {
|
|
|
|
|
b := &strings.Builder{}
|
|
|
|
|
for i, q := range msgs {
|
2026-10-18 08:48:57 -07:00
|
|
|
if l.implementer() && isMergeInPrompt(q.actor, q.text) {
|
|
|
|
|
m.takeMergeTurn(l)
|
|
|
|
|
}
|
2026-10-15 18:33:18 -07:00
|
|
|
err := m.emit(ctx, l, q.actor, "user.message", map[string]any{"text": q.text, "mid_turn": true})
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("record mid-turn message", "session", l.id, "err", err)
|
|
|
|
|
}
|
|
|
|
|
if i > 0 {
|
|
|
|
|
b.WriteString("\n\n")
|
|
|
|
|
}
|
2026-10-18 08:48:57 -07:00
|
|
|
if q.actor == boardActor {
|
|
|
|
|
fmt.Fprintf(b, "While you were working, %s\n\nTake it into account now.", q.text)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-15 18:33:18 -07:00
|
|
|
fmt.Fprintf(b, "%s sent you this message while you were working; take it into account now:\n\n%s", q.actor, q.text)
|
|
|
|
|
}
|
|
|
|
|
slog.Info("queued messages sent mid-turn", "session", l.id, "count", len(msgs))
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|