feat: add claude agent sdk runner

This commit is contained in:
Andrey Avtomonov 2026-05-15 12:57:16 +02:00
parent a66e68a653
commit eb90d2f32c
3 changed files with 283 additions and 0 deletions

View file

@ -0,0 +1,130 @@
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { createAgentTool } from './agent-tool.js';
import { ClaudeAgentSdkRunnerService } from './claude-agent-sdk-runner.service.js';
function asyncMessages(messages: unknown[]) {
return {
async *[Symbol.asyncIterator]() {
for (const message of messages) {
yield message;
}
},
close: vi.fn(),
};
}
describe('ClaudeAgentSdkRunnerService', () => {
it('runs with isolated settings, no built-ins, KTX MCP tools, and role model mapping', async () => {
const query = vi.fn(() =>
asyncMessages([
{ type: 'system', subtype: 'init', mcp_servers: [{ name: 'ktx', status: 'connected' }] },
{
type: 'result',
subtype: 'success',
terminal_reason: 'completed',
result: 'done',
is_error: false,
permission_denials: [],
errors: [],
},
]),
);
const runner = new ClaudeAgentSdkRunnerService({
projectDir: '/tmp/project',
modelSlots: { default: 'claude-sonnet-4-6', reconcile: 'claude-opus-4-6' },
query: query as never,
createSdkMcpServer: vi.fn((input) => ({ type: 'sdk', name: input.name, instance: {} })) as never,
tool: vi.fn((name, description, inputSchema, handler) => ({ name, description, inputSchema, handler })) as never,
});
const result = await runner.runLoop({
modelRole: 'reconcile',
systemPrompt: 'system',
userPrompt: 'user',
stepBudget: 7,
telemetryTags: {},
toolSet: {
ping: createAgentTool({
name: 'ping',
description: 'Ping',
inputSchema: z.object({ value: z.string() }),
execute: async ({ value }) => ({ markdown: `pong ${value}`, structured: { value } }),
}),
},
});
expect(result).toEqual({ stopReason: 'natural' });
expect(query).toHaveBeenCalledWith({
prompt: 'user',
options: expect.objectContaining({
cwd: '/tmp/project',
systemPrompt: 'system',
model: 'claude-opus-4-6',
maxTurns: 7,
tools: [],
settingSources: [],
skills: [],
allowedTools: ['mcp__ktx__*'],
permissionMode: 'dontAsk',
}),
});
});
it('maps max-turn terminal results to budget', async () => {
const query = vi.fn(() =>
asyncMessages([
{
type: 'result',
subtype: 'error_max_turns',
terminal_reason: 'max_turns',
is_error: true,
errors: [],
permission_denials: [],
},
]),
);
const runner = new ClaudeAgentSdkRunnerService({
projectDir: '/tmp/project',
modelSlots: {},
query: query as never,
});
await expect(
runner.runLoop({
modelRole: 'default',
systemPrompt: 'system',
userPrompt: 'user',
stepBudget: 1,
telemetryTags: {},
toolSet: {},
}),
).resolves.toEqual({ stopReason: 'budget' });
});
it('denies non-KTX tool permission checks', async () => {
const query = vi.fn(() =>
asyncMessages([{ type: 'result', subtype: 'success', terminal_reason: 'completed', result: 'done' }]),
);
const runner = new ClaudeAgentSdkRunnerService({
projectDir: '/tmp/project',
modelSlots: {},
query: query as never,
});
await runner.runLoop({
modelRole: 'default',
systemPrompt: 'system',
userPrompt: 'user',
stepBudget: 1,
telemetryTags: {},
toolSet: {},
});
const options = (query as any).mock.calls[0][0].options;
await expect(options.canUseTool('Bash', {}, { signal: new AbortController().signal, toolUseID: '1' })).resolves.toEqual({
behavior: 'deny',
message: 'Only KTX MCP tools are available in this session.',
});
});
});

View file

