feat(ai): tag-based agent scoping — agents see only their tagged records

Connects the existing global tag system (@mana/shared-tags, 15+ module
junctions, TagSelector UI) to the AI agent model so different agents
can operate on different slices of the user's data.

Core additions:

1. Agent.scopeTagIds — optional array of global tag IDs. When set,
   the agent sees only records tagged with at least one of those tags
   (plus untagged records, which stay globally visible). Empty/undefined
   = General-Agent, sees everything. Agent-editor grows a <TagSelector>
   under "Bereiche (Tag-Scope)".

2. Per-agent kontext documents — new Dexie table `agentKontextDocs`
   (v22, encrypted, synced). Each agent can have its own markdown
   context doc, replacing the global singleton auto-inject. Runner
   tries agent kontext first, falls back to global singleton when
   the agent has no dedicated doc.

3. Ambient scope context — `withAgentScope(tagIds, fn)` sets a
   module-level scope during the reasoning loop. Auto-tools read it
   via `getAgentScopeTagIds()` and filter their result sets.
   `filterByScope(records, getTagIds)` is the reusable filter
   primitive (keeps untagged records, drops mismatched tagged ones).

4. Notes tag junction — `noteTags` table (v22) + `noteTagOps` via
   `createTagLinkOps`. Notes was the only major module without
   structured tag support. `list_notes` now calls `filterByScope`
   so a scoped agent only sees notes tagged with its scope.

Flow: mission starts → runner resolves owning agent → reads
agent.scopeTagIds → wraps entire reasoning loop in withAgentScope →
list_notes (and future list_tasks etc.) auto-filter → planner sees
only scope-relevant records → proposes scoped edits.

Runner tests: 8/8. shared-ai type-check: clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 13:43:33 +02:00
parent 3f60f68573
commit 10acabfed6
11 changed files with 232 additions and 7 deletions

View file

@ -0,0 +1,70 @@
/**
* Per-agent kontext documents replaces the global singleton for scoped
* agent context. Each agent can have one markdown document that's injected
* into every planner call for that agent's missions.
*
* Encrypted at rest (registered in crypto/registry.ts under
* 'agentKontextDocs'). Synced via mana-sync under appId='ai'.
*/
import { db } from '../../database';
import { encryptRecord, decryptRecords } from '../../crypto';
const TABLE = 'agentKontextDocs';
export interface LocalAgentKontextDoc {
id: string;
agentId: string;
content: string;
createdAt?: string;
updatedAt?: string;
userId?: string;
deletedAt?: string;
}
export interface AgentKontextDoc {
id: string;
agentId: string;
content: string;
}
/** Read + decrypt the kontext doc for a specific agent. Returns null if
* none exists or content is empty. */
export async function getAgentKontext(agentId: string): Promise<AgentKontextDoc | null> {
const locals = await db
.table<LocalAgentKontextDoc>(TABLE)
.where('agentId')
.equals(agentId)
.toArray();
const visible = locals.filter((d) => !d.deletedAt);
if (visible.length === 0) return null;
const [decrypted] = await decryptRecords(TABLE, visible);
if (!decrypted || !decrypted.content?.trim()) return null;
return { id: decrypted.id, agentId: decrypted.agentId, content: decrypted.content };
}
/** Create or update the kontext doc for an agent. */
export async function saveAgentKontext(agentId: string, content: string): Promise<void> {
const existing = await db
.table<LocalAgentKontextDoc>(TABLE)
.where('agentId')
.equals(agentId)
.first();
if (existing) {
const diff: Partial<LocalAgentKontextDoc> = {
content,
updatedAt: new Date().toISOString(),
};
await encryptRecord(TABLE, diff);
await db.table<LocalAgentKontextDoc>(TABLE).update(existing.id, diff);
} else {
const doc: LocalAgentKontextDoc = {
id: crypto.randomUUID(),
agentId,
content,
};
await encryptRecord(TABLE, doc);
await db.table<LocalAgentKontextDoc>(TABLE).add(doc);
}
}

View file

@ -129,6 +129,7 @@ export interface AgentPatch {
policy?: AiPolicy;
maxTokensPerDay?: number;
maxConcurrentMissions?: number;
scopeTagIds?: string[];
state?: AgentState;
}

View file

