blob: 349a24dc9a148e6a9ceb474cac21194aba83c6e1 [file]
import { z } from 'zod';
import {
TASK_STATUSES,
TASK_EVIDENCE_MAX_CHARS,
TASK_SUBJECT_MAX_CHARS,
TASK_LEDGER_MAX_TASKS,
TASK_ID_MAX_CHARS,
filterModelVisibleTaskLedgerTasks,
isSafeTaskId,
isTerminalTaskStatus,
renderSafeTaskLedgerText,
type TaskLedgerStore,
} from '@maka/core';
import type { MakaTool } from './tool-runtime.js';
export const TASK_CREATE_TOOL_NAME = 'task_create';
export const TASK_UPDATE_TOOL_NAME = 'task_update';
export const TASK_LIST_TOOL_NAME = 'task_list';
export const TASK_GET_TOOL_NAME = 'task_get';
export const LEGACY_TASK_CREATE_TOOL_NAME = 'TaskCreate';
export const LEGACY_TASK_UPDATE_TOOL_NAME = 'TaskUpdate';
export interface BuildTaskLedgerToolsOptions {
includeLegacyAliases?: boolean;
}
export function isTaskLedgerToolsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const value = env.MAKA_TASK_LEDGER_TOOLS;
return value === undefined || !/^(0|false|off)$/i.test(value.trim());
}
export function buildTaskLedgerTools(
deps: { store: TaskLedgerStore },
options: BuildTaskLedgerToolsOptions = {},
): MakaTool[] {
const tools = [
buildTaskCreateTool(deps.store, TASK_CREATE_TOOL_NAME, 'task_update'),
buildTaskUpdateTool(deps.store, TASK_UPDATE_TOOL_NAME),
buildTaskListTool(deps.store),
buildTaskGetTool(deps.store),
];
if (options.includeLegacyAliases === true) {
tools.push(
buildTaskCreateTool(deps.store, LEGACY_TASK_CREATE_TOOL_NAME, 'TaskUpdate'),
buildTaskUpdateTool(deps.store, LEGACY_TASK_UPDATE_TOOL_NAME),
);
}
return tools;
}
function buildTaskCreateTool(
store: TaskLedgerStore,
name: string,
updateToolName: string,
): MakaTool<{ tasks: Array<{ subject: string; parent_id?: string }> }, string> {
return {
name,
displayName: 'Task Create',
description:
'Add one or more tasks to the session task ledger. The full updated ledger is re-shown each turn, ' +
`so use this to record work you plan to do; update status with ${updateToolName} as you progress.`,
parameters: z.object({
tasks: z
.array(
z.object({
subject: z
.string()
.trim()
.min(1)
.max(TASK_SUBJECT_MAX_CHARS)
.describe(
`Short imperative description of the task (max ${TASK_SUBJECT_MAX_CHARS} characters).`,
),
parent_id: z
.string()
.min(1)
.max(TASK_ID_MAX_CHARS)
.refine(isSafeTaskId)
.optional()
.describe('Existing parent task UUID or short key (for example T1).'),
}),
)
.min(1)
.max(TASK_LEDGER_MAX_TASKS)
.describe('One or more tasks to add. Each starts in the pending state.'),
}),
impl: async (input, ctx) => {
const { created, total } = await store.create(
ctx.sessionId,
input.tasks.map((task) => ({
subject: task.subject,
...(task.parent_id ? { parentId: task.parent_id } : {}),
})),
{
runId: ctx.runId,
turnId: ctx.turnId,
toolCallId: ctx.toolCallId,
source: 'tool',
actor: 'main_agent',
},
);
return `Created ${created.length} task(s); ledger total: ${total}.\n${renderSafeTaskLedgerText(created)}`;
},
};
}
function buildTaskUpdateTool(
store: TaskLedgerStore,
name: string,
): MakaTool<
{
id: string;
status?: (typeof TASK_STATUSES)[number];
subject?: string;
blockedReason?: string;
failureReason?: string;
completionEvidence?: string;
explicitReopen?: boolean;
},
string
> {
return {
name,
displayName: 'Task Update',
description:
'Update a task in the session task ledger by id. Mark tasks in_progress when you start them; ' +
'blocked, failed, and completed updates require a reason or evidence field. ' +
'Reopening completed/cancelled tasks requires explicitReopen=true.',
parameters: z
.object({
id: z
.string()
.min(1)
.max(TASK_ID_MAX_CHARS)
.refine(
isSafeTaskId,
'Task reference must be a UUID or short key from the current ledger.',
)
.describe('Task UUID or short key.'),
status: z.enum(TASK_STATUSES).optional().describe('New task status.'),
subject: z
.string()
.trim()
.min(1)
.max(TASK_SUBJECT_MAX_CHARS)
.optional()
.describe(`Revised task description (max ${TASK_SUBJECT_MAX_CHARS} characters).`),
blockedReason: z
.string()
.trim()
.min(1)
.max(TASK_EVIDENCE_MAX_CHARS)
.optional()
.describe(
'Required when setting status to blocked. Explain the external input, dependency, or permission needed.',
),
failureReason: z
.string()
.trim()
.min(1)
.max(TASK_EVIDENCE_MAX_CHARS)
.optional()
.describe(
'Required when setting status to failed. Explain why the task cannot be completed.',
),
completionEvidence: z
.string()
.trim()
.min(1)
.max(TASK_EVIDENCE_MAX_CHARS)
.optional()
.describe(
'Required when setting status to completed. Cite the check, tool result, artifact, or user confirmation.',
),
explicitReopen: z
.boolean()
.optional()
.describe(
'Required only when reopening completed -> in_progress or cancelled -> pending.',
),
})
.superRefine((input, ctx) => {
if (
input.status === undefined &&
input.subject === undefined &&
input.blockedReason === undefined &&
input.failureReason === undefined &&
input.completionEvidence === undefined &&
input.explicitReopen === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide at least one task field to update.',
});
}
if (input.status === 'blocked' && input.blockedReason === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'blockedReason is required when status is blocked.',
path: ['blockedReason'],
});
}
if (input.status === 'failed' && input.failureReason === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'failureReason is required when status is failed.',
path: ['failureReason'],
});
}
if (input.status === 'completed' && input.completionEvidence === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'completionEvidence is required when status is completed.',
path: ['completionEvidence'],
});
}
}),
impl: async (input, ctx) => {
const { updated, total } = await store.update(
ctx.sessionId,
input.id,
{
...(input.status !== undefined ? { status: input.status } : {}),
...(input.subject !== undefined ? { subject: input.subject } : {}),
...(input.blockedReason !== undefined ? { blockedReason: input.blockedReason } : {}),
...(input.failureReason !== undefined ? { failureReason: input.failureReason } : {}),
...(input.completionEvidence !== undefined
? { completionEvidence: input.completionEvidence }
: {}),
...(input.explicitReopen !== undefined ? { explicitReopen: input.explicitReopen } : {}),
},
{
runId: ctx.runId,
turnId: ctx.turnId,
toolCallId: ctx.toolCallId,
source: 'tool',
actor: 'main_agent',
},
);
return `Updated 1 task; ledger total: ${total}.\n${renderSafeTaskLedgerText([updated])}`;
},
};
}
function buildTaskListTool(store: TaskLedgerStore): MakaTool<
{
status?: (typeof TASK_STATUSES)[number];
include_terminal?: boolean;
include_archived?: boolean;
},
string
> {
return {
name: TASK_LIST_TOOL_NAME,
displayName: 'Task List',
description: 'List the current session task ledger in compact form.',
parameters: z
.object({
status: z.enum(TASK_STATUSES).optional().describe('Optional exact status filter.'),
include_terminal: z
.boolean()
.optional()
.describe('Include terminal tasks. Defaults to true for compatibility.'),
include_archived: z
.boolean()
.optional()
.describe(
'Include terminal tasks older than seven days. Defaults to true for compatibility.',
),
})
.superRefine((input, ctx) => {
if (
input.status &&
isTerminalTaskStatus(input.status) &&
input.include_terminal === false
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['include_terminal'],
message: 'A terminal status filter conflicts with include_terminal=false.',
});
}
}),
impl: async (input, ctx) => {
const tasks = filterModelVisibleTaskLedgerTasks(
await store.list(ctx.sessionId, {
...(input.status ? { status: input.status } : {}),
...(input.include_terminal !== undefined
? { includeTerminal: input.include_terminal }
: {}),
...(input.include_archived !== undefined
? { includeArchived: input.include_archived }
: {}),
}),
);
return tasks.length === 0
? 'Task ledger is empty.'
: `Task ledger total: ${tasks.length}.\n${renderSafeTaskLedgerText(tasks)}`;
},
};
}
function buildTaskGetTool(store: TaskLedgerStore): MakaTool<{ id: string }, string> {
return {
name: TASK_GET_TOOL_NAME,
displayName: 'Task Get',
description: 'Get one task from the current session task ledger by id.',
parameters: z.object({
id: z
.string()
.min(1)
.max(TASK_ID_MAX_CHARS)
.refine(
isSafeTaskId,
'Task reference must be a UUID or short key from the current ledger.',
),
}),
impl: async (input, ctx) => {
const task = await store.get(ctx.sessionId, input.id);
if (task?.resumeTrust === 'untrusted') return `No such task: ${input.id}`;
if (!task) return `No such task: ${input.id}`;
return renderSafeTaskLedgerText([task]);
},
};
}