update the codebase poc ver1

This commit is contained in:
DatTT127
2026-07-07 15:54:17 +07:00
parent fed5f277f4
commit 1622dc8fc5
452 changed files with 83999 additions and 66328 deletions

View File

@@ -0,0 +1,61 @@
# NLP Agent Runtime
Shared JavaScript agent runtime for **Gemma-4-E2B (MediaPipe)** tool calling.
Location: `ml/implementation/nlp/agent_runtime/`
## Tools
| Tool | Purpose | Backend route |
|------|---------|---------------|
| `exa_search` | Web evidence via Exa (`type: auto`, highlights) | `POST /api/v1/agent/tools/exa/search` |
| `supabase_query` | Local MOH corpus via pgvector RPC | `POST /api/v1/agent/tools/supabase/query` |
| `escalate_medgemma` | Tier-3 clinical reasoning | `POST /api/v1/cloud-consult` |
Canonical Exa reference: https://docs.exa.ai/reference/search-api-guide-for-coding-agents
## Consumers
- [`ml/tests/gemma4_e2b`](../../tests/gemma4_e2b) — agent mode toggle in decode lab UI
- Frontend PWA `ClinicalChatPanel``@vkist/agent-runtime` + MediaPipe worker via `useClinicalChat`
## Package
```bash
cd agent_runtime && npm install && npm run typecheck
```
## Gemma 4 E2B integration
```bash
cd ../../tests/gemma4_e2b
npm install
npm run dev
```
Enable **Agent mode** + **Mock tools** in the sidebar, load the model, ask a clinical question.
## Environment (backend)
```env
EXA_API_KEY=...
SUPABASE_URL=...
SUPABASE_SERVICE_ROLE_KEY=...
# EMBED_QUERY_MOCK=1 # PoC only for supabase_query without embedder
```
## Architecture
MediaPipe has no native function calling. The orchestrator uses prompt-constrained output:
```
<tool_call>{"name":"exa_search","arguments":{...}}</tool_call>
```
or
```
<final>answer with citations</final>
```
Results are cached in IndexedDB (`lumina-agent-runtime`); model weights stay in OPFS.

View File

@@ -0,0 +1,59 @@
export { TOOL_CATALOG_VERSION, TOOL_DEFINITIONS } from './types/toolSchema';
export type {
ToolCall,
ToolResult,
ExaSearchArgs,
ExaSearchResult,
SupabaseQueryArgs,
SupabaseQueryResult,
EscalateMedgemmaArgs,
} from './types/toolSchema';
export type { AgentLoopConfig, AgentTrace, AgentTurn } from './types/agentSession';
export { DEFAULT_AGENT_LOOP_CONFIG } from './types/agentSession';
export type {
AgentEvent,
AgentExpectedOutputKind,
AgentDecodeOverride,
} from './types/agentEvents';
export { TOOL_STEP_DECODE, PLAN_STEP_DECODE } from './types/agentEvents';
export {
buildAgentSystemPrompt,
buildStepSystemPrompt,
buildToolCatalogBlock,
formatAgentConversationPrompt,
} from './prompt/toolSystemPrompt';
export {
parseAgentOutput,
parsePlanOutput,
buildToolResultTurn,
buildRepairPrompt,
} from './prompt/toolOutputParser';
export { BffClient, isOnline } from './transport/bffClient';
export { normalizeExaResponse } from './transport/exaTypes';
export { createToolRegistry, ToolExecutor } from './tools/registry';
export type { ToolExecutorOptions } from './tools/registry';
export { AgentOrchestrator } from './orchestrator/agentLoop';
export type {
AgentOrchestratorDeps,
AgentRunTurnInput,
AgentRunTurnResult,
LlmGenerateStepInput,
LlmGenerateStepOutput,
} from './orchestrator/agentLoop';
export {
saveAgentSession,
loadAgentSession,
getSearchCache,
putSearchCache,
enqueueToolCall,
drainToolQueue,
hashCacheKey,
} from './storage/searchCacheStore';

View File

