feat: add agent runner port

This commit is contained in:
Andrey Avtomonov 2026-05-15 12:55:46 +02:00
parent 43e6822996
commit a66e68a653
11 changed files with 73 additions and 29 deletions

View file

@ -7,6 +7,8 @@ vi.mock('ai', () => ({
}));
import { generateText } from 'ai';
import { z } from 'zod';
import { createAgentTool } from './agent-tool.js';
import { AgentRunnerService, type RunLoopStepInfo } from './agent-runner.service.js';
describe('AgentRunnerService.runLoop', () => {
@ -42,7 +44,14 @@ describe('AgentRunnerService.runLoop', () => {
(generateText as any).mockResolvedValue({ text: 'ok', toolCalls: [], steps: [] });
const repairHandler = vi.fn();
llmProvider.repairToolCallHandler.mockReturnValueOnce(repairHandler);
const tools = { noop: { description: 'noop', inputSchema: {}, execute: vi.fn() } };
const tools = {
noop: createAgentTool({
name: 'noop',
description: 'noop',
inputSchema: z.object({}),
execute: vi.fn(async () => 'ok'),
}),
};
await runner.runLoop({
modelRole: 'candidateExtraction',
systemPrompt: 'SYS',
@ -55,7 +64,7 @@ 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).toMatchObject({ noop: { description: 'noop' } });
expect(call.stopWhen).toBe(17);
expect(call.temperature).toBe(0);
expect(call.experimental_repairToolCall).toBe(repairHandler);
@ -63,6 +72,33 @@ describe('AgentRunnerService.runLoop', () => {
expect(llmProvider.repairToolCallHandler).toHaveBeenCalledWith({ source: 'ktx-agent-runner' });
});
it('converts AgentToolSet to AI SDK tools before generateText', async () => {
(generateText as any).mockResolvedValue({} as never);
await runner.runLoop({
modelRole: 'default',
systemPrompt: 'system',
userPrompt: 'user',
toolSet: {
emit_candidate: createAgentTool({
name: 'emit_candidate',
description: 'Emit candidate',
inputSchema: z.object({ key: z.string() }),
execute: async ({ key }) => ({ markdown: key, structured: { key } }),
}),
},
stepBudget: 3,
telemetryTags: {},
});
expect(generateText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
emit_candidate: expect.objectContaining({ description: 'Emit candidate' }),
}),
}),
);
});
it('returns stopReason=natural when the loop completes without error', async () => {
(generateText as any).mockResolvedValue({ text: 'done', toolCalls: [], steps: [] });
const result = await runner.runLoop({
@ -289,11 +325,12 @@ describe('AgentRunnerService.runLoop', () => {
systemPrompt: 'SECRET SYSTEM PROMPT',
userPrompt: 'SECRET USER PROMPT',
toolSet: {
emit_candidate: {
emit_candidate: createAgentTool({
name: 'emit_candidate',
description: 'SECRET TOOL DESCRIPTION',
inputSchema: {},
execute: vi.fn(),
} as any,
inputSchema: z.object({}),
execute: vi.fn(async () => 'ok'),
}),
},
stepBudget: 10,
telemetryTags: { operationName: 'ingest-bundle-wu', source: 'metabase', jobId: 'job-1', unitKey: 'cards/1' },

View file

@ -1,7 +1,8 @@
import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider, type KtxModelRole } from '@ktx/llm';
import { generateText, stepCountIs, type TelemetrySettings, type Tool } from 'ai';
import { generateText, stepCountIs, type TelemetrySettings, type ToolSet } from 'ai';
import { noopLogger, type KtxLogger } from '../core/index.js';
import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from '../llm/index.js';
import { toAiSdkToolSet, type AgentToolSet } from './agent-tool.js';
export type RunLoopStopReason = 'budget' | 'natural' | 'error';
@ -14,7 +15,7 @@ export interface RunLoopParams {
modelRole: KtxModelRole;
systemPrompt: string;
userPrompt: string;
toolSet: Record<string, Tool>;
toolSet: AgentToolSet;
stepBudget: number;
telemetryTags: Record<string, string>;
onStepFinish?: (info: RunLoopStepInfo) => void | Promise<void>;
@ -25,6 +26,10 @@ export interface RunLoopResult {
error?: Error;
}
export interface AgentRunnerPort {
runLoop(params: RunLoopParams): Promise<RunLoopResult>;
}
export interface AgentTelemetryPort {
createTelemetry(tags: Record<string, string>): TelemetrySettings;
}
@ -36,7 +41,7 @@ export interface AgentRunnerServiceDeps {
logger?: KtxLogger;
}
export class AgentRunnerService {
export class AgentRunnerService implements AgentRunnerPort {
private readonly logger: KtxLogger;
constructor(private readonly deps: AgentRunnerServiceDeps) {
@ -47,11 +52,12 @@ export class AgentRunnerService {
let stepIndex = 0;
try {
const model = this.deps.llmProvider.getModel(params.modelRole);
const aiToolSet = toAiSdkToolSet(params.toolSet);
const builder = new KtxMessageBuilder(this.deps.llmProvider);
const built = builder.wrapSimple({
system: params.systemPrompt,
messages: [{ role: 'user', content: params.userPrompt }],
tools: params.toolSet,
tools: aiToolSet,
model,
});
const promptMessages = splitKtxSystemMessages(built.messages);
@ -79,7 +85,7 @@ export class AgentRunnerService {
}),
...(promptMessages.system ? { system: promptMessages.system } : {}),
messages: promptMessages.messages,
tools: built.tools as Record<string, Tool>,
tools: built.tools as ToolSet,
onStepFinish: async () => {
stepIndex += 1;
if (!params.onStepFinish) {

View file

@ -1,6 +1,7 @@
export type { AgentToolCallOptions, AgentToolDefinition, AgentToolOutput, AgentToolSet } from './agent-tool.js';
export { agentToolOutputToText, assertAgentToolSet, createAgentTool, toAiSdkTool, toAiSdkToolSet } from './agent-tool.js';
export type {
AgentRunnerPort,
AgentRunnerServiceDeps,
AgentTelemetryPort,
RunLoopParams,

View file

@ -1,5 +1,5 @@
import type { KtxModelRole } from '@ktx/llm';
import type { AgentRunnerService, AgentToolSet } from '../../agent/index.js';
import type { AgentRunnerPort, AgentToolSet } from '../../agent/index.js';
import { type KtxLogger, noopLogger } from '../../core/index.js';
import type { MemoryAction } from '../../memory/index.js';
import type { ContextCandidateForDedup, CuratorPaginationPort, CuratorPaginationReport } from '../ports.js';
@ -49,7 +49,7 @@ interface CuratorPaginationResult extends ReconciliationOutcome {
export interface CuratorPaginationServiceDeps {
store: ContextCandidateStorePort;
agentRunner: AgentRunnerService;
agentRunner: AgentRunnerPort;
settings: CuratorPaginationSettings;
logger?: KtxLogger;
}

View file

@ -3,7 +3,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { KtxLlmProvider } from '@ktx/llm';
import YAML from 'yaml';
import type { AgentRunnerService, AgentToolSet } from '../agent/index.js';
import type { AgentRunnerPort, 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';
@ -99,7 +99,7 @@ const LOCAL_SHAPE_WARNING = 'Local ingest validates semantic-layer YAML shape on
export interface CreateLocalBundleIngestRuntimeOptions {
project: KtxLocalProject;
adapters: SourceAdapter[];
agentRunner?: AgentRunnerService;
agentRunner?: AgentRunnerPort;
llmProvider?: KtxLlmProvider;
llmDebugRequestFile?: string;
memoryModel?: string;
@ -577,7 +577,7 @@ function localIngestLlmProviderGuardMessage(projectDir: string): string {
}
function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
agentRunner: AgentRunnerService;
agentRunner: AgentRunnerPort;
llmProvider?: KtxLlmProvider;
} {
const llmProvider =

View file

@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { cp, mkdir, rm } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import type { KtxLlmProvider } from '@ktx/llm';
import type { AgentRunnerService } from '../agent/index.js';
import type { AgentRunnerPort } from '../agent/index.js';
import type { KtxSqlQueryExecutorPort } from '../connections/index.js';
import type { KtxLogger } from '../core/index.js';
import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
@ -28,7 +28,7 @@ export interface RunLocalIngestOptions {
trigger?: IngestTrigger;
jobId?: string;
memoryFlow?: MemoryFlowEventSink;
agentRunner?: AgentRunnerService;
agentRunner?: AgentRunnerPort;
llmProvider?: KtxLlmProvider;
llmDebugRequestFile?: string;
memoryModel?: string;
@ -167,7 +167,7 @@ async function runScheduledPullJob(options: {
trigger?: IngestTrigger;
jobId?: string;
memoryFlow?: MemoryFlowEventSink;
agentRunner?: AgentRunnerService;
agentRunner?: AgentRunnerPort;
llmProvider?: KtxLlmProvider;
memoryModel?: string;
semanticLayerCompute?: KtxSemanticLayerComputePort;

View file

@ -1,5 +1,5 @@
import type { KtxModelRole } from '@ktx/llm';
import type { AgentRunnerService, AgentToolSet } from '../agent/index.js';
import type { AgentRunnerPort, 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';
@ -349,7 +349,7 @@ export interface IngestBundleRunnerDeps {
registry: SourceAdapterRegistryPort;
diffSetService: DiffSetComputerPort;
sessionWorktreeService: IngestSessionWorktreePort;
agentRunner: AgentRunnerService;
agentRunner: AgentRunnerPort;
gitService: GitService;
lockingService: IngestLockPort;
storage: IngestStoragePort;

View file

@ -1,4 +1,4 @@
import type { AgentRunnerService, AgentToolSet } from '@ktx/context/agent';
import type { AgentRunnerPort, AgentToolSet } from '@ktx/context/agent';
import type { KtxModelRole } from '@ktx/llm';
import type { CaptureSession, MemoryAction } from '../../memory/index.js';
import { listTouchedSlSources, type TouchedSlSource } from '../../tools/index.js';
@ -13,7 +13,7 @@ export interface TouchedValidationResult {
export interface WorkUnitExecutionDeps {
sessionWorktreeGit: { revParseHead(): Promise<string | null> };
agentRunner: AgentRunnerService;
agentRunner: AgentRunnerPort;
validateTouchedSources: (touched: TouchedSlSource[]) => Promise<TouchedValidationResult>;
resetHardTo: (targetSha: string) => Promise<void>;
buildSystemPrompt: (wu: WorkUnit) => string;

View file

@ -1,4 +1,4 @@
import type { AgentRunnerService, AgentToolSet } from '@ktx/context/agent';
import type { AgentRunnerPort, AgentToolSet } from '@ktx/context/agent';
import type { KtxModelRole } from '@ktx/llm';
import type { EvictionUnit } from '../types.js';
import type { StageIndex } from './stage-index.types.js';
@ -6,7 +6,7 @@ import type { StageIndex } from './stage-index.types.js';
export interface ReconciliationContext {
stageIndex: StageIndex;
evictionUnit: EvictionUnit | undefined;
agentRunner: AgentRunnerService;
agentRunner: AgentRunnerPort;
buildSystemPrompt: (idx: StageIndex, ev: EvictionUnit | undefined) => string;
buildUserPrompt: (idx: StageIndex, ev: EvictionUnit | undefined) => string;
buildToolSet: () => AgentToolSet;

View file

@ -2,7 +2,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { KtxLlmProvider } from '@ktx/llm';
import YAML from 'yaml';
import { AgentRunnerService } from '../agent/index.js';
import { AgentRunnerService, type AgentRunnerPort } from '../agent/index.js';
import { localConnectionInfoFromConfig } from '../connections/index.js';
import type { KtxEmbeddingPort, KtxFileStorePort, KtxFileWriteResult } from '../core/index.js';
import { type KtxLogger, noopLogger, SessionWorktreeService } from '../core/index.js';
@ -64,7 +64,7 @@ const LOCAL_SHAPE_WARNING = 'Local memory capture validates semantic-layer YAML
export interface CreateLocalProjectMemoryCaptureOptions {
llmProvider?: KtxLlmProvider;
agentRunner?: AgentRunnerService;
agentRunner?: AgentRunnerPort;
memoryModel?: string;
semanticLayerCompute?: KtxSemanticLayerComputePort;
queryExecutor?: { execute(input: { connectionId: string; sql: string; maxRows?: number }): Promise<KtxQueryResult> };

View file

@ -1,4 +1,4 @@
import type { AgentRunnerService, AgentToolSet } from '../agent/index.js';
import type { AgentRunnerPort, 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';
@ -149,7 +149,7 @@ export interface MemoryAgentServiceDeps {
slSourcesRepository: SlSourcesIndexPort;
sessionWorktreeService: SessionWorktreeService<MemoryFileStorePort>;
semanticLayerSourceReconciler: MemorySlSourceReconcilerPort;
agentRunner: AgentRunnerService;
agentRunner: AgentRunnerPort;
slValidator: SlValidatorPort<SlValidationDeps>;
toolsetFactory: MemoryToolsetFactoryPort;
telemetry?: MemoryTelemetryPort;