Skip to content

Commit 1bf4ff2

Browse files
author
Snidely
committed
feat: LLM-backed agent decision-making + RCC fleet registration
Implements wq-API-1774263637952 + wq-API-1774263637983. ### internal/decision/llm_maker.go — LLMMaker New AI-backed DecisionMaker that calls the RCC brain API (POST /api/brain/request) to reason about which agent should handle a task. Graceful fallback chain: 1. RCC brain API (LOOM_RCC_BRAIN_URL + LOOM_RCC_AGENT_TOKEN) 2. SimpleMaker (rule-based) — if brain unreachable or response unusable buildPrompt(): constructs task description + agent capability list prompt. callBrain(): POST /api/brain/request with 10s timeout; parses completed/result. parseAgentChoice(): exact match → prefix match → nil (triggers fallback). NewLLMMaker(url, token): reads from env if args empty. 6 unit tests (all PASS): FallbackWhenUnconfigured, UsesLLMResponse, FallbackOnBrainError, FallbackOnUnknownAgentName, AnyResponse, TimeoutFallback. ### internal/rcc/client.go — RCC fleet integration New rcc.Client that connects loom to the Rocky Command Center: Register(ctx): POST /api/agents/register — loom appears in fleet dashboard Heartbeat(ctx,...): POST /api/heartbeat/<name> — keep loom visible StartHeartbeat(d): background goroutine, heartbeats every d interval StopHeartbeat(): clean shutdown FetchWorkItems(ctx): GET /api/queue — filtered to loom's assignee ClaimItem(ctx, id): PATCH /api/item/:id to in-progress CompleteItem(ctx,...):PATCH /api/item/:id to completed Config via env: LOOM_RCC_URL, LOOM_RCC_AGENT_TOKEN, LOOM_RCC_AGENT_NAME, LOOM_RCC_HOST, LOOM_RCC_ROLE
1 parent 20d16c1 commit 1bf4ff2

3 files changed

Lines changed: 656 additions & 0 deletions

File tree

