Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Todo Tool Module - Planning & Task Management
|
||||
|
||||
Provides an in-memory, revisioned 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), is re-injected into
|
||||
the conversation after context compression events, and every write bumps a
|
||||
monotonic revision so UI clients can reject stale updates.
|
||||
|
||||
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 Any, Dict, List, Optional
|
||||
|
||||
|
||||
# Valid status values for todo items
|
||||
VALID_STATUSES = {"pending", "in_progress", "completed", "cancelled"}
|
||||
|
||||
# Bounds on persisted todo state. The todo list is a planning aid the model
|
||||
# re-reads after every context-compression event (see format_for_injection),
|
||||
# so unbounded item content or count defeats the compression it rides through.
|
||||
# These caps keep a single oversized item (whether authored by the model or
|
||||
# replayed from caller-supplied history on the API server) from inflating the
|
||||
# re-injection block. Generous relative to real plans — a todo item is a short
|
||||
# task description, and active lists are a handful of items, not hundreds.
|
||||
MAX_TODO_CONTENT_CHARS = 4000
|
||||
MAX_TODO_ITEMS = 256
|
||||
# Upper bound on a single todo tool-result payload accepted during history
|
||||
# hydration. The gateway/API server replays caller-supplied conversation
|
||||
# history to rebuild the store, so an oversized forged result is dropped
|
||||
# before it is parsed and re-injected (see AIAgent._hydrate_todo_store).
|
||||
MAX_TODO_RESULT_CHARS = 512_000
|
||||
_TRUNCATION_MARKER = "… [truncated]"
|
||||
# Persisted as ordinary message content. ContextCompressor uses this stable
|
||||
# header to distinguish the synthetic post-compaction row from a real user.
|
||||
TODO_INJECTION_HEADER = (
|
||||
"[Your active task list was preserved across context compression]"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
- parent: optional id of another item, for nested subtasks
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: List[Dict[str, str]] = []
|
||||
self._revision = 0
|
||||
|
||||
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.
|
||||
"""
|
||||
before = self.read()
|
||||
if not merge:
|
||||
# Replace mode: new list entirely
|
||||
self._items = self._normalize_order(
|
||||
[self._validate(t) for t in self._dedupe_by_id(todos)]
|
||||
)
|
||||
else:
|
||||
# Merge mode: update existing items by id, append new ones
|
||||
existing = {item["id"]: item for item in self._items}
|
||||
for t in self._dedupe_by_id(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"] = self._cap_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
|
||||
if "parent" in t:
|
||||
parent = str(t["parent"] or "").strip()
|
||||
if parent:
|
||||
existing[item_id]["parent"] = parent
|
||||
else:
|
||||
existing[item_id].pop("parent", None)
|
||||
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 = self._normalize_order(rebuilt)
|
||||
# Bound total item count so a replayed/oversized list can't grow the
|
||||
# re-injection block without limit. Keep the highest-priority head
|
||||
# (list order is priority).
|
||||
if len(self._items) > MAX_TODO_ITEMS:
|
||||
self._items = self._items[:MAX_TODO_ITEMS]
|
||||
self._sanitize_parents(self._items)
|
||||
if self._items != before:
|
||||
self._revision += 1
|
||||
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 bool(self._items)
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
"""Return the full state clients can reconcile atomically."""
|
||||
return {"todos": self.read(), "revision": self._revision}
|
||||
|
||||
def restore(
|
||||
self,
|
||||
todos: List[Dict[str, Any]],
|
||||
*,
|
||||
revision: Any = 0,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Restore a trusted snapshot without manufacturing a new revision."""
|
||||
self._items = self._normalize_order(
|
||||
[self._validate(t) for t in self._dedupe_by_id(todos)]
|
||||
)[:MAX_TODO_ITEMS]
|
||||
try:
|
||||
self._revision = max(0, int(revision or 0))
|
||||
except (TypeError, ValueError):
|
||||
self._revision = 0
|
||||
return self.read()
|
||||
|
||||
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. A parent
|
||||
# is kept (with its real status marker) when any descendant is
|
||||
# active, so subtasks keep their context.
|
||||
active = {"pending", "in_progress"}
|
||||
children: Dict[str, List[Dict[str, str]]] = {}
|
||||
roots: List[Dict[str, str]] = []
|
||||
for item in self._items:
|
||||
parent = item.get("parent")
|
||||
if parent:
|
||||
children.setdefault(parent, []).append(item)
|
||||
else:
|
||||
roots.append(item)
|
||||
|
||||
def render(item: Dict[str, str], depth: int, out: List[str]) -> bool:
|
||||
kid_lines: List[str] = []
|
||||
has_active_kid = False
|
||||
for kid in children.get(item["id"], []):
|
||||
has_active_kid |= render(kid, depth + 1, kid_lines)
|
||||
keep = item["status"] in active or has_active_kid
|
||||
if keep:
|
||||
marker = markers.get(item["status"], "[?]")
|
||||
out.append(
|
||||
f"{' ' * depth}- {marker} {item['id']}. "
|
||||
f"{item['content']} ({item['status']})"
|
||||
)
|
||||
out.extend(kid_lines)
|
||||
return keep
|
||||
|
||||
lines = [TODO_INJECTION_HEADER]
|
||||
for item in roots:
|
||||
render(item, 0, lines)
|
||||
if len(lines) == 1:
|
||||
return None
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _cap_content(content: str) -> str:
|
||||
"""Truncate oversized todo content to MAX_TODO_CONTENT_CHARS.
|
||||
|
||||
A single huge item would otherwise inflate the post-compression
|
||||
re-injection block (format_for_injection) without bound. Keep the
|
||||
head — the actionable part of a task description — plus a marker.
|
||||
"""
|
||||
if len(content) > MAX_TODO_CONTENT_CHARS:
|
||||
keep = MAX_TODO_CONTENT_CHARS - len(_TRUNCATION_MARKER)
|
||||
return content[:keep] + _TRUNCATION_MARKER
|
||||
return content
|
||||
|
||||
@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}.
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
return {"id": "?", "content": "(invalid item)", "status": "pending"}
|
||||
|
||||
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)"
|
||||
else:
|
||||
content = TodoStore._cap_content(content)
|
||||
|
||||
status = str(item.get("status", "pending")).strip().lower()
|
||||
if status not in VALID_STATUSES:
|
||||
status = "pending"
|
||||
|
||||
result = {"id": item_id, "content": content, "status": status}
|
||||
parent = str(item.get("parent") or "").strip()
|
||||
if parent and parent != item_id:
|
||||
result["parent"] = parent
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_parents(items: List[Dict[str, str]]) -> None:
|
||||
"""Drop dangling parent refs and break cycles (in place).
|
||||
|
||||
A parent pointing at a missing id, or a chain that loops back on
|
||||
itself, would corrupt tree rendering — such items become roots.
|
||||
"""
|
||||
ids = {item["id"] for item in items}
|
||||
by_id = {item["id"]: item for item in items}
|
||||
for item in items:
|
||||
parent = item.get("parent")
|
||||
if parent and parent not in ids:
|
||||
item.pop("parent", None)
|
||||
for item in items:
|
||||
seen = {item["id"]}
|
||||
node = item
|
||||
while node.get("parent"):
|
||||
if node["parent"] in seen:
|
||||
item.pop("parent", None)
|
||||
break
|
||||
seen.add(node["parent"])
|
||||
node = by_id[node["parent"]]
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_by_id(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Collapse duplicate ids, keeping the last occurrence in its position."""
|
||||
last_index: Dict[str, int] = {}
|
||||
for i, item in enumerate(todos):
|
||||
if not isinstance(item, dict):
|
||||
# Non-dict items get a synthetic key so _validate can handle them
|
||||
last_index[f"__invalid_{i}"] = i
|
||||
continue
|
||||
item_id = str(item.get("id", "")).strip() or "?"
|
||||
last_index[item_id] = i
|
||||
return [todos[i] for i in sorted(last_index.values())]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_order(items: List[Dict[str, str]]) -> List[Dict[str, str]]:
|
||||
"""Lift the active step ahead of any earlier unfinished placeholders."""
|
||||
# Nested lists keep authored order — reordering a flat position would
|
||||
# tear a subtask away from its siblings.
|
||||
if any(item.get("parent") for item in items):
|
||||
return items
|
||||
active_index = next(
|
||||
(i for i, item in enumerate(items) if item["status"] == "in_progress"),
|
||||
None,
|
||||
)
|
||||
if active_index is None:
|
||||
return items
|
||||
|
||||
pending_index = next(
|
||||
(
|
||||
i for i, item in enumerate(items[:active_index])
|
||||
if item["status"] == "pending"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pending_index is None:
|
||||
return items
|
||||
|
||||
normalized = items.copy()
|
||||
active_item = normalized.pop(active_index)
|
||||
normalized.insert(pending_index, active_item)
|
||||
return normalized
|
||||
|
||||
|
||||
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 tool_error("TodoStore not initialized")
|
||||
|
||||
if todos is not None:
|
||||
# Guard: LLM sometimes sends todos as a JSON string instead of a list
|
||||
if isinstance(todos, str):
|
||||
try:
|
||||
todos = json.loads(todos)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return tool_error("todos must be a list of objects, got unparseable string")
|
||||
if not isinstance(todos, list):
|
||||
return tool_error(
|
||||
f"todos must be a list, got {type(todos).__name__}"
|
||||
)
|
||||
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,
|
||||
"revision": store.snapshot()["revision"],
|
||||
"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_list",
|
||||
# Dieted (#95681): the item shape and merge semantics live ONLY in the
|
||||
# parameter schema below — the description teaches behavior, not
|
||||
# structure the params already define.
|
||||
"description": (
|
||||
"Track a task list for multi-step work (3+ steps). Use for complex tasks "
|
||||
"with 3+ steps or when the user provides multiple tasks. "
|
||||
"For 'all N items' tasks, enumerate every instance as its own checklist "
|
||||
"item so none are silently dropped. "
|
||||
"Call with no parameters to read the current list.\n"
|
||||
"List order is priority. Only ONE item in_progress at a time. "
|
||||
"Break large phases into subtasks via parent. "
|
||||
"Mark an item completed only after the work is verified done, never "
|
||||
"based on intent. If something fails, cancel it and add a revised "
|
||||
"item. Always returns the full current list."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"description": "Task items to write.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Task description"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "completed", "cancelled"]
|
||||
},
|
||||
"parent": {
|
||||
"type": "string",
|
||||
"description": "Optional id of another item, making this a nested subtask. Omit for top-level."
|
||||
}
|
||||
},
|
||||
"required": ["id", "content", "status"]
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"true: update existing items by id, add new ones. "
|
||||
"false (default): replace the entire list with a fresh plan."
|
||||
),
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# --- Registry ---
|
||||
from tools.registry import registry, tool_error
|
||||
|
||||
registry.register(
|
||||
name="todo_list",
|
||||
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="📋",
|
||||
)
|
||||
Reference in New Issue
Block a user