From 8f3c142791d5512c0d211a0bd4e9264cf1a88f4d Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 15 May 2026 16:04:08 +0200 Subject: [PATCH] feat: add ktx llm runtime port --- .../src/agent/agent-runner.service.test.ts | 9 +- .../context/src/agent/agent-runner.service.ts | 111 ++---------- packages/context/src/llm/ai-sdk-runtime.ts | 164 ++++++++++++++++++ packages/context/src/llm/generation.ts | 87 +--------- packages/context/src/llm/index.ts | 17 ++ packages/context/src/llm/runtime-port.ts | 75 ++++++++ .../context/src/llm/runtime-tools.test.ts | 43 +++++ packages/context/src/llm/runtime-tools.ts | 69 ++++++++ packages/context/src/tools/base-tool.ts | 19 ++ 9 files changed, 420 insertions(+), 174 deletions(-) create mode 100644 packages/context/src/llm/ai-sdk-runtime.ts create mode 100644 packages/context/src/llm/runtime-port.ts create mode 100644 packages/context/src/llm/runtime-tools.test.ts create mode 100644 packages/context/src/llm/runtime-tools.ts diff --git a/packages/context/src/agent/agent-runner.service.test.ts b/packages/context/src/agent/agent-runner.service.test.ts index 3208bda7..dea9e325 100644 --- a/packages/context/src/agent/agent-runner.service.test.ts +++ b/packages/context/src/agent/agent-runner.service.test.ts @@ -55,7 +55,14 @@ describe('AgentRunnerService.runLoop', () => { expect(call.system).toEqual({ role: 'system', content: 'SYS' }); expect(call.messages).toEqual([{ role: 'user', content: 'USR' }]); expect(call.prompt).toBeUndefined(); - expect(call.tools).toEqual(tools); + expect(call.tools.noop).toEqual( + expect.objectContaining({ + description: 'noop', + inputSchema: {}, + execute: expect.any(Function), + toModelOutput: expect.any(Function), + }), + ); expect(call.stopWhen).toBe(17); expect(call.temperature).toBe(0); expect(call.experimental_repairToolCall).toBe(repairHandler); diff --git a/packages/context/src/agent/agent-runner.service.ts b/packages/context/src/agent/agent-runner.service.ts index 128818f9..8e53079d 100644 --- a/packages/context/src/agent/agent-runner.service.ts +++ b/packages/context/src/agent/agent-runner.service.ts @@ -1,33 +1,15 @@ -import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider, type KtxModelRole } from '@ktx/llm'; -import { generateText, stepCountIs, type TelemetrySettings, type Tool } from 'ai'; -import { noopLogger, type KtxLogger } from '../core/index.js'; -import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from '../llm/index.js'; - -export type RunLoopStopReason = 'budget' | 'natural' | 'error'; - -export interface RunLoopStepInfo { - stepIndex: number; - stepBudget: number; -} - -export interface RunLoopParams { - modelRole: KtxModelRole; - systemPrompt: string; - userPrompt: string; - toolSet: Record; - stepBudget: number; - telemetryTags: Record; - onStepFinish?: (info: RunLoopStepInfo) => void | Promise; -} - -export interface RunLoopResult { - stopReason: RunLoopStopReason; - error?: Error; -} - -export interface AgentTelemetryPort { - createTelemetry(tags: Record): TelemetrySettings; -} +import type { KtxLlmProvider } from '@ktx/llm'; +import { AiSdkKtxLlmRuntime, type AgentTelemetryPort } from '../llm/ai-sdk-runtime.js'; +import type { KtxLlmDebugRequestRecorder } from '../llm/debug-request-recorder.js'; +import type { AgentRunnerPort, RunLoopParams, RunLoopResult } from '../llm/runtime-port.js'; +export type { + AgentRunnerPort, + RunLoopParams, + RunLoopResult, + RunLoopStepInfo, + RunLoopStopReason, +} from '../llm/runtime-port.js'; +export type { AgentTelemetryPort } from '../llm/ai-sdk-runtime.js'; export interface AgentRunnerServiceDeps { llmProvider: KtxLlmProvider; @@ -36,71 +18,14 @@ export interface AgentRunnerServiceDeps { logger?: KtxLogger; } -export class AgentRunnerService { - private readonly logger: KtxLogger; +export class AgentRunnerService implements AgentRunnerPort { + private readonly runtime: AiSdkKtxLlmRuntime; - constructor(private readonly deps: AgentRunnerServiceDeps) { - this.logger = deps.logger ?? noopLogger; + constructor(deps: AgentRunnerServiceDeps) { + this.runtime = new AiSdkKtxLlmRuntime(deps); } - async runLoop(params: RunLoopParams): Promise { - let stepIndex = 0; - try { - const model = this.deps.llmProvider.getModel(params.modelRole); - const builder = new KtxMessageBuilder(this.deps.llmProvider); - const built = builder.wrapSimple({ - system: params.systemPrompt, - messages: [{ role: 'user', content: params.userPrompt }], - tools: params.toolSet, - model, - }); - const promptMessages = splitKtxSystemMessages(built.messages); - - await this.deps.debugRequestRecorder?.record( - summarizeKtxLlmDebugRequest({ - operationName: params.telemetryTags.operationName ?? 'ktx-agent-runner', - source: params.telemetryTags.source, - jobId: params.telemetryTags.jobId, - unitKey: params.telemetryTags.unitKey, - modelRole: params.modelRole, - modelId: (model as { modelId?: string }).modelId ?? params.modelRole, - messages: built.messages, - tools: built.tools as Record, - }), - ); - - await generateText({ - model, - temperature: 0, - stopWhen: stepCountIs(params.stepBudget), - experimental_telemetry: this.deps.telemetry?.createTelemetry(params.telemetryTags), - experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ - source: params.telemetryTags.operationName ?? 'ktx-agent-runner', - }), - ...(promptMessages.system ? { system: promptMessages.system } : {}), - messages: promptMessages.messages, - tools: built.tools as Record, - onStepFinish: async () => { - stepIndex += 1; - if (!params.onStepFinish) { - return; - } - try { - await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget }); - } catch (err) { - this.logger.warn( - `[agent-runner] onStepFinish callback threw; ignoring: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - }, - }); - return { stopReason: 'natural' }; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.warn(`[agent-runner] loop failed: ${err.message}`); - return { stopReason: 'error', error: err }; - } + runLoop(params: RunLoopParams): Promise { + return this.runtime.runAgentLoop(params); } } diff --git a/packages/context/src/llm/ai-sdk-runtime.ts b/packages/context/src/llm/ai-sdk-runtime.ts new file mode 100644 index 00000000..56afaffd --- /dev/null +++ b/packages/context/src/llm/ai-sdk-runtime.ts @@ -0,0 +1,164 @@ +import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider } from '@ktx/llm'; +import { generateText, Output, stepCountIs, type FlexibleSchema, type TelemetrySettings } from 'ai'; +import type { z } from 'zod'; +import { noopLogger, type KtxLogger } from '../core/index.js'; +import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from './debug-request-recorder.js'; +import { createAiSdkToolSet } from './runtime-tools.js'; +import type { + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + RunLoopParams, + RunLoopResult, +} from './runtime-port.js'; + +export interface AgentTelemetryPort { + createTelemetry(tags: Record): TelemetrySettings; +} + +export interface AiSdkKtxLlmRuntimeDeps { + llmProvider: KtxLlmProvider; + telemetry?: AgentTelemetryPort; + logger?: KtxLogger; + debugRequestRecorder?: KtxLlmDebugRequestRecorder; +} + +function hasTools(tools: Record): boolean { + return Object.keys(tools).length > 0; +} + +export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { + private readonly logger: KtxLogger; + + constructor(private readonly deps: AiSdkKtxLlmRuntimeDeps) { + this.logger = deps.logger ?? noopLogger; + } + + async generateText(input: KtxGenerateTextInput): Promise { + const model = this.deps.llmProvider.getModel(input.role); + if ((model as { provider?: string }).provider === 'deterministic') { + return `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`; + } + const tools = createAiSdkToolSet(input.tools ?? {}); + const built = new KtxMessageBuilder(this.deps.llmProvider).wrapSimple({ + system: input.system, + messages: [{ role: 'user', content: input.prompt }], + tools, + model, + }); + const split = splitKtxSystemMessages(built.messages); + const result = await generateText({ + model, + temperature: input.temperature ?? 0, + ...(split.system ? { system: split.system } : {}), + messages: split.messages, + tools: built.tools, + ...(hasTools(tools) + ? { + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: `ktx-${input.role}`, + }), + } + : {}), + }); + if (typeof result.text !== 'string') { + throw new Error('KTX LLM text generation returned no text'); + } + return result.text; + } + + async generateObject>( + input: KtxGenerateObjectInput, + ): Promise { + const model = this.deps.llmProvider.getModel(input.role); + const tools = createAiSdkToolSet(input.tools ?? {}); + const built = new KtxMessageBuilder(this.deps.llmProvider).wrapSimple({ + system: input.system, + messages: [{ role: 'user', content: input.prompt }], + tools, + model, + }); + const split = splitKtxSystemMessages(built.messages); + const result = await generateText({ + model, + temperature: input.temperature ?? 0, + ...(split.system ? { system: split.system } : {}), + messages: split.messages, + tools: built.tools, + ...(hasTools(tools) + ? { + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: `ktx-${input.role}`, + }), + } + : {}), + output: Output.object({ schema: input.schema as unknown as FlexibleSchema }), + }); + if (result.output == null) { + throw new Error('KTX LLM object generation returned no output'); + } + return result.output as TOutput; + } + + async runAgentLoop(params: RunLoopParams): Promise { + let stepIndex = 0; + try { + const model = this.deps.llmProvider.getModel(params.modelRole); + const tools = createAiSdkToolSet(params.toolSet); + const builder = new KtxMessageBuilder(this.deps.llmProvider); + const built = builder.wrapSimple({ + system: params.systemPrompt, + messages: [{ role: 'user', content: params.userPrompt }], + tools, + model, + }); + const promptMessages = splitKtxSystemMessages(built.messages); + + await this.deps.debugRequestRecorder?.record( + summarizeKtxLlmDebugRequest({ + operationName: params.telemetryTags.operationName ?? 'ktx-agent-runner', + source: params.telemetryTags.source, + jobId: params.telemetryTags.jobId, + unitKey: params.telemetryTags.unitKey, + modelRole: params.modelRole, + modelId: (model as { modelId?: string }).modelId ?? params.modelRole, + messages: built.messages, + tools: built.tools as Record, + }), + ); + + await generateText({ + model, + temperature: 0, + stopWhen: stepCountIs(params.stepBudget), + experimental_telemetry: this.deps.telemetry?.createTelemetry(params.telemetryTags) ?? this.deps.llmProvider.telemetryConfig(), + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: params.telemetryTags.operationName ?? 'ktx-agent-runner', + }), + ...(promptMessages.system ? { system: promptMessages.system } : {}), + messages: promptMessages.messages, + tools: built.tools, + onStepFinish: async () => { + stepIndex += 1; + if (!params.onStepFinish) { + return; + } + try { + await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget }); + } catch (err) { + this.logger.warn( + `[agent-runner] onStepFinish callback threw; ignoring: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }, + }); + return { stopReason: 'natural' }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.warn(`[agent-runner] loop failed: ${err.message}`); + return { stopReason: 'error', error: err }; + } + } +} diff --git a/packages/context/src/llm/generation.ts b/packages/context/src/llm/generation.ts index 7cb11d58..91019a09 100644 --- a/packages/context/src/llm/generation.ts +++ b/packages/context/src/llm/generation.ts @@ -1,85 +1,12 @@ -import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider, type KtxModelRole } from '@ktx/llm'; -import { generateText, Output, type FlexibleSchema, type ToolSet } from 'ai'; +import type { z } from 'zod'; +import type { KtxGenerateObjectInput, KtxGenerateTextInput, KtxLlmRuntimePort } from './runtime-port.js'; -type GenerateTextInput = Parameters[0]; -type GenerateTextFn = (input: GenerateTextInput) => Promise<{ text?: string; output?: unknown }>; - -function hasTools(tools: ToolSet): boolean { - return Object.keys(tools).length > 0; +export async function generateKtxText(input: KtxGenerateTextInput & { runtime: KtxLlmRuntimePort }): Promise { + return input.runtime.generateText(input); } -interface GenerateKtxTextInput { - llmProvider: KtxLlmProvider; - role: KtxModelRole; - prompt: string; - system?: string; - tools?: ToolSet; - temperature?: number; - generateText?: GenerateTextFn; -} - -export async function generateKtxText(input: GenerateKtxTextInput): Promise { - const model = input.llmProvider.getModel(input.role); - if ((model as { provider?: string }).provider === 'deterministic') { - return `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`; - } - const built = new KtxMessageBuilder(input.llmProvider).wrapSimple({ - system: input.system, - messages: [{ role: 'user', content: input.prompt }], - tools: input.tools ?? {}, - model, - }); - const split = splitKtxSystemMessages(built.messages); - const result = await (input.generateText ?? generateText)({ - model, - temperature: input.temperature ?? 0, - ...(split.system ? { system: split.system } : {}), - messages: split.messages, - tools: built.tools as ToolSet, - ...(hasTools(built.tools as ToolSet) - ? { - experimental_repairToolCall: input.llmProvider.repairToolCallHandler({ - source: `ktx-${input.role}`, - }), - } - : {}), - }); - if (typeof result.text !== 'string') { - throw new Error('KTX LLM text generation returned no text'); - } - return result.text; -} - -export async function generateKtxObject( - input: GenerateKtxTextInput & { schema: TSchema }, +export async function generateKtxObject>( + input: KtxGenerateObjectInput & { runtime: KtxLlmRuntimePort }, ): Promise { - const model = input.llmProvider.getModel(input.role); - const built = new KtxMessageBuilder(input.llmProvider).wrapSimple({ - system: input.system, - messages: [{ role: 'user', content: input.prompt }], - tools: input.tools ?? {}, - model, - }); - const split = splitKtxSystemMessages(built.messages); - const result = await (input.generateText ?? generateText)({ - model, - temperature: input.temperature ?? 0, - ...(split.system ? { system: split.system } : {}), - messages: split.messages, - tools: built.tools as ToolSet, - ...(hasTools(built.tools as ToolSet) - ? { - experimental_repairToolCall: input.llmProvider.repairToolCallHandler({ - source: `ktx-${input.role}`, - }), - } - : {}), - output: Output.object({ - schema: input.schema as FlexibleSchema, - }), - }); - if (result.output == null) { - throw new Error('KTX LLM object generation returned no output'); - } - return result.output as TOutput; + return input.runtime.generateObject(input); } diff --git a/packages/context/src/llm/index.ts b/packages/context/src/llm/index.ts index c9f039b8..47a2f363 100644 --- a/packages/context/src/llm/index.ts +++ b/packages/context/src/llm/index.ts @@ -1,5 +1,22 @@ export { KtxIngestEmbeddingPortAdapter, KtxScanEmbeddingPortAdapter } from './embedding-port.js'; +export { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; +export type { AgentTelemetryPort, AiSdkKtxLlmRuntimeDeps } from './ai-sdk-runtime.js'; export { generateKtxObject, generateKtxText } from './generation.js'; +export type { + AgentRunnerPort, + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + KtxRuntimeToolDescriptor, + KtxRuntimeToolOutput, + KtxRuntimeToolSet, + RunLoopParams, + RunLoopResult, + RunLoopStepInfo, + RunLoopStopReason, +} from './runtime-port.js'; +export { RuntimeAgentRunner } from './runtime-port.js'; +export { createAiSdkToolSet, createClaudeSdkTools, normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; export type { KtxLlmDebugProviderOptionsEntry, KtxLlmDebugRequest, diff --git a/packages/context/src/llm/runtime-port.ts b/packages/context/src/llm/runtime-port.ts new file mode 100644 index 00000000..6fddfd80 --- /dev/null +++ b/packages/context/src/llm/runtime-port.ts @@ -0,0 +1,75 @@ +import type { KtxModelRole } from '@ktx/llm'; +import type { z } from 'zod'; + +export interface KtxRuntimeToolOutput { + markdown: string; + structured?: TOutput; +} + +export interface KtxRuntimeToolDescriptor { + name: string; + description: string; + inputSchema: z.ZodObject; + execute(input: TInput): Promise>; +} + +export type KtxRuntimeToolSet = Record; + +export type RunLoopStopReason = 'budget' | 'natural' | 'error'; + +export interface RunLoopStepInfo { + stepIndex: number; + stepBudget: number; +} + +export interface RunLoopParams { + modelRole: KtxModelRole; + systemPrompt: string; + userPrompt: string; + toolSet: KtxRuntimeToolSet; + stepBudget: number; + telemetryTags: Record; + onStepFinish?: (info: RunLoopStepInfo) => void | Promise; +} + +export interface RunLoopResult { + stopReason: RunLoopStopReason; + error?: Error; +} + +export interface KtxGenerateTextInput { + role: KtxModelRole; + prompt: string; + system?: string; + tools?: KtxRuntimeToolSet; + temperature?: number; +} + +export interface KtxGenerateObjectInput> { + role: KtxModelRole; + prompt: string; + system?: string; + tools?: KtxRuntimeToolSet; + temperature?: number; + schema: TSchema; +} + +export interface KtxLlmRuntimePort { + generateText(input: KtxGenerateTextInput): Promise; + generateObject>( + input: KtxGenerateObjectInput, + ): Promise; + runAgentLoop(params: RunLoopParams): Promise; +} + +export interface AgentRunnerPort { + runLoop(params: RunLoopParams): Promise; +} + +export class RuntimeAgentRunner implements AgentRunnerPort { + constructor(private readonly runtime: KtxLlmRuntimePort) {} + + runLoop(params: RunLoopParams): Promise { + return this.runtime.runAgentLoop(params); + } +} diff --git a/packages/context/src/llm/runtime-tools.test.ts b/packages/context/src/llm/runtime-tools.test.ts new file mode 100644 index 00000000..c1276d7d --- /dev/null +++ b/packages/context/src/llm/runtime-tools.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createAiSdkToolSet, createClaudeSdkTools, normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; +import type { KtxRuntimeToolDescriptor } from './runtime-port.js'; + +describe('runtime tool descriptors', () => { + const descriptor: KtxRuntimeToolDescriptor<{ id: string }, { ok: boolean }> = { + name: 'read_thing', + description: 'Read one thing.', + inputSchema: z.object({ id: z.string() }), + execute: vi.fn(async (input) => ({ + markdown: `Read ${input.id}`, + structured: { ok: true }, + })), + }; + + it('normalizes string and object tool outputs into markdown plus optional structured payload', () => { + expect(normalizeKtxRuntimeToolOutput('plain text')).toEqual({ markdown: 'plain text' }); + expect(normalizeKtxRuntimeToolOutput({ markdown: 'shown', structured: { id: 1 } })).toEqual({ + markdown: 'shown', + structured: { id: 1 }, + }); + expect(normalizeKtxRuntimeToolOutput({ name: 'skill', content: 'body' })).toEqual({ + markdown: '```json\n{\n "name": "skill",\n "content": "body"\n}\n```', + structured: { name: 'skill', content: 'body' }, + }); + }); + + it('builds AI SDK tools that expose markdown to the model', async () => { + const tools = createAiSdkToolSet({ read_thing: descriptor }); + const output = await tools.read_thing.execute?.({ id: 'a' }, { toolCallId: 'call-1', messages: [] } as never); + const modelOutput = tools.read_thing.toModelOutput?.({ output } as never); + + expect(modelOutput).toEqual({ type: 'text', value: 'Read a' }); + }); + + it('builds Claude SDK tools that return text content only', async () => { + const tools = createClaudeSdkTools({ read_thing: descriptor }); + const result = await tools[0].handler({ id: 'b' } as never, {}); + + expect(result).toEqual({ content: [{ type: 'text', text: 'Read b' }] }); + }); +}); diff --git a/packages/context/src/llm/runtime-tools.ts b/packages/context/src/llm/runtime-tools.ts new file mode 100644 index 00000000..1a0ebcbd --- /dev/null +++ b/packages/context/src/llm/runtime-tools.ts @@ -0,0 +1,69 @@ +import { tool as aiTool, type ToolSet } from 'ai'; +import { tool as claudeTool, type SdkMcpToolDefinition } from '@anthropic-ai/claude-agent-sdk'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { KtxRuntimeToolDescriptor, KtxRuntimeToolOutput, KtxRuntimeToolSet } from './runtime-port.js'; + +function isRuntimeOutput(value: unknown): value is KtxRuntimeToolOutput { + return Boolean( + value && + typeof value === 'object' && + 'markdown' in value && + typeof (value as { markdown?: unknown }).markdown === 'string', + ); +} + +export function normalizeKtxRuntimeToolOutput(value: unknown): KtxRuntimeToolOutput { + if (isRuntimeOutput(value)) { + return 'structured' in value ? { markdown: value.markdown, structured: value.structured } : { markdown: value.markdown }; + } + if (typeof value === 'string') { + return { markdown: value }; + } + return { + markdown: `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``, + structured: value, + }; +} + +function assertObjectSchema(name: string, schema: z.ZodType): asserts schema is z.ZodObject { + if (!(schema instanceof z.ZodObject)) { + throw new Error(`KTX runtime tool "${name}" must use z.object input schema for claude-code`); + } +} + +export function createAiSdkToolSet(tools: KtxRuntimeToolSet = {}): ToolSet { + return Object.fromEntries( + Object.entries(tools).map(([name, descriptor]) => [ + name, + aiTool({ + description: descriptor.description, + inputSchema: descriptor.inputSchema, + execute: async (input) => descriptor.execute(input), + toModelOutput: ({ output }) => { + const normalized = normalizeKtxRuntimeToolOutput(output); + return { type: 'text', value: normalized.markdown }; + }, + }), + ]), + ); +} + +export function createClaudeSdkTools(tools: KtxRuntimeToolSet = {}): Array> { + return Object.values(tools).map((descriptor) => { + assertObjectSchema(descriptor.name, descriptor.inputSchema); + return claudeTool( + descriptor.name, + descriptor.description, + descriptor.inputSchema.shape, + async (input): Promise => { + const normalized = normalizeKtxRuntimeToolOutput(await descriptor.execute(input)); + return { content: [{ type: 'text', text: normalized.markdown }] }; + }, + ); + }); +} + +export function mcpToolIds(tools: KtxRuntimeToolSet = {}): string[] { + return Object.keys(tools).map((name) => `mcp__ktx__${name}`); +} diff --git a/packages/context/src/tools/base-tool.ts b/packages/context/src/tools/base-tool.ts index 0566a0ca..5d3f359c 100644 --- a/packages/context/src/tools/base-tool.ts +++ b/packages/context/src/tools/base-tool.ts @@ -1,6 +1,8 @@ import { tool } from 'ai'; import { z, type ZodType } from 'zod'; import { noopLogger, type KtxLogger } from '../core/index.js'; +import type { KtxRuntimeToolDescriptor } from '../llm/runtime-port.js'; +import { normalizeKtxRuntimeToolOutput } from '../llm/runtime-tools.js'; import type { IngestToolMetadata, ToolSession } from './tool-session.js'; export interface ToolOutput { @@ -164,6 +166,23 @@ export abstract class BaseTool { }); } + toRuntimeTool(context: ToolContext): KtxRuntimeToolDescriptor { + const toolName = this.name; + return { + name: toolName, + description: this.description, + inputSchema: this.inputSchema 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); + return normalizeKtxRuntimeToolOutput(await this.call(parsedInput, callContext)); + }, + }; + } + parseInput(input: Record): z.infer { return this.inputSchema.parse(input); }