- Add 'emoji' field to ToolEntry and 'get_emoji()' to ToolRegistry - Add emoji= to all 50+ registry.register() calls across tool files - Add get_tool_emoji() helper in agent/display.py with 3-tier resolution: skin override → registry default → hardcoded fallback - Replace hardcoded emoji maps in run_agent.py, delegate_tool.py, and gateway/run.py with centralized get_tool_emoji() calls - Add 'tool_emojis' field to SkinConfig so skins can override per-tool emojis (e.g. ares skin could use swords instead of wrenches) - Add 11 tests (5 registry emoji, 6 display/skin integration) - Update AGENTS.md skin docs table Based on the approach from PR #1061 by ForgingAlex (emoji centralization in registry). This salvage fixes several issues from the original: - Does NOT split the cronjob tool (which would crash on missing schemas) - Does NOT change image_generate toolset/requires_env/is_async - Does NOT delete existing tests - Completes the centralization (gateway/run.py was missed) - Hooks into the skin system for full customizability
269 lines
9.4 KiB
Python
269 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Todo Tool Module - Planning & Task Management
|
|
|
|
Provides an in-memory task list the agent uses to decompose complex tasks,
|
|
track progress, and maintain focus across long conversations. The state
|
|
lives on the AIAgent instance (one per session) and is re-injected into
|
|
the conversation after context compression events.
|
|
|
|
Design:
|
|
- Single `todo` tool: provide `todos` param to write, omit to read
|
|
- Every call returns the full current list
|
|
- No system prompt mutation, no tool response modification
|
|
- Behavioral guidance lives entirely in the tool schema description
|
|
"""
|
|
|
|
import json
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
|
|
# Valid status values for todo items
|
|
VALID_STATUSES = {"pending", "in_progress", "completed", "cancelled"}
|
|
|
|
|
|
class TodoStore:
|
|
"""
|
|
In-memory todo list. One instance per AIAgent (one per session).
|
|
|
|
Items are ordered -- list position is priority. Each item has:
|
|
- id: unique string identifier (agent-chosen)
|
|
- content: task description
|
|
- status: pending | in_progress | completed | cancelled
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._items: List[Dict[str, str]] = []
|
|
|
|
def write(self, todos: List[Dict[str, Any]], merge: bool = False) -> List[Dict[str, str]]:
|
|
"""
|
|
Write todos. Returns the full current list after writing.
|
|
|
|
Args:
|
|
todos: list of {id, content, status} dicts
|
|
merge: if False, replace the entire list. If True, update
|
|
existing items by id and append new ones.
|
|
"""
|
|
if not merge:
|
|
# Replace mode: new list entirely
|
|
self._items = [self._validate(t) for t in todos]
|
|
else:
|
|
# Merge mode: update existing items by id, append new ones
|
|
existing = {item["id"]: item for item in self._items}
|
|
for t in todos:
|
|
item_id = str(t.get("id", "")).strip()
|
|
if not item_id:
|
|
continue # Can't merge without an id
|
|
|
|
if item_id in existing:
|
|
# Update only the fields the LLM actually provided
|
|
if "content" in t and t["content"]:
|
|
existing[item_id]["content"] = str(t["content"]).strip()
|
|
if "status" in t and t["status"]:
|
|
status = str(t["status"]).strip().lower()
|
|
if status in VALID_STATUSES:
|
|
existing[item_id]["status"] = status
|
|
else:
|
|
# New item -- validate fully and append to end
|
|
validated = self._validate(t)
|
|
existing[validated["id"]] = validated
|
|
self._items.append(validated)
|
|
# Rebuild _items preserving order for existing items
|
|
seen = set()
|
|
rebuilt = []
|
|
for item in self._items:
|
|
current = existing.get(item["id"], item)
|
|
if current["id"] not in seen:
|
|
rebuilt.append(current)
|
|
seen.add(current["id"])
|
|
self._items = rebuilt
|
|
return self.read()
|
|
|
|
def read(self) -> List[Dict[str, str]]:
|
|
"""Return a copy of the current list."""
|
|
return [item.copy() for item in self._items]
|
|
|
|
def has_items(self) -> bool:
|
|
"""Check if there are any items in the list."""
|
|
return len(self._items) > 0
|
|
|
|
def format_for_injection(self) -> Optional[str]:
|
|
"""
|
|
Render the todo list for post-compression injection.
|
|
|
|
Returns a human-readable string to append to the compressed
|
|
message history, or None if the list is empty.
|
|
"""
|
|
if not self._items:
|
|
return None
|
|
|
|
# Status markers for compact display
|
|
markers = {
|
|
"completed": "[x]",
|
|
"in_progress": "[>]",
|
|
"pending": "[ ]",
|
|
"cancelled": "[~]",
|
|
}
|
|
|
|
# Only inject pending/in_progress items — completed/cancelled ones
|
|
# cause the model to re-do finished work after compression.
|
|
active_items = [
|
|
item for item in self._items
|
|
if item["status"] in ("pending", "in_progress")
|
|
]
|
|
if not active_items:
|
|
return None
|
|
|
|
lines = ["[Your active task list was preserved across context compression]"]
|
|
for item in active_items:
|
|
marker = markers.get(item["status"], "[?]")
|
|
lines.append(f"- {marker} {item['id']}. {item['content']} ({item['status']})")
|
|
|
|
return "\n".join(lines)
|
|
|
|
@staticmethod
|
|
def _validate(item: Dict[str, Any]) -> Dict[str, str]:
|
|
"""
|
|
Validate and normalize a todo item.
|
|
|
|
Ensures required fields exist and status is valid.
|
|
Returns a clean dict with only {id, content, status}.
|
|
"""
|
|
item_id = str(item.get("id", "")).strip()
|
|
if not item_id:
|
|
item_id = "?"
|
|
|
|
content = str(item.get("content", "")).strip()
|
|
if not content:
|
|
content = "(no description)"
|
|
|
|
status = str(item.get("status", "pending")).strip().lower()
|
|
if status not in VALID_STATUSES:
|
|
status = "pending"
|
|
|
|
return {"id": item_id, "content": content, "status": status}
|
|
|
|
|
|
def todo_tool(
|
|
todos: Optional[List[Dict[str, Any]]] = None,
|
|
merge: bool = False,
|
|
store: Optional[TodoStore] = None,
|
|
) -> str:
|
|
"""
|
|
Single entry point for the todo tool. Reads or writes depending on params.
|
|
|
|
Args:
|
|
todos: if provided, write these items. If None, read current list.
|
|
merge: if True, update by id. If False (default), replace entire list.
|
|
store: the TodoStore instance from the AIAgent.
|
|
|
|
Returns:
|
|
JSON string with the full current list and summary metadata.
|
|
"""
|
|
if store is None:
|
|
return json.dumps({"error": "TodoStore not initialized"}, ensure_ascii=False)
|
|
|
|
if todos is not None:
|
|
items = store.write(todos, merge)
|
|
else:
|
|
items = store.read()
|
|
|
|
# Build summary counts
|
|
pending = sum(1 for i in items if i["status"] == "pending")
|
|
in_progress = sum(1 for i in items if i["status"] == "in_progress")
|
|
completed = sum(1 for i in items if i["status"] == "completed")
|
|
cancelled = sum(1 for i in items if i["status"] == "cancelled")
|
|
|
|
return json.dumps({
|
|
"todos": items,
|
|
"summary": {
|
|
"total": len(items),
|
|
"pending": pending,
|
|
"in_progress": in_progress,
|
|
"completed": completed,
|
|
"cancelled": cancelled,
|
|
},
|
|
}, ensure_ascii=False)
|
|
|
|
|
|
def check_todo_requirements() -> bool:
|
|
"""Todo tool has no external requirements -- always available."""
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# OpenAI Function-Calling Schema
|
|
# =============================================================================
|
|
# Behavioral guidance is baked into the description so it's part of the
|
|
# static tool schema (cached, never changes mid-conversation).
|
|
|
|
TODO_SCHEMA = {
|
|
"name": "todo",
|
|
"description": (
|
|
"Manage your task list for the current session. Use for complex tasks "
|
|
"with 3+ steps or when the user provides multiple tasks. "
|
|
"Call with no parameters to read the current list.\n\n"
|
|
"Writing:\n"
|
|
"- Provide 'todos' array to create/update items\n"
|
|
"- merge=false (default): replace the entire list with a fresh plan\n"
|
|
"- merge=true: update existing items by id, add any new ones\n\n"
|
|
"Each item: {id: string, content: string, "
|
|
"status: pending|in_progress|completed|cancelled}\n"
|
|
"List order is priority. Only ONE item in_progress at a time.\n"
|
|
"Mark items completed immediately when done. If something fails, "
|
|
"cancel it and add a revised item.\n\n"
|
|
"Always returns the full current list."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"todos": {
|
|
"type": "array",
|
|
"description": "Task items to write. Omit to read current list.",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {
|
|
"type": "string",
|
|
"description": "Unique item identifier"
|
|
},
|
|
"content": {
|
|
"type": "string",
|
|
"description": "Task description"
|
|
},
|
|
"status": {
|
|
"type": "string",
|
|
"enum": ["pending", "in_progress", "completed", "cancelled"],
|
|
"description": "Current status"
|
|
}
|
|
},
|
|
"required": ["id", "content", "status"]
|
|
}
|
|
},
|
|
"merge": {
|
|
"type": "boolean",
|
|
"description": (
|
|
"true: update existing items by id, add new ones. "
|
|
"false (default): replace the entire list."
|
|
),
|
|
"default": False
|
|
}
|
|
},
|
|
"required": []
|
|
}
|
|
}
|
|
|
|
|
|
# --- Registry ---
|
|
from tools.registry import registry
|
|
|
|
registry.register(
|
|
name="todo",
|
|
toolset="todo",
|
|
schema=TODO_SCHEMA,
|
|
handler=lambda args, **kw: todo_tool(
|
|
todos=args.get("todos"), merge=args.get("merge", False), store=kw.get("store")),
|
|
check_fn=check_todo_requirements,
|
|
emoji="📋",
|
|
)
|