@@ -0,0 +1,403 @@
import type { AgentLoopConfig } from '../types/agentSession';
import type { AgentEvent, AgentExpectedOutputKind } from '../types/agentEvents';
import { PLAN_STEP_DECODE, TOOL_STEP_DECODE } from '../types/agentEvents';
import type { ToolCall, ToolResult } from '../types/toolSchema';
import {
buildAgentSystemPrompt,
buildStepSystemPrompt,
formatAgentConversationPrompt,
} from '../prompt/toolSystemPrompt';
import {
buildRepairPrompt,
buildToolResultTurn,
parseAgentOutput,
} from '../prompt/toolOutputParser';
import { BffClient } from '../transport/bffClient';
import { createToolRegistry } from '../tools/registry';
import { saveAgentSession } from '../storage/searchCacheStore';
import { TOOL_CATALOG_VERSION } from '../types/toolSchema';
export interface LlmGenerateStepInput {
conversationPrompt: string;
chainOfThought: boolean;
expectedKind: AgentExpectedOutputKind;
decodeOverride?: {
maxTokens?: number;
topK?: number;
temperature?: number;
randomSeed?: number;
};
}
export interface LlmGenerateStepOutput {
rawOutput: string;
}
export interface AgentOrchestratorDeps {
generateStep: (input: LlmGenerateStepInput) => Promise<LlmGenerateStepOutput>;
config: AgentLoopConfig;
baseSystemPrompt: string;
onEvent?: (event: AgentEvent) => void;
}
export interface AgentRunTurnInput {
sessionId: string;
userMessage: string;
chainOfThought: boolean;
}
export interface AgentRunTurnResult {
finalAnswer: string;
steps: number;
toolCalls: ToolCall[];
toolResults: ToolResult[];
planChecklist: string[];
}
function needsRetrieval(userMessage: string): boolean {
const lower = userMessage.toLowerCase();
return (
lower.includes('synovitis') ||
lower.includes('grade') ||
lower.includes('guideline') ||
lower.includes('moh') ||
lower.includes('clinical') ||
lower.includes('diagnosis')
);
}
function pickAutoRetrievalTool(userMessage: string): ToolCall {
const lower = userMessage.toLowerCase();
const useExa =
lower.includes('recent') ||
lower.includes('latest') ||
lower.includes('update') ||
lower.includes('2024') ||
lower.includes('2025') ||
lower.includes('2026');
if (useExa) {
return {
name: 'exa_search',
arguments: {
query: userMessage.slice(0, 512),
type: 'auto',
numResults: 10,
session_id: 'auto',
},
};
}
return {
name: 'supabase_query',
arguments: {
rpc: 'match_semantic_chunks',
args: { query_text: userMessage.slice(0, 512), match_count: 5 },
session_id: 'auto',
},
};
}
function resolveExpectedKind(
config: AgentLoopConfig,
userMessage: string,
retrievalDone: boolean,
): AgentExpectedOutputKind {
if (config.requireRetrievalBeforeFinal && needsRetrieval(userMessage) && !retrievalDone) {
return 'tool_call';
}
return 'final';
}
function decodeOverrideForKind(expectedKind: AgentExpectedOutputKind): LlmGenerateStepInput['decodeOverride'] {
if (expectedKind === 'tool_call') {
return TOOL_STEP_DECODE;
}
if (expectedKind === 'plan') {
return PLAN_STEP_DECODE;
}
return undefined;
}
export class AgentOrchestrator {
private readonly executor;
constructor(private readonly deps: AgentOrchestratorDeps) {
this.executor = createToolRegistry({
bff: new BffClient(deps.config.bffBaseUrl),
authToken: deps.config.authToken,
mockTools: deps.config.mockTools,
});
}
private emit(event: AgentEvent): void {
this.deps.onEvent?.(event);
}
async runTurn(input: AgentRunTurnInput, signal?: AbortSignal): Promise<AgentRunTurnResult> {
const baseAgentPrompt = buildAgentSystemPrompt({
baseSystemPrompt: this.deps.baseSystemPrompt,
chainOfThought: input.chainOfThought,
});
const conversation: Array<{ role: 'user' | 'model'; content: string }> = [
{ role: 'user', content: input.userMessage },
];
const toolCalls: ToolCall[] = [];
const toolResults: ToolResult[] = [];
let retrievalDone = false;
let planChecklist: string[] = [];
if (this.deps.config.enablePlanPhase) {
planChecklist = await this.runPlanPhase(
input,
baseAgentPrompt,
conversation,
signal,
);
}
for (let step = 0; step < this.deps.config.maxSteps; step += 1) {
if (signal?.aborted) {
throw new DOMException('Agent turn aborted', 'AbortError');
}
if (
step === 0 &&
this.deps.config.requireRetrievalBeforeFinal &&
needsRetrieval(input.userMessage) &&
!retrievalDone
) {
await this.runAutoRetrieval(input.sessionId, input.userMessage, conversation, toolCalls, toolResults);
retrievalDone = toolResults.some((r) => r.ok);
}
const expectedKind = resolveExpectedKind(this.deps.config, input.userMessage, retrievalDone);
this.emit({ type: 'step_start', step: step + 1, expectedKind });
const parsed = await this.generateAndParse({
input,
baseAgentPrompt,
conversation,
expectedKind,
signal,
});
if (parsed.kind === 'final') {
if (
this.deps.config.requireRetrievalBeforeFinal &&
needsRetrieval(input.userMessage) &&
!retrievalDone
) {
conversation.push({ role: 'model', content: `<final>${parsed.text}</final>` });
conversation.push({
role: 'user',
content: 'You must call supabase_query or exa_search before giving a final clinical answer.',
});
continue;
}
await saveAgentSession({
sessionId: input.sessionId,
toolCatalogVersion: TOOL_CATALOG_VERSION,
turns: conversation,
updatedAt: Date.now(),
});
this.emit({ type: 'turn_complete', steps: step + 1 });
return {
finalAnswer: parsed.text,
steps: step + 1,
toolCalls,
toolResults,
planChecklist,
};
}
if (parsed.kind !== 'tool_call') {
throw new Error('Unexpected parser result after repair');
}
parsed.call.arguments.session_id =
typeof parsed.call.arguments.session_id === 'string'
? parsed.call.arguments.session_id
: input.sessionId;
toolCalls.push(parsed.call);
this.emit({ type: 'tool_call', call: parsed.call });
this.emit({ type: 'tool_running', call: parsed.call });
const result = await this.executor.run(parsed.call);
toolResults.push(result);
this.emit({ type: 'tool_result', call: parsed.call, result });
if (parsed.call.name === 'exa_search' || parsed.call.name === 'supabase_query') {
retrievalDone = result.ok;
}
conversation.push({
role: 'model',
content: `<tool_call>${JSON.stringify(parsed.call)}</tool_call>`,
});
conversation.push({
role: 'user',
content: buildToolResultTurn(parsed.call.name, result),
});
}
throw new Error(`Agent exceeded max steps (${this.deps.config.maxSteps})`);
}
private async runPlanPhase(
input: AgentRunTurnInput,
baseAgentPrompt: string,
conversation: Array<{ role: 'user' | 'model'; content: string }>,
signal?: AbortSignal,
): Promise<string[]> {
this.emit({ type: 'plan_start' });
const expectedKind: AgentExpectedOutputKind = 'plan';
const systemPrompt = buildStepSystemPrompt(baseAgentPrompt, expectedKind, input.chainOfThought);
const conversationPrompt = formatAgentConversationPrompt(systemPrompt, conversation, input.chainOfThought);
this.emit({ type: 'llm_generating', expectedKind });
const { rawOutput } = await this.deps.generateStep({
conversationPrompt,
chainOfThought: input.chainOfThought,
expectedKind,
decodeOverride: decodeOverrideForKind(expectedKind),
});
if (signal?.aborted) {
throw new DOMException('Agent turn aborted', 'AbortError');
}
let parsed = parseAgentOutput(rawOutput, input.chainOfThought, expectedKind);
if (parsed.kind === 'invalid') {
this.emit({ type: 'repair', reason: parsed.reason, expectedKind });
conversation.push({ role: 'model', content: rawOutput });
conversation.push({ role: 'user', content: buildRepairPrompt(parsed.reason, expectedKind) });
const repairPrompt = formatAgentConversationPrompt(
buildStepSystemPrompt(baseAgentPrompt, expectedKind, input.chainOfThought),
conversation,
input.chainOfThought,
);
this.emit({ type: 'llm_generating', expectedKind });
const repairOutput = await this.deps.generateStep({
conversationPrompt: repairPrompt,
chainOfThought: input.chainOfThought,
expectedKind,
decodeOverride: decodeOverrideForKind(expectedKind),
});
parsed = parseAgentOutput(repairOutput.rawOutput, input.chainOfThought, expectedKind);
}
if (parsed.kind !== 'plan') {
const fallback = ['Analyze the question', 'Retrieve evidence if needed', 'Compose cited answer'];
this.emit({ type: 'plan', checklist: fallback, raw: rawOutput });
conversation.push({
role: 'user',
content: `Follow this plan:\n${fallback.map((item, index) => `${index + 1}. ${item}`).join('\n')}`,
});
return fallback;
}
this.emit({ type: 'plan', checklist: parsed.checklist, raw: parsed.raw });
conversation.push({
role: 'user',
content: `Follow this plan:\n${parsed.checklist.map((item, index) => `${index + 1}. ${item}`).join('\n')}`,
});
return parsed.checklist;
}
private async runAutoRetrieval(
sessionId: string,
userMessage: string,
conversation: Array<{ role: 'user' | 'model'; content: string }>,
toolCalls: ToolCall[],
toolResults: ToolResult[],
): Promise<void> {
const autoCall = pickAutoRetrievalTool(userMessage);
autoCall.arguments.session_id = sessionId;
this.emit({ type: 'tool_call', call: autoCall, auto: true });
this.emit({ type: 'tool_running', call: autoCall });
toolCalls.push(autoCall);
const autoResult = await this.executor.run(autoCall);
toolResults.push(autoResult);
this.emit({ type: 'tool_result', call: autoCall, result: autoResult });
conversation.push({
role: 'model',
content: `<tool_call>${JSON.stringify(autoCall)}</tool_call>`,
});
conversation.push({
role: 'user',
content: buildToolResultTurn(autoCall.name, autoResult),
});
}
private async generateAndParse(args: {
input: AgentRunTurnInput;
baseAgentPrompt: string;
conversation: Array<{ role: 'user' | 'model'; content: string }>;
expectedKind: AgentExpectedOutputKind;
signal?: AbortSignal;
}) {
const { input, baseAgentPrompt, conversation, expectedKind, signal } = args;
const systemPrompt = buildStepSystemPrompt(baseAgentPrompt, expectedKind, input.chainOfThought);
const conversationPrompt = formatAgentConversationPrompt(systemPrompt, conversation, input.chainOfThought);
if (expectedKind === 'final') {
this.emit({ type: 'final_start' });
}
this.emit({ type: 'llm_generating', expectedKind });
let { rawOutput } = await this.deps.generateStep({
conversationPrompt,
chainOfThought: input.chainOfThought,
expectedKind,
decodeOverride: decodeOverrideForKind(expectedKind),
});
if (signal?.aborted) {
throw new DOMException('Agent turn aborted', 'AbortError');
}
let parsed = parseAgentOutput(rawOutput, input.chainOfThought, expectedKind);
if (parsed.kind === 'invalid') {
this.emit({ type: 'repair', reason: parsed.reason, expectedKind });
conversation.push({ role: 'model', content: rawOutput });
conversation.push({ role: 'user', content: buildRepairPrompt(parsed.reason, expectedKind) });
const repairPrompt = formatAgentConversationPrompt(
buildStepSystemPrompt(baseAgentPrompt, expectedKind, input.chainOfThought),
conversation,
input.chainOfThought,
);
this.emit({ type: 'llm_generating', expectedKind });
const repairOutput = await this.deps.generateStep({
conversationPrompt: repairPrompt,
chainOfThought: input.chainOfThought,
expectedKind,
decodeOverride: decodeOverrideForKind(expectedKind),
});
rawOutput = repairOutput.rawOutput;
parsed = parseAgentOutput(rawOutput, input.chainOfThought, expectedKind);
if (parsed.kind === 'invalid') {
throw new Error(`Agent output invalid: ${parsed.reason}`);
}
}
return parsed;
}
}

