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
+214
View File
@@ -0,0 +1,214 @@
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"slices"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func (s *Server) ListInbox(w http.ResponseWriter, r *http.Request, params api.ListInboxParams) {
f := store.InboxFilter{State: api.Open}
if params.ProjectId != nil {
f.ProjectID = *params.ProjectId
}
if params.TaskId != nil {
f.TaskID = *params.TaskId
}
if params.State != nil {
f.State = *params.State
}
if params.IncludeSnoozed != nil {
f.IncludeSnoozed = *params.IncludeSnoozed
}
items, err := s.store.ListInbox(r.Context(), f)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) GetInboxItem(w http.ResponseWriter, r *http.Request, itemID string) {
item, err := s.store.GetInboxItem(r.Context(), itemID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
type httpError struct {
status int
err error
}
func (s *Server) RespondInbox(w http.ResponseWriter, r *http.Request) {
req := api.InboxRespond{}
if !readJSON(w, r, &req) {
return
}
if len(req.Responses) == 0 {
writeError(w, http.StatusBadRequest, errors.New("responses is empty"))
return
}
resolutions := []store.Resolution{}
sessions := []string{}
seen := map[string]bool{}
for _, create := range req.Responses {
if seen[create.ItemId] {
writeError(w, http.StatusBadRequest, fmt.Errorf("item %s answered twice in one submission", create.ItemId))
return
}
seen[create.ItemId] = true
item, err := s.store.GetInboxItem(r.Context(), create.ItemId)
if err != nil {
writeStoreError(w, err)
return
}
state, herr := checkResponse(item, create)
if herr != nil {
writeError(w, herr.status, herr.err)
return
}
attachments := []api.Attachment{}
if create.Attachments != nil {
attachments = *create.Attachments
}
resolutions = append(resolutions, store.Resolution{
State: state,
Response: api.InboxResponse{
Id: store.NewID(),
ItemId: item.Id,
User: actor(r),
Type: create.Type,
Option: create.Option,
Text: create.Text,
Attachments: attachments,
SnoozeUntil: create.SnoozeUntil,
CreatedAt: time.Now().UTC(),
},
})
if !slices.Contains(sessions, item.AgentSessionId) {
sessions = append(sessions, item.AgentSessionId)
}
}
err := s.store.Respond(r.Context(), actor(r), resolutions)
if err != nil {
writeStoreError(w, err)
return
}
for _, id := range sessions {
go func() {
err := s.agents.Deliver(context.Background(), id)
if err != nil {
slog.Error("deliver inbox responses", "session", id, "err", err)
}
}()
}
items := []api.InboxItem{}
for _, res := range resolutions {
item, err := s.store.GetInboxItem(r.Context(), res.Response.ItemId)
if err != nil {
writeStoreError(w, err)
return
}
items = append(items, item)
}
writeJSON(w, http.StatusOK, items)
}
func checkResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxState, *httpError) {
if item.State != api.Open {
return "", &httpError{http.StatusConflict, fmt.Errorf("item %s is %s", item.Id, item.State)}
}
text := ""
if c.Text != nil {
text = strings.TrimSpace(*c.Text)
}
question := item.Kind == api.InboxItemKindQuestion
bad := func(format string, args ...any) (api.InboxState, *httpError) {
return "", &httpError{http.StatusBadRequest, fmt.Errorf("item %s: "+format, append([]any{item.Id}, args...)...)}
}
switch c.Type {
case api.InboxResponseTypeOption:
if !question {
return bad("only questions have options")
}
if c.Option == nil || !hasOption(item, *c.Option) {
return bad("option must be one of the question's options")
}
return api.Answered, nil
case api.InboxResponseTypeFreeForm:
if !question {
return bad("only questions take answers")
}
if text == "" {
return bad("free_form needs text")
}
return api.Answered, nil
case api.InboxResponseTypeQuestion:
if !question {
return bad("only questions take follow-up questions")
}
if text == "" {
return bad("question needs text")
}
return api.Open, nil
case api.InboxResponseTypeRedirect:
if text == "" {
return bad("redirect needs text")
}
return api.Redirected, nil
case api.InboxResponseTypeSnooze:
if c.SnoozeUntil == nil || !c.SnoozeUntil.After(time.Now()) {
return bad("snooze needs a future snooze_until")
}
return api.Open, nil
case api.InboxResponseTypeDismiss:
if question {
return bad("questions need an answer or a redirect, not a dismissal")
}
return api.Dismissed, nil
}
return bad("unknown response type %q", c.Type)
}
func hasOption(item api.InboxItem, option string) bool {
options, ok := item.Payload["options"].([]any)
if !ok {
return false
}
for _, o := range options {
if o == option {
return true
}
}
return false
}
+67
View File
@@ -0,0 +1,67 @@
package server
import (
"net/http"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
)
func TestCheckResponse(t *testing.T) {
question := api.InboxItem{
Id: "q1",
Kind: api.InboxItemKindQuestion,
State: api.Open,
Payload: map[string]any{"options": []any{"red", "blue"}},
}
report := api.InboxItem{Id: "r1", Kind: api.InboxItemKindReport, State: api.Open, Payload: map[string]any{}}
closed := question
closed.State = api.Answered
str := func(s string) *string { return &s }
future := time.Now().Add(time.Hour)
past := time.Now().Add(-time.Hour)
cases := []struct {
name string
item api.InboxItem
create api.InboxResponseCreate
state api.InboxState
status int
}{
{"option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("blue")}, api.Answered, 0},
{"option with note", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red"), Text: str("but darker")}, api.Answered, 0},
{"unknown option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("green")}, "", http.StatusBadRequest},
{"missing option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption}, "", http.StatusBadRequest},
{"free form", question, api.InboxResponseCreate{Type: api.InboxResponseTypeFreeForm, Text: str("green")}, api.Answered, 0},
{"empty free form", question, api.InboxResponseCreate{Type: api.InboxResponseTypeFreeForm, Text: str(" ")}, "", http.StatusBadRequest},
{"follow-up question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeQuestion, Text: str("why?")}, api.Open, 0},
{"redirect question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeRedirect, Text: str("stop")}, api.Redirected, 0},
{"redirect report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeRedirect, Text: str("stop")}, api.Redirected, 0},
{"snooze", question, api.InboxResponseCreate{Type: api.InboxResponseTypeSnooze, SnoozeUntil: &future}, api.Open, 0},
{"snooze into past", question, api.InboxResponseCreate{Type: api.InboxResponseTypeSnooze, SnoozeUntil: &past}, "", http.StatusBadRequest},
{"dismiss report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeDismiss}, api.Dismissed, 0},
{"dismiss question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeDismiss}, "", http.StatusBadRequest},
{"option on report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red")}, "", http.StatusBadRequest},
{"already answered", closed, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red")}, "", http.StatusConflict},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
state, herr := checkResponse(c.item, c.create)
if c.status != 0 {
if herr == nil || herr.status != c.status {
t.Fatalf("want status %d, got %+v", c.status, herr)
}
return
}
if herr != nil {
t.Fatalf("unexpected error %v", herr.err)
}
if state != c.state {
t.Fatalf("want state %s, got %s", c.state, state)
}
})
}
}