managarten/packages/shared-ai/src/planner/parser.ts
Till JS 0d90b12d1c feat(shared-ai): extract planner + mission types to @mana/shared-ai
Single source of truth for AI Workbench types shared between the webapp
(Vite/SvelteKit) and the server-side mana-ai Bun service. Prevents the
two runtimes from drifting on prompt shape or mission structure.

- `@mana/shared-ai` package:
  - `actor.ts` — Actor union (user | ai | system) + helpers, mirrors the
    webapp's runtime type so server-side consumers parse incoming actors
    without re-declaring
  - `missions/types.ts` — Mission, MissionCadence, MissionInputRef,
    MissionIteration, PlanStep, MissionState. Adds optional
    `iteration.source: 'browser' | 'server'` to distinguish foreground
    vs server-produced iterations (groundwork for proposal write-back)
  - `planner/prompt.ts` — `buildPlannerPrompt` pure function
  - `planner/parser.ts` — `parsePlannerResponse` strict JSON validator
  - Vitest smoke tests (2) cover prompt → parse round-trip + unknown-
    tool rejection
- Webapp:
  - `missions/types.ts` re-exports from shared-ai, keeps webapp-local
    `MISSIONS_TABLE` constant + `planStepStatusFromProposal` bridge
  - `missions/planner/{types,prompt,parser}.ts` become re-export stubs
    so existing imports keep working unchanged
  - Existing webapp tests (60) continue to pass — the wire code didn't
    move, just its home

Next: mana-ai service imports buildPlannerPrompt/parsePlannerResponse
from shared-ai + wires mana-llm + writes iteration back as a
'source=server' row (tracked in services/mana-ai/CLAUDE.md).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:01:57 +02:00

96 lines
3 KiB
TypeScript

/**
* Parser for Planner LLM output.
*
* Strict: we only accept the fenced `json` block the system prompt
* prescribes, validate shape, and surface errors so the Runner can
* record them on the iteration instead of silently producing a bad plan.
*/
import type { AiPlanOutput, PlannedStep } from './types';
export type ParseResult =
| { readonly ok: true; readonly value: AiPlanOutput }
| { readonly ok: false; readonly reason: string; readonly raw?: string };
export function parsePlannerResponse(text: string, knownToolNames: Set<string>): ParseResult {
const block = extractJsonBlock(text);
if (!block) return { ok: false, reason: 'no JSON block found', raw: text };
let parsed: unknown;
try {
parsed = JSON.parse(block);
} catch (err) {
return {
ok: false,
reason: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}`,
raw: block,
};
}
if (typeof parsed !== 'object' || parsed === null) {
return { ok: false, reason: 'top-level value is not an object', raw: block };
}
const obj = parsed as Record<string, unknown>;
const summary = typeof obj.summary === 'string' ? obj.summary : '';
const rawSteps = obj.steps;
if (!Array.isArray(rawSteps)) {
return { ok: false, reason: '`steps` must be an array', raw: block };
}
const steps: PlannedStep[] = [];
for (let i = 0; i < rawSteps.length; i++) {
const step = rawSteps[i];
const validation = validateStep(step, knownToolNames, i);
if (!validation.ok) {
return { ok: false, reason: validation.reason, raw: block };
}
steps.push(validation.value);
}
return { ok: true, value: { summary, steps } };
}
function extractJsonBlock(text: string): string | null {
const fenced = /```(?:json)?\s*\n?([\s\S]*?)\n?```/;
const m = text.match(fenced);
if (m) return m[1].trim();
const trimmed = text.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed;
return null;
}
function validateStep(
raw: unknown,
knownToolNames: Set<string>,
index: number
): { ok: true; value: PlannedStep } | { ok: false; reason: string } {
if (typeof raw !== 'object' || raw === null) {
return { ok: false, reason: `step[${index}] is not an object` };
}
const obj = raw as Record<string, unknown>;
const toolName = obj.toolName;
if (typeof toolName !== 'string' || toolName.length === 0) {
return { ok: false, reason: `step[${index}].toolName missing or not a string` };
}
if (!knownToolNames.has(toolName)) {
return {
ok: false,
reason: `step[${index}].toolName "${toolName}" is not in the allowed tool set`,
};
}
const summary = typeof obj.summary === 'string' ? obj.summary : '';
const rationale = typeof obj.rationale === 'string' ? obj.rationale : '';
if (rationale.length === 0) {
return { ok: false, reason: `step[${index}].rationale is required (user will see this)` };
}
const params =
typeof obj.params === 'object' && obj.params !== null
? (obj.params as Record<string, unknown>)
: {};
return {
ok: true,
value: { summary, toolName, rationale, params },
};
}