Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/kernel/src/agent/context-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ interface BuildContextMessagesArgs {
type ContextMessage = {
role: "user";
content: string;
providerOptions?: { anthropic?: { cacheControl?: { type: "ephemeral" } } };
providerOptions?: {
anthropic?: { cacheControl?: { type: "ephemeral"; ttl?: "5m" | "1h" } };
};
};

/**
Expand Down
58 changes: 57 additions & 1 deletion apps/kernel/src/agent/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,67 @@ Filename is kebab-case under \`r2://skills/\`. Cap the body at roughly 80 lines
Mention skill creations in your reply with one line ("Saved \`skills/<name>.md\`"); skip mentioning memory writes.
</memory_and_skills>

<task_management>
For multi-step requests, plan and track your work with manage_tasks. The
<tasks> context block (when present) is your source of truth for task state.
It refreshes at every step boundary so it never lags behind your tool calls.

WHEN TO PLAN VS JUST DO
The question to ask yourself: is the work substantial, or trivial?
- "Read this file and tell me what it says" → just do it. One tool call.
- "What time is it in Tokyo" → just do it.
- "Research X, write Y, post Y to slack" → plan tasks first.
- "Refactor this module" if it's one file → just do it.
- "Refactor this module" if it spans many files → plan tasks first.

Planning costs tool calls. Only plan when the work has independent steps you
want to track, retry, or summarize. When the user signals depth ("deep dive",
"thoroughly", "do this properly"), lean toward planning even if it looks small.

LIFECYCLE
- create: batch all tasks up front with name + description.
name: short user-visible label.
description: full plan — what to do, what to produce, any constraints.
Be specific. Your future self (next step) reads this and must act on it
without re-asking the user.
- update to in_progress before you start work on a task.
- update to complete when done. Set result to 1-3 sentences of what was
produced or accomplished. Result is optional but strongly preferred — it's
how future turns see "this got done, here's what it was."
- update to failed if you tried and gave up; set result to the reason.
- update to cancelled if you decided not to do this task; set result to why.

DESCRIPTION QUALITY
Bad: "Refactor parser"
Good: "Refactor parser: split parse() into tokenize() and parseTokens().
Preserve current public API. Update the 3 call sites in handlers.ts.
Aim under 80 lines per function."

A vague description means you (or a future step) won't know what "done"
looks like.

DISCIPLINE
- One task in_progress at a time. Mark the current one terminal before
starting the next.
- Update status the moment state changes. Stale in_progress tasks make the
user think you're confused.
- Tasks persist for the whole thread. Completed ones stay visible as your
audit trail.

DON'T
- Don't plan tasks for trivial requests.
- Don't create a task and mark it complete in the same call (no fake
productivity).
- Don't recreate a failed task with the same description. Either update its
status for a retry, or create a NEW task whose description includes what
went wrong and what's different.
</task_management>

<roadmap>
Three things on the roadmap, not shipped yet. If the user asks for them, name what's missing and offer the closest thing today.

- **Sub-agents.** Spawning child agents with their own context windows for parallel sub-tasks. Closest thing today: do the work serially, or split it into multiple tool calls in parallel.
- **Plans / task lists.** A side panel of checkable steps. Closest thing today: write the plan inline as markdown, the user can copy it.
- **Plans / task lists in the TUI.** A side panel of checkable steps. Closest thing today: I track tasks internally via manage_tasks and the user sees them surface in my responses, but the TUI doesn't render them as a panel yet.
- **Scheduled / recurring work.** Cron-fired prompts that run without you. Closest thing today: nothing. Own this gap honestly.
</roadmap>

Expand Down
99 changes: 99 additions & 0 deletions apps/kernel/src/agent/tasks-block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { asc, eq, schema, type DB, type Task } from "@agent-os/models";

const PENDING_DESC_MAX = 120;
const TERMINAL_DESC_MAX = 80;

const TERMINAL_STATUSES = ["complete", "failed", "cancelled"] as const;

function isTerminalStatus(status: Task["status"]): boolean {
return (TERMINAL_STATUSES as readonly string[]).includes(status);
}

/**
* Collapse whitespace and hard-truncate to `n` chars. Caller appends the
* ellipsis when `truncated === true`.
*/
function truncate(s: string, n: number): { text: string; truncated: boolean } {
const trimmed = s.replace(/\s+/g, " ").trim();
if (trimmed.length <= n) return { text: trimmed, truncated: false };
return { text: trimmed.slice(0, n), truncated: true };
}

function formatDescription(
status: Task["status"],
description: string
): string {
if (status === "in_progress") {
return description.replace(/\s+/g, " ").trim();
}
const max = isTerminalStatus(status) ? TERMINAL_DESC_MAX : PENDING_DESC_MAX;
const { text, truncated } = truncate(description, max);
return truncated ? `${text}…` : text;
}

/**
* Build the `<tasks>` block. Returns `null` when the thread has zero tasks
* (block is omitted entirely from the prompt — same pattern as
* <previous_thread_summary>).
*
* Called from `prepareStep` in `turn.ts` on every step boundary inside
* `streamText`. Pure SQL fetch — one index hit on `idx_task_thread_created`.
*
* Truncation policy:
* in_progress → full description (active focus needs the plan)
* pending → trimmed to 120 chars + ellipsis if truncated
* complete/failed/cancelled → trimmed to 80 chars; result shown in full
* when non-null
*
* Full descriptions remain in the agent's conversation history via every
* `manage_tasks` tool_result — truncation here is per-turn cache hygiene,
* not lossy storage.
*
* Failure mode: SQL throw → error placeholder so the block still ships and
* the agent isn't blind to its task list breaking.
*/
export async function buildTasksBlock(args: {
db: DB;
threadId: string;
}): Promise<string | null> {
let rows: Task[];
try {
rows = await args.db.query.task.findMany({
where: eq(schema.task.threadId, args.threadId),
orderBy: [asc(schema.task.createdAt)]
});
} catch (e) {
console.warn("[tasks-block] SQL failed", {
threadId: args.threadId,
error: e instanceof Error ? e.message : String(e)
});
return [
"<tasks>",
"(error loading task list — manage_tasks may still work, try it)",
"</tasks>"
].join("\n");
}

if (rows.length === 0) return null;

const lines = rows.map((t) => {
const head = `- ${t.id} [${t.status}] "${t.name}"`;
const plan = ` plan: ${formatDescription(t.status, t.description)}`;
const isTerminal = isTerminalStatus(t.status);
if (isTerminal && t.result) {
const result = t.result.replace(/\s+/g, " ").trim();
return [head, plan, ` result: ${result}`].join("\n");
}
return [head, plan].join("\n");
});

return [
"<tasks>",
"Plan and track multi-step work here. Use manage_tasks to create/update tasks.",
"Convention: one task in_progress at a time. Mark complete/failed/cancelled",
"before moving on. Skip task planning for trivial single-step requests.",
"",
...lines,
"</tasks>"
].join("\n");
}
4 changes: 3 additions & 1 deletion apps/kernel/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buildFSTools } from "./fs-tools";
import { buildGetTimeTool } from "./get-time";
import { buildProcessAttachmentTool } from "./process-attachment";
import { buildSessionsTools } from "./sessions";
import { buildTasksTools } from "./tasks";
import { buildWebSearchTools } from "./web-search";
import { type PerTurnContext } from "./wrapped-tool";

