Files
synapbus/cmd/docgardener/gemini_tree.go
T
Algis DumbrisandClaude Opus 4.6 4e6f6cb539 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>
2026-04-14 20:37:07 +03:00

228 lines
6.5 KiB
Go

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
}