View File

@@ -0,0 +1,193 @@
import type { AgentExpectedOutputKind } from '../types/agentEvents';
import type { ToolCall } from '../types/toolSchema';
const TOOL_CALL_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/i;
const FINAL_RE = /<final>\s*([\s\S]*?)\s*<\/final>/i;
const GEMMA_SPECIAL_IN_JSON = /<\|/;
export type ParsedAgentOutput =
| { kind: 'tool_call'; call: ToolCall }
| { kind: 'final'; text: string }
| { kind: 'plan'; checklist: string[]; raw: string }
| { kind: 'invalid'; raw: string; reason: string };
export function stripThoughtChannelForParsing(rawOutput: string): string {
const start = rawOutput.indexOf('<|channel>thought');
if (start === -1) {
return rawOutput;
}
const endMarker = '<channel|>';
const afterStart = rawOutput.slice(start);
const endIdx = afterStart.indexOf(endMarker);
if (endIdx === -1) {
return rawOutput;
}
return afterStart.slice(endIdx + endMarker.length).trim();
}
function stripOuterWhitespace(text: string): string {
return text.trim();
}
function hasForbiddenTag(text: string, tag: 'tool_call' | 'final'): boolean {
if (tag === 'tool_call') {
return /<tool_call/i.test(text);
}
return /<final/i.test(text);
}
function parseToolCallJson(jsonText: string): ToolCall | { error: string } {
if (GEMMA_SPECIAL_IN_JSON.test(jsonText)) {
return { error: 'tool_call JSON contains Gemma special tokens' };
}
try {
const parsed = JSON.parse(jsonText) as { name?: string; arguments?: Record<string, unknown> };
if (!parsed.name || typeof parsed.name !== 'string') {
return { error: 'tool_call missing name' };
}
return {
name: parsed.name,
arguments: parsed.arguments ?? {},
};
} catch {
return { error: 'tool_call JSON parse failed' };
}
}
function extractFirstToolCall(text: string): ToolCall | null {
const toolMatch = text.match(TOOL_CALL_RE);
if (!toolMatch) {
return null;
}
const parsed = parseToolCallJson(toolMatch[1]);
if ('error' in parsed) {
return null;
}
return parsed;
}
function extractFinal(text: string): string | null {
const finalMatch = text.match(FINAL_RE);
if (!finalMatch) {
return null;
}
return finalMatch[1].trim();
}
function isOnlyToolCallBlock(text: string): boolean {
const match = text.match(/^<tool_call>\s*[\s\S]*?\s*<\/tool_call>$/i);
return match !== null;
}
function isOnlyFinalBlock(text: string): boolean {
const match = text.match(/^<final>\s*[\s\S]*?\s*<\/final>$/i);
return match !== null;
}
export function parsePlanOutput(rawOutput: string, chainOfThought: boolean): ParsedAgentOutput {
const text = chainOfThought ? stripThoughtChannelForParsing(rawOutput) : stripOuterWhitespace(rawOutput);
const lines = text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^\d+[\).\]]\s*/, '').trim())
.filter((line) => line.length > 0 && !line.startsWith('<'));
if (lines.length === 0) {
return { kind: 'invalid', raw: text, reason: 'plan step produced no checklist lines' };
}
return { kind: 'plan', checklist: lines, raw: text };
}
export function parseAgentOutput(
rawOutput: string,
chainOfThought: boolean,
expectedKind: AgentExpectedOutputKind,
): ParsedAgentOutput {
const text = chainOfThought ? stripThoughtChannelForParsing(rawOutput) : stripOuterWhitespace(rawOutput);
if (expectedKind === 'plan') {
return parsePlanOutput(rawOutput, chainOfThought);
}
const hasTool = hasForbiddenTag(text, 'tool_call');
const hasFinal = hasForbiddenTag(text, 'final');
if (expectedKind === 'tool_call') {
if (hasFinal) {
return { kind: 'invalid', raw: text, reason: 'tool step must not contain <final>' };
}
if (!hasTool) {
if (text.length > 0) {
return { kind: 'invalid', raw: text, reason: 'tool step requires <tool_call> block only' };
}
return { kind: 'invalid', raw: text, reason: 'empty tool step output' };
}
if (!isOnlyToolCallBlock(text)) {
return { kind: 'invalid', raw: text, reason: 'tool step must be exactly one <tool_call> block' };
}
const toolMatch = text.match(TOOL_CALL_RE);
if (!toolMatch) {
return { kind: 'invalid', raw: text, reason: 'malformed tool_call tags' };
}
const parsed = parseToolCallJson(toolMatch[1]);
if ('error' in parsed) {
return { kind: 'invalid', raw: text, reason: parsed.error };
}
return { kind: 'tool_call', call: parsed };
}
// expectedKind === 'final'
if (hasTool && hasFinal) {
return { kind: 'invalid', raw: text, reason: 'final step must not combine <tool_call> and <final>' };
}
if (hasTool && !hasFinal) {
const call = extractFirstToolCall(text);
if (call && !text.replace(TOOL_CALL_RE, '').trim()) {
return { kind: 'tool_call', call };
}
return { kind: 'invalid', raw: text, reason: 'final step received tool_call without <final>' };
}
if (hasFinal) {
if (!isOnlyFinalBlock(text)) {
return { kind: 'invalid', raw: text, reason: 'final step must be exactly one <final> block' };
}
const finalText = extractFinal(text);
if (!finalText) {
return { kind: 'invalid', raw: text, reason: 'empty final block' };
}
return { kind: 'final', text: finalText };
}
if (text.includes('<tool_call>') || text.includes('<final>')) {
return { kind: 'invalid', raw: text, reason: 'malformed tool_call or final tags' };
}
return { kind: 'invalid', raw: text, reason: 'final step requires <final>...</final> block' };
}
export function buildToolResultTurn(toolName: string, result: unknown): string {
return `<tool_result>${JSON.stringify({ tool: toolName, result })}</tool_result>`;
}
export function buildRepairPrompt(reason: string, expectedKind: AgentExpectedOutputKind): string {
if (expectedKind === 'tool_call') {
return (
`Invalid (${reason}). TOOL STEP — reply with ONLY one block, no other text:\n` +
`<tool_call>{"name":"tool_name","arguments":{...}}</tool_call>`
);
}
if (expectedKind === 'plan') {
return (
`Invalid (${reason}). PLAN STEP — reply with a numbered checklist only (one step per line). ` +
`No tool_call, no final, no prose.`
);
}
return (
`Invalid (${reason}). FINAL STEP — reply with ONLY one block, no other text:\n` +
`<final>your complete answer with citations</final>`
);
}

