feat(companion): chat on runPlannerLoop with native function calling

The companion chat had its own ad-hoc 3-round tool-calling pipeline:
build a system prompt with tool descriptions, ask the LLM to emit
```tool JSON blocks, regex-extract, execute, feed back the result as
a synthetic user message. Same fragility class as the old text-JSON
planner — and now unnecessary since mana-llm speaks native function
calling.

Migrates companion/engine.ts to the shared runPlannerLoop, same as
the mission runner (commit 5a) and the server tick (commit 6). Tools
go to the LLM as proper function-schemas; tool_calls come back
structured; the executor runs them directly under USER_ACTOR.

Extends shared-ai/planner/loop.ts with an optional priorMessages[]
input field so the chat can preserve multi-turn history between
turns (missions don't need this and leave it empty).

Deletes the old llm-tasks/companion-chat.ts LlmTask wrapper. Nothing
else imported it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 16:45:33 +02:00
parent 80dbb3b3b6
commit 9f7d2f24b3
3 changed files with 105 additions and 272 deletions

View file

@ -1,53 +0,0 @@
/**
* companionChatTask LLM task definition for the Companion Brain chat.
*
* Routes through the shared LlmOrchestrator so the user's tier settings
* and privacy rules are respected:
*
* - minTier: browser (needs at least local Gemma; no rules fallback)
* - contentClass: 'personal' user messages may reference their data
* but aren't the most sensitive class (which is reserved for things
* like Journal, Dreams, Finance). 'personal' allows mana-server or
* cloud if the user opted in; 'sensitive' would restrict to browser.
* - streaming: true the chat UI relies on per-token updates
*
* Individual callers can override the tier via settings.taskOverrides
* (e.g. force cloud for Companion even if the default is browser).
*/
import type { LlmBackend, LlmTask, GenerateResult } from '@mana/shared-llm';
export interface CompanionChatInput {
messages: { role: 'user' | 'assistant' | 'system'; content: string }[];
temperature?: number;
maxTokens?: number;
onToken?: (token: string) => void;
}
export type CompanionChatOutput = {
content: string;
usage?: GenerateResult['usage'];
};
export const companionChatTask: LlmTask<CompanionChatInput, CompanionChatOutput> = {
name: 'companion.chat',
minTier: 'browser',
contentClass: 'personal',
requires: { streaming: true },
displayLabel: 'Companion Chat',
async runLlm(input: CompanionChatInput, backend: LlmBackend): Promise<CompanionChatOutput> {
const result = await backend.generate({
taskName: 'companion.chat',
contentClass: 'personal',
messages: input.messages,
temperature: input.temperature ?? 0.7,
maxTokens: input.maxTokens ?? 1024,
onToken: input.onToken,
});
return {
content: result.content,
usage: result.usage,
};
},
};

View file

@ -1,267 +1,148 @@
/**
* Companion Chat Engine Orchestrates LLM + Context Document + Tool Calling.
* Companion Chat Engine runs the shared runPlannerLoop against the
* mana-llm client with native function calling. Identical pipeline to
* the Mission Runner (commit 5a) the only difference is what the
* LLM sees: a system prompt framed for conversation rather than for
* autonomous mission execution, plus the user's prior chat history.
*
* Routes through the shared LlmOrchestrator (4-tier system). The orchestrator
* picks browser/mana-server/cloud based on user settings + the task's
* contentClass ('personal'). Users can override per-task via their LLM
* settings (e.g. "Companion always via cloud" or "never leave device").
*
* Tool calling is simulated via JSON extraction since none of the tiers
* natively speak function calling (Gemma doesn't, Gemini via our proxy
* routes through text-completion).
* Tool calls execute directly under the USER_ACTOR (the caller is the
* human typing in the chat UI; there is no intermediate approval). If
* a tool's agent-policy says deny, the executor returns a refusal and
* the LLM relays it in its response.
*/
import { llmOrchestrator, LlmError } from '@mana/shared-llm';
import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm';
import { companionChatTask } from '$lib/llm-tasks/companion-chat';
import { generateContextDocument } from '$lib/data/projections/context-document';
import { getToolsForLlm, executeTool } from '$lib/data/tools';
import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types';
import { emitDomainEvent } from '$lib/data/events';
import {
runPlannerLoop,
AI_TOOL_CATALOG,
type ChatMessage,
type ToolCallRequest,
type ToolResult,
} from '@mana/shared-ai';
import { createManaLlmClient } from '$lib/data/ai/missions/llm-client';
import { executeTool } from '$lib/data/tools/executor';
import { getTool } from '$lib/data/tools/registry';
import { generateContextDocument } from '$lib/data/projections/context-document';
import { emitDomainEvent } from '$lib/data/events';
import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types';
import type { LocalMessage } from './types';
import type { ToolResult } from '$lib/data/tools/types';
const MAX_TOOL_ROUNDS = 3;
type LlmMessage = { role: 'user' | 'assistant' | 'system'; content: string };
/**
* Route an LLM call through the orchestrator. The orchestrator handles
* tier selection, privacy enforcement, and fallbacks. If the browser
* tier is chosen but the local model hasn't loaded yet, we trigger
* the download first so the UI can show progress.
*/
async function callLlm(messages: LlmMessage[], onToken?: (token: string) => void): Promise<string> {
// If browser tier is available, preload the model so the
// CompanionChat UI can show download progress before generation starts.
if (isLocalLlmSupported()) {
const status = getLocalLlmStatus();
if (status.current.state === 'idle' || status.current.state === 'checking') {
// Fire-and-forget — the orchestrator will await isReady() anyway
void loadLocalLlm().catch(() => {
/* fall through to next tier */
});
}
}
try {
const result = await llmOrchestrator.run(companionChatTask, {
messages,
onToken,
temperature: 0.7,
maxTokens: 1024,
});
return result.value.content;
} catch (err) {
if (err instanceof LlmError) {
return err.getUserMessage();
}
const msg = err instanceof Error ? err.message : String(err);
return `LLM nicht verfügbar: ${msg}\n\n[KI-Einstellungen öffnen](/?app=settings#ai-options)`;
}
}
const llm = createManaLlmClient();
interface EngineResult {
content: string;
toolCalls: { name: string; params: Record<string, unknown>; result: ToolResult }[];
toolCalls: {
name: string;
params: Record<string, unknown>;
result: ToolResult;
}[];
}
function buildSystemPrompt(day: DaySnapshot, streaks: StreakInfo[]): string {
const context = generateContextDocument(day, streaks);
const toolSchemas = getToolsForLlm();
const toolList = toolSchemas.map((t) => `- ${t.name}: ${t.description}`).join('\n');
return `Du bist der Mana Companion — ein hilfreicher persoenlicher Assistent.
Du hast Zugriff auf die Daten und Aktionen des Nutzers ueber verschiedene Module.
${context}
## Verfuegbare Tools
${toolList}
## Tool-Aufruf Format WICHTIG
**Du MUSST Tools nutzen wenn der Nutzer Daten sehen oder etwas tun will.**
Wenn der Nutzer fragt:
- "Welche Tasks habe ich?" \`list_tasks\` aufrufen
- "Wie viel Wasser?" \`get_drink_progress\` aufrufen
- "Erstell mir einen Task X" \`create_task\` aufrufen
- "Log 200ml Wasser" \`log_drink\` aufrufen
- "Welche Termine heute?" \`get_todays_events\` aufrufen
- "Erledige Task X" (per Name) \`complete_tasks_by_title\` mit titleMatch
Tool-Aufruf in genau diesem Format (NUR JSON in einem Code-Block):
\`\`\`tool
{"name": "tool_name", "params": {"key": "value"}}
\`\`\`
Nach dem Tool-Ergebnis bekommst du die Daten zurueck und kannst dem Nutzer antworten.
## ID-Konvention
Listen-Tools (wie \`list_tasks\`) zeigen IDs in eckigen Klammern: \`• [abc123] Task-Titel\`.
Wenn der Nutzer eine Aktion auf einem Listen-Eintrag will, nutze diese ID fuer den Tool-Aufruf
(z.B. \`complete_task\` mit \`taskId: "abc123"\`).
Du kannst Tool-Results aus VORHERIGEN Nachrichten referenzieren sie sind als
"[Previous tool result]" markiert.
## Verhalten
- Antworte auf Deutsch
- Sei kurz und hilfreich
- **Erfinde keine Daten** wenn du Listen oder Werte brauchst, RUFE EIN TOOL AUF
- Zeige dem Nutzer NIE die rohen IDs in eckigen Klammern die sind nur fuer dich
- Wenn der Nutzer etwas loggen oder erstellen will, nutze das passende Tool
- Ermutige den Nutzer bei Fortschritt und Streaks`;
return [
'Du bist der Mana Companion — ein hilfreicher persönlicher Assistent.',
'Du hast Zugriff auf die Daten und Aktionen des Nutzers über Function-Calling-Tools.',
'',
context,
'',
'## Verhalten',
'',
'- Antworte auf Deutsch',
'- Sei kurz und hilfreich',
'- Rufe Tools proaktiv auf wenn der Nutzer Daten sehen oder etwas tun will — erfinde keine Daten.',
'- Bei einem "Welche Tasks habe ich?"-Stil: erst `list_tasks` aufrufen, dann antworten.',
'- Bei "Log 200ml Wasser" → `log_drink` aufrufen.',
'- Nach einem Tool-Ergebnis fasse kurz zusammen was der Nutzer wissen soll.',
'- Ermutige den Nutzer bei Fortschritt und Streaks.',
].join('\n');
}
function extractToolCall(
text: string
): { name: string; params: Record<string, unknown>; before: string; after: string } | null {
// Try fenced ```tool block first
const fenced = /```(?:tool|json)?\s*\n?([\s\S]*?)\n?```/;
const fencedMatch = text.match(fenced);
if (fencedMatch) {
try {
const parsed = JSON.parse(fencedMatch[1]) as {
name: string;
params: Record<string, unknown>;
};
if (parsed.name) {
const before = text.slice(0, fencedMatch.index).trim();
const after = text.slice((fencedMatch.index ?? 0) + fencedMatch[0].length).trim();
return { name: parsed.name, params: parsed.params ?? {}, before, after };
}
} catch {
// Fall through to bare JSON detection
}
}
// Fallback: bare JSON object with "name" and "params" keys
const bareJson = /\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"params"\s*:\s*\{[^}]*\}\s*\}/;
const bareMatch = text.match(bareJson);
if (bareMatch) {
try {
const parsed = JSON.parse(bareMatch[0]) as { name: string; params: Record<string, unknown> };
if (parsed.name) {
const before = text.slice(0, bareMatch.index).trim();
const after = text.slice((bareMatch.index ?? 0) + bareMatch[0].length).trim();
return { name: parsed.name, params: parsed.params ?? {}, before, after };
}
} catch {
// Not valid JSON
}
}
return null;
}
function messagesToLlm(
messages: LocalMessage[]
): { role: 'user' | 'assistant' | 'system'; content: string }[] {
const result: { role: 'user' | 'assistant' | 'system'; content: string }[] = [];
for (const m of messages) {
/** Turn the companion's LocalMessage history into shared-ai ChatMessages.
* tool_result entries come back as user-role messages so the LLM can
* reference them across turns (we don't keep tool_call_ids across UI
* refreshes, so we can't round-trip them as role='tool'). */
function historyToChatMessages(history: LocalMessage[]): ChatMessage[] {
const out: ChatMessage[] = [];
for (const m of history) {
if (m.role === 'tool_result' && m.toolResult) {
// Surface previous tool results to the LLM so it can
// reference IDs/data from earlier turns.
const data = m.toolResult.data ? `\nData: ${JSON.stringify(m.toolResult.data)}` : '';
result.push({
out.push({
role: 'user',
content: `[Previous tool result]\n${m.toolResult.message}${data}`,
});
} else if (m.role === 'assistant' && m.toolCall) {
// Skip the empty placeholder messages for tool calls
// Empty placeholder for an old-style tool call — skip.
continue;
} else if (m.role === 'user' || m.role === 'assistant' || m.role === 'system') {
if (m.content) result.push({ role: m.role, content: m.content });
} else if (m.role === 'user' || m.role === 'assistant') {
if (m.content) out.push({ role: m.role, content: m.content });
}
}
return result;
return out;
}
/**
* Send a message to the Companion and get a response.
*
* @param userMessage - The user's input text
* @param history - Previous messages in this conversation
* @param day - Current DaySnapshot projection
* @param streaks - Current streak info
* @param onToken - Streaming callback for progressive UI updates
*/
export async function runCompanionChat(
userMessage: string,
history: LocalMessage[],
day: DaySnapshot,
streaks: StreakInfo[],
onToken?: (token: string) => void
_onToken?: (token: string) => void
): Promise<EngineResult> {
const systemPrompt = buildSystemPrompt(day, streaks);
const priorMessages = historyToChatMessages(history);
const toolCalls: EngineResult['toolCalls'] = [];
const llmMessages: LlmMessage[] = [
{ role: 'system', content: systemPrompt },
...messagesToLlm(history),
{ role: 'user', content: userMessage },
];
try {
const result = await runPlannerLoop({
llm,
input: {
systemPrompt,
userPrompt: userMessage,
priorMessages,
tools: AI_TOOL_CATALOG,
model: 'google/gemini-2.5-flash',
maxRounds: MAX_TOOL_ROUNDS,
temperature: 0.7,
},
onToolCall: async (call: ToolCallRequest): Promise<ToolResult> => {
const startedAt = Date.now();
const toolResult = await executeTool(call.name, call.arguments);
const latencyMs = Date.now() - startedAt;
let finalContent = '';
const toolDef = getTool(call.name);
emitDomainEvent('CompanionToolCalled', 'companion', 'tools', call.name, {
tool: call.name,
module: toolDef?.module ?? 'unknown',
success: toolResult.success,
latencyMs,
errorMessage: toolResult.success ? undefined : toolResult.message,
});
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
const text = await callLlm(llmMessages, round === 0 ? onToken : undefined);
const toolCall = extractToolCall(text);
if (!toolCall) {
finalContent = text;
break;
}
// Execute the tool with timing
const toolStartedAt = Date.now();
const toolResult = await executeTool(toolCall.name, toolCall.params);
const toolLatencyMs = Date.now() - toolStartedAt;
// Emit observability event for the tool call
const toolDef = getTool(toolCall.name);
emitDomainEvent('CompanionToolCalled', 'companion', 'tools', toolCall.name, {
tool: toolCall.name,
module: toolDef?.module ?? 'unknown',
success: toolResult.success,
latencyMs: toolLatencyMs,
errorMessage: toolResult.success ? undefined : toolResult.message,
toolCalls.push({ name: call.name, params: call.arguments, result: toolResult });
return toolResult;
},
});
toolCalls.push({ name: toolCall.name, params: toolCall.params, result: toolResult });
const content =
result.summary ??
(toolCalls.length > 0
? toolCalls.map((tc) => tc.result.message).join('\n')
: 'Keine Antwort erhalten.');
// Build response text from before/after the tool block
const parts = [toolCall.before, toolCall.after].filter(Boolean);
// Feed tool result back into conversation
llmMessages.push({
role: 'assistant',
content: text,
});
llmMessages.push({
role: 'user',
content: `Tool-Ergebnis fuer ${toolCall.name}: ${toolResult.message}${toolResult.data ? `\nDaten: ${JSON.stringify(toolResult.data)}` : ''}`,
});
// If this was the last round, use what we have
if (round === MAX_TOOL_ROUNDS) {
finalContent = parts.join('\n\n') || `Aktion ausgefuehrt: ${toolResult.message}`;
}
return { content, toolCalls };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: `LLM nicht verfügbar: ${msg}\n\n[KI-Einstellungen öffnen](/?app=settings#ai-options)`,
toolCalls,
};
}
return { content: finalContent, toolCalls };
}
/**
* Check if the Companion Chat is available delegates to the orchestrator
* which considers the user's enabled tiers and backend readiness.
*/
/** The companion speaks to mana-llm unconditionally readiness is a
* property of the backend, not a client preference. Kept as a function
* for API compatibility with the CompanionChat component. */
export function isCompanionAvailable(): boolean {
return llmOrchestrator.canRun(companionChatTask);
return true;
}

View file

@ -63,6 +63,10 @@ export interface LlmClient {
export interface PlannerLoopInput {
readonly systemPrompt: string;
readonly userPrompt: string;
/** Optional prior conversation turns inserted between the system
* prompt and the new user turn. Used by the companion chat to
* preserve multi-turn history; missions leave this empty. */
readonly priorMessages?: readonly ChatMessage[];
readonly tools: readonly ToolSchema[];
readonly model: string;
readonly temperature?: number;
@ -113,6 +117,7 @@ export async function runPlannerLoop(opts: {
const messages: ChatMessage[] = [
{ role: 'system', content: input.systemPrompt },
...(input.priorMessages ?? []),
{ role: 'user', content: input.userPrompt },
];
const executedCalls: ExecutedCall[] = [];