Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/reference/tools_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pkg/agent/agent_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_"`
}
Expand Down Expand Up @@ -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":
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,9 @@ func DefaultConfig() *Config {
Subagent: ToolConfig{
Enabled: true,
},
UpdatePlan: ToolConfig{
Enabled: false,
},
WebFetch: ToolConfig{
Enabled: true,
},
Expand Down
166 changes: 166 additions & 0 deletions pkg/tools/update_plan.go
Original file line number Diff line number Diff line change
@@ -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
}
101 changes: 101 additions & 0 deletions pkg/tools/update_plan_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}