feat: structured task system with dependency graph (task_create/list/get/update)
Replaces the flat todo_write approach for non-trivial multi-step work with a structured, incrementally-updated task store — dependencies, ownership, status, metadata — ported from origin/v0.6.0 and adapted to the local Session/ctx. - src/tools/task.ts: TaskStore (in-memory, per-session) + four read-only tools: task_create (returns id), task_list (summaries), task_get (full details), task_update (status pending|in_progress|completed|deleted, rename, claim via owner, addBlocks/addBlockedBy dependency edges, merge-patch metadata — null deletes a key). Self-refs/unknown ids ignored; direct 2-cycles skipped; delete prunes dangling refs. Schemas declared before use (fixes a TDZ in the v0.6.0 version). todo_write kept alongside for simpler cases. - types.ts: ctx.taskStore. session.ts: TaskStore on every Session. loop.ts: expose session.taskStore in the tool ctx + on sub-agent sessions. - tools/index.ts: register the four task tools. - task.test.ts: 9 cases (create/list/get/update status, dependency edges both ways, self-ref/unknown/2-cycle guards, delete+prune, metadata merge-patch, absent-store error). - README: document the structured tasks. Verified: typecheck clean, build 275.10 KB, 301 tests pass (+9).
This commit is contained in:
@@ -90,9 +90,9 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
|
|||||||
- **Ctrl+F**: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — **Files**, the project's collapsible file tree (directories in cyan, same `node_modules`/`.git`/`dist` exclusions as `@` mentions), and **Activity**, the files `read_file`/`write_file`/`edit_file` have touched so far this session, most recent first, with a status glyph (`·` read, `+` written, `~` edited) and a repeat count. **Ctrl+G** switches between the two tabs. While the panel is focused, `↑`/`↓` move the selection (auto-scrolling to keep it in view), `↵`/`←`/`→` expand or collapse the selected folder, and **Esc** hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history.
|
- **Ctrl+F**: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — **Files**, the project's collapsible file tree (directories in cyan, same `node_modules`/`.git`/`dist` exclusions as `@` mentions), and **Activity**, the files `read_file`/`write_file`/`edit_file` have touched so far this session, most recent first, with a status glyph (`·` read, `+` written, `~` edited) and a repeat count. **Ctrl+G** switches between the two tabs. While the panel is focused, `↑`/`↓` move the selection (auto-scrolling to keep it in view), `↵`/`←`/`→` expand or collapse the selected folder, and **Esc** hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history.
|
||||||
|
|
||||||
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
|
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
|
||||||
- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
|
- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write`, `task_create`, `task_list`, `task_get`, `task_update` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
|
||||||
- **Permission modes**: `default` (ask before every mutating tool), `plan` (research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead), `auto-edit` (file edits auto-approved, `bash`/`git_commit` still ask), `auto-accept` (everything auto-approved — use with care). Cycle with `Shift+Tab` or set directly with `/perm <mode>`.
|
- **Permission modes**: `default` (ask before every mutating tool), `plan` (research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead), `auto-edit` (file edits auto-approved, `bash`/`git_commit` still ask), `auto-accept` (everything auto-approved — use with care). Cycle with `Shift+Tab` or set directly with `/perm <mode>`.
|
||||||
- **Task checklists**: for multi-step work the model can call `todo_write` to show a live checklist (`☐`/`◐`/`☑`) in the transcript instead of silently working through a list you can't see progress on.
|
- **Structured tasks**: for multi-step work the model can call `task_create`/`task_list`/`task_get`/`task_update` to track units of work with a dependency graph (`blocks`/`blockedBy`), ownership (`owner`), status, and free-form metadata — created incrementally rather than replaced wholesale. The older flat `todo_write` live checklist (`☐`/`◐`/`☑`) remains for simpler cases.
|
||||||
- **Project instructions**: a `CLAUDE.md` (or `AGENTS.md`) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told.
|
- **Project instructions**: a `CLAUDE.md` (or `AGENTS.md`) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told.
|
||||||
- **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import <path> [caption]`.
|
- **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import <path> [caption]`.
|
||||||
- **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text.
|
- **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
|
|||||||
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
|
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
|
||||||
import { repairPartialJson } from "../toolcalling/partialJson.js";
|
import { repairPartialJson } from "../toolcalling/partialJson.js";
|
||||||
import { notifyFileChanged } from "../codeintel/lspManager.js";
|
import { notifyFileChanged } from "../codeintel/lspManager.js";
|
||||||
|
import { TaskStore } from "../tools/task.js";
|
||||||
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
||||||
import { estimateTokens } from "../utils/tokens.js";
|
import { estimateTokens } from "../utils/tokens.js";
|
||||||
import { runHooksForEvent } from "../hooks/runner.js";
|
import { runHooksForEvent } from "../hooks/runner.js";
|
||||||
@@ -511,6 +512,7 @@ async function gateAndRun(
|
|||||||
session.todos = todos;
|
session.todos = todos;
|
||||||
emit({ type: "todos_update", todos });
|
emit({ type: "todos_update", todos });
|
||||||
},
|
},
|
||||||
|
taskStore: session.taskStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
|
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
|
||||||
@@ -717,6 +719,7 @@ async function runSubAgentTurn(
|
|||||||
// Independent from the parent's — a sub-agent runs headless (see class doc above), so its own
|
// Independent from the parent's — a sub-agent runs headless (see class doc above), so its own
|
||||||
// checklist has nowhere to render even if it called todo_write.
|
// checklist has nowhere to render even if it called todo_write.
|
||||||
todos: [],
|
todos: [],
|
||||||
|
taskStore: new TaskStore(),
|
||||||
// SHARED with the parent (not independent) — this is the whole point of the mutation gate: a
|
// SHARED with the parent (not independent) — this is the whole point of the mutation gate: a
|
||||||
// batch of parallel sub-agents all funnel their mutating calls through the parent's single
|
// batch of parallel sub-agents all funnel their mutating calls through the parent's single
|
||||||
// gate, so they can't race on the one permission slot or interleave filesystem writes. A
|
// gate, so they can't race on the one permission slot or interleave filesystem writes. A
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ConfirmFn } from "../permissions/types.js";
|
|||||||
import { TOOLS } from "../tools/index.js";
|
import { TOOLS } from "../tools/index.js";
|
||||||
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
||||||
import type { TodoItem, ToolDef } from "../tools/types.js";
|
import type { TodoItem, ToolDef } from "../tools/types.js";
|
||||||
|
import { TaskStore } from "../tools/task.js";
|
||||||
import { estimateTokens } from "../utils/tokens.js";
|
import { estimateTokens } from "../utils/tokens.js";
|
||||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||||
|
|
||||||
@@ -86,6 +87,9 @@ export interface Session {
|
|||||||
/** Current task checklist shown to the user via the `todo_write` tool — session-scoped state
|
/** Current task checklist shown to the user via the `todo_write` tool — session-scoped state
|
||||||
* since checklist items are a snapshot of progress, not part of the model-visible conversation. */
|
* since checklist items are a snapshot of progress, not part of the model-visible conversation. */
|
||||||
todos: TodoItem[];
|
todos: TodoItem[];
|
||||||
|
/** Structured task store backing the task_create/list/get/update tools — session-scoped,
|
||||||
|
* in-memory (not persisted), and independent of the flat `todos` checklist. */
|
||||||
|
taskStore: TaskStore;
|
||||||
/** A serialized-mutation gate shared by EVERY tool call in this session — including sub-agents
|
/** A serialized-mutation gate shared by EVERY tool call in this session — including sub-agents
|
||||||
* spawned in parallel via the `agent` tool's `tasks` array. Parallel sub-agents share their
|
* spawned in parallel via the `agent` tool's `tasks` array. Parallel sub-agents share their
|
||||||
* parent's session, so without a lock two of them could simultaneously call a mutating tool,
|
* parent's session, so without a lock two of them could simultaneously call a mutating tool,
|
||||||
@@ -138,6 +142,7 @@ export function createSession(
|
|||||||
mutationCommitLength: null,
|
mutationCommitLength: null,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
todos: [],
|
todos: [],
|
||||||
|
taskStore: new TaskStore(),
|
||||||
mutationGate: Promise.resolve(),
|
mutationGate: Promise.resolve(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,7 @@ export function createSessionFromRecord(
|
|||||||
mutationCommitLength: null,
|
mutationCommitLength: null,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
todos: [],
|
todos: [],
|
||||||
|
taskStore: new TaskStore(),
|
||||||
mutationGate: Promise.resolve(),
|
mutationGate: Promise.resolve(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { listFilesTool } from "./listFiles.js";
|
|||||||
import { notebookEditTool } from "./notebookEdit.js";
|
import { notebookEditTool } from "./notebookEdit.js";
|
||||||
import { readFileTool } from "./readFile.js";
|
import { readFileTool } from "./readFile.js";
|
||||||
import { todoWriteTool } from "./todoWrite.js";
|
import { todoWriteTool } from "./todoWrite.js";
|
||||||
|
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool } from "./task.js";
|
||||||
import { webFetchTool } from "./webFetch.js";
|
import { webFetchTool } from "./webFetch.js";
|
||||||
import { webSearchTool } from "./webSearch.js";
|
import { webSearchTool } from "./webSearch.js";
|
||||||
import { writeFileTool } from "./writeFile.js";
|
import { writeFileTool } from "./writeFile.js";
|
||||||
@@ -35,6 +36,10 @@ export const TOOLS: ToolDef[] = [
|
|||||||
bashKillTool,
|
bashKillTool,
|
||||||
gitCommitTool,
|
gitCommitTool,
|
||||||
todoWriteTool,
|
todoWriteTool,
|
||||||
|
taskCreateTool,
|
||||||
|
taskListTool,
|
||||||
|
taskGetTool,
|
||||||
|
taskUpdateTool,
|
||||||
agentTool,
|
agentTool,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool, TaskStore } from "./task.js";
|
||||||
|
import type { ToolContext } from "./types.js";
|
||||||
|
|
||||||
|
function ctxWithStore(): { ctx: ToolContext; store: TaskStore } {
|
||||||
|
const store = new TaskStore();
|
||||||
|
return { ctx: { taskStore: store } as ToolContext, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("task tools", () => {
|
||||||
|
it("task_create creates a pending task and returns it with an id", async () => {
|
||||||
|
const { ctx, store } = ctxWithStore();
|
||||||
|
const out = (await taskCreateTool.handler({ subject: "Fix bug", description: "details" }, ctx)) as {
|
||||||
|
id: string;
|
||||||
|
task: { status: string; blocks: string[]; blockedBy: string[] };
|
||||||
|
};
|
||||||
|
expect(out.id).toMatch(/^t\d+$/);
|
||||||
|
expect(out.task.status).toBe("pending");
|
||||||
|
expect(out.task.blocks).toEqual([]);
|
||||||
|
expect(out.task.blockedBy).toEqual([]);
|
||||||
|
expect(store.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("task_list returns all task summaries", async () => {
|
||||||
|
const { ctx } = ctxWithStore();
|
||||||
|
await taskCreateTool.handler({ subject: "A", description: "x" }, ctx);
|
||||||
|
await taskCreateTool.handler({ subject: "B", description: "y" }, ctx);
|
||||||
|
const out = (await taskListTool.handler({}, ctx)) as { tasks: { subject: string }[] };
|
||||||
|
expect(out.tasks.map((t) => t.subject)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("task_get returns full details and errors on unknown id", async () => {
|
||||||
|
const { ctx } = ctxWithStore();
|
||||||
|
const created = (await taskCreateTool.handler({ subject: "A", description: "long desc" }, ctx)) as {
|
||||||
|
id: string;
|
||||||
|
task: { description: string };
|
||||||
|
};
|
||||||
|
const got = (await taskGetTool.handler({ taskId: created.id }, ctx)) as { task: { description: string } };
|
||||||
|
expect(got.task.description).toBe("long desc");
|
||||||
|
const miss = (await taskGetTool.handler({ taskId: "nope" }, ctx)) as { error: string };
|
||||||
|
expect(miss.error).toMatch(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("task_update sets status and marks in_progress/completed", async () => {
|
||||||
|
const { ctx } = ctxWithStore();
|
||||||
|
const created = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||||
|
const upd = (await taskUpdateTool.handler({ taskId: created.id, status: "in_progress" }, ctx)) as {
|
||||||
|
task: { status: string };
|
||||||
|
};
|
||||||
|
expect(upd.task.status).toBe("in_progress");
|
||||||
|
const done = (await taskUpdateTool.handler({ taskId: created.id, status: "completed" }, ctx)) as {
|
||||||
|
task: { status: string };
|
||||||
|
};
|
||||||
|
expect(done.task.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addBlocks/addBlockedBy link two tasks both ways", async () => {
|
||||||
|
const { ctx } = ctxWithStore();
|
||||||
|
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||||
|
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
|
||||||
|
// B is blocked by A (one-directional: sets B.blockedBy, not A.blocks)
|
||||||
|
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
|
||||||
|
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
|
||||||
|
expect(bAfter.task.blockedBy).toContain(a.id);
|
||||||
|
// Add the back-ref explicitly: A blocks B.
|
||||||
|
await taskUpdateTool.handler({ taskId: a.id, addBlocks: [b.id] }, ctx);
|
||||||
|
const aAfter = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blocks: string[] } };
|
||||||
|
expect(aAfter.task.blocks).toContain(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores self-refs, unknown ids, and direct 2-cycles", async () => {
|
||||||
|
const { ctx } = ctxWithStore();
|
||||||
|
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||||
|
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
|
||||||
|
// self-ref ignored
|
||||||
|
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [a.id] }, ctx);
|
||||||
|
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
|
||||||
|
// unknown id ignored
|
||||||
|
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: ["zzz"] }, ctx);
|
||||||
|
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
|
||||||
|
// B waits on A; now make A wait on B — should be skipped (2-cycle)
|
||||||
|
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
|
||||||
|
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [b.id] }, ctx);
|
||||||
|
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status deleted removes the task and prunes dangling refs", async () => {
|
||||||
|
const { ctx } = ctxWithStore();
|
||||||
|
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||||
|
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
|
||||||
|
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
|
||||||
|
const del = (await taskUpdateTool.handler({ taskId: a.id, status: "deleted" }, ctx)) as { deleted: string };
|
||||||
|
expect(del.deleted).toBe(a.id);
|
||||||
|
// B no longer blocked by the removed A
|
||||||
|
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
|
||||||
|
expect(bAfter.task.blockedBy).toEqual([]);
|
||||||
|
expect(((await taskListTool.handler({}, ctx)) as { tasks: unknown[] }).tasks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("metadata merge-patch: set keys, null deletes", async () => {
|
||||||
|
const { ctx } = ctxWithStore();
|
||||||
|
const a = (await taskCreateTool.handler({ subject: "A", description: "x", metadata: { k: 1 } }, ctx)) as { id: string };
|
||||||
|
await taskUpdateTool.handler({ taskId: a.id, metadata: { k2: "v" } }, ctx);
|
||||||
|
let t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
|
||||||
|
expect(t.task.metadata).toEqual({ k: 1, k2: "v" });
|
||||||
|
await taskUpdateTool.handler({ taskId: a.id, metadata: { k: null } }, ctx);
|
||||||
|
t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
|
||||||
|
expect(t.task.metadata).toEqual({ k2: "v" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error when taskStore is absent", async () => {
|
||||||
|
const ctx = {} as ToolContext;
|
||||||
|
const out = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { error: string };
|
||||||
|
expect(out.error).toMatch(/not available/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { ToolDef } from "./types.js";
|
||||||
|
|
||||||
|
/** A task's lifecycle state. `deleted` is only used as an update target (it removes the task); it is
|
||||||
|
* never a stored status. */
|
||||||
|
export type TaskStatus = "pending" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
/** A structured, trackable unit of work. Tasks form a dependency graph via `blocks`/`blockedBy`
|
||||||
|
* (each lists the other's task ids), can be owned/claimed by a named agent, and carry free-form
|
||||||
|
* metadata. Unlike the flat todo list, tasks are created and updated incrementally (not replaced
|
||||||
|
* wholesale) so dependencies and ownership can be expressed. */
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
/** Present-continuous label shown in a spinner while the task is in_progress (e.g. "Running tests"). */
|
||||||
|
activeForm?: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
/** Who has claimed the task (an agent name). Unset = unclaimed. */
|
||||||
|
owner?: string;
|
||||||
|
/** Ids of tasks THIS task blocks (i.e. that depend on it). */
|
||||||
|
blocks: string[];
|
||||||
|
/** Ids of tasks that must be completed before this one can start. */
|
||||||
|
blockedBy: string[];
|
||||||
|
/** Free-form metadata; merge-patched on update (set a key to null to delete it). */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A compact, list-view projection of a Task — enough to render the checklist without the long
|
||||||
|
* description/metadata. */
|
||||||
|
export interface TaskSummary {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
owner?: string;
|
||||||
|
blockedBy: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory task store. The task tools operate on it via `ctx.taskStore`. Mutations emit a
|
||||||
|
* snapshot through an optional `onChange` callback the loop wires up, so each create/update can
|
||||||
|
* refresh a UI checklist. Purely in-memory (per-session); not persisted. */
|
||||||
|
export class TaskStore {
|
||||||
|
private tasks = new Map<string, Task>();
|
||||||
|
private seq = 0;
|
||||||
|
private emitter: ((tasks: TaskSummary[]) => void) | undefined;
|
||||||
|
|
||||||
|
/** Wired by the host so the store can broadcast a snapshot after each mutation. */
|
||||||
|
setEmitter(emit: (tasks: TaskSummary[]) => void): void {
|
||||||
|
this.emitter = emit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextId(): string {
|
||||||
|
this.seq += 1;
|
||||||
|
return `t${this.seq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshot(): TaskSummary[] {
|
||||||
|
return [...this.tasks.values()].map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
subject: t.subject,
|
||||||
|
status: t.status,
|
||||||
|
owner: t.owner,
|
||||||
|
blockedBy: [...t.blockedBy],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(): void {
|
||||||
|
this.emitter?.(this.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: {
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
activeForm?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): Task {
|
||||||
|
const task: Task = {
|
||||||
|
id: this.nextId(),
|
||||||
|
subject: input.subject,
|
||||||
|
description: input.description,
|
||||||
|
activeForm: input.activeForm,
|
||||||
|
status: "pending",
|
||||||
|
blocks: [],
|
||||||
|
blockedBy: [],
|
||||||
|
metadata: input.metadata ? { ...input.metadata } : undefined,
|
||||||
|
};
|
||||||
|
this.tasks.set(task.id, task);
|
||||||
|
this.emit();
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): TaskSummary[] {
|
||||||
|
return this.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): Task | undefined {
|
||||||
|
return this.tasks.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies an update. `status: "deleted"` removes the task (and prunes dangling block/blockedBy
|
||||||
|
* refs). Returns the updated task, or undefined if the task was deleted or doesn't exist. */
|
||||||
|
update(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
status?: TaskStatus | "deleted";
|
||||||
|
subject?: string;
|
||||||
|
description?: string;
|
||||||
|
activeForm?: string;
|
||||||
|
owner?: string;
|
||||||
|
addBlocks?: string[];
|
||||||
|
addBlockedBy?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Task | undefined {
|
||||||
|
const task = this.tasks.get(id);
|
||||||
|
if (!task) return undefined;
|
||||||
|
if (updates.status === "deleted") {
|
||||||
|
this.remove(id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (updates.status) task.status = updates.status;
|
||||||
|
if (updates.subject !== undefined) task.subject = updates.subject;
|
||||||
|
if (updates.description !== undefined) task.description = updates.description;
|
||||||
|
if (updates.activeForm !== undefined) task.activeForm = updates.activeForm;
|
||||||
|
if (updates.owner !== undefined) task.owner = updates.owner;
|
||||||
|
if (updates.addBlocks) {
|
||||||
|
for (const b of updates.addBlocks) {
|
||||||
|
// Only link to existing OTHER tasks; ignore self-refs, unknown ids, and duplicates.
|
||||||
|
if (b !== id && this.tasks.has(b) && !task.blocks.includes(b)) task.blocks.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.addBlockedBy) {
|
||||||
|
for (const b of updates.addBlockedBy) {
|
||||||
|
if (b === id || !this.tasks.has(b) || task.blockedBy.includes(b)) continue;
|
||||||
|
// Skip a direct 2-cycle: if b is already waiting on this task, don't make them wait on each other.
|
||||||
|
const other = this.tasks.get(b);
|
||||||
|
if (other && other.blockedBy.includes(id)) continue;
|
||||||
|
task.blockedBy.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.metadata) {
|
||||||
|
task.metadata = mergeMetadata(task.metadata, updates.metadata);
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(id: string): void {
|
||||||
|
this.tasks.delete(id);
|
||||||
|
// Prune dangling dependency refs in surviving tasks.
|
||||||
|
for (const other of this.tasks.values()) {
|
||||||
|
other.blocks = other.blocks.filter((b) => b !== id);
|
||||||
|
other.blockedBy = other.blockedBy.filter((b) => b !== id);
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge-patches metadata: a null value deletes the key, any other value sets it. */
|
||||||
|
function mergeMetadata(
|
||||||
|
existing: Record<string, unknown> | undefined,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const out: Record<string, unknown> = { ...(existing ?? {}) };
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (v === null) delete out[k];
|
||||||
|
else out[k] = v;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a deep-enough copy of a task so handing it back in a tool result can't let the caller
|
||||||
|
* mutate the store's internal object. */
|
||||||
|
function serializeTask(t: Task): Task {
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
blocks: [...t.blocks],
|
||||||
|
blockedBy: [...t.blockedBy],
|
||||||
|
metadata: t.metadata ? { ...t.metadata } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataSchema = z.record(z.string(), z.any()).optional();
|
||||||
|
|
||||||
|
const taskCreateSchema = z.object({
|
||||||
|
subject: z.string().min(1).describe("A brief, actionable title in imperative form (e.g. 'Fix authentication bug')."),
|
||||||
|
description: z.string().describe("What needs to be done, in enough detail to act on."),
|
||||||
|
activeForm: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Present-continuous label shown in the spinner while in_progress (e.g. 'Running tests'). Optional."),
|
||||||
|
metadata: metadataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskCreateTool: ToolDef<z.infer<typeof taskCreateSchema>> = {
|
||||||
|
name: "task_create",
|
||||||
|
description:
|
||||||
|
"Create a structured task to track a unit of multi-step work. Use for non-trivial work (3+ steps) so progress is " +
|
||||||
|
"visible and dependencies can be expressed. Returns the new task with its id. Call task_list to see all tasks, " +
|
||||||
|
"task_get for full details, and task_update to set status, add dependencies (addBlocks/addBlockedBy), or claim ownership.",
|
||||||
|
schema: taskCreateSchema,
|
||||||
|
// Purely informational (tracks state in-memory, never touches the filesystem) — no confirmation prompt.
|
||||||
|
mutating: false,
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
if (!ctx.taskStore) return { error: "Task tracking is not available in this context." };
|
||||||
|
const task = ctx.taskStore.create(args);
|
||||||
|
return { id: task.id, task: serializeTask(task) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskListSchema = z.object({});
|
||||||
|
|
||||||
|
export const taskListTool: ToolDef<z.infer<typeof taskListSchema>> = {
|
||||||
|
name: "task_list",
|
||||||
|
description:
|
||||||
|
"List all tasks with their id, subject, status, owner, and what blocks them. Use this to see overall progress and " +
|
||||||
|
"find the next available task to claim.",
|
||||||
|
schema: taskListSchema,
|
||||||
|
mutating: false,
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
return { tasks: ctx.taskStore?.list() ?? [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskGetSchema = z.object({ taskId: z.string().min(1) });
|
||||||
|
|
||||||
|
export const taskGetTool: ToolDef<z.infer<typeof taskGetSchema>> = {
|
||||||
|
name: "task_get",
|
||||||
|
description:
|
||||||
|
"Get a task's full details (description, activeForm, blocks, blockedBy, metadata). Use before starting a task to " +
|
||||||
|
"verify its blockedBy list is empty — if it isn't, the blocking tasks must complete first.",
|
||||||
|
schema: taskGetSchema,
|
||||||
|
mutating: false,
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const task = ctx.taskStore?.get(args.taskId);
|
||||||
|
return task ? { task: serializeTask(task) } : { error: `Task ${args.taskId} not found.` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskUpdateSchema = z.object({
|
||||||
|
taskId: z.string().min(1),
|
||||||
|
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||||
|
subject: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
activeForm: z.string().optional(),
|
||||||
|
owner: z.string().optional(),
|
||||||
|
addBlocks: z.array(z.string()).optional(),
|
||||||
|
addBlockedBy: z.array(z.string()).optional(),
|
||||||
|
metadata: metadataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskUpdateTool: ToolDef<z.infer<typeof taskUpdateSchema>> = {
|
||||||
|
name: "task_update",
|
||||||
|
description:
|
||||||
|
"Update a task: set status (pending|in_progress|completed — or 'deleted' to remove it), rename subject/description, " +
|
||||||
|
"set owner to claim it, add dependencies via addBlocks/addBlockedBy (task ids), or merge-patch metadata (set a key " +
|
||||||
|
"to null to delete it). Mark a task in_progress when starting it and completed when done. Verify blockedBy is empty " +
|
||||||
|
"before starting. Returns the updated task, or { deleted: id } when status is 'deleted'.",
|
||||||
|
schema: taskUpdateSchema,
|
||||||
|
mutating: false,
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const store = ctx.taskStore;
|
||||||
|
if (!store) return { error: "Task tracking is not available in this context." };
|
||||||
|
if (!store.get(args.taskId)) return { error: `Task ${args.taskId} not found.` };
|
||||||
|
if (args.status === "deleted") {
|
||||||
|
store.update(args.taskId, args);
|
||||||
|
return { deleted: args.taskId };
|
||||||
|
}
|
||||||
|
const task = store.update(args.taskId, args);
|
||||||
|
return task ? { task: serializeTask(task) } : { deleted: args.taskId };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
import type { TaskStore } from "./task.js";
|
||||||
|
|
||||||
export interface SubAgentTask {
|
export interface SubAgentTask {
|
||||||
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
|
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
|
||||||
@@ -45,6 +46,9 @@ export interface ToolContext {
|
|||||||
/** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a
|
/** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a
|
||||||
* future tool context is built without one — every session-backed context provides it. */
|
* future tool context is built without one — every session-backed context provides it. */
|
||||||
setTodos?: (todos: TodoItem[]) => void;
|
setTodos?: (todos: TodoItem[]) => void;
|
||||||
|
/** The session's structured task store (used by the task_create/list/get/update tools). Absent
|
||||||
|
* only if a tool context is built without one — every session-backed context provides it. */
|
||||||
|
taskStore?: TaskStore;
|
||||||
/** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool
|
/** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool
|
||||||
* polls `requested` and, once true, detaches into the background job registry instead of
|
* polls `requested` and, once true, detaches into the background job registry instead of
|
||||||
* awaiting completion. Absent for tools that don't support backgrounding. */
|
* awaiting completion. Absent for tools that don't support backgrounding. */
|
||||||
|
|||||||
Reference in New Issue
Block a user