Expand All @@ -28,7 +29,7 @@ function withTailCache<T extends Record<string, object>>(tools: T): T {
[lastKey]: {
...tools[lastKey]!,
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" } }
anthropic: { cacheControl: { type: "ephemeral", ttl: "5m" } }
}
}
} as T;
Expand All @@ -50,5 +51,6 @@ export const buildTools = (perTurn: PerTurnContext) =>
...buildExecCodeTool(perTurn),
...buildComputerUseTools(perTurn),
...buildSessionsTools(perTurn),
...buildTasksTools(perTurn),
get_time: buildGetTimeTool(perTurn)
});
148 changes: 148 additions & 0 deletions apps/kernel/src/tools/tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { asc, eq, schema, ulid } from "@agent-os/models";
import { z } from "zod";

import { wrappedTool, type PerTurnContext } from "./wrapped-tool";

const TASK_STATUS = [
"pending",
"in_progress",
"complete",
"failed",
"cancelled"
] as const;

const createOpSchema = z.object({
action: z.literal("create"),
name: z.string().min(1).describe("Short user-visible label"),
description: z
.string()
.min(1)
.describe("Full plan — deliverables, deps, expected output")
});

const updateOpSchema = z.object({
action: z.literal("update"),
id: z.string().describe("Task id (ULID) to update"),
name: z.string().min(1).optional(),
description: z.string().min(1).optional(),
status: z.enum(TASK_STATUS).optional(),
result: z
.string()
.optional()
.describe("Set on terminal status with 1-3 sentence summary")
});

