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())
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
// goals_tools.go registers the spec-018 (dynamic agent spawning) MCP
|
||||
// tools. All tools require an authenticated agent context (the caller
|
||||
// is identified via extractAgentName). They wrap the goals/goal_tasks/
|
||||
// secrets services so LLM agents can drive the full
|
||||
// goal → task tree → spawn → verify flow through MCP.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/goals"
|
||||
"github.com/synapbus/synapbus/internal/goaltasks"
|
||||
"github.com/synapbus/synapbus/internal/secrets"
|
||||
)
|
||||
|
||||
// GoalsToolRegistrar wires the spec-018 primitives into MCP tool calls.
|
||||
type GoalsToolRegistrar struct {
|
||||
goals *goals.Service
|
||||
tasks *goaltasks.Service
|
||||
agents *agents.AgentService
|
||||
secrets *secrets.Store
|
||||
db *sql.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewGoalsToolRegistrar builds a registrar. All dependencies are
|
||||
// optional — handlers return a clear error when they need something
|
||||
// that wasn't wired.
|
||||
func NewGoalsToolRegistrar(
|
||||
g *goals.Service,
|
||||
t *goaltasks.Service,
|
||||
a *agents.AgentService,
|
||||
sec *secrets.Store,
|
||||
db *sql.DB,
|
||||
) *GoalsToolRegistrar {
|
||||
return &GoalsToolRegistrar{
|
||||
goals: g,
|
||||
tasks: t,
|
||||
agents: a,
|
||||
secrets: sec,
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "mcp-goals-tools"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAllOnServer attaches create_goal, propose_task_tree,
|
||||
// propose_agent, claim_task, request_resource, list_resources to the
|
||||
// MCP server.
|
||||
func (r *GoalsToolRegistrar) RegisterAllOnServer(s *server.MCPServer) {
|
||||
s.AddTool(r.createGoalTool(), r.handleCreateGoal)
|
||||
s.AddTool(r.proposeTaskTreeTool(), r.handleProposeTaskTree)
|
||||
s.AddTool(r.proposeAgentTool(), r.handleProposeAgent)
|
||||
s.AddTool(r.claimTaskTool(), r.handleClaimTask)
|
||||
s.AddTool(r.requestResourceTool(), r.handleRequestResource)
|
||||
s.AddTool(r.listResourcesTool(), r.handleListResources)
|
||||
r.logger.Info("spec-018 MCP tools registered", "count", 6)
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
|
||||
func (r *GoalsToolRegistrar) createGoalTool() mcplib.Tool {
|
||||
return mcplib.NewTool("create_goal",
|
||||
mcplib.WithDescription("Create a new top-level goal owned by the calling agent's human owner. Returns the goal id + backing channel id. The goal starts in draft status — call propose_task_tree to populate it and then use the execute tool to transition it to active."),
|
||||
mcplib.WithString("title", mcplib.Description("Short goal title"), mcplib.Required()),
|
||||
mcplib.WithString("description", mcplib.Description("Full goal brief (the 'blob' from the human)"), mcplib.Required()),
|
||||
mcplib.WithNumber("budget_dollars_cents", mcplib.Description("Optional dollar budget in cents (e.g. 5000 = $50)")),
|
||||
mcplib.WithNumber("budget_tokens", mcplib.Description("Optional token budget")),
|
||||
mcplib.WithNumber("max_spawn_depth", mcplib.Description("Max agent spawn depth (default 3)")),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) proposeTaskTreeTool() mcplib.Tool {
|
||||
return mcplib.NewTool("propose_task_tree",
|
||||
mcplib.WithDescription("Materialize a task tree under a goal. The tree field is a JSON TreeNode with {title, description, acceptance_criteria, billing_code, children[]}. Tasks are inserted in 'approved' status so specialists can claim them immediately."),
|
||||
mcplib.WithNumber("goal_id", mcplib.Description("Goal id from create_goal"), mcplib.Required()),
|
||||
mcplib.WithString("tree", mcplib.Description("JSON-encoded TreeNode"), mcplib.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) proposeAgentTool() mcplib.Tool {
|
||||
return mcplib.NewTool("propose_agent",
|
||||
mcplib.WithDescription("Propose creating a new specialist agent. Writes an agent_proposals row so a human can approve it via the #approvals channel. Returns the proposal id."),
|
||||
mcplib.WithString("name", mcplib.Description("Desired agent name"), mcplib.Required()),
|
||||
mcplib.WithString("display_name", mcplib.Description("Human-readable display name")),
|
||||
mcplib.WithString("system_prompt", mcplib.Description("System prompt for the spawned agent"), mcplib.Required()),
|
||||
mcplib.WithString("tool_scope", mcplib.Description("Comma-separated scope (e.g. 'messages:read,messages:send')")),
|
||||
mcplib.WithNumber("parent_task_id", mcplib.Description("The task this agent will work on")),
|
||||
mcplib.WithString("autonomy_tier", mcplib.Description("supervised | assisted | autonomous (default assisted)")),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) claimTaskTool() mcplib.Tool {
|
||||
return mcplib.NewTool("claim_task",
|
||||
mcplib.WithDescription("Atomically claim an approved task. Returns the claimed task or an error if another agent got it first."),
|
||||
mcplib.WithNumber("task_id", mcplib.Description("Task id to claim"), mcplib.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) requestResourceTool() mcplib.Tool {
|
||||
return mcplib.NewTool("request_resource",
|
||||
mcplib.WithDescription("Post a structured resource request to the #requests channel when a task needs a secret the agent doesn't currently have. The human reads it and provisions the secret via `synapbus secrets set`."),
|
||||
mcplib.WithString("resource_name", mcplib.Description("Uppercase env var name, e.g. BREVO_API_KEY"), mcplib.Required()),
|
||||
mcplib.WithString("reason", mcplib.Description("Why this resource is needed"), mcplib.Required()),
|
||||
mcplib.WithNumber("task_id", mcplib.Description("Task this request is attached to"), mcplib.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) listResourcesTool() mcplib.Tool {
|
||||
return mcplib.NewTool("list_resources",
|
||||
mcplib.WithDescription("List the names of secrets currently available to the calling agent. Returns names only — never values. Use this to check whether a secret has been provisioned after calling request_resource."),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
func (r *GoalsToolRegistrar) handleCreateGoal(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcplib.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
if r.goals == nil {
|
||||
return mcplib.NewToolResultError("goals service not configured"), nil
|
||||
}
|
||||
title := req.GetString("title", "")
|
||||
description := req.GetString("description", "")
|
||||
if title == "" || description == "" {
|
||||
return mcplib.NewToolResultError("title and description are required"), nil
|
||||
}
|
||||
agent, err := r.agents.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
|
||||
}
|
||||
var ownerUsername string
|
||||
_ = r.db.QueryRowContext(ctx, `SELECT username FROM users WHERE id=?`, agent.OwnerID).Scan(&ownerUsername)
|
||||
|
||||
var budgetTokens, budgetDollars *int64
|
||||
if v := req.GetInt("budget_tokens", 0); v > 0 {
|
||||
t := int64(v)
|
||||
budgetTokens = &t
|
||||
}
|
||||
if v := req.GetInt("budget_dollars_cents", 0); v > 0 {
|
||||
t := int64(v)
|
||||
budgetDollars = &t
|
||||
}
|
||||
maxDepth := req.GetInt("max_spawn_depth", 3)
|
||||
|
||||
g, err := r.goals.CreateGoal(ctx, goals.CreateGoalInput{
|
||||
Title: title,
|
||||
Description: description,
|
||||
OwnerUserID: agent.OwnerID,
|
||||
OwnerUsername: ownerUsername,
|
||||
CoordinatorAgentID: &agent.ID,
|
||||
BudgetTokens: budgetTokens,
|
||||
BudgetDollarsCents: budgetDollars,
|
||||
MaxSpawnDepth: maxDepth,
|
||||
})
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("create_goal: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"goal_id": g.ID,
|
||||
"slug": g.Slug,
|
||||
"channel_id": g.ChannelID,
|
||||
"status": g.Status,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) handleProposeTaskTree(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcplib.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
if r.tasks == nil {
|
||||
return mcplib.NewToolResultError("goal_tasks service not configured"), nil
|
||||
}
|
||||
goalID := int64(req.GetInt("goal_id", 0))
|
||||
if goalID <= 0 {
|
||||
return mcplib.NewToolResultError("goal_id is required"), nil
|
||||
}
|
||||
treeJSON := req.GetString("tree", "")
|
||||
if treeJSON == "" {
|
||||
return mcplib.NewToolResultError("tree is required"), nil
|
||||
}
|
||||
var tree goaltasks.TreeNode
|
||||
if err := json.Unmarshal([]byte(treeJSON), &tree); err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("parse tree: %s", err)), nil
|
||||
}
|
||||
|
||||
agent, err := r.agents.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
|
||||
}
|
||||
|
||||
rootID, allIDs, err := r.tasks.CreateTree(ctx, goaltasks.CreateTreeInput{
|
||||
GoalID: goalID,
|
||||
CreatedByAgent: &agent.ID,
|
||||
Root: tree,
|
||||
InitialStatus: goaltasks.StatusApproved,
|
||||
DefaultBilling: "mcp",
|
||||
})
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("create tree: %s", err)), nil
|
||||
}
|
||||
|
||||
// Also set the goal's root_task_id.
|
||||
_, _ = r.db.ExecContext(ctx, `UPDATE goals SET root_task_id=? WHERE id=?`, rootID, goalID)
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"root_task_id": rootID,
|
||||
"task_count": len(allIDs),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) handleProposeAgent(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcplib.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
if r.db == nil {
|
||||
return mcplib.NewToolResultError("db not configured"), nil
|
||||
}
|
||||
name := req.GetString("name", "")
|
||||
if name == "" {
|
||||
return mcplib.NewToolResultError("name is required"), nil
|
||||
}
|
||||
systemPrompt := req.GetString("system_prompt", "")
|
||||
if systemPrompt == "" {
|
||||
return mcplib.NewToolResultError("system_prompt is required"), nil
|
||||
}
|
||||
toolScope := req.GetString("tool_scope", "")
|
||||
if toolScope == "" {
|
||||
toolScope = "[]"
|
||||
} else if !strings.HasPrefix(toolScope, "[") {
|
||||
// Accept comma-separated convenience form.
|
||||
parts := strings.Split(toolScope, ",")
|
||||
for i := range parts {
|
||||
parts[i] = `"` + strings.TrimSpace(parts[i]) + `"`
|
||||
}
|
||||
toolScope = "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
tier := req.GetString("autonomy_tier", "assisted")
|
||||
parentTaskID := int64(req.GetInt("parent_task_id", 0))
|
||||
if parentTaskID <= 0 {
|
||||
return mcplib.NewToolResultError("parent_task_id is required (proposals must attach to a task)"), nil
|
||||
}
|
||||
|
||||
agent, err := r.agents.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
|
||||
}
|
||||
|
||||
// Resolve goal_id from the parent task.
|
||||
var goalID int64
|
||||
if err := r.db.QueryRowContext(ctx,
|
||||
`SELECT goal_id FROM goal_tasks WHERE id=?`, parentTaskID).Scan(&goalID); err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve goal from task %d: %s", parentTaskID, err)), nil
|
||||
}
|
||||
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO agent_proposals (
|
||||
proposer_agent_id, goal_id, parent_task_id, proposed_name,
|
||||
proposed_model, proposed_system_prompt, proposed_tool_scope_json,
|
||||
proposed_autonomy_tier, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
|
||||
agent.ID, goalID, parentTaskID, name,
|
||||
"gemini-2.5-flash", systemPrompt, toolScope, tier)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("insert proposal: %s", err)), nil
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"proposal_id": id,
|
||||
"status": "pending",
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) handleClaimTask(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcplib.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
if r.tasks == nil {
|
||||
return mcplib.NewToolResultError("goal_tasks service not configured"), nil
|
||||
}
|
||||
taskID := int64(req.GetInt("task_id", 0))
|
||||
if taskID <= 0 {
|
||||
return mcplib.NewToolResultError("task_id is required"), nil
|
||||
}
|
||||
agent, err := r.agents.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
|
||||
}
|
||||
if err := r.tasks.Claim(ctx, taskID, agent.ID, nil); err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("claim_task: %s", err)), nil
|
||||
}
|
||||
_ = r.tasks.Transition(ctx, taskID, goaltasks.StatusInProgress, goaltasks.Extras{})
|
||||
return resultJSON(map[string]any{
|
||||
"task_id": taskID,
|
||||
"status": "claimed",
|
||||
})
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) handleRequestResource(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcplib.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
resourceName := strings.ToUpper(strings.TrimSpace(req.GetString("resource_name", "")))
|
||||
reason := req.GetString("reason", "")
|
||||
taskID := int64(req.GetInt("task_id", 0))
|
||||
if resourceName == "" || reason == "" || taskID <= 0 {
|
||||
return mcplib.NewToolResultError("resource_name, reason, and task_id are all required"), nil
|
||||
}
|
||||
agent, err := r.agents.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO resource_requests (requester_agent_id, task_id, resource_name, resource_type, reason, status)
|
||||
VALUES (?, ?, ?, 'env_var', ?, 'pending')`,
|
||||
agent.ID, taskID, resourceName, reason)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("insert resource_request: %s", err)), nil
|
||||
}
|
||||
return resultJSON(map[string]any{
|
||||
"status": "pending",
|
||||
"resource": resourceName,
|
||||
"next_action": fmt.Sprintf("synapbus secrets set %s <value> --scope agent:%s", resourceName, agentName),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) handleListResources(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcplib.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
if r.secrets == nil {
|
||||
return resultJSON(map[string]any{"resources": []any{}})
|
||||
}
|
||||
agent, err := r.agents.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
|
||||
}
|
||||
infos, err := r.secrets.List(ctx, []secrets.Scope{
|
||||
{Type: secrets.ScopeUser, ID: agent.OwnerID},
|
||||
{Type: secrets.ScopeAgent, ID: agent.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("list secrets: %s", err)), nil
|
||||
}
|
||||
names := make([]string, 0, len(infos))
|
||||
for _, i := range infos {
|
||||
names = append(names, i.Name)
|
||||
}
|
||||
return resultJSON(map[string]any{
|
||||
"resources": names,
|
||||
"count": len(names),
|
||||
})
|
||||
}
|
||||
@@ -206,6 +206,16 @@ func NewMCPServer(
|
||||
return s
|
||||
}
|
||||
|
||||
// WireGoalsTools registers the spec-018 tool surface (create_goal,
|
||||
// propose_task_tree, propose_agent, claim_task, request_resource,
|
||||
// list_resources) on the MCP server. Must be called after NewMCPServer.
|
||||
func (s *MCPServer) WireGoalsTools(r *GoalsToolRegistrar) {
|
||||
if r == nil || s.mcpServer == nil {
|
||||
return
|
||||
}
|
||||
r.RegisterAllOnServer(s.mcpServer)
|
||||
}
|
||||
|
||||
// SetQueryExecutor sets the SQL query executor for agent queries via the execute tool.
|
||||
func (s *MCPServer) SetQueryExecutor(exec *agentquery.Executor) {
|
||||
if s.hybridRegistrar != nil {
|
||||
|
||||
Reference in New Issue
Block a user