Add ask_human and the inbox with batched responses and delivery to agents

This commit is contained in:
Ian Gulliver
2026-10-14 07:02:56 -07:00
parent 09951e64e7
commit befd476598
16 changed files with 1962 additions and 129 deletions
+402
View File
@@ -0,0 +1,402 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
)
var priorities = []api.InboxItemPriority{api.Blocked, api.Gate, api.Review, api.Fyi}
func priorityRank(p api.InboxItemPriority) int {
for i, q := range priorities {
if q == p {
return i
}
}
panic("unknown inbox priority " + string(p))
}
func DeliverableResponse(t api.InboxResponseType) bool {
switch t {
case api.InboxResponseTypeOption, api.InboxResponseTypeFreeForm, api.InboxResponseTypeRedirect, api.InboxResponseTypeQuestion:
return true
}
return false
}
func (s *Store) CreateInboxItem(ctx context.Context, item api.InboxItem) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
superseded := []string{}
rows, err := tx.QueryContext(ctx, `SELECT id FROM inbox_items WHERE thread_id = ? AND state = ?`, item.ThreadId, string(api.Open))
if err != nil {
return err
}
for rows.Next() {
id := ""
err = rows.Scan(&id)
if err != nil {
rows.Close()
return err
}
superseded = append(superseded, id)
}
rows.Close()
for _, id := range superseded {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ? WHERE id = ?`, string(api.Superseded), id)
if err != nil {
return err
}
err = emitItem(ctx, tx, emit, "inbox.updated", id, agentActor)
if err != nil {
return err
}
}
payload, err := json.Marshal(item.Payload)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, item.Id, item.ProjectId, item.TaskId, item.AgentSessionId, item.ThreadId, string(item.Kind), priorityRank(item.Priority),
item.Blocking, string(payload), string(item.State), formatTime(item.CreatedAt))
if err != nil {
return err
}
return emitItem(ctx, tx, emit, "inbox.created", item.Id, agentActor)
})
}
const agentActor = "agent"
func (s *Store) GetInboxItem(ctx context.Context, id string) (api.InboxItem, error) {
item, err := getItem(ctx, s.db, id)
if errors.Is(err, sql.ErrNoRows) {
return item, ErrNotFound
}
return item, err
}
type InboxFilter struct {
ProjectID string
TaskID string
SessionID string
State api.InboxState
IncludeSnoozed bool
}
func (s *Store) ListInbox(ctx context.Context, f InboxFilter) ([]api.InboxItem, error) {
where := []string{"state = ?"}
args := []any{string(f.State)}
if f.ProjectID != "" {
where = append(where, "project_id = ?")
args = append(args, f.ProjectID)
}
if f.TaskID != "" {
where = append(where, "task_id = ?")
args = append(args, f.TaskID)
}
if f.SessionID != "" {
where = append(where, "agent_session_id = ?")
args = append(args, f.SessionID)
}
if !f.IncludeSnoozed {
where = append(where, "(snoozed_until IS NULL OR snoozed_until <= ?)")
args = append(args, now())
}
rows, err := s.db.QueryContext(ctx, `SELECT id FROM inbox_items WHERE `+strings.Join(where, " AND ")+` ORDER BY priority, created_at`, args...)
if err != nil {
return nil, err
}
ids := []string{}
for rows.Next() {
id := ""
err = rows.Scan(&id)
if err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
rows.Close()
err = rows.Err()
if err != nil {
return nil, err
}
items := []api.InboxItem{}
for _, id := range ids {
item, err := getItem(ctx, s.db, id)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
type Resolution struct {
Response api.InboxResponse
State api.InboxState
}
func (s *Store) Respond(ctx context.Context, actor string, resolutions []Resolution) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
for _, r := range resolutions {
resp := r.Response
attachments, err := json.Marshal(resp.Attachments)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, snooze_until, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, resp.Id, resp.ItemId, resp.User, string(resp.Type), resp.Option, resp.Text, string(attachments),
formatTimePtr(resp.SnoozeUntil), formatTime(resp.CreatedAt))
if err != nil {
return err
}
if r.State == api.Open {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET snoozed_until = ? WHERE id = ?`, formatTimePtr(resp.SnoozeUntil), resp.ItemId)
} else {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ?`,
string(r.State), actor, now(), resp.ItemId)
}
if err != nil {
return err
}
err = emitItem(ctx, tx, emit, "inbox.updated", resp.ItemId, actor)
if err != nil {
return err
}
}
return nil
})
}
func (s *Store) AwaitingHuman(ctx context.Context, sessionID string) (int, error) {
n := 0
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM inbox_items i
WHERE i.agent_session_id = ? AND i.kind = ? AND i.blocking AND i.state = ?
AND NOT EXISTS (SELECT 1 FROM inbox_responses r WHERE r.item_id = i.id AND r.type IN (?, ?, ?, ?))
`, sessionID, string(api.InboxItemKindQuestion), string(api.Open),
string(api.InboxResponseTypeOption), string(api.InboxResponseTypeFreeForm), string(api.InboxResponseTypeRedirect), string(api.InboxResponseTypeQuestion)).Scan(&n)
return n, err
}
type Pending struct {
Item api.InboxItem
Response api.InboxResponse
}
func (s *Store) Undelivered(ctx context.Context, sessionID string) ([]Pending, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.id, r.item_id FROM inbox_responses r JOIN inbox_items i ON i.id = r.item_id
WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?, ?, ?)
ORDER BY r.created_at
`, sessionID, string(api.InboxResponseTypeOption), string(api.InboxResponseTypeFreeForm), string(api.InboxResponseTypeRedirect), string(api.InboxResponseTypeQuestion))
if err != nil {
return nil, err
}
type pair struct{ resp, item string }
pairs := []pair{}
for rows.Next() {
p := pair{}
err = rows.Scan(&p.resp, &p.item)
if err != nil {
rows.Close()
return nil, err
}
pairs = append(pairs, p)
}
rows.Close()
err = rows.Err()
if err != nil {
return nil, err
}
pending := []Pending{}
for _, p := range pairs {
item, err := getItem(ctx, s.db, p.item)
if err != nil {
return nil, err
}
for _, r := range item.Responses {
if r.Id == p.resp {
pending = append(pending, Pending{Item: item, Response: r})
}
}
}
return pending, nil
}
func (s *Store) MarkDelivered(ctx context.Context, responseIDs []string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
at := now()
for _, id := range responseIDs {
_, err := tx.ExecContext(ctx, `UPDATE inbox_responses SET delivered_at = ? WHERE id = ?`, at, id)
if err != nil {
return err
}
}
return nil
})
}
type querier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
item := api.InboxItem{}
kind := ""
priority := 0
payload := ""
state := ""
createdAt := ""
snoozed := sql.NullString{}
resolvedBy := sql.NullString{}
resolvedAt := sql.NullString{}
err := q.QueryRowContext(ctx, `
SELECT id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state,
snoozed_until, created_at, resolved_by, resolved_at
FROM inbox_items WHERE id = ?
`, id).Scan(&item.Id, &item.ProjectId, &item.TaskId, &item.AgentSessionId, &item.ThreadId, &kind, &priority, &item.Blocking,
&payload, &state, &snoozed, &createdAt, &resolvedBy, &resolvedAt)
if err != nil {
return item, err
}
item.Kind = api.InboxItemKind(kind)
item.Priority = priorities[priority]
item.State = api.InboxState(state)
err = json.Unmarshal([]byte(payload), &item.Payload)
if err != nil {
return item, err
}
item.CreatedAt, err = parseTime(createdAt)
if err != nil {
return item, err
}
item.SnoozedUntil, err = parseTimePtr(snoozed)
if err != nil {
return item, err
}
item.ResolvedAt, err = parseTimePtr(resolvedAt)
if err != nil {
return item, err
}
if resolvedBy.Valid {
item.ResolvedBy = &resolvedBy.String
}
item.Responses, err = getResponses(ctx, q, id)
return item, err
}
func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxResponse, error) {
rows, err := q.QueryContext(ctx, `
SELECT id, item_id, user_login, type, option, text, attachments, snooze_until, created_at, delivered_at
FROM inbox_responses WHERE item_id = ? ORDER BY created_at
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
responses := []api.InboxResponse{}
for rows.Next() {
r := api.InboxResponse{}
typ := ""
option := sql.NullString{}
text := sql.NullString{}
attachments := ""
snooze := sql.NullString{}
createdAt := ""
delivered := sql.NullString{}
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &option, &text, &attachments, &snooze, &createdAt, &delivered)
if err != nil {
return nil, err
}
r.Type = api.InboxResponseType(typ)
if option.Valid {
r.Option = &option.String
}
if text.Valid {
r.Text = &text.String
}
err = json.Unmarshal([]byte(attachments), &r.Attachments)
if err != nil {
return nil, err
}
r.SnoozeUntil, err = parseTimePtr(snooze)
if err != nil {
return nil, err
}
r.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
}
r.DeliveredAt, err = parseTimePtr(delivered)
if err != nil {
return nil, err
}
responses = append(responses, r)
}
return responses, rows.Err()
}
func emitItem(ctx context.Context, tx *sql.Tx, emit emitFunc, kind, id, actor string) error {
item, err := getItem(ctx, tx, id)
if err != nil {
return err
}
return emit(api.Event{
Kind: kind,
ProjectId: &item.ProjectId,
TaskId: &item.TaskId,
AgentSessionId: &item.AgentSessionId,
Actor: actor,
Payload: ToPayload(item),
})
}
func formatTimePtr(t *time.Time) any {
if t == nil {
return nil
}
return formatTime(*t)
}
func parseTimePtr(s sql.NullString) (*time.Time, error) {
if !s.Valid {
return nil, nil
}
t, err := parseTime(s.String)
if err != nil {
return nil, err
}
return &t, nil
}
+55 -3
View File
@@ -78,6 +78,41 @@ CREATE TABLE agent_sessions (
CREATE INDEX agent_sessions_task ON agent_sessions(task_id, created_at);
CREATE INDEX agent_sessions_state ON agent_sessions(state);
`, `
CREATE TABLE inbox_items (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
task_id TEXT NOT NULL REFERENCES tasks(id),
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
thread_id TEXT NOT NULL,
kind TEXT NOT NULL,
priority INTEGER NOT NULL,
blocking INTEGER NOT NULL,
payload TEXT NOT NULL,
state TEXT NOT NULL,
snoozed_until TEXT,
created_at TEXT NOT NULL,
resolved_by TEXT REFERENCES users(login),
resolved_at TEXT
);
CREATE INDEX inbox_items_open ON inbox_items(state, priority, created_at);
CREATE INDEX inbox_items_session ON inbox_items(agent_session_id, state);
CREATE TABLE inbox_responses (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES inbox_items(id),
user_login TEXT NOT NULL REFERENCES users(login),
type TEXT NOT NULL,
option TEXT,
text TEXT,
attachments TEXT NOT NULL,
snooze_until TEXT,
created_at TEXT NOT NULL,
delivered_at TEXT
);
CREATE INDEX inbox_responses_item ON inbox_responses(item_id, created_at);
`}
type Store struct {
@@ -384,13 +419,24 @@ func (s *Store) querySessions(ctx context.Context, query string, args ...any) ([
return sessions, rows.Err()
}
var ErrStateChanged = errors.New("session state changed")
func (s *Store) SetSessionState(ctx context.Context, id string, state api.AgentSessionState, actor string, payload map[string]any) error {
return s.transitionSession(ctx, id, nil, state, actor, payload)
}
func (s *Store) TransitionSession(ctx context.Context, id string, from, to api.AgentSessionState, actor string) error {
return s.transitionSession(ctx, id, &from, to, actor, nil)
}
func (s *Store) transitionSession(ctx context.Context, id string, from *api.AgentSessionState, state api.AgentSessionState, actor string, payload map[string]any) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
taskID := ""
projectID := ""
current := ""
err := tx.QueryRowContext(ctx, `
SELECT t.id, t.project_id FROM agent_sessions s JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
`, id).Scan(&taskID, &projectID)
SELECT t.id, t.project_id, s.state FROM agent_sessions s JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
`, id).Scan(&taskID, &projectID, &current)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -398,6 +444,10 @@ func (s *Store) SetSessionState(ctx context.Context, id string, state api.AgentS
return err
}
if from != nil && current != string(*from) {
return fmt.Errorf("%w: session %s is %s, not %s", ErrStateChanged, id, current, *from)
}
_, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(state), id)
if err != nil {
return err
@@ -560,8 +610,10 @@ func now() string {
return formatTime(time.Now())
}
const timeLayout = "2006-01-02T15:04:05.000000000Z07:00"
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339Nano)
return t.UTC().Format(timeLayout)
}
func parseTime(s string) (time.Time, error) {