From f6e4a58a8ebb5e3011b3a628ea0c96b6404aed2c Mon Sep 17 00:00:00 2001 From: Michael Estrem Date: Sat, 13 Jun 2026 10:58:15 -0400 Subject: [PATCH] feat: add integration tools abstraction and TrustFoundry legal research MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a pluggable ToolIntegration interface that allows external legal research providers to register tools alongside Mike's built-in document and workflow tools. Each integration declares its own tool schemas, a user-facing display name, and a stateless run() handler — keeping provider-specific logic fully encapsulated outside of chatTools.ts. TrustFoundry is included as the first integration (disabled by default). When enabled via TRUSTFOUNDRY_API_KEY and TRUSTFOUNDRY_ENABLED, it exposes agentic and direct legal search across cases, statutes, and regulations, plus citation validation and authority summaries. Backend changes: - New backend/src/lib/integrations/ module with ToolIntegration type, registry, and TrustFoundry implementation - chatTools.ts: merge integration tools into the active tool list, dispatch unrecognized tool calls through the integration registry, and emit displayName on tool_call_start SSE events Frontend changes: - Add optional displayName field to tool_call_start in AssistantEvent type - Pass displayName through SSE parser in useAssistantChat - Use displayName in toolCallLabel() for friendly UI labels (e.g. "Running TrustFoundry Legal Research...") Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/lib/chatTools.ts | 28 +- backend/src/lib/integrations/index.ts | 41 ++ backend/src/lib/integrations/trustfoundry.ts | 579 ++++++++++++++++++ backend/src/lib/integrations/types.ts | 24 + docs/integrations.md | 20 + .../components/assistant/AssistantMessage.tsx | 5 +- frontend/src/app/components/shared/types.ts | 1 + frontend/src/app/hooks/useAssistantChat.ts | 4 + 8 files changed, 697 insertions(+), 5 deletions(-) create mode 100644 backend/src/lib/integrations/index.ts create mode 100644 backend/src/lib/integrations/trustfoundry.ts create mode 100644 backend/src/lib/integrations/types.ts create mode 100644 docs/integrations.md diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index 98d5256b4..ad4bc8192 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -38,6 +38,11 @@ import { type OpenAIToolSchema, } from "./llm"; import { safeErrorMessage } from "./safeError"; +import { + integrationToolDisplayName, + runConfiguredIntegrationToolCall, + withConfiguredIntegrationTools, +} from "./integrations"; const STANDARD_FONT_DATA_URL = (() => { try { @@ -3587,6 +3592,20 @@ export async function runToolCalls( tool_call_id: tc.id, content: JSON.stringify(toolResultPayload), }); + } else { + const integrationToolResult = + await runConfiguredIntegrationToolCall(tc, args); + if (integrationToolResult) { + toolResults.push(integrationToolResult); + } else { + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + error: `Unknown tool: ${tc.function.name}`, + }), + }); + } } } @@ -3926,9 +3945,11 @@ export async function runLLMStream(params: { } = params; const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : []; const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS]; - const activeTools = extraTools?.length - ? [...baseTools, ...extraTools] - : baseTools; + const activeTools = withConfiguredIntegrationTools( + (extraTools?.length + ? [...baseTools, ...extraTools] + : baseTools) as OpenAIToolSchema[], + ); // Extract system prompt; pass remaining turns to the adapter as // plain user/assistant messages. @@ -4104,6 +4125,7 @@ export async function runLLMStream(params: { `data: ${JSON.stringify({ type: "tool_call_start", name: call.name, + displayName: integrationToolDisplayName(call.name), })}\n\n`, ); }, diff --git a/backend/src/lib/integrations/index.ts b/backend/src/lib/integrations/index.ts new file mode 100644 index 000000000..09cb0e40e --- /dev/null +++ b/backend/src/lib/integrations/index.ts @@ -0,0 +1,41 @@ +import type { OpenAIToolSchema } from "../llm"; +import { trustFoundryIntegration } from "./trustfoundry"; +import type { + IntegrationToolCall, + IntegrationToolResult, + ToolIntegration, +} from "./types"; + +const integrations: ToolIntegration[] = [trustFoundryIntegration]; + +export function configuredIntegrationTools(): OpenAIToolSchema[] { + return integrations.flatMap((integration) => + integration.isEnabled() ? integration.tools() : [], + ); +} + +export function withConfiguredIntegrationTools( + tools: OpenAIToolSchema[], +): OpenAIToolSchema[] { + return [...tools, ...configuredIntegrationTools()]; +} + +export function integrationToolDisplayName(toolName: string): string | null { + const integration = integrations.find( + (candidate) => + candidate.isEnabled() && candidate.canHandle(toolName), + ); + return integration?.displayName(toolName) ?? null; +} + +export async function runConfiguredIntegrationToolCall( + toolCall: IntegrationToolCall, + args: Record, +): Promise { + const integration = integrations.find( + (candidate) => + candidate.isEnabled() && candidate.canHandle(toolCall.function.name), + ); + + return integration ? integration.run(toolCall, args) : null; +} diff --git a/backend/src/lib/integrations/trustfoundry.ts b/backend/src/lib/integrations/trustfoundry.ts new file mode 100644 index 000000000..13434c546 --- /dev/null +++ b/backend/src/lib/integrations/trustfoundry.ts @@ -0,0 +1,579 @@ +import type { OpenAIToolSchema } from "../llm"; +import type { ToolIntegration } from "./types"; + +const DEFAULT_BASE_URL = "https://api.trustfoundry.ai"; +const MAX_SEARCH_EVENTS = 80; + +const TRUSTFOUNDRY_TOOL_DISPLAY_NAMES: Record = { + trustfoundry_agentic_legal_search: "TrustFoundry Legal Research", + trustfoundry_direct_legal_search: "TrustFoundry Legal Search", + trustfoundry_get_search_results: "TrustFoundry Search Results", + trustfoundry_describe_search_result: "TrustFoundry Authority Summary", + trustfoundry_validate_citations: "TrustFoundry Citation Validation", + trustfoundry_get_usage: "TrustFoundry Usage Check", +}; + +export const TRUSTFOUNDRY_TOOLS: OpenAIToolSchema[] = [ + { + type: "function", + function: { + name: "trustfoundry_agentic_legal_search", + description: + "Run a broad TrustFoundry legal research search across laws, regulations, and case law. Use for open-ended legal research questions. When summarizing results, state that the research was run through TrustFoundry, cite authorities using citation text returned by the tool, and use Markdown links only when the tool returns a URL.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "Natural-language legal research query.", + }, + default_state: { + type: "string", + description: + "Default jurisdiction as a 2-letter uppercase state code or FED. Defaults to FED.", + }, + }, + required: ["query"], + }, + }, + }, + { + type: "function", + function: { + name: "trustfoundry_direct_legal_search", + description: + "Run a targeted TrustFoundry search for one legal source type. Use for specific cases, statutes, regulations, key facts, or exact-citation style searches. When summarizing results, state that the research was run through TrustFoundry, cite authorities using citation text returned by the tool, and use Markdown links only when the tool returns a URL.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "Search query.", + }, + state: { + type: "string", + description: + "Jurisdiction as a 2-letter uppercase state code or FED.", + }, + model_type: { + type: "string", + enum: [ + "case_question", + "law_question", + "reg_question", + "case_key_fact", + ], + description: + "Source/search type: case_question, law_question, reg_question, or case_key_fact.", + }, + }, + required: ["query", "state", "model_type"], + }, + }, + }, + { + type: "function", + function: { + name: "trustfoundry_get_search_results", + description: + "Retrieve a TrustFoundry search result set by UUID, including result item UUIDs, citations, URLs, excerpts, and metadata. Use only returned citations, URLs, and metadata when citing or linking authorities; do not invent source details.", + parameters: { + type: "object", + properties: { + uuid: { + type: "string", + description: + "Search set UUID returned by a TrustFoundry search.", + }, + }, + required: ["uuid"], + }, + }, + }, + { + type: "function", + function: { + name: "trustfoundry_describe_search_result", + description: + "Retrieve a summarized legal description for one TrustFoundry search result item. Use item UUIDs returned from a search result set. Use only returned citations, URLs, and metadata when citing or linking authorities; do not invent source details.", + parameters: { + type: "object", + properties: { + uuid: { + type: "string", + description: + "Search result item UUID returned by TrustFoundry.", + }, + full_text: { + type: "boolean", + description: + "When true, request full text where supported.", + }, + }, + required: ["uuid"], + }, + }, + }, + { + type: "function", + function: { + name: "trustfoundry_validate_citations", + description: + "Extract and validate legal citations from user-provided text using TrustFoundry. State that citation validation was run through TrustFoundry. Treat the returned validation status as the source of truth for what TrustFoundry verified; unsupported or unable-to-process citations are not TrustFoundry-verified. Do not use model knowledge to upgrade a TrustFoundry non-verification into a verified citation.", + parameters: { + type: "object", + properties: { + text: { + type: "string", + description: + "Text containing citations to extract and validate. Maximum 10,000 characters.", + }, + context_before: { + type: "integer", + description: + "Characters before each citation to include as context, 0-250.", + }, + context_after: { + type: "integer", + description: + "Characters after each citation to include as context, 0-250.", + }, + }, + required: ["text"], + }, + }, + }, + { + type: "function", + function: { + name: "trustfoundry_get_usage", + description: + "Check TrustFoundry usage, quota, credits, and billing status for the connected API key.", + parameters: { type: "object", properties: {} }, + }, + }, +]; + +type NdjsonParseResult = { + events: unknown[]; + rest: string; +}; + +export class TrustFoundryApiError extends Error { + status: number; + requestId: string | null; + retryAfter: string | null; + code: string; + + constructor(params: { + status: number; + message: string; + requestId?: string | null; + retryAfter?: string | null; + code?: string; + }) { + super(params.message); + this.name = "TrustFoundryApiError"; + this.status = params.status; + this.requestId = params.requestId ?? null; + this.retryAfter = params.retryAfter ?? null; + this.code = params.code ?? "trustfoundry_error"; + } +} + +export function trustFoundryBaseUrl(env = process.env): string { + return (env.TRUSTFOUNDRY_API_BASE_URL?.trim() || DEFAULT_BASE_URL).replace( + /\/+$/, + "", + ); +} + +export function parseNdjsonChunk(buffer: string): NdjsonParseResult { + const events: unknown[] = []; + const lines = buffer.split("\n"); + const rest = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + events.push(JSON.parse(trimmed)); + } catch { + throw new TrustFoundryApiError({ + status: 502, + code: "malformed_response", + message: "Malformed response from TrustFoundry.", + }); + } + } + + return { events, rest }; +} + +export function parseNdjsonChunks(chunks: string[]): unknown[] { + let rest = ""; + const events: unknown[] = []; + for (const chunk of chunks) { + const parsed = parseNdjsonChunk(rest + chunk); + events.push(...parsed.events); + rest = parsed.rest; + } + const trailing = rest.trim(); + if (trailing) events.push(JSON.parse(trailing)); + return events; +} + +export function mapTrustFoundryError(params: { + status: number; + bodyText: string; + requestId?: string | null; + retryAfter?: string | null; +}): TrustFoundryApiError { + let remoteError = ""; + try { + const parsed = JSON.parse(params.bodyText) as { error?: unknown }; + remoteError = + typeof parsed.error === "string" ? parsed.error : params.bodyText; + } catch { + remoteError = params.bodyText; + } + + if (params.status === 401) { + return new TrustFoundryApiError({ + status: params.status, + code: "invalid_api_key", + requestId: params.requestId, + message: + "TrustFoundry API key is invalid or missing. Configure a valid TrustFoundry API key in the server environment.", + }); + } + + if (params.status === 402) { + return new TrustFoundryApiError({ + status: params.status, + code: "insufficient_credits", + requestId: params.requestId, + message: + remoteError || "TrustFoundry credits are insufficient for this request.", + }); + } + + if (params.status === 429) { + const isQuota = remoteError === "Quota exceeded"; + return new TrustFoundryApiError({ + status: params.status, + code: isQuota ? "quota_exceeded" : "rate_limited", + requestId: params.requestId, + retryAfter: params.retryAfter, + message: [ + isQuota + ? "TrustFoundry quota is exhausted." + : "TrustFoundry rate limit exceeded.", + params.retryAfter + ? `Retry after ${params.retryAfter} seconds.` + : null, + ] + .filter(Boolean) + .join(" "), + }); + } + + return new TrustFoundryApiError({ + status: params.status, + code: "request_failed", + requestId: params.requestId, + message: + remoteError || + `TrustFoundry request failed with HTTP ${params.status}.`, + }); +} + +export function formatTrustFoundryToolError(err: unknown): string { + if (err instanceof TrustFoundryApiError) { + return JSON.stringify({ + error: { + code: err.code, + message: err.message, + status: err.status, + request_id: err.requestId, + retry_after: err.retryAfter, + }, + }); + } + return JSON.stringify({ + error: { + code: "trustfoundry_error", + message: err instanceof Error ? err.message : String(err), + }, + }); +} + +export function isTrustFoundryToolName(name: string): boolean { + return TRUSTFOUNDRY_TOOLS.some((tool) => tool.function.name === name); +} + +export function trustFoundryToolDisplayName(name: string): string | null { + return TRUSTFOUNDRY_TOOL_DISPLAY_NAMES[name] ?? null; +} + +export function trustFoundryEnabled(env = process.env): boolean { + return ["1", "true", "yes", "on"].includes( + (env.TRUSTFOUNDRY_ENABLED ?? "").trim().toLowerCase(), + ); +} + +export function trustFoundryApiKey(env = process.env): string | null { + return env.TRUSTFOUNDRY_API_KEY?.trim() || null; +} + + +export function buildTrustFoundryRequest(params: { + apiKey: string; + path: string; + init?: RequestInit; + env?: NodeJS.ProcessEnv; +}): { url: string; init: RequestInit } { + const init = params.init ?? {}; + const headers = new Headers(init.headers); + headers.set("X-API-Key", params.apiKey); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + return { + url: `${trustFoundryBaseUrl(params.env)}${params.path}`, + init: { + ...init, + headers, + }, + }; +} + +async function trustFoundryFetch( + apiKey: string, + path: string, + init: RequestInit = {}, +): Promise { + const request = buildTrustFoundryRequest({ apiKey, path, init }); + const response = await fetch(request.url, request.init); + + if (!response.ok) { + const bodyText = await response.text().catch(() => ""); + throw mapTrustFoundryError({ + status: response.status, + bodyText, + requestId: response.headers.get("X-Request-Id"), + retryAfter: response.headers.get("Retry-After"), + }); + } + + return response; +} + +async function readNdjsonResponse(response: Response): Promise { + if (!response.body) return []; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const events: unknown[] = []; + let rest = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const parsed = parseNdjsonChunk( + rest + decoder.decode(value, { stream: true }), + ); + events.push(...parsed.events); + rest = parsed.rest; + } + + const trailing = rest.trim(); + if (trailing) events.push(JSON.parse(trailing)); + return events; +} + +function searchSummary(events: unknown[]) { + const statuses: string[] = []; + let searchSet: unknown = null; + let confused: unknown = null; + + for (const event of events as Record[]) { + if (event.type === "citations_ready") searchSet = event.content; + if (event.type === "confused") confused = event.content; + if ( + (event.type === "thinking_delta" || + event.type === "search_start" || + event.type === "search_end") && + typeof event.content === "string" && + statuses.length < MAX_SEARCH_EVENTS + ) { + statuses.push(event.content); + } + if (event.type === "error") { + throw new TrustFoundryApiError({ + status: 502, + code: "stream_error", + message: + typeof event.content === "object" && + event.content && + "message" in event.content + ? String( + (event.content as { message?: unknown }).message, + ) + : "TrustFoundry search stream returned an error.", + }); + } + } + + return { + search_set: searchSet, + confused, + statuses, + event_count: events.length, + }; +} + +function stringArg( + args: Record, + name: string, + fallback = "", +): string { + const value = args[name]; + return typeof value === "string" && value.trim() + ? value.trim() + : fallback; +} + +function intArg( + args: Record, + name: string, +): number | undefined { + const value = args[name]; + return typeof value === "number" && Number.isFinite(value) + ? Math.trunc(value) + : undefined; +} + +export async function executeTrustFoundryTool(params: { + name: string; + args: Record; + apiKey: string; +}): Promise { + const { name, args, apiKey } = params; + + if (name === "trustfoundry_agentic_legal_search") { + const response = await trustFoundryFetch( + apiKey, + "/public/v1/agentic-search", + { + method: "POST", + body: JSON.stringify({ + query: stringArg(args, "query"), + default_state: stringArg(args, "default_state", "FED"), + }), + }, + ); + return JSON.stringify(searchSummary(await readNdjsonResponse(response))); + } + + if (name === "trustfoundry_direct_legal_search") { + const response = await trustFoundryFetch(apiKey, "/public/v1/search", { + method: "POST", + body: JSON.stringify({ + query: stringArg(args, "query"), + state: stringArg(args, "state", "FED"), + model_type: stringArg(args, "model_type"), + }), + }); + return JSON.stringify(searchSummary(await readNdjsonResponse(response))); + } + + if (name === "trustfoundry_get_search_results") { + const uuid = encodeURIComponent(stringArg(args, "uuid")); + const response = await trustFoundryFetch( + apiKey, + `/public/v1/search/results/${uuid}`, + ); + return JSON.stringify(await response.json()); + } + + if (name === "trustfoundry_describe_search_result") { + const uuid = encodeURIComponent(stringArg(args, "uuid")); + const fullText = args.full_text === true ? "?full_text=true" : ""; + const response = await trustFoundryFetch( + apiKey, + `/public/v1/search/results/items/describe/${uuid}${fullText}`, + ); + return JSON.stringify(await response.json()); + } + + if (name === "trustfoundry_validate_citations") { + const response = await trustFoundryFetch( + apiKey, + "/public/v1/citation/validate", + { + method: "POST", + body: JSON.stringify({ + text: stringArg(args, "text"), + context_before: intArg(args, "context_before") ?? 0, + context_after: intArg(args, "context_after") ?? 0, + }), + }, + ); + return JSON.stringify(await response.json()); + } + + if (name === "trustfoundry_get_usage") { + const response = await trustFoundryFetch(apiKey, "/public/v1/usage"); + return JSON.stringify(await response.json()); + } + + return JSON.stringify({ + error: { + code: "tool_not_found", + message: `TrustFoundry tool '${name}' is not available.`, + }, + }); +} + +export const trustFoundryIntegration: ToolIntegration = { + name: "trustfoundry", + isEnabled: () => trustFoundryEnabled() && !!trustFoundryApiKey(), + tools: () => TRUSTFOUNDRY_TOOLS, + canHandle: isTrustFoundryToolName, + displayName: trustFoundryToolDisplayName, + run: async (toolCall, args) => { + const apiKey = trustFoundryApiKey(); + if (!apiKey) { + return { + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify({ + error: { + code: "missing_api_key", + message: + "TrustFoundry is not configured for this Mike instance.", + }, + }), + }; + } + + try { + return { + role: "tool", + tool_call_id: toolCall.id, + content: await executeTrustFoundryTool({ + name: toolCall.function.name, + args, + apiKey, + }), + }; + } catch (err) { + return { + role: "tool", + tool_call_id: toolCall.id, + content: formatTrustFoundryToolError(err), + }; + } + }, +}; diff --git a/backend/src/lib/integrations/types.ts b/backend/src/lib/integrations/types.ts new file mode 100644 index 000000000..b91a4b08c --- /dev/null +++ b/backend/src/lib/integrations/types.ts @@ -0,0 +1,24 @@ +import type { OpenAIToolSchema } from "../llm"; + +export type IntegrationToolCall = { + id: string; + function: { name: string; arguments: string }; +}; + +export type IntegrationToolResult = { + role: "tool"; + tool_call_id: string; + content: string; +}; + +export type ToolIntegration = { + name: string; + isEnabled: () => boolean; + tools: () => OpenAIToolSchema[]; + canHandle: (toolName: string) => boolean; + displayName: (toolName: string) => string | null; + run: ( + toolCall: IntegrationToolCall, + args: Record, + ) => Promise; +}; diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 000000000..87e961869 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,20 @@ +# Optional Integrations + +This file documents optional third-party integrations that are not required to run Mike. Integrations are server-configured through `backend/.env` and are disabled unless explicitly enabled. + +## TrustFoundry + +TrustFoundry provides optional legal research and citation validation tools for assistant chats. It is not a model provider, and TrustFoundry API keys are not entered in the browser API key settings. + +To enable TrustFoundry for a Mike instance: + +1. Create an API key at `https://dashboard.trustfoundry.ai`. +2. Set the following values in `backend/.env`: + +```bash +TRUSTFOUNDRY_ENABLED=true +TRUSTFOUNDRY_API_KEY=your-trustfoundry-api-key +TRUSTFOUNDRY_API_BASE_URL=https://api.trustfoundry.ai +``` + +`TRUSTFOUNDRY_API_BASE_URL` is optional unless you need to point Mike at a different TrustFoundry API endpoint. diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index c4ce7d0f0..5657cc6e4 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -32,7 +32,8 @@ const RESPONSE_GLASS_SURFACE = const RESPONSE_GLASS_ANNOTATION = "inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950"; -function toolCallLabel(name: string): string { +function toolCallLabel(name: string, displayName?: string | null): string { + if (displayName) return `Running ${displayName}...`; if (name === "generate_docx") return "Creating document..."; if (name === "edit_document") return "Editing document..."; if (name === "read_document") return "Reading document..."; @@ -1914,7 +1915,7 @@ export function AssistantMessage({ )}
- {toolCallLabel(event.name)} + {toolCallLabel(event.name, event.displayName)}
); diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index 4e3a912ea..d9062bef4 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -90,6 +90,7 @@ export type AssistantEvent = | { type: "tool_call_start"; name: string; + displayName?: string | null; isStreaming?: boolean; } | { type: "thinking"; isStreaming?: boolean } diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index 69759a436..5338e3f3e 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -568,6 +568,10 @@ export function useAssistantChat({ pushEvent({ type: "tool_call_start", name: (data.name as string) ?? "", + displayName: + typeof data.displayName === "string" + ? data.displayName + : null, isStreaming: true, }); continue;