mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat: add backend-neutral agent tools
This commit is contained in:
parent
79369fea6c
commit
73d0f91d3c
8 changed files with 123 additions and 33 deletions
39
packages/context/src/agent/agent-tool.test.ts
Normal file
39
packages/context/src/agent/agent-tool.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { createAgentTool, toAiSdkTool, toAiSdkToolSet } from './agent-tool.js';
|
||||
|
||||
describe('agent tools', () => {
|
||||
it('converts an agent tool to an AI SDK tool and preserves markdown output', async () => {
|
||||
const execute = vi.fn(async (input: { name: string }) => ({
|
||||
markdown: `hello ${input.name}`,
|
||||
structured: { ok: true },
|
||||
}));
|
||||
const agentTool = createAgentTool({
|
||||
name: 'greet',
|
||||
description: 'Greet someone',
|
||||
inputSchema: z.object({ name: z.string() }),
|
||||
execute,
|
||||
});
|
||||
|
||||
const aiTool = toAiSdkTool(agentTool);
|
||||
const output = await aiTool.execute?.({ name: 'Ada' }, { toolCallId: 'call-1', messages: [] } as never);
|
||||
const modelOutput = aiTool.toModelOutput?.({ output } as never);
|
||||
|
||||
expect(execute).toHaveBeenCalledWith({ name: 'Ada' }, { toolCallId: 'call-1' });
|
||||
expect(modelOutput).toEqual({ type: 'content', value: [{ type: 'text', text: 'hello Ada' }] });
|
||||
});
|
||||
|
||||
it('converts a named map of agent tools to an AI SDK tool set', () => {
|
||||
const toolSet = toAiSdkToolSet({
|
||||
ping: createAgentTool({
|
||||
name: 'ping',
|
||||
description: 'Ping',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => 'pong',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(Object.keys(toolSet)).toEqual(['ping']);
|
||||
expect(toolSet.ping?.description).toBe('Ping');
|
||||
});
|
||||
});
|
||||
61
packages/context/src/agent/agent-tool.ts
Normal file
61
packages/context/src/agent/agent-tool.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { tool as aiTool, type Tool, type ToolSet } from 'ai';
|
||||
import { z, type ZodObject, type ZodRawShape } from 'zod';
|
||||
|
||||
export interface AgentToolCallOptions {
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
export type AgentToolOutput = string | { markdown: string; structured?: unknown };
|
||||
|
||||
export interface AgentToolDefinition<TInputSchema extends ZodObject<ZodRawShape> = ZodObject<ZodRawShape>> {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: TInputSchema;
|
||||
execute(input: z.infer<TInputSchema>, options: AgentToolCallOptions): Promise<AgentToolOutput>;
|
||||
}
|
||||
|
||||
export type AgentToolSet = Record<string, AgentToolDefinition>;
|
||||
|
||||
export function createAgentTool<TInputSchema extends ZodObject<ZodRawShape>>(
|
||||
definition: AgentToolDefinition<TInputSchema>,
|
||||
): AgentToolDefinition<TInputSchema> {
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function assertAgentToolSet(toolSet: AgentToolSet): void {
|
||||
for (const [name, definition] of Object.entries(toolSet)) {
|
||||
if (definition.name !== name) {
|
||||
throw new Error(`Agent tool map key "${name}" does not match definition name "${definition.name}"`);
|
||||
}
|
||||
if (!(definition.inputSchema instanceof z.ZodObject)) {
|
||||
throw new Error(`Agent tool "${name}" must use a Zod object input schema`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function agentToolOutputToText(output: AgentToolOutput): string {
|
||||
if (output && typeof output === 'object' && 'markdown' in output) {
|
||||
return output.markdown;
|
||||
}
|
||||
return String(output);
|
||||
}
|
||||
|
||||
export function toAiSdkTool(definition: AgentToolDefinition): Tool {
|
||||
return aiTool({
|
||||
description: definition.description,
|
||||
inputSchema: definition.inputSchema,
|
||||
execute: async (params, options) =>
|
||||
definition.execute(definition.inputSchema.parse(params), {
|
||||
...(options.toolCallId ? { toolCallId: options.toolCallId } : {}),
|
||||
}),
|
||||
toModelOutput: ({ output }) => ({
|
||||
type: 'content',
|
||||
value: [{ type: 'text', text: agentToolOutputToText(output as AgentToolOutput) }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function toAiSdkToolSet(toolSet: AgentToolSet): ToolSet {
|
||||
assertAgentToolSet(toolSet);
|
||||
return Object.fromEntries(Object.entries(toolSet).map(([name, definition]) => [name, toAiSdkTool(definition)]));
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export type { AgentToolCallOptions, AgentToolDefinition, AgentToolOutput, AgentToolSet } from './agent-tool.js';
|
||||
export { agentToolOutputToText, assertAgentToolSet, createAgentTool, toAiSdkTool, toAiSdkToolSet } from './agent-tool.js';
|
||||
export type {
|
||||
AgentRunnerServiceDeps,
|
||||
AgentTelemetryPort,
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import { mkdirSync } from 'node:fs';
|
|||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { KtxLlmProvider } from '@ktx/llm';
|
||||
import type { Tool } from 'ai';
|
||||
import YAML from 'yaml';
|
||||
import type { AgentRunnerService } from '../agent/index.js';
|
||||
import type { AgentRunnerService, AgentToolSet } from '../agent/index.js';
|
||||
import { AgentRunnerService as DefaultAgentRunnerService } from '../agent/index.js';
|
||||
import { localConnectionInfoFromConfig, type KtxSqlQueryExecutorPort } from '../connections/index.js';
|
||||
import type { KtxEmbeddingPort, KtxLogger } from '../core/index.js';
|
||||
|
|
@ -456,12 +455,12 @@ class NoopKnowledgeEventPort implements KnowledgeEventPort {
|
|||
class LocalIngestToolSet implements IngestToolsetLike {
|
||||
constructor(
|
||||
private readonly tools: BaseTool[],
|
||||
private readonly sourceTools: Record<string, Tool> = {},
|
||||
private readonly sourceTools: AgentToolSet = {},
|
||||
) {}
|
||||
|
||||
toAiSdkTools(context: ToolContext) {
|
||||
toAgentTools(context: ToolContext) {
|
||||
return {
|
||||
...Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toAiSdkTool(context)])),
|
||||
...Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toAgentTool(context)])),
|
||||
...this.sourceTools,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { ToolSet } from 'ai';
|
||||
import type { KtxModelRole } from '@ktx/llm';
|
||||
import type { AgentRunnerService } from '../agent/index.js';
|
||||
import type { AgentRunnerService, AgentToolSet } from '../agent/index.js';
|
||||
import type { KtxEmbeddingPort } from '../core/embedding.js';
|
||||
import type { GitService, KtxFileStorePort, KtxLogger, SessionOutcome } from '../core/index.js';
|
||||
import type { CaptureSession, MemoryAction, MemoryKnowledgeSlRefsPort } from '../memory/index.js';
|
||||
|
|
@ -163,7 +162,7 @@ export interface IngestCommitMessagePort {
|
|||
}
|
||||
|
||||
export interface IngestToolsetLike {
|
||||
toAiSdkTools(context: ToolContext): ToolSet;
|
||||
toAgentTools(context: ToolContext): AgentToolSet;
|
||||
}
|
||||
|
||||
export interface IngestToolsetFactoryPort {
|
||||
|
|
|
|||
|
|
@ -386,8 +386,8 @@ class LocalShapeOnlySlValidator implements SlValidatorPort<SlValidationDeps> {
|
|||
class LocalMemoryToolSet implements MemoryToolSetLike {
|
||||
constructor(private readonly tools: BaseTool[]) {}
|
||||
|
||||
toAiSdkTools(context: ToolContext) {
|
||||
return Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toAiSdkTool(context)]));
|
||||
toAgentTools(context: ToolContext) {
|
||||
return Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toAgentTool(context)]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Tool } from 'ai';
|
||||
import type { AgentRunnerService } from '../agent/index.js';
|
||||
import type { AgentRunnerService, AgentToolSet } from '../agent/index.js';
|
||||
import type { GitService, KtxFileStorePort, KtxLogger, SessionWorktreeService } from '../core/index.js';
|
||||
import type { PromptService } from '../prompts/index.js';
|
||||
import type { SkillsRegistryService } from '../skills/index.js';
|
||||
|
|
@ -118,7 +117,7 @@ export interface MemoryCommitMessagePort {
|
|||
export interface MemoryFileStorePort extends KtxFileStorePort<MemoryFileStorePort>, MemoryCommitMessagePort {}
|
||||
|
||||
export interface MemoryToolSetLike {
|
||||
toAiSdkTools(context: ToolContext): Record<string, Tool>;
|
||||
toAgentTools(context: ToolContext): AgentToolSet;
|
||||
}
|
||||
|
||||
export interface MemoryToolsetFactoryPort {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { tool } from 'ai';
|
||||
import { z, type ZodType } from 'zod';
|
||||
import { createAgentTool, toAiSdkTool, type AgentToolDefinition } from '../agent/agent-tool.js';
|
||||
import { noopLogger, type KtxLogger } from '../core/index.js';
|
||||
import type { IngestToolMetadata, ToolSession } from './tool-session.js';
|
||||
|
||||
|
|
@ -114,18 +114,16 @@ export abstract class BaseTool<TInput extends ZodType = ZodType> {
|
|||
};
|
||||
}
|
||||
|
||||
toAiSdkTool(context: ToolContext): any {
|
||||
toAgentTool(context: ToolContext): AgentToolDefinition<any> {
|
||||
const toolName = this.name;
|
||||
const logger = this.logger;
|
||||
|
||||
return tool({
|
||||
return createAgentTool({
|
||||
name: toolName,
|
||||
description: this.description,
|
||||
inputSchema: this.inputSchema,
|
||||
inputSchema: this.inputSchema as any,
|
||||
execute: async (params, { toolCallId }) => {
|
||||
// Create context copy with current toolCallId (safe for parallel execution)
|
||||
const callContext = { ...context, toolCallId };
|
||||
const callContext = { ...context, ...(toolCallId ? { toolCallId } : {}) };
|
||||
|
||||
// Record tool execution start (input generation has already been tracked via onChunk)
|
||||
if (callContext.timingTracker && toolCallId) {
|
||||
callContext.timingTracker.recordToolExecutionStart(callContext.messageId, toolName, toolCallId);
|
||||
}
|
||||
|
|
@ -136,8 +134,7 @@ export abstract class BaseTool<TInput extends ZodType = ZodType> {
|
|||
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;
|
||||
return await this.call(parsedInput, callContext);
|
||||
} catch (error) {
|
||||
state = 'error';
|
||||
this.logger.error(
|
||||
|
|
@ -151,19 +148,13 @@ export abstract class BaseTool<TInput extends ZodType = ZodType> {
|
|||
}
|
||||
}
|
||||
},
|
||||
// Send only markdown to the LLM; tool callers still receive the structured output.
|
||||
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) }] };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toAiSdkTool(context: ToolContext): any {
|
||||
return toAiSdkTool(this.toAgentTool(context));
|
||||
}
|
||||
|
||||
parseInput(input: Record<string, any>): z.infer<TInput> {
|
||||
return this.inputSchema.parse(input);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue