1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "io"
8 "strings"
9
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"
12)
13
14const (
15 UpdateGoalToolName = "update_goal"
16 updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked","superseded"],"description":"Start a mission, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required for active and superseded."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
17)
18
19// UpdateGoalTool lets the main agent start a mission, set its next concrete
20// active goal, or settle the current goal. Host-side persistence validates
21// transitions within an existing session mission. A supplied mission ID is
22// checked against the current mission; an omitted ID targets that mission.
23type UpdateGoalTool struct{}
24
25type updateGoalArgs struct {
26 Status string `json:"status"`
27 Objective string `json:"objective,omitempty"`
28 GoalID string `json:"goal_id,omitempty"`
29 MissionID string `json:"mission_id,omitempty"`
30 Reason string `json:"reason,omitempty"`
31}
32
33// GoalUpdate is trusted metadata returned only by UpdateGoalTool.
34type GoalUpdate struct {
35 Status core.GoalStatus
36 Objective string
37 GoalID string
38 MissionID string
39 Reason string
40}
41
42func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
43
44func (t *UpdateGoalTool) Description() string {
45 return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission."
46}
47
48func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
49
50func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
51 var args updateGoalArgs
52 decoder := json.NewDecoder(bytes.NewReader(raw))
53 decoder.DisallowUnknownFields()
54 if err := decoder.Decode(&args); err != nil {
55 return goalToolError("invalid arguments"), nil
56 }
57 if err := decoder.Decode(&struct{}{}); err != io.EOF {
58 return goalToolError("invalid arguments"), nil
59 }
60 args.Status = strings.TrimSpace(args.Status)
61 args.Objective = strings.TrimSpace(args.Objective)
62 args.GoalID = strings.TrimSpace(args.GoalID)
63 args.MissionID = strings.TrimSpace(args.MissionID)
64 args.Reason = strings.TrimSpace(args.Reason)
65
66 var update GoalUpdate
67 switch args.Status {
68 case "active":
69 if args.Objective == "" {
70 return goalToolError("objective is required when setting a goal"), nil
71 }
72 update.Status = core.GoalActive
73 update.Objective = args.Objective
74 update.MissionID = args.MissionID
75 case "complete":
76 update.Status = core.GoalDone
77 case "superseded":
78 if args.Objective == "" {
79 return goalToolError("objective is required when superseding a goal"), nil
80 }
81 if args.GoalID == "" {
82 return goalToolError("goal_id is required when superseding a goal"), nil
83 }
84 update.Status = core.GoalSuperseded
85 update.Objective = args.Objective
86 update.GoalID = args.GoalID
87 update.MissionID = args.MissionID
88 case "blocked":
89 if args.Reason == "" {
90 return goalToolError("reason is required when blocking a goal"), nil
91 }
92 update.Status = core.GoalBlocked
93 update.Reason = args.Reason
94 default:
95 return goalToolError("status must be active, complete, blocked, or superseded"), nil
96 }
97
98 text := "autonomous goal marked " + string(update.Status)
99 if update.Reason != "" {
100 text += ": " + update.Reason
101 }
102 return core.ToolResult{
103 Content: []provider.Content{provider.TextBlock{Text: text}},
104 Details: update,
105 }, nil
106}
107
108// GoalUpdateFromResult extracts a valid update_goal state transition.
109func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
110 if result.IsError {
111 return GoalUpdate{}, false
112 }
113 update, ok := result.Details.(GoalUpdate)
114 if !ok {
115 return GoalUpdate{}, false
116 }
117 if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded {
118 return GoalUpdate{}, false
119 }
120 if (update.Status == core.GoalActive || update.Status == core.GoalSuperseded) && strings.TrimSpace(update.Objective) == "" {
121 return GoalUpdate{}, false
122 }
123 if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" {
124 return GoalUpdate{}, false
125 }
126 if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {
127 return GoalUpdate{}, false
128 }
129 return update, true
130}
131
132func goalToolError(message string) core.ToolResult {
133 return core.ToolResult{
134 Content: []provider.Content{provider.TextBlock{Text: message}},
135 IsError: true,
136 }
137}