@sema-agent/core
Version:
Stateless, task-oriented AI agent core
440 lines (439 loc) • 21.8 kB
JavaScript
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];
}