mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
80dbb3b3b6
commit
9f7d2f24b3
3 changed files with 105 additions and 272 deletions
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue