ktx/packages/context/src/tools/base-tool.ts

194 lines
5.9 KiB
TypeScript
Raw Normal View History

2026-05-10 23:12:26 +02:00
import { tool } from 'ai';
import { z, type ZodType } from 'zod';
2026-05-10 23:51:24 +02:00
import { noopLogger, type KtxLogger } from '../core/index.js';
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
import type { KtxRuntimeToolDescriptor } from '../llm/runtime-port.js';
import { normalizeKtxRuntimeToolOutput } from '../llm/runtime-tools.js';
2026-05-10 23:12:26 +02:00
import type { IngestToolMetadata, ToolSession } from './tool-session.js';
export interface ToolOutput<T = unknown> {
markdown: string;
structured: T;
}
export interface ToolTimingTrackerPort {
recordToolExecutionStart(messageId: string, toolName: string, toolCallId: string): void;
recordToolExecutionEnd(messageId: string, toolName: string, toolCallId: string, state: string): void;
}
export interface ToolProgressRelayPort {
emit(event: unknown): void;
}
type ChatSource =
| 'RESEARCH'
| 'DASHBOARD'
| 'WIDGET_CONFIG'
| 'EVALUATION'
| 'METRIC_WORKSHOP'
| 'INPUT_CONFIG'
| 'SCHEDULED_RESEARCH'
| 'DASHBOARD_GENERATION';
export interface ToolContext {
sourceId: string;
messageId: string;
userId: string;
userRoles?: string[];
authToken?: string;
currentUserMessage?: string;
toolCallId?: string;
toolCallHistory?: string[];
timingTracker?: ToolTimingTrackerPort;
source?: ChatSource;
dashboardId?: string;
methodologyEntries?: MethodologyEntry[];
progressRelay?: ToolProgressRelayPort;
connectionId?: string;
ingest?: IngestToolMetadata;
/**
* Per-session state (ingest WU, memory-agent post-turn). When present, SL/wiki
* tools use session-scoped services and emit touched-set entries instead of
* writing to shared indexes immediately. Non-session callers leave this unset.
*/
session?: ToolSession;
currentDefinition?: {
sql: string;
measures: unknown[];
dimensions: unknown[];
parameters: unknown[];
segments: unknown[];
name?: string;
description?: string;
};
}
export interface MethodologyEntry {
key: string;
toolName: string;
label: string;
args: Record<string, unknown>;
result?: unknown;
}
/**
* SECURITY: All tools require authentication. userId must always be provided in ToolContext.
*/
export abstract class BaseTool<TInput extends ZodType = ZodType> {
2026-05-10 23:51:24 +02:00
protected readonly logger: KtxLogger;
2026-05-10 23:12:26 +02:00
abstract readonly name: string;
2026-05-10 23:51:24 +02:00
constructor(logger: KtxLogger = noopLogger) {
2026-05-10 23:12:26 +02:00
this.logger = logger;
}
abstract get description(): string;
abstract get inputSchema(): TInput;
abstract call(input: z.infer<TInput>, context: ToolContext): Promise<any>;
getParametersSchema(): {
type: 'object';
properties: Record<string, any>;
required?: string[];
} {
const jsonSchema = z.toJSONSchema(this.inputSchema, {
target: 'draft-7',
});
return jsonSchema as any;
}
toAnthropicFormat(): {
name: string;
description: string;
input_schema: {
type: 'object';
properties: Record<string, any>;
required?: string[];
};
} {
return {
name: this.name,
description: this.description,
input_schema: this.getParametersSchema(),
};
}
toAiSdkTool(context: ToolContext): any {
const toolName = this.name;
const logger = this.logger;
return tool({
description: this.description,
inputSchema: this.inputSchema,
execute: async (params, { toolCallId }) => {
// Create context copy with current toolCallId (safe for parallel execution)
const callContext = { ...context, toolCallId };
// Record tool execution start (input generation has already been tracked via onChunk)
if (callContext.timingTracker && toolCallId) {
callContext.timingTracker.recordToolExecutionStart(callContext.messageId, toolName, toolCallId);
}
let state = 'completed';
try {
if (!callContext.userId) {
throw new Error('Authentication required: userId must be provided in ToolContext');
}
const parsedInput = this.parseInput(params as Record<string, any>);
const result = await this.call(parsedInput, callContext);
return result;
} catch (error) {
state = 'error';
this.logger.error(
`Tool ${this.name} execution failed: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
} finally {
// Record tool execution end
if (callContext.timingTracker && toolCallId) {
callContext.timingTracker.recordToolExecutionEnd(callContext.messageId, toolName, toolCallId, state);
}
}
},
// Send only markdown to the LLM; tool callers still receive the structured output.
2026-05-10 23:12:26 +02:00
toModelOutput: ({ output }) => {
if (output && typeof output === 'object' && 'markdown' in output) {
return { type: 'content', value: [{ type: 'text', text: output.markdown as string }] };
}
if (typeof output !== 'string') {
logger.warn(`Tool ${toolName} returned unexpected output type: ${typeof output}. Coercing to string.`);
}
return { type: 'content', value: [{ type: 'text', text: String(output) }] };
},
});
}
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
toRuntimeTool(context: ToolContext): KtxRuntimeToolDescriptor {
const toolName = this.name;
return {
name: toolName,
description: this.description,
inputSchema: this.inputSchema as unknown as KtxRuntimeToolDescriptor['inputSchema'],
execute: async (params) => {
const callContext = { ...context };
if (!callContext.userId) {
throw new Error('Authentication required: userId must be provided in ToolContext');
}
const parsedInput = this.parseInput(params as Record<string, any>);
return normalizeKtxRuntimeToolOutput(await this.call(parsedInput, callContext));
},
};
}
2026-05-10 23:12:26 +02:00
parseInput(input: Record<string, any>): z.infer<TInput> {
return this.inputSchema.parse(input);
}
protected getCurrentUserQuery(context: ToolContext): string | null {
return context.currentUserMessage ?? null;
}
}