internal/decision/llm_maker.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package decision
2+
3+
// LLMMaker is an AI-backed decision maker that calls the RCC brain API
4+
// for reasoning about which agent should handle a task.
5+
//
6+
// It is used in place of (or chained after) SimpleMaker when
7+
// LOOM_RCC_BRAIN_URL is set in the environment. When the brain API is
8+
// unavailable or times out, it falls back to SimpleMaker so loom is never
9+
// blocked on RCC availability.
10+
//
11+
// RCC Brain API: POST /api/brain/request
12+
// Body: { messages: [{role:"user", content: <prompt>}], maxTokens: 512, priority: "normal" }
13+
// Response: { status: "completed", result: "<model output>" }
14+
//
15+
// The prompt is constructed from the task description and the list of
16+
// available agent capabilities. The LLM is asked to respond with a
17+
// single agent name or "any" if it has no preference.
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"encoding/json"
23+
"fmt"
24+
"io"
25+
"net/http"
26+
"os"
27+
"strings"
28+
"time"
29+
30+
"github.com/jordanhubbard/loom/pkg/types"
31+
)
32+
33+
// LLMMaker implements decision-making using the RCC brain API.
34+
type LLMMaker struct {
35+
brainURL string
36+
brainToken string
37+
timeout time.Duration
38+
fallback *SimpleMaker
39+
httpClient *http.Client
40+
}
41+
42+
// NewLLMMaker creates a new LLM-backed decision maker.
43+
// brainURL is the RCC brain endpoint (e.g. "http://rocky:8789").
44+
// brainToken is the RCC agent token for authentication.
45+
// If brainURL is empty, falls back to LOOM_RCC_BRAIN_URL env var.
46+
func NewLLMMaker(brainURL, brainToken string) *LLMMaker {
47+
if brainURL == "" {
48+
brainURL = os.Getenv("LOOM_RCC_BRAIN_URL")
49+
}
50+
if brainToken == "" {
51+
brainToken = os.Getenv("LOOM_RCC_AGENT_TOKEN")
52+
}
53+
timeout := 10 * time.Second
54+
return &LLMMaker{
55+
brainURL: brainURL,
56+
brainToken: brainToken,
57+
timeout: timeout,
58+
fallback: NewSimpleMaker(),
59+
httpClient: &http.Client{Timeout: timeout},
60+
}
61+
}
62+
63+
// IsAvailable returns true if the brain URL is configured.
64+
func (m *LLMMaker) IsAvailable() bool {
65+
return m.brainURL != ""
66+
}
67+
68+
// DecideAgent selects the most appropriate agent for a task using LLM reasoning.
69+
// Falls back to SimpleMaker if the brain API is unreachable or times out.
70+
func (m *LLMMaker) DecideAgent(ctx context.Context, task *types.Task, agents []*types.Agent) (*types.Agent, error) {
71+
if !m.IsAvailable() || len(agents) == 0 {
72+
return m.fallback.DecideAgent(ctx, task, agents)
73+
}
74+
75+
prompt := m.buildPrompt(task, agents)
76+
result, err := m.callBrain(ctx, prompt)
77+
if err != nil {
78+
// Brain unreachable — fall back silently
79+
return m.fallback.DecideAgent(ctx, task, agents)
80+
}
81+
82+
// Parse LLM response: look for an agent name in the output
83+
chosen := m.parseAgentChoice(result, agents)
84+
if chosen != nil {
85+
return chosen, nil
86+
}
87+
// LLM gave an unusable response — fall back
88+
return m.fallback.DecideAgent(ctx, task, agents)
89+
}
90+
91+
// buildPrompt constructs the LLM prompt for agent selection.
92+
func (m *LLMMaker) buildPrompt(task *types.Task, agents []*types.Agent) string {
93+
var sb strings.Builder
94+
sb.WriteString("You are a task router for an autonomous agent system called Loom.\n\n")
95+
sb.WriteString("Task:\n")
96+
if task.Description != "" {
97+
sb.WriteString(fmt.Sprintf(" Description: %s\n", task.Description))
98+
}
99+
sb.WriteString(fmt.Sprintf(" Priority: %d\n", task.Priority))
100+
sb.WriteString("\nAvailable agents:\n")
101+
for _, a := range agents {
102+
if a.Status != types.AgentStatusIdle {
103+
continue
104+
}
105+
sb.WriteString(fmt.Sprintf(" - %s (type: %s, capabilities: %s)\n",
106+
a.Name, string(a.Type), strings.Join(a.Capabilities, ", ")))
107+
}
108+
sb.WriteString("\nRespond with ONLY the name of the most appropriate agent ")
109+
sb.WriteString("(exactly as listed above), or 'any' if you have no preference. ")
110+
sb.WriteString("Do not add explanation.")
111+
return sb.String()
112+
}
113+
114+
// callBrain sends the prompt to the RCC brain API and returns the text result.
115+
func (m *LLMMaker) callBrain(ctx context.Context, prompt string) (string, error) {
116+
reqBody := map[string]interface{}{
117+
"messages": []map[string]string{{"role": "user", "content": prompt}},
118+
"maxTokens": 64,
119+
"priority": "normal",
120+
"metadata": map[string]string{"source": "loom-decision"},
121+
}
122+
body, _ := json.Marshal(reqBody)
123+
124+
url := strings.TrimRight(m.brainURL, "/") + "/api/brain/request"
125+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
126+
if err != nil {
127+
return "", err
128+
}
129+
req.Header.Set("Content-Type", "application/json")
130+
if m.brainToken != "" {
131+
req.Header.Set("Authorization", "Bearer "+m.brainToken)
132+
}
133+
134+
resp, err := m.httpClient.Do(req)
135+
if err != nil {
136+
return "", fmt.Errorf("brain API unreachable: %w", err)
137+
}
138+
defer resp.Body.Close()
139+
140+
if resp.StatusCode != http.StatusOK {
141+
return "", fmt.Errorf("brain API returned HTTP %d", resp.StatusCode)
142+
}
143+
144+
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
145+
if err != nil {
146+
return "", err
147+
}
148+
149+
var respData struct {
150+
Status string `json:"status"`
151+
Result string `json:"result"`
152+
Error string `json:"error"`
153+
}
154+
if err := json.Unmarshal(respBytes, &respData); err != nil {
155+
return "", fmt.Errorf("brain API response parse error: %w", err)
156+
}
157+
if respData.Status != "completed" {
158+
return "", fmt.Errorf("brain API status %q (error: %s)", respData.Status, respData.Error)
159+
}
160+
return strings.TrimSpace(respData.Result), nil
161+
}
162+
163+
// parseAgentChoice finds the agent whose name matches the LLM response.
164+
// Returns nil if no match found.
165+
func (m *LLMMaker) parseAgentChoice(result string, agents []*types.Agent) *types.Agent {
166+
result = strings.ToLower(strings.TrimSpace(result))
167+
if result == "any" {
168+
return nil // let fallback choose
169+
}
170+
for _, a := range agents {
171+
if strings.ToLower(a.Name) == result {
172+
return a
173+
}
174+
}
175+
// Partial match — first agent whose name is a prefix of the result
176+
for _, a := range agents {
177+
if strings.HasPrefix(result, strings.ToLower(a.Name)) {
178+
return a
179+
}
180+
}
181+
return nil
182+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package decision
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/jordanhubbard/loom/pkg/types"
12+
)
13+
14+
// helpers
15+
16+
func makeAgents(names ...string) []*types.Agent {
17+
agents := make([]*types.Agent, len(names))
18+
for i, name := range names {
19+
agents[i] = &types.Agent{
20+
Name: name,
21+
Type: types.AgentTypeGeneral,
22+
Status: types.AgentStatusIdle,
23+
Capabilities: []string{"code", "planning"},
24+
}
25+
}
26+
return agents
27+
}
28+
29+
func makeTask(_, desc string) *types.Task {
30+
return &types.Task{
31+
Description: desc,
32+
Priority: 5, // mid priority (1-10 scale)
33+
}
34+
}
35+
36+
// TestLLMMaker_FallbackWhenUnconfigured verifies that LLMMaker falls back to
37+
// SimpleMaker when no brain URL is configured.
38+
func TestLLMMaker_FallbackWhenUnconfigured(t *testing.T) {
39+
m := NewLLMMaker("", "")
40+
if m.IsAvailable() {
41+
t.Error("expected IsAvailable=false when no URL configured")
42+
}
43+
agents := makeAgents("alice", "bob")
44+
chosen, err := m.DecideAgent(context.Background(), makeTask("do something", ""), agents)
45+
if err != nil {
46+
t.Fatalf("expected no error on fallback, got %v", err)
47+
}
48+
if chosen == nil {
49+
t.Error("expected a chosen agent even on fallback")
50+
}
51+
}
52+
53+
// TestLLMMaker_UsesLLMResponse verifies that when the brain returns a valid
54+
// agent name, LLMMaker returns that agent.
55+
func TestLLMMaker_UsesLLMResponse(t *testing.T) {
56+
agents := makeAgents("alice", "bob", "charlie")
57+
58+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+
// Verify Authorization header
60+
if r.Header.Get("Authorization") == "" {
61+
w.WriteHeader(http.StatusUnauthorized)
62+
return
63+
}
64+
resp := map[string]interface{}{
65+
"status": "completed",
66+
"result": "bob",
67+
}
68+
w.Header().Set("Content-Type", "application/json")
69+
_ = json.NewEncoder(w).Encode(resp)
70+
}))
71+
defer srv.Close()
72+
73+
m := NewLLMMaker(srv.URL, "test-token")
74+
if !m.IsAvailable() {
75+
t.Fatal("expected IsAvailable=true with configured URL")
76+
}
77+
78+
chosen, err := m.DecideAgent(context.Background(), makeTask("review code", "PR #42"), agents)
79+
if err != nil {
80+
t.Fatalf("unexpected error: %v", err)
81+
}
82+
if chosen == nil {
83+
t.Fatal("expected agent to be chosen")
84+
}
85+
if chosen.Name != "bob" {
86+
t.Errorf("expected 'bob', got %q", chosen.Name)
87+
}
88+
}
89+
90+
// TestLLMMaker_FallbackOnBrainError verifies graceful degradation when the
91+
// brain API returns an error status.
92+
func TestLLMMaker_FallbackOnBrainError(t *testing.T) {
93+
agents := makeAgents("alice", "bob")
94+
95+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96+
w.WriteHeader(http.StatusServiceUnavailable)
97+
}))
98+
defer srv.Close()
99+
100+
m := NewLLMMaker(srv.URL, "test-token")
101+
chosen, err := m.DecideAgent(context.Background(), makeTask("urgent task", ""), agents)
102+
if err != nil {
103+
t.Fatalf("expected no error even on brain 503, got %v", err)
104+
}
105+
if chosen == nil {
106+
t.Error("expected fallback to choose an agent")
107+
}
108+
}
109+
110+
// TestLLMMaker_FallbackOnUnknownAgentName verifies that when the LLM responds
111+
// with an unknown agent name, LLMMaker falls back to SimpleMaker.
112+
func TestLLMMaker_FallbackOnUnknownAgentName(t *testing.T) {
113+
agents := makeAgents("alice", "bob")
114+
115+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116+
resp := map[string]interface{}{"status": "completed", "result": "dana"}
117+
_ = json.NewEncoder(w).Encode(resp)
118+
}))
119+
defer srv.Close()
120+
121+
m := NewLLMMaker(srv.URL, "token")
122+
chosen, err := m.DecideAgent(context.Background(), makeTask("task", ""), agents)
123+
if err != nil {
124+
t.Fatalf("unexpected error: %v", err)
125+
}
126+
if chosen == nil {
127+
t.Error("expected fallback to choose an agent when LLM returned unknown name")
128+
}
129+
}
130+
131+
// TestLLMMaker_AnyResponse verifies that "any" response triggers fallback.
132+
func TestLLMMaker_AnyResponse(t *testing.T) {
133+
agents := makeAgents("alice", "bob")
134+
135+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+
resp := map[string]interface{}{"status": "completed", "result": "any"}
137+
_ = json.NewEncoder(w).Encode(resp)
138+
}))
139+
defer srv.Close()
140+
141+
m := NewLLMMaker(srv.URL, "token")
142+
chosen, err := m.DecideAgent(context.Background(), makeTask("task", ""), agents)
143+
if err != nil {
144+
t.Fatalf("unexpected error: %v", err)
145+
}
146+
if chosen == nil {
147+
t.Error("expected fallback to choose an agent on 'any' response")
148+
}
149+
}
150+
151+
// TestLLMMaker_TimeoutFallback verifies that a slow brain triggers fallback
152+
// rather than blocking indefinitely.
153+
func TestLLMMaker_TimeoutFallback(t *testing.T) {
154+
agents := makeAgents("alice")
155+
156+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157+
// Slow response — should be cut off by LLMMaker's timeout
158+
select {
159+
case <-r.Context().Done():
160+
case <-time.After(5 * time.Second):
161+
}
162+
w.WriteHeader(http.StatusGatewayTimeout)
163+
}))
164+
defer srv.Close()
165+
166+
m := NewLLMMaker(srv.URL, "token")
167+
m.timeout = 100 * time.Millisecond
168+
m.httpClient = &http.Client{Timeout: 100 * time.Millisecond}
169+
170+
start := time.Now()
171+
chosen, err := m.DecideAgent(context.Background(), makeTask("urgent", ""), agents)
172+
elapsed := time.Since(start)
173+
174+
if err != nil {
175+
t.Fatalf("expected no error on timeout (should fallback), got %v", err)
176+
}
177+
if chosen == nil {
178+
t.Error("expected fallback to choose an agent")
179+
}
180+
if elapsed > 2*time.Second {
181+
t.Errorf("timeout took too long: %v (should be < 1s)", elapsed)
182+
}
183+
}

0 commit comments

Comments
 (0)