port update_plan tool
This commit is contained in:
parent
5745957429
commit
557cf8ff04
7 changed files with 320 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -455,6 +455,9 @@ func DefaultConfig() *Config {
|
|||
Subagent: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
UpdatePlan: ToolConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
WebFetch: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
|
|
|
|||
166
pkg/tools/update_plan.go
Normal file
166
pkg/tools/update_plan.go
Normal 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
|
||||
}
|
||||
101
pkg/tools/update_plan_test.go
Normal file
101
pkg/tools/update_plan_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue