mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
3f60f68573
commit
10acabfed6
11 changed files with 232 additions and 7 deletions
70
apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts
Normal file
70
apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +129,7 @@ export interface AgentPatch {
|
|||
policy?: AiPolicy;
|
||||
maxTokensPerDay?: number;
|
||||
maxConcurrentMissions?: number;
|
||||
scopeTagIds?: string[];
|
||||
state?: AgentState;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
};
|
||||
|
|
|
|||
63
apps/mana/apps/web/src/lib/data/ai/scope-context.ts
Normal file
63
apps/mana/apps/web/src/lib/data/ai/scope-context.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue