diff --git a/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneHeader.svelte b/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneHeader.svelte index fb49dcd86..2a22a790c 100644 --- a/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneHeader.svelte +++ b/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneHeader.svelte @@ -14,7 +14,7 @@ import { goto } from '$app/navigation'; import { TagSelector, type Tag } from '@mana/shared-ui'; import { useAllTags } from '@mana/shared-stores'; - import { useAgents } from '$lib/data/ai/agents/queries'; + import { useAgent } from '$lib/data/ai/agents/queries'; interface Props { scene: WorkbenchScene | null; @@ -22,16 +22,14 @@ const { scene }: Props = $props(); const allTags = $derived(useAllTags()); - const agents = $derived(useAgents()); + // Only load the single bound agent, not the full agent list. + const boundAgent = $derived(scene?.viewingAsAgentId ? useAgent(scene.viewingAsAgentId) : null); // Auto-infer scopeTagIds from bound agent if scene has no explicit override const effectiveScopeTagIds = $derived.by(() => { if (!scene) return []; if (scene.scopeTagIds?.length) return scene.scopeTagIds; - if (scene.viewingAsAgentId) { - const agent = agents.value.find((a) => a.id === scene.viewingAsAgentId); - return agent?.scopeTagIds ?? []; - } + if (boundAgent?.value) return boundAgent.value.scopeTagIds ?? []; return []; }); 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 7689fe2cf..d9a007fca 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 @@ -57,6 +57,9 @@ const RESEARCH_TRIGGER = /\b(recherchier|research|news|finde|suche|aktuelle|neue * planner returns zero steps (it considers this subtask done). * 5 is generous for read-act-refine patterns ("list_notes → tag them") * without running the LLM bill dry on stuck missions. */ +/** Keep in sync with the planner system prompt in + * packages/shared-ai/src/planner/prompt.ts which tells the LLM + * "bis zu 5 Planungsrunden pro Iteration, 1–5 Schritte pro Runde". */ const MAX_REASONING_LOOP_ITERATIONS = 5; /** Min interval between Dexie phaseDetail writes during streaming. @@ -616,10 +619,13 @@ async function loadKontextAsResolvedInput(): Promise { /** Load the agent-specific kontext doc. Falls back to null (caller * may then fall back to the global singleton if desired). */ +/** Load the agent-specific kontext doc. Returns null when the agent + * has no dedicated doc (does NOT fall back to the global singleton — + * kontext injection is explicit via the input picker, not auto). */ async function loadAgentKontextAsResolvedInput(agentId: string): Promise { try { const doc = await getAgentKontext(agentId); - if (!doc) return loadKontextAsResolvedInput(); // fallback to global + if (!doc) return null; return { id: doc.id, module: 'kontext', @@ -726,7 +732,9 @@ async function runWebResearch(mission: Mission): Promise( scopeTagIds: readonly string[] | undefined, @@ -38,38 +36,13 @@ export function getAgentScopeTagIds(): readonly string[] | null { return currentScopeTagIds; } -/** - * Core filter: keep records whose tags overlap with `scopeTagIds`. - * Untagged records (tagIds=[]) always pass through (globally visible). - * When `scopeTagIds` is null/empty, returns all records (no filtering). - * - * This is the explicit, race-safe variant — pass scope directly. - */ -export async function filterByScopeExplicit( - records: T[], - scopeTagIds: readonly string[] | null | undefined, - getTagIdsForRecord: (record: T) => Promise -): Promise { - if (!scopeTagIds?.length) return records; +/** Explicit, race-safe variant — pass scope directly. */ +export { filterByScopeAsync as filterByScopeExplicit } from './scope-filter'; - const scopeSet = new Set(scopeTagIds); - const results: T[] = []; - for (const r of records) { - const tagIds = await getTagIdsForRecord(r); - if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) { - results.push(r); - } - } - return results; -} - -/** - * Convenience wrapper: reads the ambient scope from `withAgentScope()`. - * Use this in auto-tools that don't receive scope explicitly. - */ +/** Ambient convenience — reads scope from withAgentScope(). */ export async function filterByScope( records: T[], getTagIdsForRecord: (record: T) => Promise ): Promise { - return filterByScopeExplicit(records, currentScopeTagIds, getTagIdsForRecord); + return filterByScopeAsync(records, currentScopeTagIds, getTagIdsForRecord); } diff --git a/apps/mana/apps/web/src/lib/data/ai/scope-filter.ts b/apps/mana/apps/web/src/lib/data/ai/scope-filter.ts new file mode 100644 index 000000000..96b0acd91 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/scope-filter.ts @@ -0,0 +1,46 @@ +/** + * Pure scope-filter primitives — shared between AI scope-context + * (ephemeral, per-mission) and scene-scope (long-lived, per-scene). + * + * Rule: untagged records (no tags) are always globally visible. + * Tagged records must match at least one scope tag. + */ + +/** + * Synchronous batch filter using a pre-fetched tag map. + * Use when you've already called getTagIdsForMany(). + */ +export function filterByScopeTagMap( + records: T[], + scopeTagIds: readonly string[] | null | undefined, + getId: (record: T) => string, + tagMap: Map +): T[] { + if (!scopeTagIds?.length) return records; + const scopeSet = new Set(scopeTagIds); + return records.filter((r) => { + const tagIds = tagMap.get(getId(r)); + if (!tagIds || tagIds.length === 0) return true; // untagged = global + return tagIds.some((id) => scopeSet.has(id)); + }); +} + +/** + * Async per-record filter for callers that don't have a pre-fetched map. + */ +export async function filterByScopeAsync( + records: T[], + scopeTagIds: readonly string[] | null | undefined, + getTagIdsForRecord: (record: T) => Promise +): Promise { + if (!scopeTagIds?.length) return records; + const scopeSet = new Set(scopeTagIds); + const results: T[] = []; + for (const r of records) { + const tagIds = await getTagIdsForRecord(r); + if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) { + results.push(r); + } + } + return results; +} diff --git a/apps/mana/apps/web/src/lib/stores/scene-scope.svelte.ts b/apps/mana/apps/web/src/lib/stores/scene-scope.svelte.ts index 7fa93d754..dd986d469 100644 --- a/apps/mana/apps/web/src/lib/stores/scene-scope.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/scene-scope.svelte.ts @@ -2,80 +2,40 @@ * Reactive scene scope — propagates the active scene's scopeTagIds to * module queries so the UI can filter records by the scene's tags. * - * Updated by the workbench layout whenever the active scene changes. - * Module queries read `currentScopeTagIds` and filter accordingly. - * Undefined = no filtering (everything visible). - * - * This is the UI-facing counterpart of the AI-facing `withAgentScope` - * in `data/ai/scope-context.ts`. Both use the same tag system; the AI - * scope is ephemeral (duration of a mission run), this one is long- - * lived (persists as long as the scene is active). + * Core filter logic lives in `$lib/data/ai/scope-filter.ts` (shared + * with the AI scope-context). This file only owns the reactive state + * + thin wrappers that read it. */ +import { filterByScopeTagMap, filterByScopeAsync } from '$lib/data/ai/scope-filter'; + /** Reactive scope state — set by workbench layout, read by module queries. */ let _scopeTagIds = $state(undefined); -/** Set the active scene's scope. Called from the workbench layout - * when the active scene changes. Pass undefined to clear. */ export function setSceneScopeTagIds(tagIds: readonly string[] | undefined): void { _scopeTagIds = tagIds?.length ? tagIds : undefined; } -/** Read the current scene scope. Returns undefined when no scope is - * active (= show everything). Module queries use this to filter. */ export function getSceneScopeTagIds(): readonly string[] | undefined { return _scopeTagIds; } /** - * Utility: filter records by the active scene scope. Same semantics as - * `filterByScope` in AI scope-context: untagged records pass through, - * tagged records must match at least one scope tag. - * - * Synchronous if tagIds are already loaded; async variant for - * junction-table lookups (same signature as the AI version). - */ -/** - * Filter records by the active scene scope using a pre-fetched tag map. - * Pass the result of `tagOps.getTagIdsForMany(ids)` to avoid N+1 queries. - * - * @param getId - extract the record's ID (used as key into tagMap) - * @param tagMap - Map from getTagIdsForMany() + * Batch filter using a pre-fetched tag map. Preferred for list queries + * (1 Dexie call instead of N). */ export function filterBySceneScopeBatch( records: T[], getId: (record: T) => string, tagMap: Map ): T[] { - const scope = _scopeTagIds; - if (!scope) return records; - - const scopeSet = new Set(scope); - return records.filter((r) => { - const tagIds = tagMap.get(getId(r)); - // Untagged records (not in map or empty) are globally visible - if (!tagIds || tagIds.length === 0) return true; - return tagIds.some((id) => scopeSet.has(id)); - }); + return filterByScopeTagMap(records, _scopeTagIds, getId, tagMap); } -/** - * Legacy per-record variant. Prefer `filterBySceneScopeBatch` for lists. - */ +/** Legacy per-record variant. Prefer batch for lists. */ export async function filterBySceneScope( records: T[], getTagIdsForRecord: (record: T) => Promise ): Promise { - const scope = _scopeTagIds; - if (!scope) return records; - - const scopeSet = new Set(scope); - const results: T[] = []; - for (const r of records) { - const tagIds = await getTagIdsForRecord(r); - if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) { - results.push(r); - } - } - return results; + return filterByScopeAsync(records, _scopeTagIds, getTagIdsForRecord); } diff --git a/packages/shared-ai/src/planner/prompt.ts b/packages/shared-ai/src/planner/prompt.ts index 69939f3be..284d481f7 100644 --- a/packages/shared-ai/src/planner/prompt.ts +++ b/packages/shared-ai/src/planner/prompt.ts @@ -40,7 +40,7 @@ function buildSystemPrompt(input: AiPlanInput): string { return `Du bist eine KI, die im Auftrag des Nutzers an einer langlebigen Mission arbeitet. -Dein Job: aus dem aktuellen Mission-Kontext einen konkreten Plan ableiten — 1 bis 10 Schritte, jeder ein Tool-Aufruf auf Nutzerdaten. Jeder Schritt MUSS eine Begründung haben (rationale), die der Nutzer in der Review-UI sieht. +Dein Job: aus dem aktuellen Mission-Kontext einen konkreten Plan ableiten — 1 bis 5 Schritte pro Planungsrunde, jeder ein Tool-Aufruf auf Nutzerdaten. Es gibt bis zu 5 Planungsrunden pro Iteration. Jeder Schritt MUSS eine Begründung haben (rationale), die der Nutzer in der Review-UI sieht. Wichtige Regeln: 1. Nutze NUR Tools aus der Liste unten. Unbekannte Tools → Plan invalide.