@ -32,6 +32,8 @@ import { executeTool } from '../../tools/executor';
import { db } from '../../database';
import { decryptRecords } from '../../crypto';
import { discoverByQuery, searchFeeds } from '$lib/modules/news-research/api';
import { getAgentKontext } from '../agents/kontext';
import { withAgentScope } from '../scope-context';
import { isAiDebugEnabled, recordAiDebug, type AiDebugEntry, type PlannerCallDebug } from './debug';
import { makeAgentActor, LEGACY_AI_PRINCIPAL, type Actor } from '../../events/actor';
import { getAgent } from '../agents/store';
@ -192,12 +194,14 @@ export async function runMission(
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
// background. Decrypted client-side; never reaches the server.
// Auto-inject agent-specific kontext doc (if non-empty) — replaces
// the old global singleton inject. Falls back to the global singleton
// when the agent doesn't have its own doc. Decrypted client-side.
const alreadyHasKontext = mission!.inputs.some((i) => i.module === 'kontext');
if (!alreadyHasKontext) {
const kontextEntry = await loadKontextAsResolvedInput();
const kontextEntry = owningAgent
? await loadAgentKontextAsResolvedInput(owningAgent.id)
: await loadKontextAsResolvedInput();
if (kontextEntry) {
resolvedInputs.push(kontextEntry);
preStep.kontextInjected = true;
@ -449,7 +453,10 @@ export async function runMission(
let planSummary = '';
let planStepCount = 0;
try {
const result = await Promise.race([runPipeline(), timeoutPromise]);
const result = await Promise.race([
withAgentScope(owningAgent?.scopeTagIds, runPipeline),
timeoutPromise,
]);
recordedSteps = result.recordedSteps;
stagedCount = result.stagedCount;
failedCount = result.failedCount;
@ -543,6 +550,25 @@ async function loadKontextAsResolvedInput(): Promise<ResolvedInput | null> {
}
}
/** Load the agent-specific kontext doc. Falls back to null (caller
* may then fall back to the global singleton if desired). */
async function loadAgentKontextAsResolvedInput(agentId: string): Promise<ResolvedInput | null> {
try {
const doc = await getAgentKontext(agentId);
if (!doc) return loadKontextAsResolvedInput(); // fallback to global
return {
id: doc.id,
module: 'kontext',
table: 'agentKontextDocs',
title: 'Agent-Kontext',
content: doc.content,
};
} catch (err) {
console.warn('[MissionRunner] agent kontext load failed:', err);
return null;
}
}
/** 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. */

View file

@ -16,5 +16,5 @@ import type { ModuleConfig } from '$lib/data/module-registry';
export const aiModuleConfig: ModuleConfig = {
appId: 'ai',
tables: [{ name: 'aiMissions' }, { name: 'agents' }],
tables: [{ name: 'aiMissions' }, { name: 'agents' }, { name: 'agentKontextDocs' }],
};

View file

@ -0,0 +1,63 @@
/**
* Ambient scope context for AI tool execution.
*
* When a mission runs under an agent with scopeTagIds, the runner calls
* `withAgentScope(tagIds, fn)` around the reasoning loop. Auto-tools
* like `list_notes` check `getAgentScopeTagIds()` and filter their
* results to records tagged with at least one of those IDs (plus
* untagged records, which are globally visible).
*
* Pattern mirrors `runAs()` in events/actor.ts module-level mutable
* state, single-threaded browser runtime, scoped via try/finally.
*/
let currentScopeTagIds: readonly string[] | null = null;
/**
* Run `fn` with the given scope tag IDs as ambient context. Clears
* the scope when `fn` completes (or throws).
*/
export async function withAgentScope<T>(
scopeTagIds: readonly string[] | undefined,
fn: () => Promise<T>
): Promise<T> {
const prev = currentScopeTagIds;
currentScopeTagIds = scopeTagIds?.length ? scopeTagIds : null;
try {
return await fn();
} finally {
currentScopeTagIds = prev;
}
}
/**
* Read the current ambient scope. Returns null when no scope is set
* (meaning the tool should return everything General-Agent behavior).
*/
export function getAgentScopeTagIds(): readonly string[] | null {
return currentScopeTagIds;
}
/**
* Given a list of records + a function that returns their tag IDs,
* filter down to records that match the ambient scope. Records with
* no tags pass through (globally visible).
*/
export async function filterByScope<T>(
records: T[],
getTagIdsForRecord: (record: T) => Promise<string[]>
): Promise<T[]> {
const scope = currentScopeTagIds;
if (!scope) return records; // no scope = everything visible
const scopeSet = new Set(scope);
const results: T[] = [];
for (const r of records) {
const tagIds = await getTagIdsForRecord(r);
// Untagged records are globally visible; tagged records must match scope
if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) {
results.push(r);
}
}
return results;
}

View file

@ -472,6 +472,10 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// Free-form user text — encrypt the content, leave the fixed id plaintext.
kontextDoc: { enabled: true, fields: ['content'] },
// Per-agent kontext documents — same schema as kontextDoc but keyed
// per agent. Content is free-form markdown.
agentKontextDocs: { enabled: true, fields: ['content'] },
// ─── Quiz ────────────────────────────────────────────────
// User-typed text on the container (title, description, category, tags)
// plus the whole question payload (questionText, explanation, options).

View file

@ -548,6 +548,13 @@ db.version(21).stores({
quizAttempts: 'id, quizId, startedAt, [quizId+startedAt]',
});
// v22 — Notes tag junction (mirrors eventTags/contactTags/taskLabels pattern)
// + per-agent kontext documents (replaces global singleton auto-inject).
db.version(22).stores({
noteTags: 'id, noteId, tagId, [noteId+tagId]',
agentKontextDocs: 'id, agentId',
});
// ─── 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

View file

@ -33,8 +33,11 @@
import { DEFAULT_AI_POLICY } from '$lib/data/ai/policy';
import type { Agent } from '$lib/data/ai/agents/types';
import type { AiPolicy, PolicyDecision } from '@mana/shared-ai';
import { TagSelector } from '@mana/shared-ui';
import { useAllTags } from '@mana/shared-stores';
const agents = $derived(useAgents());
const allTags = $derived(useAllTags());
let mode = $state<'list' | 'create' | 'detail'>('list');
let selectedId = $state<string | null>(null);
@ -83,6 +86,7 @@
let editMemory = $state('');
let editMaxConcurrent = $state(1);
let editMaxTokensPerDay = $state<number | null>(null);
let editScopeTagIds = $state<string[]>([]);
let lastSyncedId = $state<string | null>(null);
let saveError = $state<string | null>(null);
let saving = $state(false);
@ -96,6 +100,7 @@
editMemory = selected.memory ?? '';
editMaxConcurrent = selected.maxConcurrentMissions;
editMaxTokensPerDay = selected.maxTokensPerDay ?? null;
editScopeTagIds = [...(selected.scopeTagIds ?? [])];
lastSyncedId = selected.id;
saveError = null;
}
@ -113,6 +118,7 @@
memory: editMemory || undefined,
maxConcurrentMissions: editMaxConcurrent,
maxTokensPerDay: editMaxTokensPerDay ?? undefined,
scopeTagIds: editScopeTagIds.length > 0 ? editScopeTagIds : undefined,
});
} catch (err) {
if (err instanceof DuplicateAgentNameError) {
@ -357,6 +363,20 @@
</label>
</section>
<section class="block">
<h3>Bereiche (Tag-Scope)</h3>
<p class="hint">Der Agent sieht nur Records mit diesen Tags. Leer = alles sichtbar.</p>
<TagSelector
tags={allTags.value}
selectedTags={allTags.value.filter((t) => editScopeTagIds.includes(t.id))}
onTagsChange={(tags) => {
editScopeTagIds = tags.map((t) => t.id);
}}
placeholder="Bereiche wählen…"
addTagLabel="Bereich hinzufügen"
/>
</section>
<section class="block">
<h3>Grenzen</h3>
<label class="inline-field">

View file

@ -0,0 +1,21 @@
/**
* Notes Tags Uses shared global tags + module-specific junction table.
* Mirrors the pattern in calendar/stores/tags.svelte.ts and
* contacts/stores/tags.svelte.ts.
*/
import { db } from '$lib/data/database';
import { createTagLinkOps } from '@mana/shared-stores';
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
} from '@mana/shared-stores';
export const noteTagOps = createTagLinkOps({
table: () => db.table('noteTags'),
entityIdField: 'noteId',
});

View file

@ -11,8 +11,10 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { notesStore } from './stores/notes.svelte';
import { noteTagOps } from './stores/tags.svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { filterByScope } from '$lib/data/ai/scope-context';
import type { LocalNote } from './types';
const MAX_LIST_LIMIT = 100;
@ -88,7 +90,11 @@ export const notesTools: ModuleTool[] = [
const visible = all.filter((n) => !n.deletedAt && (includeArchived || !n.isArchived));
const decrypted = await decryptRecords('notes', visible);
const rows = decrypted
// Agent scope filter: only return notes tagged with the agent's
// scope tags (or untagged notes, which are globally visible).
const scoped = await filterByScope(decrypted, async (n) => noteTagOps.getTagIds(n.id));
const rows = scoped
.filter((n) => {
if (!query) return true;
return (