Files
central_ai_llm_master/src/route/route.go
Sam Rolfe bf80efa25d AUD-49: build the Hub skeleton (hear-route-answer)
- Three inbound routes from docs/hub-api.md: POST /ask, POST /approve, GET /health.
- Five-step pipeline in src/hub, with packages hear, understand, check, route, answer.
- Routing through OmniRoute with the capability list and routing.md. The
  never-route control check runs before any LLM call.
- One real destination: Jervis house TTS, started out of band.
- Fake /ask project (src/tests/fakeproject) for the round-trip test.
- Append-only, size-capped request log. Bearer auth, source match, rate limit.
- Tests: all packages pass, including the fake /ask round trip. Race clean.
- Dockerfile and compose updated to mount routing.md and secrets, log on tmpfs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-10 17:38:48 +11:00

318 lines
8.5 KiB
Go

// 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,
}
}