2026-10-14 07:02:56 -07:00
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
)
var attachmentKinds = [] api . AttachmentKind {
api . AttachmentKindFile , api . AttachmentKindDiff , api . AttachmentKindImage , api . AttachmentKindDiagram ,
api . AttachmentKindTask , api . AttachmentKindSnapshot , api . AttachmentKindUrl ,
}
type askArgs struct {
2026-10-14 10:37:04 -07:00
Context string `json:"context"`
2026-10-14 07:02:56 -07:00
Question string `json:"question"`
WhyBlocked string `json:"why_blocked"`
Options [] string `json:"options"`
RecommendedOption string `json:"recommended_option"`
Attachments [] api . Attachment `json:"attachments"`
Blocking * bool `json:"blocking"`
ThreadID string `json:"thread_id"`
}
func ( m * Manager ) askTool ( l * live ) mcp . Tool {
return mcp . Tool {
Name : "ask_human" ,
Description : "Ask the human a question through their inbox. Use this for every question; never ask in plain prose. " +
2026-10-14 10:37:04 -07:00
"The human answers from their inbox without having read your transcript, so every question must stand on its own: " +
"put the background they need in context. " +
2026-10-14 19:35:34 -07:00
"Offer concrete options and recommend one. If blocking is true, end your turn after asking; " +
2026-10-14 07:02:56 -07:00
"the answer arrives as your next message. If blocking is false, continue with work that doesn't depend on the answer. " +
"Ask several questions in one turn by calling this once per question; they are answered together. " +
"To answer a follow-up question from the human, call this again with the thread_id they gave you." ,
InputSchema : json . RawMessage ( `{
"type": "object",
"properties": {
2026-10-14 10:37:04 -07:00
"context": {"type": "string", "description": "Everything the human needs to answer well without reading your transcript: what you are working on, what you found, the relevant facts and data, and what each option would concretely do. Several paragraphs is normal. Markdown is rendered."},
"question": {"type": "string", "description": "The question itself, in one or two sentences. Markdown is rendered."},
2026-10-14 07:02:56 -07:00
"why_blocked": {"type": "string", "description": "What you can't do until this is answered, and why you can't decide it yourself."},
"options": {"type": "array", "items": {"type": "string"}, "minItems": 2},
"recommended_option": {"type": "string", "description": "One of options."},
"attachments": {"type": "array", "items": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["file", "diff", "image", "diagram", "task", "snapshot", "url"]},
"path": {"type": "string"},
"start_line": {"type": "integer"},
"end_line": {"type": "integer"},
"ref": {"type": "string"},
"url": {"type": "string"},
"mermaid": {"type": "string"},
"task_id": {"type": "string"},
"snapshot_id": {"type": "string"},
"caption": {"type": "string"}
},
"required": ["kind"]
}},
"blocking": {"type": "boolean"},
"thread_id": {"type": "string"}
},
2026-10-14 10:37:04 -07:00
"required": ["context", "question", "why_blocked", "options", "recommended_option", "blocking"]
2026-10-14 07:02:56 -07:00
}` ),
Handler : func ( ctx context . Context , raw json . RawMessage ) ( string , error ) {
return m . ask ( ctx , l , raw )
},
}
}
func ( m * Manager ) ask ( ctx context . Context , l * live , raw json . RawMessage ) ( string , error ) {
args := askArgs {}
err := json . Unmarshal ( raw , & args )
if err != nil {
return "" , fmt . Errorf ( "malformed ask_human call: %w" , err )
}
err = m . validateAsk ( ctx , l , args )
if err != nil {
return "" , err
}
id := store . NewID ()
thread := args . ThreadID
if thread == "" {
thread = id
}
priority := api . Fyi
if * args . Blocking {
priority = api . Blocked
}
attachments := args . Attachments
if attachments == nil {
attachments = [] api . Attachment {}
}
payload := map [ string ] any {
2026-10-14 10:37:04 -07:00
"context" : args . Context ,
2026-10-14 07:02:56 -07:00
"question" : args . Question ,
"why_blocked" : args . WhyBlocked ,
"options" : args . Options ,
"recommended_option" : args . RecommendedOption ,
"attachments" : attachments ,
}
2026-10-14 10:37:04 -07:00
err = m . store . CreateInboxItem ( ctx , m . newItem ( l , id , thread , api . Question , priority , * args . Blocking , store . ToPayload ( payload )))
2026-10-14 07:02:56 -07:00
if err != nil {
return "" , err
}
2026-10-14 19:35:34 -07:00
m . mu . Lock ()
l . asked = true
m . mu . Unlock ()
2026-10-14 07:02:56 -07:00
if * args . Blocking {
2026-10-14 19:35:34 -07:00
return fmt . Sprintf ( "Question %s filed in the human's inbox. It is blocking: end your turn now. The answer will arrive as your next message." , id ), nil
2026-10-14 07:02:56 -07:00
}
return fmt . Sprintf ( "Question %s filed in the human's inbox. Continue with work that doesn't depend on it; the answer will arrive as a later message." , id ), nil
}
func ( m * Manager ) validateAsk ( ctx context . Context , l * live , args askArgs ) error {
problems := [] string {}
2026-10-14 10:37:04 -07:00
if strings . TrimSpace ( args . Context ) == "" {
problems = append ( problems , "context is empty: give the background the human needs to answer without reading your transcript" )
}
2026-10-14 07:02:56 -07:00
if strings . TrimSpace ( args . Question ) == "" {
problems = append ( problems , "question is empty" )
}
if strings . TrimSpace ( args . WhyBlocked ) == "" {
problems = append ( problems , "why_blocked is empty: say what you can't do until this is answered and why you can't decide it yourself" )
}
if len ( args . Options ) < 2 {
problems = append ( problems , "options needs at least two concrete choices" )
}
if ! slices . Contains ( args . Options , args . RecommendedOption ) {
problems = append ( problems , "recommended_option must be exactly one of options" )
}
if args . Blocking == nil {
problems = append ( problems , "blocking is required" )
}
for i , a := range args . Attachments {
if ! slices . Contains ( attachmentKinds , a . Kind ) {
problems = append ( problems , fmt . Sprintf ( "attachments[%d].kind %q is not one of %v" , i , a . Kind , attachmentKinds ))
}
}
if args . ThreadID != "" {
item , err := m . store . GetInboxItem ( ctx , args . ThreadID )
switch {
case errors . Is ( err , store . ErrNotFound ):
problems = append ( problems , fmt . Sprintf ( "thread_id %s does not exist" , args . ThreadID ))
case err != nil :
return err
case item . AgentSessionId != l . id :
problems = append ( problems , fmt . Sprintf ( "thread_id %s belongs to another session" , args . ThreadID ))
}
}
if len ( problems ) > 0 {
return fmt . Errorf ( "ask_human rejected, nothing was filed. Fix and call again: %s" , strings . Join ( problems , "; " ))
}
return nil
}
2026-10-14 19:35:34 -07:00
func ( m * Manager ) fileSummary ( l * live , text string ) string {
2026-10-14 07:02:56 -07:00
id := store . NewID ()
2026-10-14 19:35:34 -07:00
err := m . store . CreateInboxItem ( context . Background (), m . newItem ( l , id , id , api . Report , api . Fyi , false , map [ string ] any { "text" : text }))
2026-10-14 07:02:56 -07:00
if err != nil {
2026-10-14 19:35:34 -07:00
slog . Error ( "file turn summary" , "session" , l . id , "err" , err )
2026-10-14 16:03:46 -07:00
return ""
2026-10-14 07:02:56 -07:00
}
2026-10-14 16:03:46 -07:00
return id
2026-10-14 07:02:56 -07:00
}
func ( m * Manager ) fileError ( l * live , msg string ) {
id := store . NewID ()
2026-10-14 10:37:04 -07:00
err := m . store . CreateInboxItem ( context . Background (), m . newItem ( l , id , id , api . SessionError , api . Blocked , true , map [ string ] any { "error" : msg }))
2026-10-14 07:02:56 -07:00
if err != nil {
slog . Error ( "file session error" , "session" , l . id , "err" , err )
}
}
2026-10-14 09:40:12 -07:00
func ( m * Manager ) fileRateLimited ( l * live , info map [ string ] any , failure string ) {
id := store . NewID ()
payload := map [ string ] any { "vendor" : string ( l . vendor ), "rate_limit" : info , "error" : failure }
2026-10-14 10:37:04 -07:00
err := m . store . CreateInboxItem ( context . Background (), m . newItem ( l , id , id , api . RateLimited , api . Blocked , true , payload ))
2026-10-14 09:40:12 -07:00
if err != nil {
slog . Error ( "file rate limit" , "session" , l . id , "err" , err )
}
}
2026-10-14 07:02:56 -07:00
func ( m * Manager ) newItem ( l * live , id , thread string , kind api . InboxItemKind , priority api . InboxItemPriority , blocking bool , payload map [ string ] any ) api . InboxItem {
return api . InboxItem {
Id : id ,
ProjectId : l . projectID ,
TaskId : l . taskID ,
AgentSessionId : l . id ,
ThreadId : thread ,
Kind : kind ,
Priority : priority ,
Blocking : blocking ,
Payload : payload ,
State : api . Open ,
CreatedAt : time . Now (). UTC (),
Responses : [] api . InboxResponse {},
}
}
func ( m * Manager ) Deliver ( ctx context . Context , sessionID string ) error {
m . deliverMu . Lock ()
defer m . deliverMu . Unlock ()
sess , err := m . store . GetSession ( ctx , sessionID )
if err != nil {
return err
}
if sess . State != api . AgentSessionStateIdle {
return nil
}
waiting , err := m . store . AwaitingHuman ( ctx , sessionID )
if err != nil {
return err
}
if waiting > 0 {
return nil
}
pending , err := m . store . Undelivered ( ctx , sessionID )
if err != nil {
return err
}
if len ( pending ) == 0 {
return nil
}
task , err := m . store . GetTask ( ctx , sess . TaskId )
if err != nil {
return err
}
2026-10-14 09:40:12 -07:00
_ , err = m . turn ( ctx , sess , task , pending [ 0 ]. Response . User , deliveryMessage ( pending ))
2026-10-14 07:02:56 -07:00
if errors . Is ( err , ErrConflict ) {
return nil
}
if err != nil {
return err
}
ids := [] string {}
for _ , p := range pending {
ids = append ( ids , p . Response . Id )
}
return m . store . MarkDelivered ( ctx , ids )
}
func deliveryMessage ( pending [] store . Pending ) string {
b := & strings . Builder {}
b . WriteString ( "Responses from your inbox:\n" )
for _ , p := range pending {
r := p . Response
b . WriteString ( "\n" )
switch p . Item . Kind {
2026-10-14 10:37:04 -07:00
case api . Question :
2026-10-14 07:02:56 -07:00
fmt . Fprintf ( b , "Your question %s: %s\n" , p . Item . Id , p . Item . Payload [ "question" ])
2026-10-14 10:37:04 -07:00
case api . SessionError :
2026-10-14 07:02:56 -07:00
fmt . Fprintf ( b , "Your error %s: %s\n" , p . Item . Id , p . Item . Payload [ "error" ])
2026-10-14 10:37:04 -07:00
case api . RateLimited :
2026-10-14 09:40:12 -07:00
fmt . Fprintf ( b , "Your rate-limited turn %s: %s\n" , p . Item . Id , p . Item . Payload [ "error" ])
2026-10-14 07:02:56 -07:00
}
switch r . Type {
2026-10-14 10:37:04 -07:00
case api . Option :
2026-10-14 07:02:56 -07:00
fmt . Fprintf ( b , "%s chose: %s\n" , r . User , * r . Option )
2026-10-14 10:37:04 -07:00
case api . Other :
if p . Item . Kind == api . Question {
fmt . Fprintf ( b , "%s answered (none of your options): %s\n" , r . User , * r . Text )
fmt . Fprintf ( b , "If this asks you something back, answer by calling ask_human with thread_id %q.\n" , p . Item . ThreadId )
} else {
fmt . Fprintf ( b , "%s replied: %s\n" , r . User , * r . Text )
2026-10-14 07:02:56 -07:00
}
}
for _ , a := range r . Attachments {
j , err := json . Marshal ( a )
if err != nil {
continue
}
fmt . Fprintf ( b , "Attachment: %s\n" , j )
}
}
return b . String ()
}