// Package route is step 4 of the Hub: send the request to a destination. // // The action call holds no LLM (AGENTS.md rule 4). The routing decision is // already made. This package only dispatches. package route import ( "context" "strings" "sync" "time" "hub/destinations" "hub/hear" "hub/ids" "hub/understand" ) // Kind is what the Hub did. It matches route.kind in docs/hub-api.md section 5. type Kind string const ( KindDirect Kind = "direct" KindProject Kind = "project" KindFanout Kind = "fanout" KindPaperclip Kind = "paperclip" KindClarify Kind = "clarify" KindNoMatch Kind = "no_match" KindRefused Kind = "refused" ) // Status is the top-level status in the reply. type Status string const ( StatusOK Status = "ok" StatusNeedsClarification Status = "needs_clarification" StatusNeedsApproval Status = "needs_approval" StatusNoMatch Status = "no_match" StatusRefused Status = "refused" StatusUnavailable Status = "unavailable" ) // Approval is a pending Hub-owned approval. type Approval struct { ID string `json:"approval_id"` Action string `json:"action"` Summary string `json:"summary"` Reason string `json:"reason"` Expires time.Time `json:"expires"` } // Clarify is one short question the Hub asks the user. type Clarify struct { Question string `json:"question"` Options []string `json:"options,omitempty"` } // Outcome is the result of step 4. type Outcome struct { Kind Kind Status Status Answer string Project string Projects []string Expires string Approval *Approval Clarify *Clarify Reason string // Spoken records that a direct destination already used the speakers, so // the answer step must not announce again. Spoken bool } // ProjectQuerier is one data project. *destinations.ProjectClient implements it. type ProjectQuerier interface { Ask(ctx context.Context, body destinations.AskRequest) (destinations.AskReply, error) Capabilities(ctx context.Context) ([]string, error) } // Announcer starts house TTS. *destinations.Speaker implements it. type Announcer interface { Announce(ctx context.Context, text string) error } // JobFiler files one Paperclip job. *destinations.Paperclip implements it. type JobFiler interface { FileJob(ctx context.Context, title, description string) (string, error) } // ApprovalStore holds Hub-owned approvals. The hub package implements it. type ApprovalStore interface { Create(action, summary, reason string) *Approval } // Dispatcher sends a decision to its destination. type Dispatcher struct { Rules *understand.Rules Projects map[string]ProjectQuerier Speaker Announcer Paperclip JobFiler Approvals ApprovalStore } // Dispatch runs one decision. It never returns an error: a destination fault // becomes status "unavailable", because one project down is not an outage // (docs/hub-api.md section 5). func (d *Dispatcher) Dispatch(ctx context.Context, decision understand.Decision, req hear.Request) Outcome { switch decision.Action { case understand.ActionRefused: return Refused() case understand.ActionClarify: return Outcome{ Kind: KindClarify, Status: StatusNeedsClarification, Answer: decision.Question, Clarify: &Clarify{ Question: decision.Question, Options: decision.Options, }, } case understand.ActionNoMatch: return Outcome{Kind: KindNoMatch, Status: StatusNoMatch, Answer: "I do not know where to send that."} case understand.ActionRoute: return d.dispatchOne(ctx, decision.Project, req) case understand.ActionFanout: return d.dispatchFanout(ctx, decision.Projects, req) default: return Outcome{Kind: KindNoMatch, Status: StatusNoMatch, Answer: "I do not know where to send that."} } } // Refused is the fixed high-consequence control reply. func Refused() Outcome { return Outcome{ Kind: KindRefused, Status: StatusRefused, Answer: "Use the admin app to change internet limits.", Reason: "high_consequence_control", } } // dispatchOne sends to one destination. func (d *Dispatcher) dispatchOne(ctx context.Context, project string, req hear.Request) Outcome { kind := "project" if d.Rules != nil { kind = d.Rules.KindFor(project) } switch kind { case "direct": return d.dispatchDirect(ctx, project, req) case "paperclip": return d.dispatchPaperclip(ctx, req) case "peer", "way_in": // A way in or a peer is not a destination the Hub routes to. return Outcome{ Kind: KindNoMatch, Status: StatusNoMatch, Answer: "That is a way in, not a place I send work.", Reason: "not_a_destination", } default: return d.askProject(ctx, project, req) } } // dispatchDirect calls a direct destination. Only the speakers exist in v1. func (d *Dispatcher) dispatchDirect(ctx context.Context, project string, req hear.Request) Outcome { switch project { case "speakers": if d.Speaker == nil { return Outcome{Kind: KindDirect, Status: StatusUnavailable, Answer: "The speakers are not connected.", Project: project} } // House TTS is out of band. Start it and do not wait. speak := d.Speaker text := req.Text go func() { _ = speak.Announce(context.Background(), text) }() return Outcome{Kind: KindDirect, Status: StatusOK, Answer: req.Text, Project: project, Spoken: true} default: return Outcome{ Kind: KindDirect, Status: StatusUnavailable, Answer: "That destination is not built yet.", Project: project, Reason: "not_built", } } } // dispatchPaperclip creates an approval. It files nothing yet. func (d *Dispatcher) dispatchPaperclip(ctx context.Context, req hear.Request) Outcome { summary := "File a job: " + req.Text if len(summary) > 200 { summary = summary[:200] } var ap *Approval if d.Approvals != nil { ap = d.Approvals.Create("paperclip_job", summary, "A slow, multi-step job needs a yes.") } else { ap = &Approval{ ID: ids.Approval(), Action: "paperclip_job", Summary: summary, Reason: "A slow, multi-step job needs a yes.", Expires: time.Now().Add(10 * time.Minute), } } return Outcome{ Kind: KindPaperclip, Status: StatusNeedsApproval, Answer: "I need a yes on the admin app to file that job.", Approval: ap, } } // askProject forwards to one data project. func (d *Dispatcher) askProject(ctx context.Context, project string, req hear.Request) Outcome { client, ok := d.Projects[project] if !ok || client == nil { return Outcome{ Kind: KindProject, Status: StatusUnavailable, Answer: "That service is not connected.", Project: project, Reason: "no_client", } } reply, err := client.Ask(ctx, destinations.AskRequest{ Question: req.Text, Asker: req.Asker, Person: req.PersonOrDefault(), Source: string(req.Source), }) if err != nil { return Outcome{ Kind: KindProject, Status: StatusUnavailable, Answer: "That service is not answering.", Project: project, Reason: err.Error(), } } if reply.Answer == nil || strings.TrimSpace(*reply.Answer) == "" { reason := reply.Reason if reason == "" { reason = "no answer" } return Outcome{ Kind: KindProject, Status: StatusUnavailable, Answer: "That service cannot answer that.", Project: project, Reason: reason, } } return Outcome{ Kind: KindProject, Status: StatusOK, Answer: strings.TrimSpace(*reply.Answer), Project: project, Expires: reply.Expires, } } // dispatchFanout asks up to three projects in parallel, then merges the answers // into one short reply (routing.md section 2). func (d *Dispatcher) dispatchFanout(ctx context.Context, projects []string, req hear.Request) Outcome { if len(projects) == 0 { return Outcome{Kind: KindFanout, Status: StatusNoMatch, Answer: "I could not work out where to ask."} } if len(projects) > 3 { projects = projects[:3] } type result struct { project string answer string ok bool } results := make([]result, len(projects)) var wg sync.WaitGroup for i, p := range projects { wg.Add(1) go func(i int, p string) { defer wg.Done() o := d.askProject(ctx, p, req) results[i] = result{project: p, answer: o.Answer, ok: o.Status == StatusOK} }(i, p) } wg.Wait() var answers []string var used []string for _, r := range results { if r.ok { answers = append(answers, r.answer) used = append(used, r.project) } } if len(answers) == 0 { return Outcome{ Kind: KindFanout, Status: StatusUnavailable, Answer: "Those services are not answering.", Projects: projects, } } return Outcome{ Kind: KindFanout, Status: StatusOK, Answer: strings.Join(answers, " "), Projects: used, } }