diff --git a/apps/mana/apps/web/src/lib/components/ai/AiDebugBlock.svelte b/apps/mana/apps/web/src/lib/components/ai/AiDebugBlock.svelte new file mode 100644 index 000000000..7830e453e --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/ai/AiDebugBlock.svelte @@ -0,0 +1,206 @@ + + + +{#if debug.value} + {@const d = debug.value} +
+ + πŸ” Debug + + {d.resolvedInputs.length} Input(s) + {#if d.preStep.kontextInjected} + Β· Kontext{/if} + {#if d.preStep.webResearch?.ok} + Β· Web {d.preStep.webResearch.sourceCount}q + {:else if d.preStep.webResearch && !d.preStep.webResearch.ok} + Β· Web ❌ + {/if} + {#if d.planner}Β· {Math.round(d.planner.latencyMs)}ms{/if} + {#if d.plannerError}Β· Planner ❌{/if} + + + + + {#if d.preStep.webResearch} +
+
Pre-Step: Web-Recherche
+ {#if d.preStep.webResearch.ok} +

{d.preStep.webResearch.sourceCount} Quellen.

+
{d.preStep.webResearch.summary}
+ {:else} +

FEHLER: {d.preStep.webResearch.error}

+ {/if} +
+ {/if} + +
+
Resolved Inputs ({d.resolvedInputs.length})
+ {#if d.resolvedInputs.length === 0} +

β€” keine β€”

+ {:else} + {#each d.resolvedInputs as inp (inp.id)} +
+ + {inp.module}/{inp.table} + {inp.title ?? inp.id} + +
{inp.content}
+
+ {/each} + {/if} +
+ + {#if d.planner} +
+
System Prompt
+
{d.planner.systemPrompt}
+
+
+
User Prompt
+
{d.planner.userPrompt}
+
+
+
Raw LLM Response
+
{d.planner.rawResponse}
+
+ {/if} + + {#if d.plannerError} +
+
Planner Error
+

{d.plannerError}

+
+ {/if} +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/debug.ts b/apps/mana/apps/web/src/lib/data/ai/missions/debug.ts new file mode 100644 index 000000000..90320c91f --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/debug.ts @@ -0,0 +1,110 @@ +/** + * AI Mission debug log β€” per-iteration capture of what the planner saw + * and what it returned, for debugging / prompt iteration. + * + * Local-only (Dexie table `_aiDebugLog`, never synced) because the + * captured prompt contains the user's resolved inputs, which include + * decrypted note bodies and goal text. Sending those to the server + * would defeat the at-rest encryption. + * + * Toggled via localStorage flag `mana.ai.debug` ('1' enables). Defaults + * to enabled in DEV builds and disabled in production. Capped at + * MAX_ENTRIES newest rows; the writer trims older ones on every insert. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '../../database'; +import type { ResolvedInput } from './planner/types'; + +const TABLE = '_aiDebugLog'; +const STORAGE_KEY = 'mana.ai.debug'; +const MAX_ENTRIES = 50; + +/** + * Captured by `aiPlanTask` and passed back via the planner output so the + * runner can record it without the planner needing to know about Dexie. + */ +export interface PlannerCallDebug { + readonly systemPrompt: string; + readonly userPrompt: string; + readonly rawResponse: string; + readonly latencyMs: number; + readonly backendId?: string; + readonly model?: string; +} + +export interface AiDebugEntry { + /** Primary key β€” one row per iteration. */ + iterationId: string; + missionId: string; + missionTitle: string; + missionObjective: string; + capturedAt: string; + resolvedInputs: ResolvedInput[]; + preStep: { + webResearch?: { ok: true; sourceCount: number; summary: string } | { ok: false; error: string }; + kontextInjected: boolean; + }; + planner?: PlannerCallDebug; + plannerError?: string; +} + +/** True when the user has opted in to debug capture. */ +export function isAiDebugEnabled(): boolean { + if (typeof localStorage === 'undefined') return false; + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === '1') return true; + if (raw === '0') return false; + // Default: on in dev builds, off in prod. + try { + return Boolean(import.meta.env?.DEV); + } catch { + return false; + } +} + +export function setAiDebugEnabled(enabled: boolean): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0'); +} + +/** Persist one debug entry + trim oldest if over cap. Idempotent on + * iterationId β€” re-running an iteration overwrites the prior capture. */ +export async function recordAiDebug(entry: AiDebugEntry): Promise { + try { + await db.table(TABLE).put(entry); + const total = await db.table(TABLE).count(); + if (total > MAX_ENTRIES) { + const overflow = total - MAX_ENTRIES; + const oldest = await db + .table(TABLE) + .orderBy('capturedAt') + .limit(overflow) + .primaryKeys(); + if (oldest.length) { + await db.table(TABLE).bulkDelete(oldest); + } + } + } catch (err) { + console.warn('[AiDebug] persist failed:', err); + } +} + +export async function getAiDebugForIteration( + iterationId: string +): Promise { + return db.table(TABLE).get(iterationId); +} + +/** Reactive Svelte 5 query β€” returns the debug entry for an iteration + * or `null` while loading / when none exists yet. */ +export function useAiDebugForIteration(iterationId: string | null) { + return useLiveQueryWithDefault( + async () => { + if (!iterationId) return null; + const row = await db.table(TABLE).get(iterationId); + return row ?? null; + }, + null as AiDebugEntry | null + ); +} diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts index a4f61ceb4..d8b4522d1 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts @@ -32,7 +32,10 @@ import { executeTool } from '../../tools/executor'; import { db } from '../../database'; import { decryptRecords } from '../../crypto'; import { researchApi } from '$lib/api/research'; +import { isAiDebugEnabled, recordAiDebug, type AiDebugEntry } from './debug'; import { makeAgentActor, LEGACY_AI_PRINCIPAL, type Actor } from '../../events/actor'; +import { getAgent } from '../agents/store'; +import { DEFAULT_AGENT_NAME } from '../agents/types'; import type { Mission, MissionIteration, PlanStep } from './types'; import type { AiPlanInput, AiPlanOutput, PlannedStep, ResolvedInput } from './planner/types'; @@ -109,12 +112,15 @@ export async function runMission( // Use the id the store generates so finishIteration updates the same row. const startedIteration = await startIteration(mission.id, { plan: [] }); const iterationId = startedIteration.id; - // Phase 1: agent identity not yet wired (Phase 2 will). Use the - // legacy AI principal so every write is still identity-aware; the - // Phase-2 migration will rewrite these to a real agentId. + + // Resolve the owning agent. Missions that pre-date the Multi-Agent + // rollout or whose agent was deleted fall back to the legacy + // principal + default name β€” runner still attributes cleanly, UI + // renders the work as "Mana". + const owningAgent = mission.agentId ? await getAgent(mission.agentId) : null; const aiActor = makeAgentActor({ - agentId: LEGACY_AI_PRINCIPAL, - displayName: 'Mana', + agentId: owningAgent?.id ?? LEGACY_AI_PRINCIPAL, + displayName: owningAgent?.name ?? DEFAULT_AGENT_NAME, missionId: mission.id, iterationId, rationale: mission.objective, @@ -161,6 +167,7 @@ export async function runMission( ); const baseInputs = await resolveMissionInputs(mission!.inputs); const resolvedInputs: ResolvedInput[] = [...baseInputs]; + const preStep: AiDebugEntry['preStep'] = { kontextInjected: false }; // Auto-inject the kontext singleton (if non-empty and not already // linked) so every mission has the user's standing context as @@ -168,7 +175,10 @@ export async function runMission( const alreadyHasKontext = mission!.inputs.some((i) => i.module === 'kontext'); if (!alreadyHasKontext) { const kontextEntry = await loadKontextAsResolvedInput(); - if (kontextEntry) resolvedInputs.push(kontextEntry); + if (kontextEntry) { + resolvedInputs.push(kontextEntry); + preStep.kontextInjected = true; + } } // Pre-step web research: if the objective looks like research, @@ -180,12 +190,20 @@ export async function runMission( if (RESEARCH_TRIGGER.test(mission!.objective)) { await enterPhase('resolving-inputs', 'Web-Recherche…'); try { - const researchEntry = await runWebResearch(mission!); - if (researchEntry) resolvedInputs.push(researchEntry); + const research = await runWebResearch(mission!); + if (research) { + resolvedInputs.push(research.input); + preStep.webResearch = { + ok: true, + sourceCount: research.sourceCount, + summary: research.summary, + }; + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.warn('[MissionRunner] web-research pre-step failed:', err); await enterPhase('resolving-inputs', `Web-Recherche fehlgeschlagen: ${msg.slice(0, 80)}`); + preStep.webResearch = { ok: false, error: msg }; resolvedInputs.push({ id: 'web-research-error', module: 'research', @@ -209,9 +227,42 @@ export async function runMission( // ── Phase: calling-llm ───────────────────────────────── await enterPhase('calling-llm', 'frage Planner an'); - const plan = await deps.plan({ mission: mission!, resolvedInputs, availableTools }); + let plan: AiPlanOutput; + try { + plan = await deps.plan({ mission: mission!, resolvedInputs, availableTools }); + } catch (err) { + // Capture even the failure for debug visibility before re-throwing. + if (isAiDebugEnabled()) { + void recordAiDebug({ + iterationId, + missionId: mission!.id, + missionTitle: mission!.title, + missionObjective: mission!.objective, + capturedAt: new Date().toISOString(), + resolvedInputs, + preStep, + plannerError: err instanceof Error ? err.message : String(err), + }); + } + throw err; + } await checkCancel(); + // Persist debug capture if enabled. Off by default in production + // (toggle via Settings or `localStorage.setItem('mana.ai.debug','1')`). + if (isAiDebugEnabled()) { + void recordAiDebug({ + iterationId, + missionId: mission!.id, + missionTitle: mission!.title, + missionObjective: mission!.objective, + capturedAt: new Date().toISOString(), + resolvedInputs, + preStep, + planner: plan.debug, + }); + } + // ── Phase: parsing-response ──────────────────────────── await enterPhase('parsing-response', `${plan.steps.length} Step(s) erhalten`); await checkCancel(); @@ -360,7 +411,13 @@ async function loadKontextAsResolvedInput(): Promise { /** Run the deep-research pipeline against the mission objective and * collapse its summary + sources into one ResolvedInput formatted so * the planner can copy URLs into save_news_article calls. */ -async function runWebResearch(mission: Mission): Promise { +interface WebResearchOutcome { + input: ResolvedInput; + sourceCount: number; + summary: string; +} + +async function runWebResearch(mission: Mission): Promise { const result = await researchApi.startSync({ // Tag the run with the mission id so backend logs can correlate. questionId: `mission:${mission.id}`, @@ -387,11 +444,15 @@ async function runWebResearch(mission: Mission): Promise { ].join('\n'); return { - id: result.id, - module: 'research', - table: 'researchResults', - title: 'Web-Recherche zu diesem Auftrag', - content, + input: { + id: result.id, + module: 'research', + table: 'researchResults', + title: 'Web-Recherche zu diesem Auftrag', + content, + }, + sourceCount: sources.length, + summary: result.summary, }; } diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 8f7dcc930..b3cb65e43 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -516,6 +516,26 @@ db.version(18).stores({ aiMissions: 'id, state, createdAt, nextRunAt, [state+nextRunAt]', }); +// v19 β€” AI Agents: named personas that own Missions, carry policy + +// memory, and show up as identities in the Workbench timeline. +// Syncs cross-device so the same agent exists everywhere. Name +// uniqueness is enforced at write time in the store (Dexie's unique +// index would error on the default-agent-backfill race between two +// tabs). See docs/plans/multi-agent-workbench.md Β§Phase 2b. +db.version(19).stores({ + agents: 'id, state, createdAt, name, [state+name]', +}); + +// v20 β€” AI Debug Log: per-iteration capture of the prompt sent to the +// planner LLM, the raw response, the resolved-inputs the planner saw, +// and any pre-step output (e.g. web-research). LOCAL-ONLY, never synced +// (would leak personal context through mana-sync) β€” that is enforced by +// keeping it out of every module's SYNC_APP_MAP. Capped to ~50 newest +// rows by the writer so a long-running tab doesn't bloat IndexedDB. +db.version(20).stores({ + _aiDebugLog: 'iterationId, capturedAt', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts b/apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts index e493183da..f4fe7a390 100644 --- a/apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts +++ b/apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts @@ -48,6 +48,16 @@ export const aiPlanTask: LlmTask = { maxTokens: 1024, }); + // Always populate debug payload (cheap β€” strings already in memory). + // The runner decides whether to persist it based on the user's + // localStorage `mana.ai.debug` toggle. + const debug = { + systemPrompt: system, + userPrompt: user, + rawResponse: result.content, + latencyMs: result.latencyMs, + }; + const knownToolNames = new Set(input.availableTools.map((t) => t.name)); const parsed = parsePlannerResponse(result.content, knownToolNames); @@ -55,8 +65,9 @@ export const aiPlanTask: LlmTask = { return { steps: [], summary: `Plan konnte nicht erzeugt werden: ${parsed.reason}`, + debug, }; } - return parsed.value; + return { ...parsed.value, debug }; }, }; diff --git a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte index 9890843f9..976ef823c 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte @@ -21,6 +21,8 @@ import { productionDeps } from '$lib/data/ai/missions/setup'; import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte'; import MissionGrantDialog from '$lib/components/ai/MissionGrantDialog.svelte'; + import AiDebugBlock from '$lib/components/ai/AiDebugBlock.svelte'; + import { isAiDebugEnabled, setAiDebugEnabled } from '$lib/data/ai/missions/debug'; import { isMissionGrantsEnabled } from '$lib/api/config'; import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types'; @@ -28,6 +30,12 @@ let mode = $state<'list' | 'create' | 'detail'>('list'); let selectedId = $state(null); + let debugEnabled = $state(isAiDebugEnabled()); + + function toggleDebug() { + debugEnabled = !debugEnabled; + setAiDebugEnabled(debugEnabled); + } const selected = $derived( selectedId ? (missions.value.find((m) => m.id === selectedId) ?? null) : null ); @@ -294,6 +302,10 @@ + {#if selected.state === 'active'}