update the codebase poc ver1
This commit is contained in:
@@ -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.
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>`
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user