diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md index 810d91ef2..b5bc577b7 100644 --- a/docs/reference/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -242,6 +242,40 @@ as containers, VMs, or an approval flow around build-and-run commands. } ``` +## Update Plan Tool + +The `update_plan` tool lets the agent publish structured progress for multi-step tasks. It mirrors OpenClaw's planning helper: the tool records the current ordered plan, validates step statuses, and returns the updated plan to the model without performing any external side effects. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `tools.update_plan.enabled` | bool | `false` | Register the agent-facing `update_plan` tool | + +When `update_plan` is enabled, the tool description tells the model to use it only for non-trivial multi-step work, keep exactly one `in_progress` step while work is active, and avoid repeating the whole plan after each update. + +### `update_plan` Tool Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `explanation` | string | no | Short note explaining what changed in the plan | +| `plan` | array | yes | Ordered list of plan steps | +| `plan[].step` | string | yes | Short plan step | +| `plan[].status` | string | yes | One of `pending`, `in_progress`, or `completed` | + +At most one step may be `in_progress`. The tool is intended for visible coordination during longer tasks; it does not run steps, schedule work, or replace workflow engines such as cron, MCP servers, or external orchestration. + +### Example `update_plan` Call + +```json +{ + "explanation": "Implementation has started.", + "plan": [ + { "step": "Inspect existing tools", "status": "completed" }, + { "step": "Add update_plan tool", "status": "in_progress" }, + { "step": "Run focused tests", "status": "pending" } + ] +} +``` + ## Cron Tool The cron tool is used for scheduling periodic tasks. diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go index 76f12fa65..64c9e3845 100644 --- a/pkg/agent/agent_init.go +++ b/pkg/agent/agent_init.go @@ -141,6 +141,9 @@ func registerSharedTools( if cfg.Tools.IsToolEnabled("serial") { agent.Tools.Register(tools.NewSerialTool()) } + if cfg.Tools.IsToolEnabled("update_plan") { + agent.Tools.Register(tools.NewUpdatePlanTool()) + } // Message tool if cfg.Tools.IsToolEnabled("message") { diff --git a/pkg/config/config.go b/pkg/config/config.go index acceee4d5..e344a6d36 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -838,6 +838,7 @@ type ToolsConfig struct { SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` + UpdatePlan ToolConfig `json:"update_plan" yaml:"-" envPrefix:"PICOCLAW_TOOLS_UPDATE_PLAN_"` WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } @@ -1569,6 +1570,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.SPI.Enabled case "subagent": return t.Subagent.Enabled + case "update_plan": + return t.UpdatePlan.Enabled case "web_fetch": return t.WebFetch.Enabled case "send_file": diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4f1c5c5e8..9e3555cce 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -961,6 +961,16 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) { } } +func TestDefaultConfig_UpdatePlanDisabled(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.UpdatePlan.Enabled { + t.Fatal("DefaultConfig().Tools.UpdatePlan.Enabled should be false") + } + if cfg.Tools.IsToolEnabled("update_plan") { + t.Fatal("DefaultConfig().Tools.IsToolEnabled(\"update_plan\") should be false") + } +} + func TestDefaultConfig_HooksDefaults(t *testing.T) { cfg := DefaultConfig() if !cfg.Hooks.Enabled { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 8e2494ae5..6a283bedf 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -455,6 +455,9 @@ func DefaultConfig() *Config { Subagent: ToolConfig{ Enabled: true, }, + UpdatePlan: ToolConfig{ + Enabled: false, + }, WebFetch: ToolConfig{ Enabled: true, }, diff --git a/pkg/tools/update_plan.go b/pkg/tools/update_plan.go new file mode 100644 index 000000000..b38819779 --- /dev/null +++ b/pkg/tools/update_plan.go @@ -0,0 +1,166 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +var updatePlanStatuses = map[string]struct{}{ + "pending": {}, + "in_progress": {}, + "completed": {}, +} + +type UpdatePlanTool struct{} + +type updatePlanStep struct { + Step string `json:"step"` + Status string `json:"status"` +} + +type updatePlanResponse struct { + Status string `json:"status"` + Explanation string `json:"explanation,omitempty"` + Plan []updatePlanStep `json:"plan"` +} + +func NewUpdatePlanTool() *UpdatePlanTool { + return &UpdatePlanTool{} +} + +func (t *UpdatePlanTool) Name() string { + return "update_plan" +} + +func (t *UpdatePlanTool) Description() string { + return "Update the current task plan. Use this only for non-trivial multi-step work. Keep exactly one step in_progress while work is active, use pending and completed for the rest, and avoid repeating the whole plan after each update." +} + +func (t *UpdatePlanTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "explanation": map[string]any{ + "type": "string", + "description": "Optional short note explaining what changed in the plan.", + }, + "plan": map[string]any{ + "type": "array", + "minItems": 1, + "description": "Ordered list of plan steps. Keep exactly one step in_progress while work is active.", + "items": map[string]any{ + "type": "object", + "additionalProperties": true, + "properties": map[string]any{ + "step": map[string]any{ + "type": "string", + "description": "Short plan step.", + }, + "status": map[string]any{ + "type": "string", + "enum": []string{"pending", "in_progress", "completed"}, + "description": "One of pending, in_progress, or completed.", + }, + }, + "required": []string{"step", "status"}, + }, + }, + }, + "required": []string{"plan"}, + } +} + +func (t *UpdatePlanTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + _ = ctx + + steps, err := readUpdatePlanSteps(args) + if err != nil { + return ErrorResult(err.Error()) + } + + explanation, err := optionalStringArg(args, "explanation") + if err != nil { + return ErrorResult(err.Error()) + } + + response := updatePlanResponse{ + Status: "updated", + Explanation: explanation, + Plan: steps, + } + data, err := json.MarshalIndent(response, "", " ") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to encode plan update: %v", err)) + } + return SilentResult(string(data)) +} + +func readUpdatePlanSteps(args map[string]any) ([]updatePlanStep, error) { + rawPlan, ok := args["plan"].([]any) + if !ok || len(rawPlan) == 0 { + return nil, fmt.Errorf("plan required") + } + + steps := make([]updatePlanStep, 0, len(rawPlan)) + inProgressCount := 0 + for i, rawEntry := range rawPlan { + entry, ok := rawEntry.(map[string]any) + if !ok { + return nil, fmt.Errorf("plan[%d] must be an object", i) + } + + step, err := requiredStringArg(entry, "step", fmt.Sprintf("plan[%d].step", i)) + if err != nil { + return nil, err + } + status, err := requiredStringArg(entry, "status", fmt.Sprintf("plan[%d].status", i)) + if err != nil { + return nil, err + } + if _, ok := updatePlanStatuses[status]; !ok { + return nil, fmt.Errorf("plan[%d].status must be one of pending, in_progress, completed", i) + } + if status == "in_progress" { + inProgressCount++ + } + + steps = append(steps, updatePlanStep{ + Step: step, + Status: status, + }) + } + if inProgressCount > 1 { + return nil, fmt.Errorf("plan can contain at most one in_progress step") + } + return steps, nil +} + +func requiredStringArg(args map[string]any, key, label string) (string, error) { + value, ok := args[key] + if !ok || value == nil { + return "", fmt.Errorf("%s required", label) + } + str, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", label) + } + str = strings.TrimSpace(str) + if str == "" { + return "", fmt.Errorf("%s required", label) + } + return str, nil +} + +func optionalStringArg(args map[string]any, key string) (string, error) { + value, ok := args[key] + if !ok || value == nil { + return "", nil + } + str, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + return strings.TrimSpace(str), nil +} diff --git a/pkg/tools/update_plan_test.go b/pkg/tools/update_plan_test.go new file mode 100644 index 000000000..7f151baef --- /dev/null +++ b/pkg/tools/update_plan_test.go @@ -0,0 +1,101 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestUpdatePlanToolParameters(t *testing.T) { + tool := NewUpdatePlanTool() + + if got := tool.Name(); got != "update_plan" { + t.Fatalf("Name() = %q, want update_plan", got) + } + description := tool.Description() + for _, want := range []string{"non-trivial multi-step work", "exactly one step in_progress", "avoid repeating the whole plan"} { + if !strings.Contains(description, want) { + t.Fatalf("Description() missing %q: %s", want, description) + } + } + params := tool.Parameters() + if params["type"] != "object" { + t.Fatalf("Parameters type = %v, want object", params["type"]) + } + if params["properties"] == nil { + t.Fatal("Parameters should include properties") + } +} + +func TestUpdatePlanToolExecute(t *testing.T) { + tool := NewUpdatePlanTool() + + result := tool.Execute(context.Background(), map[string]any{ + "explanation": "Starting implementation.", + "plan": []any{ + map[string]any{"step": "Inspect existing tools", "status": "completed"}, + map[string]any{"step": "Add update_plan", "status": "in_progress"}, + map[string]any{"step": "Run tests", "status": "pending"}, + }, + }) + if result == nil { + t.Fatal("Execute returned nil") + } + if result.IsError { + t.Fatalf("Execute returned error: %s", result.ForLLM) + } + if !result.Silent { + t.Fatal("update_plan should be silent") + } + + var response updatePlanResponse + if err := json.Unmarshal([]byte(result.ForLLM), &response); err != nil { + t.Fatalf("result is not JSON: %v\n%s", err, result.ForLLM) + } + if response.Status != "updated" { + t.Fatalf("status = %q, want updated", response.Status) + } + if response.Explanation != "Starting implementation." { + t.Fatalf("explanation = %q", response.Explanation) + } + if len(response.Plan) != 3 { + t.Fatalf("plan length = %d, want 3", len(response.Plan)) + } + if response.Plan[1].Status != "in_progress" { + t.Fatalf("second step status = %q, want in_progress", response.Plan[1].Status) + } +} + +func TestUpdatePlanToolRejectsMultipleInProgress(t *testing.T) { + tool := NewUpdatePlanTool() + + result := tool.Execute(context.Background(), map[string]any{ + "plan": []any{ + map[string]any{"step": "One", "status": "in_progress"}, + map[string]any{"step": "Two", "status": "in_progress"}, + }, + }) + if result == nil || !result.IsError { + t.Fatalf("Execute should reject multiple in_progress steps, got %#v", result) + } + if !strings.Contains(result.ForLLM, "at most one in_progress") { + t.Fatalf("unexpected error: %s", result.ForLLM) + } +} + +func TestUpdatePlanToolRejectsInvalidStatus(t *testing.T) { + tool := NewUpdatePlanTool() + + result := tool.Execute(context.Background(), map[string]any{ + "plan": []any{ + map[string]any{"step": "One", "status": "blocked"}, + }, + }) + if result == nil || !result.IsError { + t.Fatalf("Execute should reject invalid status, got %#v", result) + } + if !strings.Contains(result.ForLLM, "must be one of") { + t.Fatalf("unexpected error: %s", result.ForLLM) + } +}