mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat: add claude agent sdk runner
This commit is contained in:
parent
a66e68a653
commit
eb90d2f32c
3 changed files with 283 additions and 0 deletions
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
151
packages/context/src/agent/claude-agent-sdk-runner.service.ts
Normal file
151
packages/context/src/agent/claude-agent-sdk-runner.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue