1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "io"
8
9 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"
11)
12
13const (
14 PlanToolName = "plan"
15 planSchema = `{
16 "type":"object",
17 "properties":{
18 "action":{"type":"string","enum":["set","add","update","remove","clear","show"],"description":"Plan action to perform."},
19 "explanation":{"type":"string","description":"Optional explanation for this plan change."},
20 "steps":{"type":"array","description":"Steps for the set and add actions. set replaces the whole checklist and requires at least one step; add appends. Use clear to remove every step.","items":{"type":"object","properties":{"step":{"type":"string","description":"Task step text."},"status":{"type":"string","enum":["pending","in_progress","completed"],"description":"Step status, pending when omitted."}},"required":["step"],"additionalProperties":false}},
21 "index":{"type":"integer","minimum":1,"description":"1-based step position for the update and remove actions."},
22 "status":{"type":"string","enum":["pending","in_progress","completed"],"description":"New status for the update action."},
23 "step":{"type":"string","description":"New step text for the update action."}
24 },
25 "required":["action"],
26 "additionalProperties":false
27}`
28)
29
30// PlanTool lets the model maintain a persisted, session-scoped task checklist
31// through one CRUD surface. It only parses arguments: core owns the plan state,
32// so validation against the current checklist and the model-visible result text
33// happen after execution, where that state is available.
34type PlanTool struct{}
35
36var _ core.ToolArgumentRewritePolicy = (*PlanTool)(nil)
37
38type planArgs struct {
39 Action *string `json:"action"`
40 Explanation *string `json:"explanation"`
41 Steps []planArgStep `json:"steps"`
42 Index int `json:"index"`
43 Status string `json:"status"`
44 Step *string `json:"step"`
45}
46
47type planArgStep struct {
48 Step *string `json:"step"`
49 Status *string `json:"status"`
50}
51
52func (t *PlanTool) Name() string { return PlanToolName }
53
54// AllowArgumentRewrite keeps the displayed checklist identical to the plan the
55// model submitted. Guards may still allow or refuse the call.
56func (t *PlanTool) AllowArgumentRewrite() bool { return false }
57
58func (t *PlanTool) Description() string {
59 return "Maintain a persisted task checklist. Actions: set (replace every step), add, update one step, remove one step, clear, show the current checklist. Several steps may be in_progress at once when work runs in parallel (for example concurrent sub-agents); prefer add/update/remove deltas over re-sending the whole list with set."
60}
61
62func (t *PlanTool) Schema() json.RawMessage { return json.RawMessage(planSchema) }
63
64func (t *PlanTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
65 var args planArgs
66 decoder := json.NewDecoder(bytes.NewReader(raw))
67 decoder.DisallowUnknownFields()
68 if err := decoder.Decode(&args); err != nil {
69 return planError(), nil
70 }
71 if err := decoder.Decode(&struct{}{}); err != io.EOF {
72 return planError(), nil
73 }
74 if args.Action == nil || *args.Action == "" {
75 return planError(), nil
76 }
77
78 op := core.PlanOperation{
79 Action: *args.Action,
80 Explanation: args.Explanation,
81 Index: args.Index,
82 // Status and its values are validated by core, which rejects an unknown
83 // status with an actionable message. The tool only carries the value.
84 Status: core.PlanStepStatus(args.Status),
85 Text: args.Step,
86 }
87 for _, step := range args.Steps {
88 if step.Step == nil {
89 return planError(), nil
90 }
91 var status core.PlanStepStatus
92 if step.Status != nil {
93 status = core.PlanStepStatus(*step.Status)
94 }
95 op.Steps = append(op.Steps, core.PlanStep{Step: *step.Step, Status: status})
96 }
97
98 return core.ToolResult{
99 // Placeholder content: core replaces it with the summary or listing
100 // produced against the current plan.
101 Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
102 Details: op,
103 }, nil
104}
105
106func planError() core.ToolResult {
107 return core.ToolResult{
108 Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}},
109 IsError: true,
110 }
111}