- 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>
318 lines
8.5 KiB
Go
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,
|
|
}
|
|
}
|