@ -0,0 +1,151 @@
import {
createSdkMcpServer,
query,
tool,
type CanUseTool,
type SDKMessage,
type SDKResultMessage,
} from '@anthropic-ai/claude-agent-sdk';
import type { KtxModelRole } from '@ktx/llm';
import { noopLogger, type KtxLogger } from '../core/index.js';
import {
agentToolOutputToText,
assertAgentToolSet,
type AgentToolDefinition,
type AgentToolSet,
} from './agent-tool.js';
import type { AgentRunnerPort, RunLoopParams, RunLoopResult, RunLoopStopReason } from './agent-runner.service.js';
type QueryFn = typeof query;
type CreateSdkMcpServerFn = typeof createSdkMcpServer;
type ToolFn = typeof tool;
const BUILT_IN_TOOLS = [
'Agent',
'AskUserQuestion',
'Bash',
'Edit',
'ExitPlanMode',
'Glob',
'Grep',
'ListMcpResources',
'NotebookEdit',
'Read',
'ReadMcpResource',
'Task',
'TodoWrite',
'WebFetch',
'WebSearch',
'Write',
];
export interface ClaudeAgentSdkRunnerServiceDeps {
projectDir: string;
modelSlots: Partial<Record<KtxModelRole, string>>;
query?: QueryFn;
createSdkMcpServer?: CreateSdkMcpServerFn;
tool?: ToolFn;
logger?: KtxLogger;
}
export class ClaudeAgentSdkRunnerService implements AgentRunnerPort {
private readonly query: QueryFn;
private readonly createSdkMcpServer: CreateSdkMcpServerFn;
private readonly tool: ToolFn;
private readonly logger: KtxLogger;
constructor(private readonly deps: ClaudeAgentSdkRunnerServiceDeps) {
this.query = deps.query ?? query;
this.createSdkMcpServer = deps.createSdkMcpServer ?? createSdkMcpServer;
this.tool = deps.tool ?? tool;
this.logger = deps.logger ?? noopLogger;
}
async runLoop(params: RunLoopParams): Promise<RunLoopResult> {
try {
assertAgentToolSet(params.toolSet);
const result = await this.consumeQuery(params);
return { stopReason: this.mapResultToStopReason(result) };
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(`[claude-agent-sdk-runner] loop failed: ${err.message}`);
return { stopReason: 'error', error: err };
}
}
private async consumeQuery(params: RunLoopParams): Promise<SDKResultMessage | undefined> {
let result: SDKResultMessage | undefined;
let stepIndex = 0;
const session = this.query({
prompt: params.userPrompt,
options: {
cwd: this.deps.projectDir,
systemPrompt: params.systemPrompt,
maxTurns: params.stepBudget,
...this.modelOption(params.modelRole),
mcpServers: {
ktx: this.createSdkMcpServer({
name: 'ktx',
version: '1.0.0',
tools: Object.values(params.toolSet).map((definition) => this.toSdkTool(definition)),
}),
},
tools: [],
settingSources: [],
skills: [],
allowedTools: ['mcp__ktx__*'],
disallowedTools: BUILT_IN_TOOLS,
permissionMode: 'dontAsk',
canUseTool: this.canUseKtxTool,
},
});
for await (const message of session as AsyncIterable<SDKMessage>) {
if (message.type === 'assistant') {
stepIndex += 1;
if (params.onStepFinish) {
await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget });
}
}
if (message.type === 'result') {
result = message;
}
}
return result;
}
private modelOption(role: KtxModelRole): { model?: string } {
const model = this.deps.modelSlots[role] ?? this.deps.modelSlots.default;
return model ? { model } : {};
}
private toSdkTool(definition: AgentToolDefinition) {
return this.tool(definition.name, definition.description, definition.inputSchema.shape, async (args) => {
const output = await definition.execute(definition.inputSchema.parse(args), {});
return { content: [{ type: 'text' as const, text: agentToolOutputToText(output) }] };
});
}
private readonly canUseKtxTool: CanUseTool = async (toolName) => {
if (toolName.startsWith('mcp__ktx__')) {
return { behavior: 'allow', updatedInput: undefined };
}
return {
behavior: 'deny',
message: 'Only KTX MCP tools are available in this session.',
};
};
private mapResultToStopReason(result: SDKResultMessage | undefined): RunLoopStopReason {
if (!result) {
return 'error';
}
if (result.subtype === 'error_max_turns' || result.terminal_reason === 'max_turns') {
return 'budget';
}
if (result.subtype === 'success' && (!result.terminal_reason || result.terminal_reason === 'completed')) {
return 'natural';
}
return 'error';
}
}

View file

@ -1,5 +1,7 @@
export type { AgentToolCallOptions, AgentToolDefinition, AgentToolOutput, AgentToolSet } from './agent-tool.js';
export { agentToolOutputToText, assertAgentToolSet, createAgentTool, toAiSdkTool, toAiSdkToolSet } from './agent-tool.js';
export type { ClaudeAgentSdkRunnerServiceDeps } from './claude-agent-sdk-runner.service.js';
export { ClaudeAgentSdkRunnerService } from './claude-agent-sdk-runner.service.js';
export type {
AgentRunnerPort,
AgentRunnerServiceDeps,