blob: d35da04470b8d38d8d2d36f6cc2dbfcc5b19c40d [file]
import { createHash } from 'node:crypto';
import { z } from 'zod';
import type {
PlanExecution,
PlanMutationResult,
PlanProposal,
PlanStepStatus,
PlanStore,
} from '@maka/core';
import {
PLAN_MAX_FILES_PER_STEP,
PLAN_MAX_RISKS,
PLAN_MAX_STEPS,
PLAN_LIFECYCLE_REASON_MAX_BYTES,
PLAN_STEP_TITLE_MAX_CHARS,
isCanonicalPlanEntityId,
isPlanProposalLifecycleAdmissible,
isPlanTextWithinLimit,
} from '@maka/core';
import type { MakaTool } from './tool-runtime.js';
const MARKDOWN_PATTERN =
/(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~)|!?(?:\[[^\]\n]+\]\([^)\n]+\))|(?:\*\*|__|`)/;
const boundedTextSchema = (label: string, maxBytes?: number) =>
z
.string()
.trim()
.min(1)
.refine((value) => isPlanTextWithinLimit(value, maxBytes), {
message: `${label} exceeds the Plan text limit`,
});
const plainTextSchema = (label: string) =>
z
.string()
.trim()
.min(1)
.refine(isPlanTextWithinLimit, { message: `${label} exceeds the Plan text limit` })
.refine((value) => !MARKDOWN_PATTERN.test(value), {
message: `${label} must be plain text without Markdown formatting`,
});
const stepDefinitionSchema = z.object({
id: z.string().trim().refine(isCanonicalPlanEntityId, {
message: 'Plan step id must be a canonical entity id',
}),
title: plainTextSchema('Plan step title').max(PLAN_STEP_TITLE_MAX_CHARS),
description: plainTextSchema('Plan step description'),
files: z.array(boundedTextSchema('Plan step file')).max(PLAN_MAX_FILES_PER_STEP).optional(),
complexity: z.enum(['low', 'medium', 'high']).optional(),
});
const executionStepSchema = z.object({
id: z.string().refine(isCanonicalPlanEntityId, {
message: 'Plan step id must be a canonical entity id',
}),
status: z.enum(['pending', 'in_progress', 'completed', 'skipped']),
note: boundedTextSchema('Plan step note').optional(),
});
export type PlanToolResult =
| {
kind: 'plan_submitted';
proposal: PlanProposal;
storeVersion: number;
}
| {
kind: 'plan_progress_updated' | 'plan_execution_completed';
execution: PlanExecution;
storeVersion: number;
}
| {
kind: 'plan_execution_cancelled';
execution: PlanExecution;
storeVersion: number;
};
export function buildSubmitPlanTool(
planStore: PlanStore,
sourceExecutionId?: string,
): MakaTool<
{
title: string;
overview?: string;
steps: Array<{
id: string;
title: string;
description: string;
files?: string[];
complexity?: 'low' | 'medium' | 'high';
}>;
risks?: string[];
},
PlanToolResult
> {
return {
name: 'SubmitPlan',
description:
'Submit the finished implementation plan for user approval. Every step requires a concise plain-text title and a detailed plain-text description; do not use Markdown in either field. This ends the planning turn, so do not call it until the plan is ready to review.',
parameters: z
.object({
title: boundedTextSchema('Plan title'),
overview: boundedTextSchema('Plan overview').optional(),
steps: z.array(stepDefinitionSchema).min(1).max(PLAN_MAX_STEPS),
risks: z.array(boundedTextSchema('Plan risk')).max(PLAN_MAX_RISKS).optional(),
})
.refine(
isPlanProposalLifecycleAdmissible,
'Plan proposal cannot fit its execution lifecycle projection',
),
recoveryMode: 'idempotent',
nesting: 'direct_only',
impl: async (input, context) => {
const result = await planStore.submitProposal({
operationId: planToolOperationId('submit', context),
sessionId: context.sessionId,
turnId: context.turnId,
...(sourceExecutionId ? { sourceExecutionId } : {}),
...input,
});
return {
kind: 'plan_submitted',
proposal:
result.event.type === 'plan_submitted'
? result.event.proposal
: missingPlanToolProjection('SubmitPlan'),
storeVersion: result.state.storeVersion,
};
},
};
}
export function buildUpdatePlanTool(
planStore: PlanStore,
executionId: string,
): MakaTool<
{
steps: Array<{ id: string; status: PlanStepStatus; note?: string }>;
explanation?: string;
},
PlanToolResult
> {
return {
name: 'update_plan',
description:
'Update execution progress for the approved plan. Include every plan step and keep at most one step in_progress.',
parameters: z.object({
steps: z.array(executionStepSchema).min(1).max(PLAN_MAX_STEPS),
explanation: boundedTextSchema('Plan progress explanation').optional(),
}),
recoveryMode: 'idempotent',
nesting: 'direct_only',
impl: async (input, context) => {
const result = await planStore.updateExecution({
operationId: planToolOperationId('update', context),
sessionId: context.sessionId,
executionId,
...input,
});
return executionResult(result);
},
};
}
export function buildCancelPlanTool(
planStore: PlanStore,
executionId: string,
): MakaTool<{ reason: string }, PlanToolResult> {
return {
name: 'cancel_plan',
description:
'Cancel the active plan execution when the user explicitly asks to abandon it. Explain the user request in reason.',
parameters: z.object({
reason: boundedTextSchema('Plan cancellation reason', PLAN_LIFECYCLE_REASON_MAX_BYTES),
}),
recoveryMode: 'idempotent',
nesting: 'direct_only',
impl: async ({ reason }, context) => {
const result = await planStore.cancelExecution({
operationId: planToolOperationId('cancel', context),
sessionId: context.sessionId,
executionId,
reason,
});
return executionResult(result);
},
};
}
function planToolOperationId(
kind: 'submit' | 'update' | 'cancel',
context: { sessionId: string; turnId: string; toolCallId: string },
): string {
return `plan_${createHash('sha256')
.update(kind)
.update('\0')
.update(context.sessionId)
.update('\0')
.update(context.turnId)
.update('\0')
.update(context.toolCallId)
.digest('hex')}`;
}
function missingPlanToolProjection(toolName: string): never {
throw new Error(`${toolName} received an incompatible idempotent Plan result`);
}
function executionResult(result: PlanMutationResult): PlanToolResult {
const executionId =
'executionId' in result.event ? result.event.executionId : result.state.activeExecutionId;
const execution = result.state.executions.find(
(candidate) => candidate.executionId === executionId,
);
if (!execution) throw new Error('Plan execution projection is missing');
if (result.event.type === 'plan_execution_completed') {
return { kind: 'plan_execution_completed', execution, storeVersion: result.state.storeVersion };
}
if (result.event.type === 'plan_execution_cancelled') {
return { kind: 'plan_execution_cancelled', execution, storeVersion: result.state.storeVersion };
}
return { kind: 'plan_progress_updated', execution, storeVersion: result.state.storeVersion };
}