mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
Address overengineering audit findings across cli/context/connector packages: - F1 Snowflake `query`: drop bare catch that flattened all errors to empty result - F2 memory-agent: treat LLM `stopReason === 'error'` as crash (skip squash-merge) - F3 WikiSearchTool: description honest about token-only fallback vs sqlite-fts5 hybrid - F5 Scan enrichment provider resolution: return discriminated status and surface distinct `llm_unavailable` / `embedding_unavailable` warnings per failure mode - F6 Relationship validation budget: drop dead `tableCount === undefined → 'all'` branch; update tests to pass `tableCount` like production - F8 `ktx sql`: use canonical `resolveOutputMode` (now honors KTX_OUTPUT/CI/TTY) - F9 MCP stdio server: default `protocolIo.stderr` to `process.stderr` so memory_ingest startup failures are visible - F13/F14 Scan/setup JSON readers: distinguish ENOENT from corruption instead of silently treating both as missing - F15 `createKtxCliScanConnector`: throw config-shape error when driver matches but type guard rejects, instead of "no native connector" - F16 ContextEvidenceSearchTool: surface `embedding_unhealthy:<reason>` instead of silently dropping the semantic lane - F17 PromptService: default partials to `[]` (removes stale `clinical_policy` reference from a prior product) - F20 `contextBuildCommands`: drop unused `runId` parameter Dead-code removal: - F4 Delete `AgentRunnerService` (duplicated `RuntimeAgentRunner`, only test-used); migrate tests to exercise `AiSdkKtxLlmRuntime.runAgentLoop` directly - F7 Delete `KtxScanOrchestrator` and its test (no production callers; the inline pipeline in `runLocalScan` is the single source of truth) - F18 Delete `generateKtxText`/`generateKtxObject` pass-through helpers; inline the single `runtime.generateObject` call at its caller Plus a clarifying comment on the SQLite `resolveStringReference` `file:` carve-out (load-bearing for SQLite URI form, not a bug).
148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
import { z } from 'zod';
|
|
import type { KtxEmbeddingPort } from '../core/index.js';
|
|
import { BaseTool, type ToolContext, type ToolOutput } from './base-tool.js';
|
|
import type { ContextEvidenceToolStorePort } from './context-evidence-tool-store.js';
|
|
import { ingestMetadataRequired, resolveIngestMetadata, type ToolFailure } from './context-ingest-metadata.js';
|
|
|
|
const contextEvidenceSearchInputSchema = z.object({
|
|
query: z.string().min(1),
|
|
connectionId: z.string().uuid().optional(),
|
|
sourceKey: z.string().min(1).optional(),
|
|
limit: z.number().int().min(1).max(25).default(10),
|
|
includeDeleted: z.boolean().default(false),
|
|
});
|
|
|
|
type ContextEvidenceSearchInput = z.infer<typeof contextEvidenceSearchInputSchema>;
|
|
|
|
interface ContextEvidenceSearchStructured {
|
|
success: true;
|
|
results: Array<{
|
|
chunkId: string;
|
|
documentId: string;
|
|
externalId: string;
|
|
title: string;
|
|
path: string;
|
|
url: string | null;
|
|
snippet: string;
|
|
score: number;
|
|
matchReasons?: string[];
|
|
lanes?: Array<{
|
|
lane: string;
|
|
status: 'available' | 'skipped' | 'failed';
|
|
requestedCandidatePoolLimit: number;
|
|
effectiveCandidatePoolLimit: number;
|
|
returnedCandidateCount: number;
|
|
weight: number;
|
|
reason?: string;
|
|
}>;
|
|
citation: unknown;
|
|
stableCitationKey: string;
|
|
syncId: string;
|
|
lastEditedAt: string | null;
|
|
}>;
|
|
totalFound: number;
|
|
}
|
|
|
|
export class ContextEvidenceSearchTool extends BaseTool<typeof contextEvidenceSearchInputSchema> {
|
|
readonly name = 'context_evidence_search';
|
|
|
|
constructor(
|
|
private readonly store: ContextEvidenceToolStorePort,
|
|
private readonly embeddingService: Pick<KtxEmbeddingPort, 'computeEmbedding'>,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
get description(): string {
|
|
return (
|
|
'Search the internal context evidence index for the current ingest source. ' +
|
|
'Use this to research indexed evidence before writing candidates or curating wiki knowledge.'
|
|
);
|
|
}
|
|
|
|
get inputSchema() {
|
|
return contextEvidenceSearchInputSchema;
|
|
}
|
|
|
|
async call(
|
|
input: ContextEvidenceSearchInput,
|
|
context: ToolContext,
|
|
): Promise<ToolOutput<ContextEvidenceSearchStructured | ToolFailure>> {
|
|
const ingest = resolveIngestMetadata(context);
|
|
if (!ingest) {
|
|
return ingestMetadataRequired();
|
|
}
|
|
|
|
let queryEmbedding: number[] | null = null;
|
|
let embeddingUnhealthyReason: string | null = null;
|
|
try {
|
|
queryEmbedding = await this.embeddingService.computeEmbedding(input.query);
|
|
} catch (error) {
|
|
queryEmbedding = null;
|
|
embeddingUnhealthyReason = error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
const connectionId = input.connectionId ?? context.connectionId ?? context.session?.connectionId;
|
|
if (!connectionId) {
|
|
return {
|
|
markdown: 'Error: no connectionId is available for context evidence search.',
|
|
structured: {
|
|
success: false,
|
|
error: 'CONNECTION_REQUIRED',
|
|
message: 'Provide connectionId or run this inside an ingest session with a connectionId.',
|
|
},
|
|
};
|
|
}
|
|
|
|
const results = await this.store.searchRRF({
|
|
connectionId,
|
|
sourceKey: input.sourceKey ?? ingest.sourceKey,
|
|
queryEmbedding,
|
|
queryText: input.query,
|
|
limit: input.limit,
|
|
includeDeleted: input.includeDeleted,
|
|
currentRunId: ingest.runId,
|
|
});
|
|
|
|
const embeddingHealthSuffix = embeddingUnhealthyReason
|
|
? ` (semantic lane skipped: embedding_unhealthy:${embeddingUnhealthyReason})`
|
|
: '';
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
markdown: `No context evidence found for "${input.query}"${embeddingHealthSuffix}.`,
|
|
structured: { success: true, results: [], totalFound: 0 },
|
|
};
|
|
}
|
|
|
|
return {
|
|
markdown: [
|
|
`Found ${results.length} evidence chunk(s)${embeddingHealthSuffix}:`,
|
|
'',
|
|
...results.map((result, index) => {
|
|
const reasonLine =
|
|
result.matchReasons && result.matchReasons.length > 0
|
|
? ` matchReasons: ${result.matchReasons.join(', ')}\n`
|
|
: '';
|
|
return (
|
|
`${index + 1}. **${result.title}** (${result.path})\n` +
|
|
` chunkId: ${result.chunkId}\n` +
|
|
` stableCitationKey: ${result.stableCitationKey}\n` +
|
|
reasonLine +
|
|
` snippet: ${result.snippet}`
|
|
);
|
|
}),
|
|
].join('\n'),
|
|
structured: {
|
|
success: true,
|
|
totalFound: results.length,
|
|
results: results.map((result) => ({
|
|
...result,
|
|
...(result.matchReasons ? { matchReasons: result.matchReasons } : {}),
|
|
...(result.lanes ? { lanes: result.lanes } : {}),
|
|
lastEditedAt: result.lastEditedAt?.toISOString() ?? null,
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
}
|