View File

@@ -0,0 +1,98 @@
import type { AgentExpectedOutputKind } from '../types/agentEvents';
import { TOOL_CATALOG_VERSION, TOOL_DEFINITIONS } from '../types/toolSchema';
export interface ToolSystemPromptOptions {
baseSystemPrompt: string;
chainOfThought?: boolean;
}
export function buildToolCatalogBlock(): string {
const lines = TOOL_DEFINITIONS.map((tool) => {
const params = Object.entries(tool.parameters)
.map(([key, desc]) => ` ${key}: ${desc}`)
.join('\n');
return `- ${tool.name}: ${tool.description}\n parameters:\n${params}`;
});
return lines.join('\n\n');
}
export function buildPhaseSuffix(expectedKind: AgentExpectedOutputKind, chainOfThought: boolean): string {
if (expectedKind === 'plan') {
return (
`\n\nPLAN STEP (strict):\n` +
`- Output a numbered checklist of steps you will take to answer the user.\n` +
`- One step per line (e.g. "1. Search …").\n` +
`- No <tool_call>, no <final>, no greetings, no answer prose.\n`
);
}
if (expectedKind === 'tool_call') {
return (
`\n\nTOOL STEP (strict):\n` +
`- Your entire reply MUST be exactly:\n` +
` <tool_call>{"name":"tool_name","arguments":{...}}</tool_call>\n` +
`- Do NOT output <final> or any other text before or after the block.\n` +
`- Do NOT explain, greet, or answer the user in this turn.\n` +
(chainOfThought
? `- Put reasoning in the thought channel only; the answer channel must start with "<tool_call>".\n`
: `- Do not output any characters outside the <tool_call> block.\n`)
);
}
return (
`\n\nFINAL STEP (strict):\n` +
`- Your entire reply MUST be exactly:\n` +
` <final>…complete user-facing answer with citations…</final>\n` +
`- Do NOT output <tool_call> or any text outside the <final> block.\n` +
(chainOfThought
? `- Put reasoning in the thought channel only; the answer channel must start with "<final>".\n`
: `- Do not output any characters outside the <final> block.\n`)
);
}
export function buildAgentSystemPrompt(options: ToolSystemPromptOptions): string {
const catalog = buildToolCatalogBlock();
return (
`${options.baseSystemPrompt}\n\n` +
`You are an agent with tools (catalog v${TOOL_CATALOG_VERSION}).\n` +
`Available tools:\n${catalog}\n\n` +
`Rules:\n` +
`- Each generation step has a strict output mode (plan, tool, or final).\n` +
`- To call a tool: <tool_call>{"name":"tool_name","arguments":{...}}</tool_call>\n` +
`- When ready to answer the user: <final>your answer with citations (chunk_id or URL)</final>\n` +
`- For clinical claims, prefer supabase_query (local MOH corpus) first; use exa_search for external/recency.\n` +
`- Use escalate_medgemma only when deep reasoning is required and local retrieval is insufficient.\n` +
`- Never invent tool names or fabricate citations.\n` +
`- Never combine multiple block types in one turn.\n`
);
}
export function buildStepSystemPrompt(
baseAgentPrompt: string,
expectedKind: AgentExpectedOutputKind,
chainOfThought: boolean,
): string {
return baseAgentPrompt + buildPhaseSuffix(expectedKind, chainOfThought);
}
export function formatAgentConversationTurn(role: 'user' | 'model', content: string): string {
const turnRole = role === 'user' ? 'user' : 'model';
return `<|turn>${turnRole}\n${content}<turn|>`;
}
export function formatAgentConversationPrompt(
systemPrompt: string,
turns: Array<{ role: 'user' | 'model'; content: string }>,
chainOfThought: boolean,
): string {
const parts: string[] = [];
const systemContent = chainOfThought ? `<|think|>${systemPrompt}` : systemPrompt;
parts.push(`<|turn>system\n${systemContent}<turn|>`);
for (const turn of turns) {
parts.push(formatAgentConversationTurn(turn.role, turn.content));
}
parts.push('<|turn>model\n');
return parts.join('\n');
}

