Skip to content
Open
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: 2 additions & 2 deletions src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export async function runAgentTurn(args: {
maxSteps?: number
modelName?: string
onToolStart?: (toolName: string, input: unknown) => void
onToolResult?: (toolName: string, output: string, isError: boolean) => void
onToolResult?: (toolName: string, output: string, isError: boolean, toolInput?: unknown) => void
onAssistantMessage?: (content: string) => void
onProgressMessage?: (content: string) => void
onAutoCompact?: (result: CompressionResult) => void | Promise<void>
Expand Down Expand Up @@ -385,7 +385,7 @@ export async function runAgentTurn(args: {
if (!result.ok) {
toolErrorCount += 1
}
args.onToolResult?.(call.toolName, result.output, !result.ok)
args.onToolResult?.(call.toolName, result.output, !result.ok, call.input)

const toolResult = await replaceLargeToolResult({
role: 'tool_result',
Expand Down
5 changes: 5 additions & 0 deletions src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export const SLASH_COMMANDS: SlashCommand[] = [
usage: '/compact',
description: 'Compress conversation context to free up context window space.',
},
{
name: '/tasks',
usage: '/tasks',
description: 'Show the current task list.',
},
{
name: '/collapse',
usage: '/collapse',
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
applyContextCollapseIfNeeded,
createContextCollapseState,
} from './compact/context-collapse.js'
import { createTaskState } from './task-state.js'
import { createContentReplacementState } from './utils/tool-result-storage.js'

async function main(): Promise<void> {
Expand Down Expand Up @@ -65,9 +66,11 @@ async function main(): Promise<void> {
runtime = null
}

const taskState = createTaskState()
const tools = await createDefaultToolRegistry({
cwd,
runtime,
taskState,
})
const mcpHydration = hydrateMcpTools({
cwd,
Expand Down Expand Up @@ -128,6 +131,7 @@ async function main(): Promise<void> {
permissions,
contentReplacementState,
contextCollapseState,
taskState,
sessionId,
alreadySavedCount: 0,
resumeTarget: resolvedResumeTarget,
Expand Down
9 changes: 9 additions & 0 deletions src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export async function buildSystemPrompt(
'- Use ask_user when clarification is required; that tool ends the turn and waits for user input.',
'- Do not stop after a progress update. After a <progress> message, continue the task in the next step.',
'- Plain assistant text without <progress> is treated as a completed assistant message for this turn.',
'Task tracking:',
'- You have a task_tracker tool for maintaining a lightweight task list during multi-step work.',
'- Only use task_tracker when the work involves 3 or more distinct steps. Skip it for simple single-step requests.',
'- At the start of a multi-step task, create tasks for each major step using task_tracker with action="create".',
'- Before starting work on a task, mark it in_progress with action="update_status".',
'- After completing a task, mark it completed with action="complete" or action="update_status" status="completed".',
'- Use action="list" to review the current task list at any time.',
'- Keep tasks at a high level (3-10 items). Do not create subtasks or micro-steps.',
'- The task list is shown to the user in the header banner and via /tasks.',
]

if (permissionSummary.length > 0) {
Expand Down
77 changes: 76 additions & 1 deletion src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { MINI_CODE_PROJECTS_DIR } from './config.js'
import {
createTaskState,
fromSnapshot,
type TaskSnapshot,
type TaskState,
} from './task-state.js'
import type { ChatMessage } from './types.js'
import {
createContextCollapseState,
Expand All @@ -19,7 +25,7 @@ import {

const MAX_TITLE_LENGTH = 60

type EventType = 'system' | 'user' | 'assistant' | 'thinking' | 'progress' | 'tool_call' | 'tool_result' | 'summary' | 'compact_boundary' | 'snip_boundary' | 'context_collapse' | 'rename'
type EventType = 'system' | 'user' | 'assistant' | 'thinking' | 'progress' | 'tool_call' | 'tool_result' | 'summary' | 'compact_boundary' | 'snip_boundary' | 'context_collapse' | 'rename' | 'task_snapshot'

export type SnipBoundaryMetadata = {
type: 'snip_boundary'
Expand All @@ -44,6 +50,7 @@ type SessionEvent = {
snipMetadata?: SnipBoundaryMetadata
contextCollapseSpan?: CollapseSpan
title?: string
taskSnapshot?: TaskSnapshot
}

function projectDirName(cwd: string): string {
Expand Down Expand Up @@ -312,6 +319,33 @@ export async function appendContextCollapseSpan(
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8')
}

export async function appendTaskSnapshot(
cwd: string,
sessionId: string,
snapshot: TaskSnapshot,
): Promise<void> {
const dir = projectDir(cwd)
const filePath = sessionFilePath(cwd, sessionId)
await mkdir(dir, { recursive: true })

const lastUuid = await readLastEventUuid(filePath)
const now = new Date().toISOString()

const event: SessionEvent = {
type: 'task_snapshot',
subtype: 'task_snapshot',
uuid: randomUUID(),
timestamp: now,
sessionId,
cwd,
parentUuid: null,
logicalParentUuid: lastUuid,
taskSnapshot: snapshot,
}

await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8')
}

export async function appendCompactBoundary(
cwd: string,
sessionId: string,
Expand Down Expand Up @@ -437,6 +471,37 @@ export async function loadContextCollapseState(
}
}

export async function loadTaskState(
cwd: string,
sessionId: string,
): Promise<TaskState | null> {
try {
const content = await readFile(sessionFilePath(cwd, sessionId), 'utf8')
const lines = content.trim().split('\n').filter(Boolean)

let lastBoundaryIndex = -1
for (let i = lines.length - 1; i >= 0; i--) {
const event = parseEvent(lines[i]!)
if (event?.type === 'compact_boundary') {
lastBoundaryIndex = i
break
}
}

let lastSnapshot: TaskSnapshot | null = null
for (let i = lastBoundaryIndex + 1; i < lines.length; i++) {
const event = parseEvent(lines[i]!)
if (event?.type === 'task_snapshot' && event.taskSnapshot) {
lastSnapshot = event.taskSnapshot
}
}

return lastSnapshot ? fromSnapshot(lastSnapshot) : null
} catch {
return null
}
}

export async function clearSession(
cwd: string,
sessionId: string,
Expand Down Expand Up @@ -698,6 +763,16 @@ export async function loadTranscript(
body: `[Snipped earlier context: removed ${event.snipMetadata?.removedCount ?? '?'} messages, freed ~${event.snipMetadata?.tokensFreed ?? '?'} tokens]`,
})
break
case 'task_snapshot':
if (event.taskSnapshot) {
const completed = event.taskSnapshot.tasks.filter(t => t.status === 'completed').length
const total = event.taskSnapshot.tasks.length
entries.push({
kind: 'assistant',
body: `[Tasks: ${completed}/${total} completed]`,
})
}
break
}
}

Expand Down
108 changes: 108 additions & 0 deletions src/task-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export type TaskStatus = 'pending' | 'in_progress' | 'completed'

export type Task = {
id: number
description: string
status: TaskStatus
createdAt: string
updatedAt: string
}

export type TaskState = {
tasks: Task[]
nextId: number
}

export type TaskSnapshot = {
tasks: Task[]
nextId: number
timestamp: string
}

const VALID_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
pending: ['in_progress', 'completed'],
in_progress: ['completed', 'pending'],
completed: [],
}

const MAX_DISPLAYED_TASKS = 20

export function createTaskState(): TaskState {
return { tasks: [], nextId: 1 }
}

export function addTask(state: TaskState, description: string): Task {
const now = new Date().toISOString()
const task: Task = {
id: state.nextId,
description,
status: 'pending',
createdAt: now,
updatedAt: now,
}
state.tasks.push(task)
state.nextId += 1
return task
}

export function transitionTask(
state: TaskState,
id: number,
status: TaskStatus,
): Task {
const task = state.tasks.find(t => t.id === id)
if (!task) {
throw new Error(`Task #${id} not found`)
}

const allowed = VALID_TRANSITIONS[task.status]
if (!allowed.includes(status)) {
throw new Error(
`Task #${id} is ${task.status} and cannot transition to ${status}`,
)
}

task.status = status
task.updatedAt = new Date().toISOString()
return task
}

export function toSnapshot(state: TaskState): TaskSnapshot {
return {
tasks: state.tasks.map(t => ({ ...t })),
nextId: state.nextId,
timestamp: new Date().toISOString(),
}
}

export function fromSnapshot(snapshot: TaskSnapshot): TaskState {
return {
tasks: snapshot.tasks.map(t => ({ ...t })),
nextId: snapshot.nextId,
}
}

export function formatTaskList(state: TaskState): string {
if (state.tasks.length === 0) {
return 'No tasks tracked in this session.'
}

const completed = state.tasks.filter(t => t.status === 'completed').length
const total = state.tasks.length
const header = `Tasks (${completed}/${total} completed):`

const displayed = state.tasks.slice(0, MAX_DISPLAYED_TASKS)
const lines = displayed.map(task => {
const icon =
task.status === 'completed' ? 'x'
: task.status === 'in_progress' ? '~'
: ' '
return ` #${task.id} [${icon}] ${task.description}`
})

if (state.tasks.length > MAX_DISPLAYED_TASKS) {
lines.push(` ... and ${state.tasks.length - MAX_DISPLAYED_TASKS} more`)
}

return [header, ...lines].join('\n')
}
14 changes: 12 additions & 2 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { McpServerConfig, RuntimeConfig } from '../config.js'
import type { McpServerSummary } from '../mcp.js'
import { createMcpBackedTools } from '../mcp.js'
import { discoverSkills } from '../skills.js'
import type { TaskState } from '../task-state.js'
import { ToolRegistry } from '../tool.js'
import type { ToolDefinition } from '../tool.js'
import { askUserTool } from './ask-user.js'
import { editFileTool } from './edit-file.js'
import { grepFilesTool } from './grep-files.js'
Expand All @@ -12,6 +14,7 @@ import { modifyFileTool } from './modify-file.js'
import { patchFileTool } from './patch-file.js'
import { readFileTool } from './read-file.js'
import { runCommandTool } from './run-command.js'
import { createTaskTrackerTool } from './task-tracker.js'
import { webFetchTool } from './web-fetch.js'
import { webSearchTool } from './web-search.js'
import { writeFileTool } from './write-file.js'
Expand Down Expand Up @@ -42,11 +45,12 @@ function buildConnectingMcpSummaries(
export async function createDefaultToolRegistry(args: {
cwd: string
runtime: RuntimeConfig | null
taskState?: TaskState
}): Promise<ToolRegistry> {
const skills = await discoverSkills(args.cwd)
const mcpServers = args.runtime?.mcpServers ?? {}

return new ToolRegistry([
const tools: ToolDefinition<unknown>[] = [
askUserTool,
listFilesTool,
grepFilesTool,
Expand All @@ -59,7 +63,13 @@ export async function createDefaultToolRegistry(args: {
createLoadSkillTool(args.cwd),
webFetchTool,
webSearchTool,
], {
]

if (args.taskState) {
tools.push(createTaskTrackerTool(args.taskState))
}

return new ToolRegistry(tools, {
skills,
mcpServers: buildConnectingMcpSummaries(mcpServers),
})
Expand Down
Loading