mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 06:49:40 +02:00
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>
96 lines
3 KiB
TypeScript
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 },
|
|
};
|
|
}
|