View File

@@ -0,0 +1,130 @@
const DB_NAME = 'lumina-agent-runtime';
const DB_VERSION = 1;
const SESSIONS = 'agent_sessions';
const SEARCH_CACHE = 'search_cache';
const TOOL_QUEUE = 'tool_queue';
export interface CachedSearchEntry {
cacheKey: string;
tool: string;
payload: unknown;
cachedAt: number;
ttlMs: number;
}
export interface QueuedToolCall {
id?: number;
sessionId: string;
tool: string;
arguments: Record<string, unknown>;
queuedAt: number;
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed'));
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(SESSIONS)) {
db.createObjectStore(SESSIONS, { keyPath: 'sessionId' });
}
if (!db.objectStoreNames.contains(SEARCH_CACHE)) {
db.createObjectStore(SEARCH_CACHE, { keyPath: 'cacheKey' });
}
if (!db.objectStoreNames.contains(TOOL_QUEUE)) {
db.createObjectStore(TOOL_QUEUE, { keyPath: 'id', autoIncrement: true });
}
};
});
}
async function withStore<T>(
storeName: string,
mode: IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const request = fn(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
});
}
export async function saveAgentSession(session: unknown): Promise<void> {
await withStore(SESSIONS, 'readwrite', (store) => store.put(session));
}
export async function loadAgentSession<T>(sessionId: string): Promise<T | null> {
try {
const result = await withStore<T | undefined>(SESSIONS, 'readonly', (store) => store.get(sessionId));
return result ?? null;
} catch {
return null;
}
}
export async function getSearchCache(cacheKey: string): Promise<CachedSearchEntry | null> {
try {
const entry = await withStore<CachedSearchEntry | undefined>(SEARCH_CACHE, 'readonly', (store) =>
store.get(cacheKey),
);
if (!entry) {
return null;
}
if (Date.now() - entry.cachedAt > entry.ttlMs) {
return null;
}
return entry;
} catch {
return null;
}
}
export async function putSearchCache(entry: CachedSearchEntry): Promise<void> {
await withStore(SEARCH_CACHE, 'readwrite', (store) => store.put(entry));
}
export async function enqueueToolCall(entry: Omit<QueuedToolCall, 'id'>): Promise<void> {
await withStore(TOOL_QUEUE, 'readwrite', (store) => store.add(entry));
}
export async function drainToolQueue(sessionId: string): Promise<QueuedToolCall[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(TOOL_QUEUE, 'readwrite');
const store = tx.objectStore(TOOL_QUEUE);
const request = store.openCursor();
const drained: QueuedToolCall[] = [];
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
return;
}
const value = cursor.value as QueuedToolCall;
if (value.sessionId === sessionId) {
drained.push(value);
cursor.delete();
}
cursor.continue();
};
tx.oncomplete = () => resolve(drained);
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB cursor failed'));
});
}
export async function hashCacheKey(input: string): Promise<string> {
if (typeof crypto !== 'undefined' && crypto.subtle) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
return `fallback-${input.length}-${input.slice(0, 32)}`;
}

