From eb90d2f32c4bf3e693adc7172ab732542ae13fd7 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 15 May 2026 12:57:16 +0200 Subject: [PATCH] feat: add claude agent sdk runner --- .../claude-agent-sdk-runner.service.test.ts | 130 +++++++++++++++ .../agent/claude-agent-sdk-runner.service.ts | 151 ++++++++++++++++++ packages/context/src/agent/index.ts | 2 + 3 files changed, 283 insertions(+) create mode 100644 packages/context/src/agent/claude-agent-sdk-runner.service.test.ts create mode 100644 packages/context/src/agent/claude-agent-sdk-runner.service.ts diff --git a/packages/context/src/agent/claude-agent-sdk-runner.service.test.ts b/packages/context/src/agent/claude-agent-sdk-runner.service.test.ts new file mode 100644 index 00000000..abe06cb1 --- /dev/null +++ b/packages/context/src/agent/claude-agent-sdk-runner.service.test.ts @@ -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.', + }); + }); +}); diff --git a/packages/context/src/agent/claude-agent-sdk-runner.service.ts b/packages/context/src/agent/claude-agent-sdk-runner.service.ts new file mode 100644 index 00000000..56544c65 --- /dev/null +++ b/packages/context/src/agent/claude-agent-sdk-runner.service.ts @@ -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>; + 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 { + 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 { + 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) { + 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'; + } +} diff --git a/packages/context/src/agent/index.ts b/packages/context/src/agent/index.ts index 675828e5..db808ccf 100644 --- a/packages/context/src/agent/index.ts +++ b/packages/context/src/agent/index.ts @@ -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,