This commit is contained in:
Anton Bogdanovich 2026-05-15 11:29:30 +03:00 committed by GitHub
commit be3de14bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 320 additions and 0 deletions

View file

@ -268,6 +268,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 ## Cron Tool
The cron tool is used for scheduling periodic tasks. The cron tool is used for scheduling periodic tasks.

View file

@ -157,6 +157,9 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("serial") { if cfg.Tools.IsToolEnabled("serial") {
agent.Tools.Register(tools.NewSerialTool()) agent.Tools.Register(tools.NewSerialTool())
} }
if cfg.Tools.IsToolEnabled("update_plan") {
agent.Tools.Register(tools.NewUpdatePlanTool())
}
// Message tool // Message tool
if cfg.Tools.IsToolEnabled("message") { if cfg.Tools.IsToolEnabled("message") {

View file

@ -1006,6 +1006,7 @@ type ToolsConfig struct {
SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"`
Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` 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_"` WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
} }
@ -1737,6 +1738,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.SPI.Enabled return t.SPI.Enabled
case "subagent": case "subagent":
return t.Subagent.Enabled return t.Subagent.Enabled
case "update_plan":
return t.UpdatePlan.Enabled
case "web_fetch": case "web_fetch":
return t.WebFetch.Enabled return t.WebFetch.Enabled
case "send_file": case "send_file":

View file

@ -1274,6 +1274,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) { func TestDefaultConfig_HooksDefaults(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if !cfg.Hooks.Enabled { if !cfg.Hooks.Enabled {

View file

@ -467,6 +467,9 @@ func DefaultConfig() *Config {
Subagent: ToolConfig{ Subagent: ToolConfig{
Enabled: true, Enabled: true,
}, },
UpdatePlan: ToolConfig{
Enabled: false,
},
WebFetch: ToolConfig{ WebFetch: ToolConfig{
Enabled: true, Enabled: true,
}, },

166
pkg/tools/update_plan.go Normal file
View file

@ -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
}

View file

@ -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)
}
}