const inputSchema = z.object({
ops: z
.array(z.discriminatedUnion("action", [createOpSchema, updateOpSchema]))
.describe(
"Batch of create/update operations. Applied in order. Empty array is a no-op refresh."
)
});

type ManageTasksArgs = z.infer<typeof inputSchema>;

export interface TaskJSON {
id: string;
name: string;
description: string;
status: (typeof TASK_STATUS)[number];
result: string | null;
createdAt: string;
updatedAt: string;
}

const DESCRIPTION = [
"Plan and track multi-step work as tasks. Use ONE call to batch creates +",
"updates. Returns the full thread task list (sorted by createdAt ASC) so",
"you see canonical state immediately.",
"",
"When to use: multi-step requests (research X, write Y, post Y). Skip for",
"trivial single-step requests.",
"",
"Ops:",
" create: { action:'create', name, description }",
" update: { action:'update', id, ...any of: name, description, status, result }",
"",
"Status values: pending | in_progress | complete | failed | cancelled",
"Status transitions are unrestricted (any → any).",
"",
"Set 'result' on terminal status (complete/failed/cancelled) with a 1-3",
"sentence summary. Optional but strongly preferred — it's how future turns",
"see what was accomplished."
].join("\n");

export function buildTasksTools(perTurn: PerTurnContext) {
return {
manage_tasks: wrappedTool(
{
name: "manage_tasks",
description: DESCRIPTION,
inputSchema,
touchesFS: false,
needsApproval: false,
execute: async (args: ManageTasksArgs, ctx) => {
for (const op of args.ops) {
if (op.action === "create") {
await ctx.db.insert(schema.task).values({
id: ulid(),
threadId: ctx.threadId,
runId: ctx.runId,
name: op.name,
description: op.description
// status defaults to "pending"
// createdAt/updatedAt default via $defaultFn
});
} else {
// op.action === "update"
const existing = await ctx.db.query.task.findFirst({
where: eq(schema.task.id, op.id)
});
if (!existing) {
throw new Error(`Task ${op.id} not found`);
}
if (existing.threadId !== ctx.threadId) {
// Defensive: model gave us an id from another thread.
// Refuse rather than silently mutate.
throw new Error(
`Task ${op.id} belongs to a different thread`
);
}
await ctx.db
.update(schema.task)
.set({
...(op.name !== undefined && { name: op.name }),
...(op.description !== undefined && {
description: op.description
}),
...(op.status !== undefined && { status: op.status }),
...(op.result !== undefined && { result: op.result })
// updatedAt auto-bumped by Drizzle $onUpdate hook on task.updatedAt
})
.where(eq(schema.task.id, op.id));
}
}

// Return full updated task list sorted by createdAt ASC.
const tasks = await ctx.db.query.task.findMany({
where: eq(schema.task.threadId, ctx.threadId),
orderBy: [asc(schema.task.createdAt)]
});
const result: { tasks: TaskJSON[] } = {
tasks: tasks.map((t) => ({
id: t.id,
name: t.name,
description: t.description,
status: t.status,
result: t.result,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString()
}))
};
return result;
}
},
perTurn
)
};
}
Loading
Loading