| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | func init() { tool.RegisterBuiltin(updateGoal{}) } |
| 13 | |
| 14 | // updateGoal records the model's structured per-turn goal disposition for the |
| 15 | // active goal turn. Like complete_step it has no host side effects: the call |
| 16 | // only records candidate state, and the real FSM transition happens after the |
| 17 | // turn ends, once Delivery readiness and budget checks pass. It is a host |
| 18 | // workflow operation — it never requires write approval and grants no |
| 19 | // permissions. Outside an active goal turn it fails closed without changing |
| 20 | // any state, so plain chat cannot be hijacked into goal machinery. |
| 21 | type updateGoal struct{} |
| 22 | |
| 23 | func (updateGoal) Name() string { return "update_goal" } |
| 24 | |
| 25 | func (updateGoal) Description() string { |
| 26 | return "Report this turn's disposition for the active goal. Call it at the end of every goal turn instead of using prose markers: `continue` (work is ongoing — give a concrete next_action), `complete` (the request is fully done, output format and constraints satisfied, and verification was attempted or reported unavailable), or `blocked` (only the user can unblock: missing user-only information, an irreversible/externally visible operation, or changed scope). The host validates your claim against Delivery acceptance criteria and decides whether to continue automatically. Fields: `status` (required, one of continue|complete|blocked), `reason` (required for continue and blocked, optional for complete), `next_action` (optional concrete next step; recommended for continue)." |
| 27 | } |
| 28 | |
| 29 | func (updateGoal) Schema() json.RawMessage { |
| 30 | return json.RawMessage(`{ |
| 31 | "type":"object", |
| 32 | "properties":{ |
| 33 | "status":{"type":"string","enum":["continue","complete","blocked"],"description":"continue = keep working autonomously; complete = the goal is fully done and verified; blocked = only the user can unblock."}, |
| 34 | "reason":{"type":"string","description":"Short explanation. REQUIRED for continue and blocked; optional for complete."}, |
| 35 | "next_action":{"type":"string","description":"Optional concrete next step. Recommended for continue so the host can guide the next turn."} |
| 36 | }, |
| 37 | "required":["status"] |
| 38 | }`) |
| 39 | } |
| 40 | |
| 41 | // ReadOnly is true: update_goal only records a claim; the host performs the |
| 42 | // state transition after the turn. It never needs approval and cannot expand |
| 43 | // tool permissions or bypass sandbox policy. |
| 44 | func (updateGoal) ReadOnly() bool { return true } |
| 45 | |
| 46 | // PlanModeSafe reports true: the tool is read-only host bookkeeping, and |
| 47 | // outside an active goal turn its Execute fails closed anyway. |
| 48 | func (updateGoal) PlanModeSafe() bool { return true } |
| 49 | |
| 50 | func (updateGoal) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 51 | var p struct { |
| 52 | Status string `json:"status"` |
| 53 | Reason string `json:"reason"` |
| 54 | NextAction string `json:"next_action"` |
| 55 | } |
| 56 | if err := json.Unmarshal(args, &p); err != nil { |
| 57 | return "", fmt.Errorf("invalid update_goal args: %w", err) |
| 58 | } |
| 59 | p.Status = strings.ToLower(strings.TrimSpace(p.Status)) |
| 60 | switch p.Status { |
| 61 | case "continue", "complete", "blocked": |
| 62 | default: |
| 63 | return "", fmt.Errorf("update_goal: status must be one of continue|complete|blocked, got %q — no goal state was changed", p.Status) |
| 64 | } |
| 65 | if (p.Status == "continue" || p.Status == "blocked") && strings.TrimSpace(p.Reason) == "" { |
| 66 | return "", fmt.Errorf("update_goal: reason is required for %s — no goal state was changed", p.Status) |
| 67 | } |
| 68 | recorder, ok := tool.GoalTurnRecorderFromContext(ctx) |
| 69 | if !ok { |
| 70 | return "", fmt.Errorf("update_goal is only available while an active goal turn is running — no goal state was changed") |
| 71 | } |
| 72 | return recorder.RecordGoalReport(tool.GoalReport{ |
| 73 | Status: p.Status, |
| 74 | Reason: strings.TrimSpace(p.Reason), |
| 75 | NextAction: strings.TrimSpace(p.NextAction), |
| 76 | }) |
| 77 | } |
| 78 |