UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

440 lines (439 loc) • 21.8 kB
import { Type } from "typebox"; import { toolFace } from "../core/tool-catalog-entries.js"; const STATUS = Type.Union([ Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed"), ]); function renderTask(t) { const deps = [ t.blocks.length ? `blocks: ${t.blocks.join(", ")}` : "", t.blockedBy.length ? `blockedBy: ${t.blockedBy.join(", ")}` : "", ] .filter(Boolean) .join("; "); return [ `Task #${t.id}: ${t.subject} [${t.status}]${t.owner ? ` (owner: ${t.owner})` : ""}`, `Description: ${t.description}`, t.activeForm ? `Active form: ${t.activeForm}` : "", deps ? `Dependencies — ${deps}` : "", ] .filter(Boolean) .join("\n"); } function snapTask(t) { const n = normalizeTaskShape(t); return { ...n, blocks: [...n.blocks], blockedBy: [...n.blockedBy], ...(n.metadata ? { metadata: structuredClone(n.metadata) } : {}) }; } export function normalizeTaskShape(item) { const t = item; if (!Array.isArray(t.blocks) || !Array.isArray(t.blockedBy)) { return { ...item, blocks: Array.isArray(t.blocks) ? t.blocks : [], blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy : [], }; } return item; } export function assertJsonMetadata(value, path = "metadata") { if (value === null) return; const t = typeof value; if (t === "string" || t === "number" || t === "boolean") return; if (Array.isArray(value)) { value.forEach((v, i) => assertJsonMetadata(v, `${path}[${i}]`)); return; } if (t === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) { for (const [k, v] of Object.entries(value)) { if (v === undefined) continue; assertJsonMetadata(v, `${path}.${k}`); } return; } throw new Error(`Task ${path} must be JSON-serializable (found ${t === "object" ? "non-plain object" : t}).`); } export function compareTaskIds(a, b) { const num = (id) => (/^\d+$/.test(id) ? Number.parseInt(id, 10) : Number.NaN); const [na, nb] = [num(a), num(b)]; if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb || a.localeCompare(b); if (Number.isFinite(na)) return -1; if (Number.isFinite(nb)) return 1; return a.localeCompare(b); } export function createMemoryTaskListStore() { const tasks = new Map(); let nextId = 1; return { get: (id) => { const t = tasks.get(id); return t ? snapTask(t) : undefined; }, set: (id, item) => { if (item.metadata) assertJsonMetadata(item.metadata); tasks.set(id, snapTask(item)); }, delete: (id) => tasks.delete(id), list: () => [...tasks.values()].sort((a, b) => compareTaskIds(a.id, b.id)).map(snapTask), allocateId: () => String(nextId++), }; } const storeChains = new WeakMap(); function serialized(store, fn) { const run = () => (store.mutate ? store.mutate(fn) : fn(store)); const prev = storeChains.get(store) ?? Promise.resolve(); const next = prev.then(run, run); storeChains.set(store, next.then(() => undefined, () => undefined)); return next; } export function createTaskListTools(store) { const tasks = store ?? createMemoryTaskListStore(); const taskCreate = { name: "TaskCreate", ...toolFace("task-create"), description: "Use this tool to create a structured task list for your current session. This helps you track progress, organize complex tasks, and demonstrate thoroughness.\n" + "It also helps the user understand the progress of the task and overall progress of their requests.\n" + "\n" + "## When to Use This Tool\n" + "\n" + "Use this tool proactively in these scenarios:\n" + "\n" + "- Complex multi-step tasks - When a task requires 3 or more distinct steps or actions\n" + "- Non-trivial and complex tasks - Tasks that require careful planning or multiple operations\n" + "- Plan mode - When using plan mode, create a task list to track the work\n" + "- User explicitly requests todo list - When the user directly asks you to use the todo list\n" + "- User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)\n" + "- After receiving new instructions - Immediately capture user requirements as tasks\n" + "- When you start working on a task - Mark it as in_progress BEFORE beginning work\n" + "- After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation\n" + "\n" + "## When NOT to Use This Tool\n" + "\n" + "Skip using this tool when:\n" + "- There is only a single, straightforward task\n" + "- The task is trivial and tracking it provides no organizational benefit\n" + "- The task can be completed in less than 3 trivial steps\n" + "- The task is purely conversational or informational\n" + "\n" + "NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.\n" + "\n" + "## Task Fields\n" + "\n" + '- **subject**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow")\n' + "- **description**: What needs to be done\n" + '- **activeForm** (optional): Present continuous form shown while the task is in_progress (e.g., "Fixing authentication bug"). If omitted, the subject is shown instead.\n' + "\n" + "All tasks are created with status `pending`.\n" + "\n" + "## Tips\n" + "\n" + "- Create tasks with clear, specific subjects that describe the outcome\n" + "- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed\n" + "- Check TaskList first to avoid creating duplicate tasks\n" + "\n" + "TaskCreate creates ONE task per call and has no `tasks` or `todos` parameter. Call TaskCreate once per task.", parameters: Type.Object({ subject: Type.String({ description: "A brief title for the task" }), description: Type.String({ description: "What needs to be done" }), activeForm: Type.Optional(Type.String({ description: 'Present continuous form shown when in_progress (e.g., "Running tests")' })), metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Arbitrary metadata to attach to the task" })), }), execute: (args) => serialized(tasks, async (tx) => { const a = args; if (a.metadata) assertJsonMetadata(a.metadata); const id = String(await tx.allocateId()); const item = { id, subject: a.subject, description: a.description, activeForm: a.activeForm, status: "pending", blocks: [], blockedBy: [], metadata: a.metadata, }; await tx.set(id, item); return { content: `Task #${id} created successfully: ${a.subject}`, details: { type: "task", task: snapTask(item) }, }; }), }; const taskGet = { name: "TaskGet", ...toolFace("task-get"), description: "Use this tool to retrieve a task by its ID from the task list.\n" + "\n" + "## When to Use This Tool\n" + "\n" + "- When you need the full description and context before starting work on a task\n" + "- To understand task dependencies (what it blocks, what blocks it)\n" + "- After being assigned a task, to get complete requirements\n" + "\n" + "## Output\n" + "\n" + "Returns full task details: subject, description, status, owner, dependencies (blocks/blockedBy), and metadata.\n" + "\n" + "## Tips\n" + "\n" + "- After fetching a task, verify its blockedBy list is empty before beginning work.\n" + "- Use TaskList to see all tasks in summary form.", parameters: Type.Object({ taskId: Type.String({ description: "The ID of the task to retrieve" }), }), execute: (args) => serialized(tasks, async (tx) => { const id = String(args.taskId); const t = await tx.get(id); if (!t) { throw new Error(`Task #${id} not found.`); } return { content: renderTask(t), details: { type: "task", task: snapTask(t) } }; }), }; const taskUpdate = { name: "TaskUpdate", ...toolFace("task-update"), description: "Use this tool to update a task in the task list.\n" + "\n" + "## When to Use This Tool\n" + "\n" + "**Mark tasks as resolved:**\n" + "- When you have completed the work described in a task\n" + "- When a task is no longer needed or has been superseded\n" + "- IMPORTANT: Always mark your assigned tasks as resolved when you finish them\n" + "- After resolving, call TaskList to find your next task\n" + "\n" + "- ONLY mark a task as completed when you have FULLY accomplished it\n" + "- If you encounter errors, blockers, or cannot finish, keep the task as in_progress\n" + "- When blocked, create a new task describing what needs to be resolved\n" + "- Never mark a task as completed if:\n" + " - Tests are failing\n" + " - Implementation is partial\n" + " - You encountered unresolved errors\n" + " - You couldn't find necessary files or dependencies\n" + "\n" + "**Delete tasks:**\n" + "- When a task is no longer relevant or was created in error\n" + "- Setting status to `deleted` permanently removes the task\n" + "\n" + "**Update task details:**\n" + "- When requirements change or become clearer\n" + "- When establishing dependencies between tasks\n" + "\n" + "## Fields You Can Update\n" + "\n" + "- **status**: The task status (see Status Workflow below)\n" + '- **subject**: Change the task title (imperative form, e.g., "Run tests")\n' + "- **description**: Change the task description\n" + '- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., "Running tests")\n' + "- **owner**: Change the task owner (agent name; null releases the task to unowned)\n" + "- **ifOwnerIs**: Guard — only apply the update if the task's current owner matches (null = only if unowned). Combine with owner to claim a task safely.\n" + "- **metadata**: Merge metadata keys into the task (set a key to null to delete it)\n" + "- **addBlocks**: Mark tasks that cannot start until this one completes\n" + "- **addBlockedBy**: Mark tasks that must complete before this one can start\n" + "\n" + "## Status Workflow\n" + "\n" + "Status progresses: `pending` → `in_progress` → `completed`\n" + "\n" + "Use `deleted` to permanently remove a task.\n" + "\n" + "## Staleness\n" + "\n" + "Make sure to read a task's latest state using `TaskGet` before updating it.\n" + "\n" + "## Examples\n" + "\n" + "Mark task as in progress when starting work:\n" + '```json\n{"taskId": "1", "status": "in_progress"}\n```\n' + "\n" + "Mark task as completed after finishing work:\n" + '```json\n{"taskId": "1", "status": "completed"}\n```\n' + "\n" + "Delete a task:\n" + '```json\n{"taskId": "1", "status": "deleted"}\n```\n' + "\n" + "Claim a task by setting owner (the guard makes the claim atomic):\n" + '```json\n{"taskId": "1", "owner": "my-name", "ifOwnerIs": null}\n```\n' + "\n" + "Set up task dependencies:\n" + '```json\n{"taskId": "2", "addBlockedBy": ["1"]}\n```', parameters: Type.Object({ taskId: Type.String({ description: "The ID of the task to update" }), status: Type.Optional(Type.Union([STATUS, Type.Literal("deleted")], { description: "New status for the task" })), subject: Type.Optional(Type.String({ description: "New subject for the task" })), description: Type.Optional(Type.String({ description: "New description for the task" })), activeForm: Type.Optional(Type.String({ description: 'Present continuous form shown in spinner when in_progress (e.g., "Running tests")' })), owner: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "New owner for the task (agent name; null releases the task to unowned)", })), ifOwnerIs: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Guard: only apply this update if the task's current owner matches (null = only if unowned). Use with owner to claim a task without racing another agent.", })), metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Metadata keys to merge into the task. Set a key to null to delete it.", })), addBlocks: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that this task blocks" })), addBlockedBy: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that block this task" })), }), execute: (args) => serialized(tasks, async (tx) => { const a = args; const id = String(a.taskId); const t = await tx.get(id); if (!t) { throw new Error(`Task #${id} not found.`); } if (a.ifOwnerIs !== undefined) { const match = a.ifOwnerIs === null ? t.owner === undefined : t.owner === a.ifOwnerIs; if (!match) { throw new Error(`Task #${id} owner guard failed: task is ${t.owner === undefined ? "unowned" : `owned by "${t.owner}"`}.`); } } if (a.status === "deleted") { for (const other of await tx.list()) { if (other.id === id) continue; const blocks = other.blocks.filter((x) => x !== id); const blockedBy = other.blockedBy.filter((x) => x !== id); if (blocks.length !== other.blocks.length || blockedBy.length !== other.blockedBy.length) { await tx.set(other.id, { ...other, blocks, blockedBy }); } } await tx.delete(id); return { content: `Task #${id} deleted.`, details: { type: "task", deleted: id } }; } if (a.status) t.status = a.status; if (a.subject !== undefined) t.subject = a.subject; if (a.description !== undefined) t.description = a.description; if (a.activeForm !== undefined) t.activeForm = a.activeForm; if (a.owner !== undefined) { if (a.owner === "") throw new Error("owner must be a non-empty agent name (use null to release the task)."); t.owner = a.owner === null ? undefined : a.owner; } if (a.metadata) { assertJsonMetadata(a.metadata); t.metadata = { ...t.metadata }; for (const [k, v] of Object.entries(a.metadata)) { if (k === "__proto__" || k === "constructor" || k === "prototype") continue; if (v === null) delete t.metadata[k]; else t.metadata[k] = v; } } const ignoredDeps = []; let linkedBlocks = false; let linkedBlockedBy = false; for (const b of a.addBlocks ?? []) { const other = b === id ? t : await tx.get(b); if (!other) { if (!ignoredDeps.includes(b)) ignoredDeps.push(b); continue; } if (!t.blocks.includes(b)) { t.blocks.push(b); linkedBlocks = true; } if (!other.blockedBy.includes(id)) { other.blockedBy.push(id); linkedBlocks = true; if (other !== t) await tx.set(b, other); } } for (const b of a.addBlockedBy ?? []) { const other = b === id ? t : await tx.get(b); if (!other) { if (!ignoredDeps.includes(b)) ignoredDeps.push(b); continue; } if (!t.blockedBy.includes(b)) { t.blockedBy.push(b); linkedBlockedBy = true; } if (!other.blocks.includes(id)) { other.blocks.push(id); linkedBlockedBy = true; if (other !== t) await tx.set(b, other); } } await tx.set(id, t); const changed = [ a.status ? "status" : "", a.subject !== undefined ? "subject" : "", a.description !== undefined ? "description" : "", a.activeForm !== undefined ? "activeForm" : "", a.owner !== undefined ? "owner" : "", a.metadata ? "metadata" : "", linkedBlocks ? "blocks" : "", linkedBlockedBy ? "blockedBy" : "", ].filter(Boolean); const ignoredNote = ignoredDeps.length ? ` (ignored: no such task ${ignoredDeps.map((x) => `#${x}`).join(", ")})` : ""; return { content: `Updated task #${id}${changed.length ? ` ${changed.join(", ")}` : ""}${ignoredNote}`, details: { type: "task", task: snapTask(t) } }; }), }; const taskList = { name: "TaskList", ...toolFace("task-list"), description: "Use this tool to list all tasks in the task list.\n" + "\n" + "## When to Use This Tool\n" + "\n" + "- To see what tasks are available to work on (status: 'pending', no owner, not blocked)\n" + "- To check overall progress on the project\n" + "- To find tasks that are blocked and need dependencies resolved\n" + "- After completing a task, to check for newly unblocked work or claim the next available task\n" + "- Before creating tasks, to avoid duplicates\n" + "- **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as " + "earlier tasks often set up context for later ones\n" + "\n" + "## Output\n" + "\n" + "Returns a summary of each task:\n" + "- **id**: Task identifier (use with TaskGet, TaskUpdate)\n" + "- **subject**: Brief description of the task\n" + "- **status**: 'pending', 'in_progress', or 'completed'\n" + "- **owner**: Agent ID if assigned, empty if available\n" + "- **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be " + "claimed until dependencies resolve)\n" + "\n" + "Use TaskGet with a specific task ID to view full details including description and metadata.", parameters: Type.Object({}), execute: () => serialized(tasks, async (tx) => { const all = await tx.list(); if (all.length === 0) { return { content: "The task list is empty.", details: { type: "task-list", tasks: [] } }; } const openIds = new Set(all.filter((x) => x.status !== "completed").map((x) => x.id)); const lines = all.map((t) => { const liveBlockers = t.blockedBy.filter((b) => openIds.has(b)); return `#${t.id} [${t.status}] ${t.subject}${t.owner ? ` (owner: ${t.owner})` : ""}${liveBlockers.length ? ` (blocked by: ${liveBlockers.join(", ")})` : ""}`; }); const open = openIds.size; return { content: `${lines.join("\n")}\n(${all.length} total, ${open} open)`, details: { type: "task-list", tasks: all.map(snapTask) }, }; }), }; return [taskCreate, taskGet, taskUpdate, taskList]; }