diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9fb27715..02938a4bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3285,6 +3285,9 @@ importers: services/mana-ai: dependencies: + '@mana/shared-ai': + specifier: workspace:* + version: link:../../packages/shared-ai '@mana/shared-hono': specifier: workspace:* version: link:../../packages/shared-hono @@ -25836,7 +25839,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.3': dependencies: @@ -25898,7 +25901,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/utils@4.1.3': dependencies: diff --git a/services/mana-ai/package.json b/services/mana-ai/package.json index 7bdb4ce5c..d9fb264a9 100644 --- a/services/mana-ai/package.json +++ b/services/mana-ai/package.json @@ -9,6 +9,7 @@ "test": "bun test" }, "dependencies": { + "@mana/shared-ai": "workspace:*", "@mana/shared-hono": "workspace:*", "hono": "^4.7.0", "postgres": "^3.4.5" diff --git a/services/mana-ai/src/cron/tick.ts b/services/mana-ai/src/cron/tick.ts index 3928f9f1c..edc166497 100644 --- a/services/mana-ai/src/cron/tick.ts +++ b/services/mana-ai/src/cron/tick.ts @@ -1,25 +1,35 @@ /** - * Background tick — scans Postgres for due Missions and (eventually) runs - * them through the Planner + writes the resulting plan back as a Mission - * iteration. + * Background tick — scans Postgres for due Missions, calls mana-llm via + * the shared Planner prompt/parser, logs the resulting plan. * - * Current state (v0.1): reads due missions, logs the intent, does NOT - * write back. Writing requires deciding how proposals materialize - * server-side — see `CLAUDE.md` → "Open design questions" for the - * trade-offs. Shipping this as a scaffold unblocks: - * - deployability of the service - * - smoke-testing Postgres connectivity + mana-llm reachability - * - next PR wires the actual mission-execution flow + * Current state (v0.2): produces plans end-to-end, does NOT yet write + * them back as Mission iterations. The write-back requires RLS-scoped + * transactions on `mana_sync` (same pattern as the Go server's + * `withUser`) — tracked as the next PR in `CLAUDE.md`. + * + * Input-resolver wiring is also stubbed: `resolvedInputs: []` is handed + * to the Planner today, so the LLM sees only the mission's concept + + * objective. Real resolvers land alongside write-back. */ +import { + buildPlannerPrompt, + parsePlannerResponse, + type AiPlanInput, + type AiPlanOutput, + type Mission, +} from '@mana/shared-ai'; import { getSql } from '../db/connection'; -import { listDueMissions } from '../db/missions-projection'; +import { listDueMissions, type ServerMission } from '../db/missions-projection'; import { PlannerClient } from '../planner/client'; +import { AI_AVAILABLE_TOOLS, AI_AVAILABLE_TOOL_NAMES } from '../planner/tools'; import type { Config } from '../config'; export interface TickStats { scannedAt: string; dueMissionCount: number; + plansProduced: number; + parseFailures: number; errors: string[]; } @@ -28,11 +38,19 @@ let running = false; /** One tick pass. Idempotent; overlap-guarded at module level. */ export async function runTickOnce(config: Config): Promise { if (running) { - return { scannedAt: new Date().toISOString(), dueMissionCount: 0, errors: ['overlap-skipped'] }; + return { + scannedAt: new Date().toISOString(), + dueMissionCount: 0, + plansProduced: 0, + parseFailures: 0, + errors: ['overlap-skipped'], + }; } running = true; const errors: string[] = []; let dueMissionCount = 0; + let plansProduced = 0; + let parseFailures = 0; const scannedAt = new Date().toISOString(); try { @@ -40,36 +58,104 @@ export async function runTickOnce(config: Config): Promise { const missions = await listDueMissions(sql, scannedAt); dueMissionCount = missions.length; - if (missions.length === 0) return { scannedAt, dueMissionCount, errors }; + if (missions.length === 0) + return { scannedAt, dueMissionCount, plansProduced, parseFailures, errors }; - // Planner is instantiated here but not invoked yet — see CLAUDE.md. - // The constructor is cheap; holding onto it sets the shape for the - // next PR that actually calls `complete()` per mission. - void new PlannerClient(config.manaLlmUrl, config.serviceKey); + const planner = new PlannerClient(config.manaLlmUrl, config.serviceKey); for (const m of missions) { - console.log( - `[mana-ai tick] would plan mission=${m.id} user=${m.userId} title=${JSON.stringify( - m.title - )}` - ); + try { + const plan = await planOneMission(m, planner); + if (plan === null) { + parseFailures++; + continue; + } + plansProduced++; + console.log( + `[mana-ai tick] mission=${m.id} user=${m.userId} plan=${plan.steps.length}step(s) summary=${JSON.stringify( + plan.summary + )}` + ); + // TODO: write plan back as `Mission.iterations[]` entry with + // `source: 'server'` so the webapp staging-effect can turn + // each PlannedStep into a local Proposal. Requires RLS- + // scoped write helper (see CLAUDE.md, design option A). + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`mission=${m.id}: ${msg}`); + console.error(`[mana-ai tick] mission=${m.id} plan failed:`, msg); + } } } catch (err) { const msg = err instanceof Error ? err.message : String(err); errors.push(msg); - console.error('[mana-ai tick] error:', msg); + console.error('[mana-ai tick] scan error:', msg); } finally { running = false; } - return { scannedAt, dueMissionCount, errors }; + return { scannedAt, dueMissionCount, plansProduced, parseFailures, errors }; +} + +/** + * Turn one due ServerMission into an {@link AiPlanOutput} via the LLM. + * Returns null on parse failure — the tick records that as a separate + * stat rather than throwing, so one flaky response doesn't abort the + * queue. + */ +async function planOneMission( + m: ServerMission, + planner: PlannerClient +): Promise { + const mission = serverMissionToSharedMission(m); + const input: AiPlanInput = { + mission, + // No resolvers yet — the LLM only sees concept + objective + + // iteration history. Matches the webapp's behaviour for a mission + // with zero linked inputs. + resolvedInputs: [], + availableTools: AI_AVAILABLE_TOOLS, + }; + const messages = buildPlannerPrompt(input); + const result = await planner.complete(messages); + const parsed = parsePlannerResponse(result.content, AI_AVAILABLE_TOOL_NAMES); + if (!parsed.ok) { + console.warn( + `[mana-ai tick] mission=${m.id} parse failed: ${parsed.reason} — raw:`, + parsed.raw?.slice(0, 200) + ); + return null; + } + return parsed.value; +} + +/** + * Projection → shared-ai Mission shape. The projection leaves a few + * fields as `unknown` because the server doesn't need to interpret them + * (cadence math, iteration bookkeeping live in the webapp); we cast + * once here at the boundary. + */ +function serverMissionToSharedMission(m: ServerMission): Mission { + return { + id: m.id, + createdAt: m.nextRunAt ?? new Date().toISOString(), + updatedAt: m.nextRunAt ?? new Date().toISOString(), + title: m.title, + conceptMarkdown: m.conceptMarkdown, + objective: m.objective, + inputs: m.inputs, + cadence: m.cadence as Mission['cadence'], + state: m.state, + nextRunAt: m.nextRunAt, + iterations: m.iterations as Mission['iterations'], + userId: m.userId, + }; } let handle: ReturnType | null = null; export function startTick(config: Config): () => void { if (!config.tickEnabled || handle !== null) return stopTick; - // Kick once immediately so a just-due mission doesn't wait a full interval. void runTickOnce(config); handle = setInterval(() => void runTickOnce(config), config.tickIntervalMs); return stopTick; diff --git a/services/mana-ai/src/planner/tools.ts b/services/mana-ai/src/planner/tools.ts new file mode 100644 index 000000000..f11ea0ad3 --- /dev/null +++ b/services/mana-ai/src/planner/tools.ts @@ -0,0 +1,68 @@ +/** + * Hardcoded allow-list of tools the server-side Planner may propose. + * + * The webapp owns the full tool registry (in + * `apps/mana/apps/web/src/lib/data/tools/registry.ts`) and the policy + * (`DEFAULT_AI_POLICY` in `data/ai/policy.ts`). This file mirrors the + * subset where policy === 'propose' so the mana-ai Bun service can + * build a valid prompt without importing Dexie-bound code. + * + * Drift risk: if the webapp adds a new proposable tool and this file + * isn't updated, the mana-ai Planner simply won't suggest it — graceful + * degradation. A contract test that compares both lists would be a + * sensible follow-up. + */ + +import type { AvailableTool } from '@mana/shared-ai'; + +export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = [ + { + name: 'create_task', + module: 'todo', + description: 'Erstellt einen neuen Task mit optionalem Faelligkeitsdatum und Prioritaet', + parameters: [ + { name: 'title', type: 'string', description: 'Titel des Tasks', required: true }, + { + name: 'dueDate', + type: 'string', + description: 'Faelligkeitsdatum (YYYY-MM-DD)', + required: false, + }, + { + name: 'priority', + type: 'string', + description: 'Prioritaet', + required: false, + enum: ['low', 'medium', 'high'], + }, + { name: 'description', type: 'string', description: 'Beschreibung', required: false }, + ], + }, + { + name: 'complete_task', + module: 'todo', + description: 'Markiert einen Task als erledigt', + parameters: [{ name: 'taskId', type: 'string', description: 'ID des Tasks', required: true }], + }, + { + name: 'create_event', + module: 'calendar', + description: 'Erstellt einen Kalender-Event', + parameters: [ + { name: 'title', type: 'string', description: 'Event-Titel', required: true }, + { name: 'startIso', type: 'string', description: 'Start (ISO)', required: true }, + { name: 'endIso', type: 'string', description: 'Ende (ISO)', required: false }, + ], + }, + { + name: 'create_place', + module: 'places', + description: 'Fügt einen neuen Ort hinzu', + parameters: [ + { name: 'name', type: 'string', description: 'Name des Ortes', required: true }, + { name: 'category', type: 'string', description: 'Kategorie', required: false }, + ], + }, +]; + +export const AI_AVAILABLE_TOOL_NAMES = new Set(AI_AVAILABLE_TOOLS.map((t) => t.name));