mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat: add ktx llm runtime port
This commit is contained in:
parent
4af3a6f20b
commit
8f3c142791
9 changed files with 420 additions and 174 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, Tool>;
|
||||
stepBudget: number;
|
||||
telemetryTags: Record<string, string>;
|
||||
onStepFinish?: (info: RunLoopStepInfo) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunLoopResult {
|
||||
stopReason: RunLoopStopReason;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface AgentTelemetryPort {
|
||||
createTelemetry(tags: Record<string, string>): 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<RunLoopResult> {
|
||||
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<string, { providerOptions?: unknown }>,
|
||||
}),
|
||||
);
|
||||
|
||||
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<string, Tool>,
|
||||
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<RunLoopResult> {
|
||||
return this.runtime.runAgentLoop(params);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
164
packages/context/src/llm/ai-sdk-runtime.ts
Normal file
164
packages/context/src/llm/ai-sdk-runtime.ts
Normal file
|
|
@ -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<string, string>): TelemetrySettings;
|
||||
}
|
||||
|
||||
export interface AiSdkKtxLlmRuntimeDeps {
|
||||
llmProvider: KtxLlmProvider;
|
||||
telemetry?: AgentTelemetryPort;
|
||||
logger?: KtxLogger;
|
||||
debugRequestRecorder?: KtxLlmDebugRequestRecorder;
|
||||
}
|
||||
|
||||
function hasTools(tools: Record<string, unknown>): 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<string> {
|
||||
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<TOutput, TSchema extends z.ZodType<TOutput>>(
|
||||
input: KtxGenerateObjectInput<TOutput, TSchema>,
|
||||
): Promise<TOutput> {
|
||||
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<TOutput> }),
|
||||
});
|
||||
if (result.output == null) {
|
||||
throw new Error('KTX LLM object generation returned no output');
|
||||
}
|
||||
return result.output as TOutput;
|
||||
}
|
||||
|
||||
async runAgentLoop(params: RunLoopParams): Promise<RunLoopResult> {
|
||||
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<string, { providerOptions?: unknown }>,
|
||||
}),
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof generateText>[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<string> {
|
||||
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<string> {
|
||||
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<TOutput, TSchema>(
|
||||
input: GenerateKtxTextInput & { schema: TSchema },
|
||||
export async function generateKtxObject<TOutput, TSchema extends z.ZodType<TOutput>>(
|
||||
input: KtxGenerateObjectInput<TOutput, TSchema> & { runtime: KtxLlmRuntimePort },
|
||||
): Promise<TOutput> {
|
||||
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<TOutput>,
|
||||
}),
|
||||
});
|
||||
if (result.output == null) {
|
||||
throw new Error('KTX LLM object generation returned no output');
|
||||
}
|
||||
return result.output as TOutput;
|
||||
return input.runtime.generateObject(input);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
75
packages/context/src/llm/runtime-port.ts
Normal file
75
packages/context/src/llm/runtime-port.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { KtxModelRole } from '@ktx/llm';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export interface KtxRuntimeToolOutput<TOutput = unknown> {
|
||||
markdown: string;
|
||||
structured?: TOutput;
|
||||
}
|
||||
|
||||
export interface KtxRuntimeToolDescriptor<TInput = unknown, TOutput = unknown> {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: z.ZodObject<z.ZodRawShape>;
|
||||
execute(input: TInput): Promise<KtxRuntimeToolOutput<TOutput>>;
|
||||
}
|
||||
|
||||
export type KtxRuntimeToolSet = Record<string, KtxRuntimeToolDescriptor>;
|
||||
|
||||
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<string, string>;
|
||||
onStepFinish?: (info: RunLoopStepInfo) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunLoopResult {
|
||||
stopReason: RunLoopStopReason;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface KtxGenerateTextInput {
|
||||
role: KtxModelRole;
|
||||
prompt: string;
|
||||
system?: string;
|
||||
tools?: KtxRuntimeToolSet;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface KtxGenerateObjectInput<TOutput, TSchema extends z.ZodType<TOutput>> {
|
||||
role: KtxModelRole;
|
||||
prompt: string;
|
||||
system?: string;
|
||||
tools?: KtxRuntimeToolSet;
|
||||
temperature?: number;
|
||||
schema: TSchema;
|
||||
}
|
||||
|
||||
export interface KtxLlmRuntimePort {
|
||||
generateText(input: KtxGenerateTextInput): Promise<string>;
|
||||
generateObject<TOutput, TSchema extends z.ZodType<TOutput>>(
|
||||
input: KtxGenerateObjectInput<TOutput, TSchema>,
|
||||
): Promise<TOutput>;
|
||||
runAgentLoop(params: RunLoopParams): Promise<RunLoopResult>;
|
||||
}
|
||||
|
||||
export interface AgentRunnerPort {
|
||||
runLoop(params: RunLoopParams): Promise<RunLoopResult>;
|
||||
}
|
||||
|
||||
export class RuntimeAgentRunner implements AgentRunnerPort {
|
||||
constructor(private readonly runtime: KtxLlmRuntimePort) {}
|
||||
|
||||
runLoop(params: RunLoopParams): Promise<RunLoopResult> {
|
||||
return this.runtime.runAgentLoop(params);
|
||||
}
|
||||
}
|
||||
43
packages/context/src/llm/runtime-tools.test.ts
Normal file
43
packages/context/src/llm/runtime-tools.test.ts
Normal file
|
|
@ -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' }] });
|
||||
});
|
||||
});
|
||||
69
packages/context/src/llm/runtime-tools.ts
Normal file
69
packages/context/src/llm/runtime-tools.ts
Normal file
|
|
@ -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<z.ZodRawShape> {
|
||||
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<SdkMcpToolDefinition<z.ZodRawShape>> {
|
||||
return Object.values(tools).map((descriptor) => {
|
||||
assertObjectSchema(descriptor.name, descriptor.inputSchema);
|
||||
return claudeTool(
|
||||
descriptor.name,
|
||||
descriptor.description,
|
||||
descriptor.inputSchema.shape,
|
||||
async (input): Promise<CallToolResult> => {
|
||||
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}`);
|
||||
}
|
||||
|
|
@ -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<T = unknown> {
|
||||
|
|
@ -164,6 +166,23 @@ export abstract class BaseTool<TInput extends ZodType = ZodType> {
|
|||
});
|
||||
}
|
||||
|
||||
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<string, any>);
|
||||
return normalizeKtxRuntimeToolOutput(await this.call(parsedInput, callContext));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
parseInput(input: Record<string, any>): z.infer<TInput> {
|
||||
return this.inputSchema.parse(input);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue