feat(goals): complete_goal MCP tool + draft→active auto-transition
Three improvements that turn the doc-gardener demo from "runs but stays in 'draft' forever" into a goal that properly transitions through its lifecycle and renders a completion summary on /goals/<id>. ### 1. complete_goal MCP tool (#59, #62) New tool surface: complete_goal(goal_id, status, summary, completion_message_id?) The critic calls this from inside the sandbox after it sends its FINAL: DM. Records the one-paragraph human-readable summary on the goal row plus a pointer to the message that carried the FINAL text, so the Web UI /goals/<id> page has both the verdict and a deep link to the full findings JSON. Status parameter accepts completed | stuck | cancelled. Idempotent when called with the current status. Rejects callers owned by a different human than the goal owner. Plumbing: - New migration 026_goals_completion_summary.sql adds two columns to goals: completion_summary TEXT, completion_message_id INTEGER (FK messages.id, ON DELETE SET NULL). - internal/goals/types.go: new CompletionSummary + CompletionMessageID fields on Goal struct. - internal/goals/store.go: Get/List Scan both new columns; SetCompletion(goalID, status, summary, messageID) helper that updates status+summary+message_id atomically and populates completed_at for terminal states. - internal/goals/service.go: Complete(ctx, goalID, status, summary, messageID) wraps the store method with legalTransition gating. legalTransition expanded so draft can jump straight to completed (no mandatory "active" hop required). - internal/mcp/goals_tools.go: completeGoalTool definition + handleCompleteGoal handler. Tool count 6 → 7. - internal/api/goals_handler.go: surfaces completion_summary, completion_message_id, and completed_at on both list and detail endpoints so the Svelte /goals UI can render them. ### 2. Draft → active auto-transition in propose_task_tree (#60) handleProposeTaskTree now flips the goal from draft to active at the end. Previously the coordinator would call create_goal + propose_task_tree and dispatch inspector, but the goal stayed in draft forever because nothing transitioned it. Now the mere fact of having a task tree means the goal is active. Safe: the transition is best-effort and ignores the legal-transition error when the goal is already beyond draft. ### 3. REVISE round cap (#61) Two-layer enforcement: - Server-side: examples/doc-gardener/start.sh drops max_trigger_depth from 8 to 4. Each REVISE round costs 2 hops (critic→inspector + inspector→critic), so depth=4 caps the loop at roughly 2 rounds before the reactor refuses further dispatches. - Prompt-side: inspector now includes revision_round (starting at 0, incremented when it sees a REVISE: input) in its findings JSON. Critic reads revision_round and force-FINALs when >= 1. Prompt explicitly tells the critic to call complete_goal after sending FINAL, so the goal row gets a proper completion_summary. ### 4. run_task.sh terminal-state detection Rewrote the poll loop to watch goals.status/completion_summary as the definitive "done" signal rather than parsing DM bodies. Keeps a message-based fallback for TRIVIAL/CANNOT paths that don't create a goal. Treats "Received system trigger..." and "Coalesced trigger..." as informational (they're `__coalesced__` reactor synthetic events leaking through the coordinator reply, not real user-facing output). Bare coordinator replies are terminal only when no goal was created. 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
b5b5d6d26c
commit
42f8256df6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -42,11 +42,31 @@ printf '%s' "$GOAL" | "$BIN" --socket "$SOCKET" messages send \
|
||||
--to doc-coordinator \
|
||||
--priority 8 >&2
|
||||
|
||||
say "waiting for FINAL: / CANNOT: reply to algis (up to 600s)..."
|
||||
say "waiting for goal completion or FINAL:/CANNOT: reply to algis (up to 600s)..."
|
||||
deadline=$(( $(date +%s) + 600 ))
|
||||
last_seen_id=$BASELINE
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
# Goal completion check (definitive signal — set by complete_goal MCP).
|
||||
# A goal in 'completed'/'stuck'/'cancelled' state with a
|
||||
# completion_summary means the critic finalized the verdict.
|
||||
COMPLETED=$(sqlite3 "$DB" "
|
||||
SELECT id FROM goals
|
||||
WHERE status IN ('completed','stuck','cancelled')
|
||||
AND completion_summary IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$COMPLETED" ]; then
|
||||
say "goal $COMPLETED reached terminal state"
|
||||
GOAL_SUMMARY=$(sqlite3 "$DB" "SELECT status || ': ' || COALESCE(completion_summary,'') FROM goals WHERE id = $COMPLETED" 2>/dev/null)
|
||||
say "$GOAL_SUMMARY"
|
||||
echo "$COMPLETED" > "$SCRIPT_DIR/.last_goal_id"
|
||||
say "goal id = $COMPLETED — render with ./report.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Message-based fallback (for TRIVIAL/CANNOT paths that skip the
|
||||
# task tree and never call complete_goal).
|
||||
NEW_LINES=$(sqlite3 -separator '|' "$DB" "
|
||||
SELECT id, from_agent, replace(substr(body, 1, 280), char(10), ' ')
|
||||
FROM messages
|
||||
@@ -62,20 +82,31 @@ while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
say "← [$from #$id] $body"
|
||||
last_seen_id=$id
|
||||
case "$body" in
|
||||
DELEGATED:*|REVISING:*)
|
||||
DELEGATED:*|REVISING:*|Received\ system\ trigger*|Coalesced\ trigger*)
|
||||
;; # informational, keep waiting
|
||||
FINAL:*|CANNOT:*)
|
||||
say "terminal response received"
|
||||
GOAL_ID=$(sqlite3 "$DB" 'SELECT id FROM goals ORDER BY id DESC LIMIT 1' 2>/dev/null || echo)
|
||||
if [ -n "$GOAL_ID" ]; then
|
||||
echo "$GOAL_ID" > "$SCRIPT_DIR/.last_goal_id"
|
||||
say "goal id = $GOAL_ID — render with ./report.sh"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ "$from" = "doc-coordinator" ] || \
|
||||
[ "${body#FINAL:}" != "$body" ] || \
|
||||
[ "${body#CANNOT:}" != "$body" ]; then
|
||||
say "terminal response received"
|
||||
# Persist last goal id for ./report.sh.
|
||||
GOAL_ID=$(sqlite3 "$DB" 'SELECT id FROM goals ORDER BY id DESC LIMIT 1' 2>/dev/null || echo)
|
||||
if [ -n "$GOAL_ID" ]; then
|
||||
echo "$GOAL_ID" > "$SCRIPT_DIR/.last_goal_id"
|
||||
say "goal id = $GOAL_ID — render with ./report.sh"
|
||||
# A bare reply from doc-coordinator with no status
|
||||
# prefix is a TRIVIAL-triage direct answer. Count
|
||||
# it as terminal only if no goal was created (i.e.
|
||||
# the coordinator didn't start a pipeline).
|
||||
if [ "$from" = "doc-coordinator" ]; then
|
||||
HAS_GOAL=$(sqlite3 "$DB" 'SELECT COUNT(*) FROM goals' 2>/dev/null || echo 0)
|
||||
if [ "$HAS_GOAL" = "0" ]; then
|
||||
say "direct (trivial) response received"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
# Otherwise keep waiting — the coordinator
|
||||
# already delegated and will finalize via
|
||||
# complete_goal once the critic runs.
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -86,6 +117,6 @@ EOF
|
||||
sleep 1
|
||||
done
|
||||
|
||||
say "timed out waiting for terminal response (FINAL: or CANNOT:)"
|
||||
say "timed out waiting for terminal response"
|
||||
say "check http://localhost:18089/runs and http://localhost:18089/goals"
|
||||
exit 2
|
||||
|
||||
@@ -153,12 +153,17 @@ for name in doc-coordinator docs-inspector docs-critic; do
|
||||
done
|
||||
|
||||
say "configuring reactive trigger mode"
|
||||
# max_trigger_depth = 4 caps the conversation loop at about two
|
||||
# inspector↔critic round-trips (each REVISE costs two hops). Prevents
|
||||
# the agents from spinning forever on a badly-formed report when the
|
||||
# critic doesn't converge; see configs/critic.json for the structural
|
||||
# half of the cap.
|
||||
sqlite3 "$DATA_DIR/synapbus.db" <<SQL
|
||||
UPDATE agents SET
|
||||
trigger_mode = 'reactive',
|
||||
cooldown_seconds = 0,
|
||||
daily_trigger_budget = 50,
|
||||
max_trigger_depth = 8
|
||||
max_trigger_depth = 4
|
||||
WHERE name IN ('doc-coordinator','docs-inspector','docs-critic');
|
||||
SQL
|
||||
|
||||
|
||||
@@ -41,34 +41,43 @@ func (h *GoalsHandler) ListGoals(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type goalSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
RootTaskID *int64 `json:"root_task_id"`
|
||||
SpentTokens int64 `json:"spent_tokens"`
|
||||
SpentDollarsCents int64 `json:"spent_dollars_cents"`
|
||||
TaskCount int `json:"task_count"`
|
||||
BudgetTokens *int64 `json:"budget_tokens"`
|
||||
BudgetDollarsCents *int64 `json:"budget_dollars_cents"`
|
||||
PercentBudget float64 `json:"percent_budget"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
RootTaskID *int64 `json:"root_task_id"`
|
||||
SpentTokens int64 `json:"spent_tokens"`
|
||||
SpentDollarsCents int64 `json:"spent_dollars_cents"`
|
||||
TaskCount int `json:"task_count"`
|
||||
BudgetTokens *int64 `json:"budget_tokens"`
|
||||
BudgetDollarsCents *int64 `json:"budget_dollars_cents"`
|
||||
PercentBudget float64 `json:"percent_budget"`
|
||||
CompletionSummary *string `json:"completion_summary,omitempty"`
|
||||
CompletionMessageID *int64 `json:"completion_message_id,omitempty"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
out := make([]goalSummary, 0, len(gs))
|
||||
for _, g := range gs {
|
||||
s := goalSummary{
|
||||
ID: g.ID,
|
||||
Slug: g.Slug,
|
||||
Title: g.Title,
|
||||
Status: g.Status,
|
||||
ChannelID: g.ChannelID,
|
||||
RootTaskID: g.RootTaskID,
|
||||
BudgetTokens: g.BudgetTokens,
|
||||
BudgetDollarsCents: g.BudgetDollarsCents,
|
||||
CreatedAt: g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ID: g.ID,
|
||||
Slug: g.Slug,
|
||||
Title: g.Title,
|
||||
Status: g.Status,
|
||||
ChannelID: g.ChannelID,
|
||||
RootTaskID: g.RootTaskID,
|
||||
BudgetTokens: g.BudgetTokens,
|
||||
BudgetDollarsCents: g.BudgetDollarsCents,
|
||||
CompletionSummary: g.CompletionSummary,
|
||||
CompletionMessageID: g.CompletionMessageID,
|
||||
CreatedAt: g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if g.CompletedAt != nil {
|
||||
ca := g.CompletedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
s.CompletedAt = &ca
|
||||
}
|
||||
_ = h.db.QueryRowContext(r.Context(),
|
||||
`SELECT username FROM users WHERE id=?`, g.OwnerUserID).Scan(&s.OwnerUsername)
|
||||
@@ -286,23 +295,32 @@ func (h *GoalsHandler) GetGoal(w http.ResponseWriter, r *http.Request) {
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
var completedAt *string
|
||||
if g.CompletedAt != nil {
|
||||
s := g.CompletedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
completedAt = &s
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"goal": map[string]any{
|
||||
"id": g.ID,
|
||||
"slug": g.Slug,
|
||||
"title": g.Title,
|
||||
"description": g.Description,
|
||||
"status": g.Status,
|
||||
"channel_id": g.ChannelID,
|
||||
"coordinator_agent_id": g.CoordinatorAgentID,
|
||||
"root_task_id": g.RootTaskID,
|
||||
"owner_user_id": g.OwnerUserID,
|
||||
"owner_username": ownerUsername,
|
||||
"budget_tokens": g.BudgetTokens,
|
||||
"budget_dollars_cents": g.BudgetDollarsCents,
|
||||
"max_spawn_depth": g.MaxSpawnDepth,
|
||||
"alert_80pct_posted": g.Alert80PctPosted,
|
||||
"created_at": g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"id": g.ID,
|
||||
"slug": g.Slug,
|
||||
"title": g.Title,
|
||||
"description": g.Description,
|
||||
"status": g.Status,
|
||||
"channel_id": g.ChannelID,
|
||||
"coordinator_agent_id": g.CoordinatorAgentID,
|
||||
"root_task_id": g.RootTaskID,
|
||||
"owner_user_id": g.OwnerUserID,
|
||||
"owner_username": ownerUsername,
|
||||
"budget_tokens": g.BudgetTokens,
|
||||
"budget_dollars_cents": g.BudgetDollarsCents,
|
||||
"max_spawn_depth": g.MaxSpawnDepth,
|
||||
"alert_80pct_posted": g.Alert80PctPosted,
|
||||
"completion_summary": g.CompletionSummary,
|
||||
"completion_message_id": g.CompletionMessageID,
|
||||
"created_at": g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"completed_at": completedAt,
|
||||
},
|
||||
"tasks": out,
|
||||
"rollup": map[string]any{
|
||||
|
||||
@@ -104,6 +104,30 @@ func (s *Service) TransitionStatus(ctx context.Context, goalID int64, newStatus
|
||||
return s.store.SetStatus(ctx, goalID, newStatus)
|
||||
}
|
||||
|
||||
// Complete transitions a goal to a terminal state (completed / stuck /
|
||||
// cancelled) and records the critic's completion summary and the
|
||||
// message id the FINAL message was delivered in. Legal transitions:
|
||||
//
|
||||
// draft|active|stuck → completed
|
||||
// draft|active → stuck
|
||||
// any → cancelled
|
||||
//
|
||||
// If the goal is already in the target state, Complete is a no-op
|
||||
// (idempotent). It never demotes a completed goal.
|
||||
func (s *Service) Complete(ctx context.Context, goalID int64, newStatus, summary string, messageID int64) error {
|
||||
g, err := s.store.Get(ctx, goalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Status == newStatus {
|
||||
return nil
|
||||
}
|
||||
if !legalTransition(g.Status, newStatus) {
|
||||
return fmt.Errorf("illegal goal transition: %s → %s", g.Status, newStatus)
|
||||
}
|
||||
return s.store.SetCompletion(ctx, goalID, newStatus, summary, messageID)
|
||||
}
|
||||
|
||||
// BudgetVerdict describes what the budget enforcer wants the caller to do.
|
||||
type BudgetVerdict struct {
|
||||
PercentBudget float64 // 0..100+
|
||||
@@ -145,13 +169,20 @@ func (s *Service) MarkSoftAlertPosted(ctx context.Context, goalID int64) error {
|
||||
func legalTransition(from, to string) bool {
|
||||
switch from {
|
||||
case StatusDraft:
|
||||
return to == StatusActive || to == StatusCancelled
|
||||
// A goal can jump straight from draft to a terminal state
|
||||
// when the workflow never bothers with an explicit active
|
||||
// transition (e.g. the doc-gardener critic completes the
|
||||
// goal at the end of a fast single-round inspector pass).
|
||||
return to == StatusActive || to == StatusCompleted ||
|
||||
to == StatusStuck || to == StatusCancelled
|
||||
case StatusActive:
|
||||
return to == StatusPaused || to == StatusCompleted || to == StatusStuck || to == StatusCancelled
|
||||
return to == StatusPaused || to == StatusCompleted ||
|
||||
to == StatusStuck || to == StatusCancelled
|
||||
case StatusPaused:
|
||||
return to == StatusActive || to == StatusCancelled
|
||||
return to == StatusActive || to == StatusCompleted ||
|
||||
to == StatusStuck || to == StatusCancelled
|
||||
case StatusStuck:
|
||||
return to == StatusActive || to == StatusCancelled
|
||||
return to == StatusActive || to == StatusCompleted || to == StatusCancelled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+34
-4
@@ -60,11 +60,13 @@ func (s *Store) Get(ctx context.Context, id int64) (*Goal, error) {
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
|
||||
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
|
||||
alert_80pct_posted, created_at, updated_at, completed_at
|
||||
alert_80pct_posted, created_at, updated_at, completed_at,
|
||||
completion_summary, completion_message_id
|
||||
FROM goals WHERE id = ?`, id).Scan(
|
||||
&g.ID, &g.Slug, &g.Title, &g.Description, &g.OwnerUserID, &g.ChannelID, &g.CoordinatorAgentID,
|
||||
&g.RootTaskID, &g.Status, &g.BudgetTokens, &g.BudgetDollarsCents, &g.MaxSpawnDepth,
|
||||
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt)
|
||||
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt,
|
||||
&g.CompletionSummary, &g.CompletionMessageID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrGoalNotFound
|
||||
}
|
||||
@@ -88,13 +90,15 @@ func (s *Store) List(ctx context.Context, ownerUserID *int64, limit int) ([]*Goa
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
|
||||
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
|
||||
alert_80pct_posted, created_at, updated_at, completed_at
|
||||
alert_80pct_posted, created_at, updated_at, completed_at,
|
||||
completion_summary, completion_message_id
|
||||
FROM goals WHERE owner_user_id = ? ORDER BY id DESC LIMIT ?`, *ownerUserID, limit)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
|
||||
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
|
||||
alert_80pct_posted, created_at, updated_at, completed_at
|
||||
alert_80pct_posted, created_at, updated_at, completed_at,
|
||||
completion_summary, completion_message_id
|
||||
FROM goals ORDER BY id DESC LIMIT ?`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -110,6 +114,7 @@ func (s *Store) List(ctx context.Context, ownerUserID *int64, limit int) ([]*Goa
|
||||
&g.ID, &g.Slug, &g.Title, &g.Description, &g.OwnerUserID, &g.ChannelID, &g.CoordinatorAgentID,
|
||||
&g.RootTaskID, &g.Status, &g.BudgetTokens, &g.BudgetDollarsCents, &g.MaxSpawnDepth,
|
||||
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt,
|
||||
&g.CompletionSummary, &g.CompletionMessageID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,6 +140,31 @@ func (s *Store) SetStatus(ctx context.Context, goalID int64, newStatus string) e
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCompletion records the critic's completion verdict: status
|
||||
// transition, final summary body, and the message id the summary
|
||||
// was delivered in. Used by the complete_goal MCP tool. When
|
||||
// newStatus is "completed", completed_at is populated. For terminal
|
||||
// failure states ("stuck", "cancelled") completed_at is also set so
|
||||
// the UI treats them uniformly as "no longer running".
|
||||
func (s *Store) SetCompletion(ctx context.Context, goalID int64, newStatus, summary string, messageID int64) error {
|
||||
now := time.Now().UTC()
|
||||
var completedAt *time.Time
|
||||
switch newStatus {
|
||||
case "completed", "stuck", "cancelled":
|
||||
completedAt = &now
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE goals SET
|
||||
status = ?,
|
||||
completion_summary = ?,
|
||||
completion_message_id = ?,
|
||||
completed_at = COALESCE(?, completed_at),
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
newStatus, summary, messageID, completedAt, now, goalID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkSoftAlertPosted flips the idempotency flag so the 80 % alert is
|
||||
// posted only once per goal.
|
||||
func (s *Store) MarkSoftAlertPosted(ctx context.Context, goalID int64) error {
|
||||
|
||||
@@ -34,6 +34,13 @@ type Goal struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
// CompletionSummary is the critic's FINAL paragraph (or the
|
||||
// failure reason when status=stuck). Populated by complete_goal.
|
||||
CompletionSummary *string
|
||||
// CompletionMessageID is the id of the DM that carried the
|
||||
// completion verdict — lets /goals/<id> deep-link to the full
|
||||
// findings JSON or report body.
|
||||
CompletionMessageID *int64
|
||||
}
|
||||
|
||||
// CreateGoalInput captures the public arguments of CreateGoal.
|
||||
|
||||
@@ -53,8 +53,8 @@ func NewGoalsToolRegistrar(
|
||||
}
|
||||
|
||||
// RegisterAllOnServer attaches create_goal, propose_task_tree,
|
||||
// propose_agent, claim_task, request_resource, list_resources to the
|
||||
// MCP server.
|
||||
// propose_agent, claim_task, request_resource, list_resources, and
|
||||
// complete_goal to the MCP server.
|
||||
func (r *GoalsToolRegistrar) RegisterAllOnServer(s *server.MCPServer) {
|
||||
s.AddTool(r.createGoalTool(), r.handleCreateGoal)
|
||||
s.AddTool(r.proposeTaskTreeTool(), r.handleProposeTaskTree)
|
||||
@@ -62,7 +62,8 @@ func (r *GoalsToolRegistrar) RegisterAllOnServer(s *server.MCPServer) {
|
||||
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)
|
||||
s.AddTool(r.completeGoalTool(), r.handleCompleteGoal)
|
||||
r.logger.Info("spec-018 MCP tools registered", "count", 7)
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
@@ -120,6 +121,16 @@ func (r *GoalsToolRegistrar) listResourcesTool() mcplib.Tool {
|
||||
)
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) completeGoalTool() mcplib.Tool {
|
||||
return mcplib.NewTool("complete_goal",
|
||||
mcplib.WithDescription("Mark a goal as terminally done. The critic (or coordinator) calls this after the FINAL verdict: status=\"completed\" on success, \"stuck\" when the goal couldn't be finished, \"cancelled\" when the human aborted. Records a summary paragraph for /goals/<id> and links it to the DM that carried the FINAL message. Idempotent when called with the current status."),
|
||||
mcplib.WithNumber("goal_id", mcplib.Description("Goal id from create_goal"), mcplib.Required()),
|
||||
mcplib.WithString("status", mcplib.Description("Terminal status: completed | stuck | cancelled"), mcplib.Required()),
|
||||
mcplib.WithString("summary", mcplib.Description("One-paragraph human-readable completion summary (what was done, top findings, recommended next step). Goes straight into /goals/<id>."), mcplib.Required()),
|
||||
mcplib.WithNumber("completion_message_id", mcplib.Description("Optional id of the message that carried the verdict. When set, /goals/<id> can deep-link to the full findings JSON in that DM.")),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
func (r *GoalsToolRegistrar) handleCreateGoal(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
@@ -215,6 +226,13 @@ func (r *GoalsToolRegistrar) handleProposeTaskTree(ctx context.Context, req mcpl
|
||||
// Also set the goal's root_task_id.
|
||||
_, _ = r.db.ExecContext(ctx, `UPDATE goals SET root_task_id=? WHERE id=?`, rootID, goalID)
|
||||
|
||||
// Auto-transition draft → active. The coordinator would otherwise
|
||||
// have to call a separate tool just to flip state, which is friction
|
||||
// we don't need — if there's a task tree, the goal is active. Ignore
|
||||
// the legal-transition error when the goal isn't in draft (operators
|
||||
// may have already moved it manually).
|
||||
_ = r.goals.TransitionStatus(ctx, goalID, goals.StatusActive)
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"root_task_id": rootID,
|
||||
"task_count": len(allIDs),
|
||||
@@ -369,3 +387,57 @@ func (r *GoalsToolRegistrar) handleListResources(ctx context.Context, req mcplib
|
||||
"count": len(names),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *GoalsToolRegistrar) handleCompleteGoal(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
|
||||
}
|
||||
goalID := int64(req.GetInt("goal_id", 0))
|
||||
status := req.GetString("status", "")
|
||||
summary := req.GetString("summary", "")
|
||||
messageID := int64(req.GetInt("completion_message_id", 0))
|
||||
if goalID <= 0 || status == "" || summary == "" {
|
||||
return mcplib.NewToolResultError("goal_id, status, and summary are all required"), nil
|
||||
}
|
||||
switch status {
|
||||
case goals.StatusCompleted, goals.StatusStuck, goals.StatusCancelled:
|
||||
// ok
|
||||
default:
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("invalid status %q — must be completed|stuck|cancelled", status)), nil
|
||||
}
|
||||
|
||||
// Authorization: caller must own the goal (same human owner).
|
||||
// Coordinator + critic both run as ai agents owned by the same
|
||||
// user in the doc-gardener example; this check keeps one user's
|
||||
// agents from closing another user's goals.
|
||||
callerAgent, err := r.agents.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
|
||||
}
|
||||
g, err := r.goals.GetGoal(ctx, goalID)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("get goal: %s", err)), nil
|
||||
}
|
||||
if callerAgent.OwnerID != g.OwnerUserID {
|
||||
return mcplib.NewToolResultError("goal is owned by a different user — refusing to transition"), nil
|
||||
}
|
||||
|
||||
if err := r.goals.Complete(ctx, goalID, status, summary, messageID); err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("complete_goal: %s", err)), nil
|
||||
}
|
||||
|
||||
r.logger.Info("goal completed via MCP",
|
||||
"goal_id", goalID,
|
||||
"status", status,
|
||||
"by_agent", agentName,
|
||||
)
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"goal_id": goalID,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 026_goals_completion_summary.sql — record the critic's FINAL summary
|
||||
-- on the goal row so /goals/<id> can render it without a JOIN through
|
||||
-- goal_tasks. Also stash the message id the completion came from so
|
||||
-- the UI can deep-link to the full findings JSON.
|
||||
--
|
||||
-- Backfill is unnecessary — existing goals never completed cleanly
|
||||
-- (the demo had no complete_goal path before this migration).
|
||||
|
||||
ALTER TABLE goals ADD COLUMN completion_summary TEXT;
|
||||
ALTER TABLE goals ADD COLUMN completion_message_id INTEGER
|
||||
REFERENCES messages(id) ON DELETE SET NULL;
|
||||
Reference in New Issue
Block a user