View File

@@ -0,0 +1,253 @@
import type {
EscalateMedgemmaArgs,
ExaSearchArgs,
ExaSearchResult,
SupabaseQueryArgs,
SupabaseQueryResult,
ToolCall,
ToolResult,
} from '../types/toolSchema';
import { BffClient, isOnline } from '../transport/bffClient';
import { normalizeExaResponse } from '../transport/exaTypes';
import {
drainToolQueue,
enqueueToolCall,
getSearchCache,
hashCacheKey,
putSearchCache,
} from '../storage/searchCacheStore';
const EXA_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const SUPABASE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function validateQuery(query: unknown): string {
if (typeof query !== 'string' || !query.trim()) {
throw new Error('query is required');
}
const trimmed = query.trim();
if (trimmed.length > 512) {
throw new Error('query exceeds 512 characters');
}
return trimmed;
}
function validateSessionId(sessionId: unknown): string {
if (typeof sessionId !== 'string' || !sessionId.trim()) {
throw new Error('session_id is required');
}
return sessionId.trim();
}
export interface ToolExecutorOptions {
bff: BffClient;
authToken?: string;
mockTools?: boolean;
}
export class ToolExecutor {
constructor(private readonly options: ToolExecutorOptions) {}
async run(call: ToolCall): Promise<ToolResult> {
try {
switch (call.name) {
case 'exa_search':
return await this.runExaSearch(call.arguments);
case 'supabase_query':
return await this.runSupabaseQuery(call.arguments);
case 'escalate_medgemma':
return await this.runEscalateMedgemma(call.arguments);
default:
return { tool: call.name, ok: false, error: `Unknown tool: ${call.name}` };
}
} catch (error) {
return {
tool: call.name,
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async flushOfflineQueue(sessionId: string): Promise<ToolResult[]> {
if (!isOnline()) {
return [];
}
const queued = await drainToolQueue(sessionId);
const results: ToolResult[] = [];
for (const item of queued) {
results.push(await this.run({ name: item.tool, arguments: item.arguments }));
}
return results;
}
private async runExaSearch(raw: Record<string, unknown>): Promise<ToolResult> {
const args: ExaSearchArgs = {
query: validateQuery(raw.query),
type: (raw.type as ExaSearchArgs['type']) ?? 'auto',
numResults: typeof raw.numResults === 'number' ? raw.numResults : 10,
includeDomains: raw.includeDomains as string[] | undefined,
excludeDomains: raw.excludeDomains as string[] | undefined,
session_id: validateSessionId(raw.session_id),
};
const cacheKey = await hashCacheKey(`exa:${JSON.stringify(args)}`);
const cached = await getSearchCache(cacheKey);
if (cached) {
return { tool: 'exa_search', ok: true, data: cached.payload };
}
if (this.options.mockTools) {
const mock: ExaSearchResult = {
hits: [
{
id: 'mock-exa-1',
url: 'https://example.org/guideline/synovitis',
title: 'Mock Exa — Synovitis grading overview',
highlights: ['Power Doppler grade 2 indicates moderate synovial inflammation.'],
},
],
};
await putSearchCache({
cacheKey,
tool: 'exa_search',
payload: mock,
cachedAt: Date.now(),
ttlMs: EXA_CACHE_TTL_MS,
});
return { tool: 'exa_search', ok: true, data: mock };
}
if (!isOnline()) {
await enqueueToolCall({
sessionId: args.session_id,
tool: 'exa_search',
arguments: raw,
queuedAt: Date.now(),
});
return { tool: 'exa_search', ok: false, error: 'Offline — exa_search queued for retry' };
}
const response = await this.options.bff.post<{ hits: ExaSearchResult['hits']; requestId?: string }>(
'/api/v1/agent/tools/exa/search',
args,
this.options.authToken,
);
const normalized: ExaSearchResult = response.hits ? response : normalizeExaResponse(response);
await putSearchCache({
cacheKey,
tool: 'exa_search',
payload: normalized,
cachedAt: Date.now(),
ttlMs: EXA_CACHE_TTL_MS,
});
return { tool: 'exa_search', ok: true, data: normalized };
}
private async runSupabaseQuery(raw: Record<string, unknown>): Promise<ToolResult> {
const args: SupabaseQueryArgs = {
rpc: raw.rpc as SupabaseQueryArgs['rpc'],
args: (raw.args as Record<string, unknown>) ?? {},
session_id: validateSessionId(raw.session_id),
};
if (args.rpc !== 'match_semantic_chunks' && args.rpc !== 'get_corpus_citation') {
return { tool: 'supabase_query', ok: false, error: 'rpc not allowlisted' };
}
const cacheKey = await hashCacheKey(`supabase:${JSON.stringify(args)}`);
const cached = await getSearchCache(cacheKey);
if (cached) {
return { tool: 'supabase_query', ok: true, data: cached.payload };
}
if (this.options.mockTools) {
const mock: SupabaseQueryResult = {
rpc: args.rpc,
rows: [
{
chunk_id: 'mock-chunk-001',
content: 'Synovitis grade 2: moderate synovial thickening with positive power Doppler signal.',
book_id: 'mor',
parent_title: 'Mock MOH Guideline',
similarity: 0.91,
},
],
};
await putSearchCache({
cacheKey,
tool: 'supabase_query',
payload: mock,
cachedAt: Date.now(),
ttlMs: SUPABASE_CACHE_TTL_MS,
});
return { tool: 'supabase_query', ok: true, data: mock };
}
if (!isOnline()) {
await enqueueToolCall({
sessionId: args.session_id,
tool: 'supabase_query',
arguments: raw,
queuedAt: Date.now(),
});
return { tool: 'supabase_query', ok: false, error: 'Offline — supabase_query queued for retry' };
}
const response = await this.options.bff.post<SupabaseQueryResult>(
'/api/v1/agent/tools/supabase/query',
args,
this.options.authToken,
);
await putSearchCache({
cacheKey,
tool: 'supabase_query',
payload: response,
cachedAt: Date.now(),
ttlMs: SUPABASE_CACHE_TTL_MS,
});
return { tool: 'supabase_query', ok: true, data: response };
}
private async runEscalateMedgemma(raw: Record<string, unknown>): Promise<ToolResult> {
const args: EscalateMedgemmaArgs = {
session_id: validateSessionId(raw.session_id),
task_type: (raw.task_type as EscalateMedgemmaArgs['task_type']) ?? 'clinical_deep_reasoning',
prompt: validateQuery(raw.prompt),
include_images: Boolean(raw.include_images),
stream: raw.stream !== false,
redaction_hash: typeof raw.redaction_hash === 'string' ? raw.redaction_hash : undefined,
};
if (this.options.mockTools) {
return {
tool: 'escalate_medgemma',
ok: true,
data: {
tier: 'medgemma',
text: '[Mock MedGemma] Deep clinical reasoning would stream here after consent.',
},
};
}
if (!isOnline()) {
return { tool: 'escalate_medgemma', ok: false, error: 'Offline — MedGemma escalation requires network + consent' };
}
const response = await this.options.bff.post<{ text: string; tier: string }>(
'/api/v1/cloud-consult',
{
session_id: args.session_id,
prompt: args.prompt,
task_type: args.task_type,
stream: false,
redaction_hash: args.redaction_hash,
},
this.options.authToken,
);
return { tool: 'escalate_medgemma', ok: true, data: response };
}
}
export function createToolRegistry(options: ToolExecutorOptions): ToolExecutor {
return new ToolExecutor(options);
}

View File

@@ -0,0 +1,45 @@
export interface BffRequestOptions {
method?: 'GET' | 'POST';
body?: unknown;
authToken?: string;
signal?: AbortSignal;
}
export class BffClient {
constructor(private readonly baseUrl: string) {}
async post<T>(path: string, body: unknown, authToken?: string): Promise<T> {
const url = `${this.baseUrl.replace(/\/$/, '')}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
let detail = response.statusText;
try {
const errBody = (await response.json()) as { detail?: string };
if (errBody.detail) {
detail = errBody.detail;
}
} catch {
// ignore
}
throw new Error(`BFF ${path} failed (${response.status}): ${detail}`);
}
return (await response.json()) as T;
}
}
export function isOnline(): boolean {
return typeof navigator === 'undefined' ? true : navigator.onLine;
}

View File

@@ -0,0 +1,26 @@
import type { ExaSearchHit, ExaSearchResult } from '../types/toolSchema';
export function normalizeExaResponse(payload: unknown): ExaSearchResult {
const body = payload as {
results?: Array<{
id?: string;
url?: string;
title?: string;
publishedDate?: string;
score?: number;
highlights?: string[];
}>;
requestId?: string;
};
const hits: ExaSearchHit[] = (body.results ?? []).map((row, index) => ({
id: row.id ?? `exa-${index}`,
url: row.url ?? '',
title: row.title ?? row.url ?? 'Untitled',
highlights: Array.isArray(row.highlights) ? row.highlights : [],
publishedDate: row.publishedDate,
score: row.score,
}));
return { hits, requestId: body.requestId };
}

View File

@@ -0,0 +1,35 @@
import type { ToolCall, ToolResult } from './toolSchema';
/** Which structured block the model must emit for this generation step. */
export type AgentExpectedOutputKind = 'plan' | 'tool_call' | 'final';
export type AgentEvent =
| { type: 'plan_start' }
| { type: 'plan'; checklist: string[]; raw: string }
| { type: 'step_start'; step: number; expectedKind: AgentExpectedOutputKind }
| { type: 'llm_generating'; expectedKind: AgentExpectedOutputKind }
| { type: 'tool_call'; call: ToolCall; auto?: boolean }
| { type: 'tool_running'; call: ToolCall }
| { type: 'tool_result'; call: ToolCall; result: ToolResult }
| { type: 'repair'; reason: string; expectedKind: AgentExpectedOutputKind }
| { type: 'final_start' }
| { type: 'final_token'; partial: string }
| { type: 'turn_complete'; steps: number };
export interface AgentDecodeOverride {
maxTokens?: number;
topK?: number;
temperature?: number;
randomSeed?: number;
}
/** topK/temperature only — maxTokens is prompt-sized in the LLM worker (input + output combined). */
export const TOOL_STEP_DECODE: AgentDecodeOverride = {
topK: 20,
temperature: 0.3,
};
export const PLAN_STEP_DECODE: AgentDecodeOverride = {
topK: 20,
temperature: 0.4,
};

View File

@@ -0,0 +1,39 @@
import type { ToolCall, ToolResult } from './toolSchema';
export type AgentTurnRole = 'user' | 'assistant' | 'tool' | 'system';
export interface AgentTurn {
id: string;
role: AgentTurnRole;
content: string;
toolCall?: ToolCall;
toolResult?: ToolResult;
timestamp: number;
}
export interface AgentTrace {
sessionId: string;
toolCatalogVersion: string;
turns: AgentTurn[];
consultMode: 'tier_1' | 'tier_2' | 'tier_3';
updatedAt: number;
}
export interface AgentLoopConfig {
maxSteps: number;
mockTools: boolean;
bffBaseUrl: string;
authToken?: string;
requireRetrievalBeforeFinal: boolean;
enablePlanPhase: boolean;
}
export const DEFAULT_AGENT_LOOP_CONFIG: AgentLoopConfig = {
maxSteps: 6,
mockTools: false,
bffBaseUrl: typeof import.meta !== 'undefined'
? (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_API_BASE_URL ?? 'http://localhost:8000'
: 'http://localhost:8000',
requireRetrievalBeforeFinal: true,
enablePlanPhase: true,
};

View File

@@ -0,0 +1,119 @@
export const TOOL_CATALOG_VERSION = '1.0.0';
export type ExaSearchType =
| 'auto'
| 'fast'
| 'instant'
| 'deep-lite'
| 'deep'
| 'deep-reasoning';
export type SupabaseRpcName = 'match_semantic_chunks' | 'get_corpus_citation';
export type MedGemmaTaskType = 'clinical_deep_reasoning' | 'report_finalization';
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, string>;
}
export interface ToolCall {
name: string;
arguments: Record<string, unknown>;
}
export interface ToolResult {
tool: string;
ok: boolean;
data?: unknown;
error?: string;
}
export interface ExaSearchArgs {
query: string;
type?: ExaSearchType;
numResults?: number;
includeDomains?: string[];
excludeDomains?: string[];
session_id: string;
}
export interface SupabaseQueryArgs {
rpc: SupabaseRpcName;
args: Record<string, unknown>;
session_id: string;
}
export interface EscalateMedgemmaArgs {
session_id: string;
task_type: MedGemmaTaskType;
prompt: string;
include_images?: boolean;
stream?: boolean;
redaction_hash?: string;
}
export interface ExaSearchHit {
id: string;
url: string;
title: string;
highlights: string[];
publishedDate?: string;
score?: number;
}
export interface ExaSearchResult {
hits: ExaSearchHit[];
requestId?: string;
}
export interface SupabaseChunkHit {
chunk_id: string;
content: string;
book_id: string;
parent_title?: string;
page_start?: number;
page_end?: number;
similarity?: number;
}
export interface SupabaseQueryResult {
rpc: SupabaseRpcName;
rows: SupabaseChunkHit[];
}
export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: 'exa_search',
description: 'Search the web for clinical or technical evidence via Exa (highlights mode).',
parameters: {
query: 'string (required, max 512 chars, PHI-scrubbed)',
type: 'auto | fast | instant | deep-lite | deep | deep-reasoning (default auto)',
numResults: 'number 1-10 (default 10)',
includeDomains: 'string[] optional authoritative domains',
excludeDomains: 'string[] optional exclusions',
session_id: 'string (required)',
},
},
{
name: 'supabase_query',
description: 'Read-only Supabase knowledge RPC (local MOH textbook corpus).',
parameters: {
rpc: 'match_semantic_chunks | get_corpus_citation',
args: 'object validated server-side (query_text, filter_book_ids, match_count)',
session_id: 'string (required)',
},
},
{
name: 'escalate_medgemma',
description: 'Escalate to Tier-3 MedGemma for deep clinical reasoning (requires consent).',
parameters: {
session_id: 'string (required)',
task_type: 'clinical_deep_reasoning | report_finalization',
prompt: 'string (PHI-scrubbed, redaction_hash recommended)',
include_images: 'boolean optional',
stream: 'boolean default true',
},
},
];

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}