feat(writing): agent.defaultWritingStyleId — M8 persona-linkage follow-up

Agents can now pin a default writing style. When an AI-actor runs
`create_draft` without an explicit styleId, the tool resolves to the
agent's `defaultWritingStyleId` so e.g. a "Marketing-Agent" always
drafts in the Corporate-Tone style and a "Memoir-Agent" in Memoir.

- @mana/shared-ai: optional `defaultWritingStyleId?: string` added to
  the Agent interface (plaintext FK, format `preset:<id>` or a custom
  WritingStyle uuid). No migration — existing rows stay undefined and
  the fallback path no-ops for them.
- ai-agents store: field threaded through CreateAgentInput + AgentPatch
  + the create function's copy-list. `updateAgent` already deep-clones
  the patch so nothing else to change there.
- ai-agents ListView: new "Writing" section in the agent detail panel
  with a StylePicker (reuses the writing module's component — Vorlagen
  + Meine Stile optgroups). Empty = kein Default.
- writing/tools.ts: `resolveAgentDefaultStyle()` reads the current
  actor, guards `isAiActor`, loads the agent row, and returns its
  defaultWritingStyleId. Wired into `create_draft` as a fallback when
  `params.styleId` is missing. User-invoked calls skip the lookup — a
  human omitting styleId means "ad-hoc, no style", not "my default".
  `generate_draft_content` needs no change because the draft's styleId
  is already set at create time.

107 shared-ai tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:36:20 +02:00
parent e7398b2dee
commit 6545498dc2
4 changed files with 55 additions and 2 deletions

View file

@ -40,6 +40,8 @@ export interface CreateAgentInput {
policy?: AiPolicy;
maxTokensPerDay?: number;
maxConcurrentMissions?: number;
scopeTagIds?: string[];
defaultWritingStyleId?: string;
state?: AgentState;
}
@ -66,6 +68,8 @@ export async function createAgent(input: CreateAgentInput): Promise<Agent> {
avatar: input.avatar,
systemPrompt: input.systemPrompt,
memory: input.memory,
scopeTagIds: input.scopeTagIds,
defaultWritingStyleId: input.defaultWritingStyleId,
policy: deepClone(input.policy ?? DEFAULT_AI_POLICY),
maxTokensPerDay: input.maxTokensPerDay,
maxConcurrentMissions: input.maxConcurrentMissions ?? 1,
@ -130,6 +134,7 @@ export interface AgentPatch {
maxTokensPerDay?: number;
maxConcurrentMissions?: number;
scopeTagIds?: string[];
defaultWritingStyleId?: string;
state?: AgentState;
}

View file

@ -37,6 +37,7 @@
import type { AiPolicy, PolicyDecision } from '@mana/shared-ai';
import { TagSelector } from '@mana/shared-ui';
import { useAllTags } from '@mana/shared-stores';
import StylePicker from '$lib/modules/writing/components/StylePicker.svelte';
const agents = $derived(useAgents());
const allTags = $derived(useAllTags());
@ -89,6 +90,7 @@
let editMaxConcurrent = $state(1);
let editMaxTokensPerDay = $state<number | null>(null);
let editScopeTagIds = $state<string[]>([]);
let editDefaultWritingStyleId = $state<string | null>(null);
let lastSyncedId = $state<string | null>(null);
let saveError = $state<string | null>(null);
let saving = $state(false);
@ -103,6 +105,7 @@
editMaxConcurrent = selected.maxConcurrentMissions;
editMaxTokensPerDay = selected.maxTokensPerDay ?? null;
editScopeTagIds = [...(selected.scopeTagIds ?? [])];
editDefaultWritingStyleId = selected.defaultWritingStyleId ?? null;
lastSyncedId = selected.id;
saveError = null;
}
@ -121,6 +124,7 @@
maxConcurrentMissions: editMaxConcurrent,
maxTokensPerDay: editMaxTokensPerDay ?? undefined,
scopeTagIds: editScopeTagIds.length > 0 ? editScopeTagIds : undefined,
defaultWritingStyleId: editDefaultWritingStyleId ?? undefined,
});
} catch (err) {
if (err instanceof DuplicateAgentNameError) {
@ -455,6 +459,18 @@
</label>
</section>
<section class="block">
<h3>Writing</h3>
<p class="hint">
Default-Schreibstil, den der Agent beim Anlegen eines Drafts nutzt, wenn keiner explizit
übergeben wird.
</p>
<StylePicker
value={editDefaultWritingStyleId}
onchange={(next) => (editDefaultWritingStyleId = next)}
/>
</section>
<div class="save-row">
{#if saveError}
<span class="form-error">{saveError}</span>

View file

@ -27,6 +27,8 @@ import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
import { toDraft, toDraftVersion } from './queries';
import { STYLE_PRESETS } from './presets/styles';
import { writingStyleTable } from './collections';
import { getCurrentActor, isAiActor } from '$lib/data/events';
import { getAgent } from '$lib/data/ai/agents/store';
import type {
LocalDraft,
LocalDraftVersion,
@ -35,6 +37,23 @@ import type {
DraftStatus,
} from './types';
/**
* When an AI actor is running a writing tool without an explicit
* styleId, fall back to the agent's `defaultWritingStyleId`. User-
* invoked calls skip this a human passing no styleId means "ad-hoc,
* no style", not "use my default".
*/
async function resolveAgentDefaultStyle(): Promise<string | null> {
const actor = getCurrentActor();
if (!isAiActor(actor)) return null;
try {
const agent = await getAgent(actor.principalId);
return agent?.defaultWritingStyleId ?? null;
} catch {
return null;
}
}
const KINDS: DraftKind[] = [
'blog',
'essay',
@ -266,11 +285,16 @@ export const writingTools: ModuleTool[] = [
const targetWordsRaw =
typeof params.targetWords === 'number' ? Math.round(params.targetWords) : null;
const explicitStyleId =
typeof params.styleId === 'string' && params.styleId.length > 0 ? params.styleId : null;
// Persona-Linkage: AI actors inherit the agent's default style
// when they don't pass one; user-invoked calls do not — a human
// omitting styleId means "ad-hoc, no style".
const styleId = explicitStyleId ?? (await resolveAgentDefaultStyle());
const { draft } = await draftsStore.createDraft({
kind,
title,
styleId:
typeof params.styleId === 'string' && params.styleId.length > 0 ? params.styleId : null,
styleId,
briefing: {
topic,
audience: typeof params.audience === 'string' ? params.audience : null,