365 lines
13 KiB
Python
365 lines
13 KiB
Python
# tool/planning.py
|
|
from typing import Dict, List, Literal, Optional
|
|
|
|
from app.exceptions import ToolError
|
|
from app.tool.base import BaseTool, ToolResult
|
|
|
|
|
|
_PLANNING_TOOL_DESCRIPTION = """
|
|
A planning tool that allows the agent to create and manage plans for solving complex tasks.
|
|
The tool provides functionality for creating plans, updating plan steps, and tracking progress.
|
|
"""
|
|
|
|
|
|
class PlanningTool(BaseTool):
|
|
"""
|
|
A planning tool that allows the agent to create and manage plans for solving complex tasks.
|
|
The tool provides functionality for creating plans, updating plan steps, and tracking progress.
|
|
"""
|
|
|
|
name: str = "planning"
|
|
description: str = _PLANNING_TOOL_DESCRIPTION
|
|
parameters: dict = {
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {
|
|
"description": "The command to execute. Available commands: create, update, list, get, set_active, mark_step, delete.",
|
|
"enum": [
|
|
"create",
|
|
"update",
|
|
"list",
|
|
"get",
|
|
"set_active",
|
|
"mark_step",
|
|
"delete",
|
|
],
|
|
"type": "string",
|
|
},
|
|
"plan_id": {
|
|
"description": "Unique identifier for the plan. Required for create, update, set_active, and delete commands. Optional for get and mark_step (uses active plan if not specified).",
|
|
"type": "string",
|
|
},
|
|
"title": {
|
|
"description": "Title for the plan. Required for create command, optional for update command.",
|
|
"type": "string",
|
|
},
|
|
"steps": {
|
|
"description": "List of plan steps. Required for create command, optional for update command.",
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
},
|
|
"step_index": {
|
|
"description": "Index of the step to update (0-based). Required for mark_step command.",
|
|
"type": "integer",
|
|
},
|
|
"step_status": {
|
|
"description": "Status to set for a step. Used with mark_step command.",
|
|
"enum": ["not_started", "in_progress", "completed", "blocked"],
|
|
"type": "string",
|
|
},
|
|
"step_notes": {
|
|
"description": "Additional notes for a step. Optional for mark_step command.",
|
|
"type": "string",
|
|
},
|
|
},
|
|
"required": ["command"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
_plans = {} # Dictionary to store plans by plan_id
|
|
_current_plan_id = None # Track the current active plan
|
|
|
|
# async def __call__(
|
|
async def execute(
|
|
self,
|
|
*,
|
|
command: Literal[
|
|
"create", "update", "list", "get", "set_active", "mark_step", "delete"
|
|
],
|
|
plan_id: Optional[str] = None,
|
|
title: Optional[str] = None,
|
|
steps: Optional[List[str]] = None,
|
|
step_index: Optional[int] = None,
|
|
step_status: Optional[
|
|
Literal["not_started", "in_progress", "completed", "blocked"]
|
|
] = None,
|
|
step_notes: Optional[str] = None,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Execute the planning tool with the given command and parameters.
|
|
|
|
Parameters:
|
|
- command: The operation to perform
|
|
- plan_id: Unique identifier for the plan
|
|
- title: Title for the plan (used with create command)
|
|
- steps: List of steps for the plan (used with create command)
|
|
- step_index: Index of the step to update (used with mark_step command)
|
|
- step_status: Status to set for a step (used with mark_step command)
|
|
- step_notes: Additional notes for a step (used with mark_step command)
|
|
"""
|
|
|
|
if command == "create":
|
|
return self._create_plan(plan_id, title, steps)
|
|
elif command == "update":
|
|
return self._update_plan(plan_id, title, steps)
|
|
elif command == "list":
|
|
return self._list_plans()
|
|
elif command == "get":
|
|
return self._get_plan(plan_id)
|
|
elif command == "set_active":
|
|
return self._set_active_plan(plan_id)
|
|
elif command == "mark_step":
|
|
return self._mark_step(plan_id, step_index, step_status, step_notes)
|
|
elif command == "delete":
|
|
return self._delete_plan(plan_id)
|
|
else:
|
|
raise ToolError(
|
|
f"Unrecognized command: {command}. Allowed commands are: create, update, list, get, set_active, mark_step, delete"
|
|
)
|
|
|
|
def _create_plan(
|
|
self, plan_id: Optional[str], title: Optional[str], steps: Optional[List[str]]
|
|
) -> ToolResult:
|
|
"""Create a new plan with the given ID, title, and steps."""
|
|
if not plan_id:
|
|
raise ToolError("Parameter `plan_id` is required for command: create")
|
|
|
|
if plan_id in self._plans:
|
|
raise ToolError(
|
|
f"A plan with ID '{plan_id}' already exists. Use 'update' to modify existing plans."
|
|
)
|
|
|
|
if not title:
|
|
raise ToolError("Parameter `title` is required for command: create")
|
|
|
|
if (
|
|
not steps
|
|
or not isinstance(steps, list)
|
|
or not all(isinstance(step, str) for step in steps)
|
|
):
|
|
raise ToolError(
|
|
"Parameter `steps` must be a non-empty list of strings for command: create"
|
|
)
|
|
|
|
# Create a new plan with initialized step statuses
|
|
plan = {
|
|
"plan_id": plan_id,
|
|
"title": title,
|
|
"steps": steps,
|
|
"step_statuses": ["not_started"] * len(steps),
|
|
"step_notes": [""] * len(steps),
|
|
}
|
|
|
|
self._plans[plan_id] = plan
|
|
self._current_plan_id = plan_id # Set as active plan
|
|
|
|
return ToolResult(
|
|
output=f"Plan created successfully with ID: {plan_id}\n\n{self._format_plan(plan)}"
|
|
)
|
|
|
|
def _update_plan(
|
|
self, plan_id: Optional[str], title: Optional[str], steps: Optional[List[str]]
|
|
) -> ToolResult:
|
|
"""Update an existing plan with new title or steps."""
|
|
if not plan_id:
|
|
raise ToolError("Parameter `plan_id` is required for command: update")
|
|
|
|
if plan_id not in self._plans:
|
|
raise ToolError(f"No plan found with ID: {plan_id}")
|
|
|
|
plan = self._plans[plan_id]
|
|
|
|
if title:
|
|
plan["title"] = title
|
|
|
|
if steps:
|
|
if not isinstance(steps, list) or not all(
|
|
isinstance(step, str) for step in steps
|
|
):
|
|
raise ToolError(
|
|
"Parameter `steps` must be a list of strings for command: update"
|
|
)
|
|
|
|
# Preserve existing step statuses for unchanged steps
|
|
old_steps = plan["steps"]
|
|
old_statuses = plan["step_statuses"]
|
|
old_notes = plan["step_notes"]
|
|
|
|
# Create new step statuses and notes
|
|
new_statuses = []
|
|
new_notes = []
|
|
|
|
for i, step in enumerate(steps):
|
|
# If the step exists at the same position in old steps, preserve status and notes
|
|
if i < len(old_steps) and step == old_steps[i]:
|
|
new_statuses.append(old_statuses[i])
|
|
new_notes.append(old_notes[i])
|
|
else:
|
|
new_statuses.append("not_started")
|
|
new_notes.append("")
|
|
|
|
plan["steps"] = steps
|
|
plan["step_statuses"] = new_statuses
|
|
plan["step_notes"] = new_notes
|
|
|
|
return ToolResult(
|
|
output=f"Plan updated successfully: {plan_id}\n\n{self._format_plan(plan)}"
|
|
)
|
|
|
|
def _list_plans(self) -> ToolResult:
|
|
"""List all available plans."""
|
|
if not self._plans:
|
|
return ToolResult(
|
|
output="No plans available. Create a plan with the 'create' command."
|
|
)
|
|
|
|
output = "Available plans:\n"
|
|
for plan_id, plan in self._plans.items():
|
|
current_marker = " (active)" if plan_id == self._current_plan_id else ""
|
|
completed = sum(
|
|
1 for status in plan["step_statuses"] if status == "completed"
|
|
)
|
|
total = len(plan["steps"])
|
|
progress = f"{completed}/{total} steps completed"
|
|
output += f"• {plan_id}{current_marker}: {plan['title']} - {progress}\n"
|
|
|
|
return ToolResult(output=output)
|
|
|
|
def _get_plan(self, plan_id: Optional[str]) -> ToolResult:
|
|
"""Get details of a specific plan."""
|
|
if not plan_id:
|
|
# If no plan_id is provided, use the current active plan
|
|
if not self._current_plan_id:
|
|
raise ToolError(
|
|
"No active plan. Please specify a plan_id or set an active plan."
|
|
)
|
|
plan_id = self._current_plan_id
|
|
|
|
if plan_id not in self._plans:
|
|
raise ToolError(f"No plan found with ID: {plan_id}")
|
|
|
|
plan = self._plans[plan_id]
|
|
return ToolResult(output=self._format_plan(plan))
|
|
|
|
def _set_active_plan(self, plan_id: Optional[str]) -> ToolResult:
|
|
"""Set a plan as the active plan."""
|
|
if not plan_id:
|
|
raise ToolError("Parameter `plan_id` is required for command: set_active")
|
|
|
|
if plan_id not in self._plans:
|
|
raise ToolError(f"No plan found with ID: {plan_id}")
|
|
|
|
self._current_plan_id = plan_id
|
|
return ToolResult(
|
|
output=f"Plan '{plan_id}' is now the active plan.\n\n{self._format_plan(self._plans[plan_id])}"
|
|
)
|
|
|
|
def _mark_step(
|
|
self,
|
|
plan_id: Optional[str],
|
|
step_index: Optional[int],
|
|
step_status: Optional[str],
|
|
step_notes: Optional[str],
|
|
) -> ToolResult:
|
|
"""Mark a step with a specific status and optional notes."""
|
|
if not plan_id:
|
|
# If no plan_id is provided, use the current active plan
|
|
if not self._current_plan_id:
|
|
raise ToolError(
|
|
"No active plan. Please specify a plan_id or set an active plan."
|
|
)
|
|
plan_id = self._current_plan_id
|
|
|
|
if plan_id not in self._plans:
|
|
raise ToolError(f"No plan found with ID: {plan_id}")
|
|
|
|
if step_index is None:
|
|
raise ToolError("Parameter `step_index` is required for command: mark_step")
|
|
|
|
plan = self._plans[plan_id]
|
|
|
|
if step_index < 0 or step_index >= len(plan["steps"]):
|
|
raise ToolError(
|
|
f"Invalid step_index: {step_index}. Valid indices range from 0 to {len(plan['steps'])-1}."
|
|
)
|
|
|
|
if step_status and step_status not in [
|
|
"not_started",
|
|
"in_progress",
|
|
"completed",
|
|
"blocked",
|
|
]:
|
|
raise ToolError(
|
|
f"Invalid step_status: {step_status}. Valid statuses are: not_started, in_progress, completed, blocked"
|
|
)
|
|
|
|
if step_status:
|
|
plan["step_statuses"][step_index] = step_status
|
|
|
|
if step_notes:
|
|
plan["step_notes"][step_index] = step_notes
|
|
|
|
return ToolResult(
|
|
output=f"Step {step_index} updated in plan '{plan_id}'.\n\n{self._format_plan(plan)}"
|
|
)
|
|
|
|
def _delete_plan(self, plan_id: Optional[str]) -> ToolResult:
|
|
"""Delete a plan."""
|
|
if not plan_id:
|
|
raise ToolError("Parameter `plan_id` is required for command: delete")
|
|
|
|
if plan_id not in self._plans:
|
|
raise ToolError(f"No plan found with ID: {plan_id}")
|
|
|
|
del self._plans[plan_id]
|
|
|
|
# If the deleted plan was the active plan, clear the active plan
|
|
if self._current_plan_id == plan_id:
|
|
self._current_plan_id = None
|
|
|
|
return ToolResult(output=f"Plan '{plan_id}' has been deleted.")
|
|
|
|
def _format_plan(self, plan: Dict) -> str:
|
|
"""Format a plan for display."""
|
|
output = f"Plan: {plan['title']} (ID: {plan['plan_id']})\n"
|
|
output += "=" * len(output) + "\n\n"
|
|
|
|
# Calculate progress statistics
|
|
total_steps = len(plan["steps"])
|
|
completed = sum(1 for status in plan["step_statuses"] if status == "completed")
|
|
in_progress = sum(
|
|
1 for status in plan["step_statuses"] if status == "in_progress"
|
|
)
|
|
blocked = sum(1 for status in plan["step_statuses"] if status == "blocked")
|
|
not_started = sum(
|
|
1 for status in plan["step_statuses"] if status == "not_started"
|
|
)
|
|
|
|
output += f"Progress: {completed}/{total_steps} steps completed "
|
|
if total_steps > 0:
|
|
percentage = (completed / total_steps) * 100
|
|
output += f"({percentage:.1f}%)\n"
|
|
else:
|
|
output += "(0%)\n"
|
|
|
|
output += f"Status: {completed} completed, {in_progress} in progress, {blocked} blocked, {not_started} not started\n\n"
|
|
output += "Steps:\n"
|
|
|
|
# Add each step with its status and notes
|
|
for i, (step, status, notes) in enumerate(
|
|
zip(plan["steps"], plan["step_statuses"], plan["step_notes"])
|
|
):
|
|
status_symbol = {
|
|
"not_started": "[ ]",
|
|
"in_progress": "[→]",
|
|
"completed": "[✓]",
|
|
"blocked": "[!]",
|
|
}.get(status, "[ ]")
|
|
|
|
output += f"{i}. {status_symbol} {step}\n"
|
|
if notes:
|
|
output += f" Notes: {notes}\n"
|
|
|
|
return output
|