feat(018): Gemini LLM coordinator + spec-018 MCP tool surface
- cmd/docgardener: coordinator calls the `gemini` CLI with the goal
brief when SYNAPBUS_GEMINI_MODEL is set, parses the returned JSON
into a goaltasks.TreeNode, and aligns leaf billing codes so the
fixed dispatch table still routes specialists correctly. Falls
back to the hardcoded template on any failure (missing CLI, non-
zero exit, bad JSON) so the demo still works offline.
- internal/mcp: new GoalsToolRegistrar exposing 6 spec-018 tools —
create_goal, propose_task_tree, propose_agent, claim_task,
request_resource, list_resources. All require an authenticated
agent context; wire-only changes on the MCP server side.
- main.go: builds + attaches the new registrar after the hybrid
tool registrar, logs the 6 tools at startup.
Verified e2e: demo run with Gemini produces an LLM-generated root
task title ("Verify and patch mcpproxy documentation drift"), all
3 specialists dispatched and completed, $1.05 cost rollup on the
/goals/1 page, and the MCP server registers 11 tools total (5
hybrid + 6 spec-018) at boot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f9d8f1a1c9
commit
4e6f6cb539
@@ -175,11 +175,22 @@ func (a *agentRunner) handleCoordinatorKickoff(ctx context.Context) (string, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Task tree.
|
||||
// Task tree — prefer a real Gemini-generated decomposition when
|
||||
// SYNAPBUS_GEMINI_MODEL is set; fall back to the fixed template
|
||||
// on any failure so the demo works offline.
|
||||
tree := buildTaskTree()
|
||||
if llmTree, llmErr := geminiTaskTree(ctx, logger, a.msg.Body); llmErr == nil && llmTree != nil {
|
||||
tree = *llmTree
|
||||
a.postSystemMessage(ctx, g.ChannelID,
|
||||
fmt.Sprintf("🤖 Coordinator invoked Gemini (%s) and materialized an LLM-generated task tree with %d leaves.",
|
||||
os.Getenv("SYNAPBUS_GEMINI_MODEL"), countLeaves(llmTree)))
|
||||
} else if llmErr != nil {
|
||||
logger.Info("gemini coordinator skipped", "reason", llmErr)
|
||||
}
|
||||
rootID, allIDs, err := a.tasks.CreateTree(ctx, goaltasks.CreateTreeInput{
|
||||
GoalID: g.ID,
|
||||
CreatedByAgent: &coordID,
|
||||
Root: buildTaskTree(),
|
||||
Root: tree,
|
||||
InitialStatus: goaltasks.StatusApproved,
|
||||
DefaultBilling: "doc-gardener",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/goaltasks"
|
||||
)
|
||||
|
||||
// geminiTaskTree calls the `gemini` CLI with the goal brief and asks it
|
||||
// to emit a JSON task tree. If GEMINI is not configured (env var
|
||||
// SYNAPBUS_GEMINI_MODEL unset) or the call fails for any reason, it
|
||||
// returns a nil tree and a non-nil error — callers fall back to the
|
||||
// fixed buildTaskTree() template.
|
||||
//
|
||||
// Why shell out rather than use the Gemini SDK directly?
|
||||
// 1. Zero extra dependencies keeps the docgardener binary lean.
|
||||
// 2. Users already have `gemini` configured (see cold-topic-explainer
|
||||
// wrapper.sh) and the CLI's auth is persistent.
|
||||
// 3. The prompt is small enough that subprocess overhead is negligible.
|
||||
func geminiTaskTree(ctx context.Context, logger *slog.Logger, goalBrief string) (*goaltasks.TreeNode, error) {
|
||||
model := os.Getenv("SYNAPBUS_GEMINI_MODEL")
|
||||
if model == "" {
|
||||
return nil, errors.New("SYNAPBUS_GEMINI_MODEL not set")
|
||||
}
|
||||
if _, err := exec.LookPath("gemini"); err != nil {
|
||||
return nil, fmt.Errorf("gemini CLI not found on PATH: %w", err)
|
||||
}
|
||||
|
||||
prompt := geminiPrompt(goalBrief)
|
||||
_ = os.WriteFile("gemini_prompt.txt", []byte(prompt), 0o644)
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "gemini",
|
||||
"-m", model,
|
||||
"--approval-mode", "yolo",
|
||||
"-p", prompt,
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "NO_COLOR=1")
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return nil, fmt.Errorf("gemini exit %d: %s", ee.ExitCode(), string(ee.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("gemini exec: %w", err)
|
||||
}
|
||||
|
||||
raw := string(out)
|
||||
_ = os.WriteFile("gemini_response.txt", []byte(raw), 0o644)
|
||||
|
||||
// Gemini sometimes prepends diagnostic lines ("MCP issues detected…")
|
||||
// and often wraps JSON in a ```json fenced block. Extract the first
|
||||
// balanced {...} object.
|
||||
jsonBlob, err := extractJSONObject(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini response had no JSON object: %w (raw=%q)", err, truncate(raw, 500))
|
||||
}
|
||||
|
||||
var tree goaltasks.TreeNode
|
||||
if err := json.Unmarshal([]byte(jsonBlob), &tree); err != nil {
|
||||
return nil, fmt.Errorf("parse gemini JSON: %w (raw=%q)", err, truncate(jsonBlob, 500))
|
||||
}
|
||||
if tree.Title == "" || len(tree.Children) == 0 {
|
||||
return nil, fmt.Errorf("gemini returned an empty or rootless tree")
|
||||
}
|
||||
|
||||
// Force the leaves to use the billing codes docgardener's dispatcher
|
||||
// matches on; if Gemini invents its own leaves we keep the coordinator
|
||||
// happy by aligning them with the 3 role slots.
|
||||
alignLeafBillingCodes(&tree)
|
||||
|
||||
logger.Info("gemini task tree", "leaves", countLeaves(&tree), "bytes", len(jsonBlob))
|
||||
return &tree, nil
|
||||
}
|
||||
|
||||
func geminiPrompt(brief string) string {
|
||||
return `You are the doc-gardener coordinator. A human has given you this brief:
|
||||
|
||||
"""
|
||||
` + brief + `
|
||||
"""
|
||||
|
||||
Decompose this into a task tree with exactly 3 leaf tasks for these 3 specialists:
|
||||
|
||||
1. docs-scanner — scans the documentation website and extracts every CLI flag
|
||||
and config option mentioned; emits #finding messages.
|
||||
2. cli-verifier — runs the target binary and reacts to each #finding with
|
||||
#verified or #missing.
|
||||
3. drift-reporter — aggregates the verifier reactions and produces a final
|
||||
drift report with counts of matches, drifts, and patches.
|
||||
|
||||
Return ONLY a JSON object with this exact schema (no prose, no markdown fences):
|
||||
|
||||
{
|
||||
"title": "<root task title>",
|
||||
"description": "<root task description>",
|
||||
"acceptance_criteria": "<root acceptance criteria>",
|
||||
"billing_code": "doc-gardener",
|
||||
"children": [
|
||||
{
|
||||
"title": "Scan docs for CLI flags and config keys",
|
||||
"description": "<specifics>",
|
||||
"acceptance_criteria": "<specifics>",
|
||||
"billing_code": "doc-gardener/scan"
|
||||
},
|
||||
{
|
||||
"title": "Verify flags exist in mcpproxy binary",
|
||||
"description": "<specifics>",
|
||||
"acceptance_criteria": "<specifics>",
|
||||
"billing_code": "doc-gardener/verify"
|
||||
},
|
||||
{
|
||||
"title": "Produce drift report",
|
||||
"description": "<specifics>",
|
||||
"acceptance_criteria": "<specifics>",
|
||||
"billing_code": "doc-gardener/report"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
The billing_code values MUST be exactly "doc-gardener", "doc-gardener/scan",
|
||||
"doc-gardener/verify", "doc-gardener/report" — the coordinator routes tasks to
|
||||
specialists by matching on those strings. Emit ONLY the JSON object.`
|
||||
}
|
||||
|
||||
// extractJSONObject finds the first balanced {...} object in raw, ignoring
|
||||
// markdown fences and preamble.
|
||||
func extractJSONObject(raw string) (string, error) {
|
||||
// Strip common preambles.
|
||||
raw = strings.TrimPrefix(raw, "MCP issues detected. Run /mcp list for status.")
|
||||
start := strings.IndexByte(raw, '{')
|
||||
if start < 0 {
|
||||
return "", errors.New("no '{' in output")
|
||||
}
|
||||
depth := 0
|
||||
inStr := false
|
||||
escape := false
|
||||
for i := start; i < len(raw); i++ {
|
||||
ch := raw[i]
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' && inStr {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inStr = !inStr
|
||||
continue
|
||||
}
|
||||
if inStr {
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return raw[start : i+1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("unbalanced braces")
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func countLeaves(n *goaltasks.TreeNode) int {
|
||||
if len(n.Children) == 0 {
|
||||
return 1
|
||||
}
|
||||
total := 0
|
||||
for i := range n.Children {
|
||||
total += countLeaves(&n.Children[i])
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// alignLeafBillingCodes ensures exactly the 3 expected billing codes are set
|
||||
// on the leaves so the coordinator's dispatch-by-billing-code logic still
|
||||
// works even when Gemini drifts slightly. Leaves are aligned in order.
|
||||
func alignLeafBillingCodes(root *goaltasks.TreeNode) {
|
||||
want := []string{"doc-gardener/scan", "doc-gardener/verify", "doc-gardener/report"}
|
||||
if root.BillingCode == "" {
|
||||
root.BillingCode = "doc-gardener"
|
||||
}
|
||||
// If the root's children are themselves parents, descend.
|
||||
leaves := collectLeaves(root)
|
||||
for i, leaf := range leaves {
|
||||
if i >= len(want) {
|
||||
break
|
||||
}
|
||||
leaf.BillingCode = want[i]
|
||||
if leaf.VerifierConfig == nil {
|
||||
leaf.VerifierConfig = &goaltasks.VerifierConfig{Kind: goaltasks.VerifierKindAuto}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectLeaves(n *goaltasks.TreeNode) []*goaltasks.TreeNode {
|
||||
if len(n.Children) == 0 {
|
||||
return []*goaltasks.TreeNode{n}
|
||||
}
|
||||
var out []*goaltasks.TreeNode
|
||||
for i := range n.Children {
|
||||
out = append(out, collectLeaves(&n.Children[i])...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -569,6 +569,19 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
mcpSrv.SetMarketplaceService(marketplaceSvc)
|
||||
slog.Info("agent marketplace service initialized (spec 016)")
|
||||
|
||||
// Wire the spec-018 tool surface (dynamic agent spawning). Only
|
||||
// registered if the goals/tasks services are up — which they
|
||||
// always are after the block above.
|
||||
goalsToolReg := mcpserver.NewGoalsToolRegistrar(
|
||||
goalsService,
|
||||
goalTasksService,
|
||||
agentService,
|
||||
secretsStore,
|
||||
db.DB,
|
||||
)
|
||||
mcpSrv.WireGoalsTools(goalsToolReg)
|
||||
slog.Info("spec-018 MCP tools wired (create_goal, propose_task_tree, propose_agent, claim_task, request_resource, list_resources)")
|
||||
|
||||
// Set up SQL query executor for agents (uses read pool if available)
|
||||
queryDB := db.QueryDB()
|
||||
queryExec := agentquery.New(queryDB, slog.Default())
|
||||
|
||||
Reference in New Issue
Block a user