288 lines
8.8 KiB
Go
288 lines
8.8 KiB
Go
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/report"
|
||
|
|
"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 {
|
||
|
|
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. " +
|
||
|
|
"Offer concrete options and recommend one. If blocking is true, call report and end your turn after asking; " +
|
||
|
|
"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": {
|
||
|
|
"question": {"type": "string"},
|
||
|
|
"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"}
|
||
|
|
},
|
||
|
|
"required": ["question", "why_blocked", "options", "recommended_option", "blocking"]
|
||
|
|
}`),
|
||
|
|
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{
|
||
|
|
"question": args.Question,
|
||
|
|
"why_blocked": args.WhyBlocked,
|
||
|
|
"options": args.Options,
|
||
|
|
"recommended_option": args.RecommendedOption,
|
||
|
|
"attachments": attachments,
|
||
|
|
}
|
||
|
|
|
||
|
|
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.InboxItemKindQuestion, priority, *args.Blocking, store.ToPayload(payload)))
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
if *args.Blocking {
|
||
|
|
return fmt.Sprintf("Question %s filed in the human's inbox. It is blocking: call report, then end your turn. The answer will arrive as your next message.", id), nil
|
||
|
|
}
|
||
|
|
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{}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) fileReport(l *live, r report.Report) {
|
||
|
|
id := store.NewID()
|
||
|
|
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindReport, api.Fyi, false, store.ToPayload(r)))
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("file report", "session", l.id, "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) fileError(l *live, msg string) {
|
||
|
|
id := store.NewID()
|
||
|
|
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindSessionError, api.Blocked, true, map[string]any{"error": msg}))
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("file session error", "session", l.id, "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), true)
|
||
|
|
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 {
|
||
|
|
case api.InboxItemKindQuestion:
|
||
|
|
fmt.Fprintf(b, "Your question %s: %s\n", p.Item.Id, p.Item.Payload["question"])
|
||
|
|
case api.InboxItemKindReport:
|
||
|
|
fmt.Fprintf(b, "Your report %s\n", p.Item.Id)
|
||
|
|
case api.InboxItemKindSessionError:
|
||
|
|
fmt.Fprintf(b, "Your error %s: %s\n", p.Item.Id, p.Item.Payload["error"])
|
||
|
|
}
|
||
|
|
|
||
|
|
switch r.Type {
|
||
|
|
case api.InboxResponseTypeOption:
|
||
|
|
fmt.Fprintf(b, "%s chose: %s\n", r.User, *r.Option)
|
||
|
|
if r.Text != nil && *r.Text != "" {
|
||
|
|
fmt.Fprintf(b, "Note: %s\n", *r.Text)
|
||
|
|
}
|
||
|
|
case api.InboxResponseTypeFreeForm:
|
||
|
|
fmt.Fprintf(b, "%s answered (none of your options): %s\n", r.User, *r.Text)
|
||
|
|
case api.InboxResponseTypeRedirect:
|
||
|
|
fmt.Fprintf(b, "%s redirected you. These instructions replace your current direction: %s\n", r.User, *r.Text)
|
||
|
|
case api.InboxResponseTypeQuestion:
|
||
|
|
fmt.Fprintf(b, "%s asked a follow-up question: %s\nAnswer it by calling ask_human with thread_id %q.\n", r.User, *r.Text, p.Item.ThreadId)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, a := range r.Attachments {
|
||
|
|
j, err := json.Marshal(a)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
fmt.Fprintf(b, "Attachment: %s\n", j)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return b.String()
|
||
|
|
}
|