feat: wire claude-code agent runner backend

This commit is contained in:
Andrey Avtomonov 2026-05-15 13:04:16 +02:00
parent eb90d2f32c
commit 3de32c43a1
16 changed files with 229 additions and 21 deletions

View file

@ -250,6 +250,17 @@ canonical revenue reporting.
Together, these four pillars give agents enough context to produce analytics artifacts that match what your team would produce - not just syntactically valid SQL, but the right query for the question.
## Agent runner backends
KTX separates the global LLM provider from the agent runner. The global
provider powers non-agent calls such as scan enrichment and relationship
proposals. The agent runner powers curated tool loops used by ingest and memory
capture.
Use `llm.agentRunner.backend: claude-code` when you want those curated loops to
run through the Claude Agent SDK. KTX registers only its stage-specific MCP
tools for the session and disables Claude Code built-in tools for that backend.
## How KTX compares
KTX is a context layer with an agent-native semantic layer at its core. MetricFlow, Cube, and Malloy model metrics, dimensions, joins, and generated SQL. KTX covers that semantic-layer work, then adds the context agents need to use and maintain it: wiki pages, schema scans, provenance, ingestion, validation, and agent-facing CLI commands.

View file

@ -59,7 +59,7 @@ completed setup steps and resumes from the remaining work.
KTX uses a Claude model for ingest agents that turn schemas, SQL, BI metadata,
and documents into semantic-layer sources and wiki context.
Setup supports two LLM provider paths:
Setup supports two hosted LLM provider paths:
| Provider | Use when | Credential model |
|----------|----------|------------------|
@ -78,6 +78,28 @@ Setup checks the selected model before saving. Anthropic API setup fetches live
Claude model choices when possible and falls back to bundled defaults if model
discovery is unavailable.
### Use a local Claude Code session for ingest agents
KTX can run ingest and memory-agent loops through your local Claude Code
session. This affects only agentic loops; scan enrichment, page triage, and
relationship proposals still use `llm.provider`.
```bash
ktx setup --llm-backend claude-code --anthropic-model claude-sonnet-4-6
```
The generated `ktx.yaml` uses:
```yaml
llm:
provider:
backend: none
agentRunner:
backend: claude-code
models:
default: claude-sonnet-4-6
```
## Step 3: Configure embeddings
KTX uses embeddings for semantic search over semantic-layer sources, wiki

View file

@ -34,6 +34,7 @@
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.142",
"@clack/prompts": "1.4.0",
"@commander-js/extra-typings": "14.0.0",
"@ktx/connector-bigquery": "workspace:*",

View file

@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
}
function llmBackend(value: string): KtxSetupLlmBackend {
if (value === 'anthropic' || value === 'vertex') {
if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
@ -361,12 +361,18 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
context.setExitCode(1);
return;
}
if (options.llmBackend === 'vertex' && (options.anthropicApiKeyEnv || options.anthropicApiKeyFile)) {
if (
(options.llmBackend === 'vertex' || options.llmBackend === 'claude-code') &&
(options.anthropicApiKeyEnv || options.anthropicApiKeyFile)
) {
context.io.stderr.write('Anthropic API key flags are only valid with --llm-backend anthropic.\n');
context.setExitCode(1);
return;
}
if (options.llmBackend === 'anthropic' && (options.vertexProject || options.vertexLocation)) {
if (
(options.llmBackend === 'anthropic' || options.llmBackend === 'claude-code') &&
(options.vertexProject || options.vertexLocation)
) {
context.io.stderr.write('Vertex AI flags are only valid with --llm-backend vertex.\n');
context.setExitCode(1);
return;

View file

@ -913,6 +913,7 @@ describe('runContextBuild', () => {
llm: {
provider: { backend: 'gateway', gateway: { api_key: 'env:KTX_GATEWAY_API_KEY' } }, // pragma: allowlist secret
models: { default: 'gpt-test' },
agentRunner: { backend: 'ai-sdk' },
},
scan: {
...projectWithConnections({ warehouse: { driver: 'postgres' } }).config.scan,

View file

@ -1074,6 +1074,41 @@ describe('runKtxCli', () => {
);
});
it('dispatches Claude Code setup flags to the setup runner', async () => {
const setup = vi.fn(async () => 0);
const setupIo = makeIo();
await expect(
runKtxCli(
[
'--project-dir',
tempDir,
'setup',
'--no-input',
'--llm-backend',
'claude-code',
'--anthropic-model',
'claude-sonnet-4-6',
],
setupIo.io,
{ setup },
),
).resolves.toBe(0);
expect(setup).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
projectDir: tempDir,
inputMode: 'disabled',
cliVersion: '0.0.0-private',
llmBackend: 'claude-code',
anthropicModel: 'claude-sonnet-4-6',
skipLlm: false,
}),
setupIo.io,
);
});
it('rejects conflicting Anthropic credential setup flags', async () => {
const setup = vi.fn(async () => 0);
const setupIo = makeIo();

View file

@ -271,7 +271,7 @@ export class CliLookerSlWritingAgentRunner extends AgentRunnerService {
verifiedIdentifiers: ['prod-warehouse', 'public.orders'],
unverifiedIdentifiers: [],
},
{ toolCallId: 'cli-looker-verification-ledger', messages: [] },
{ toolCallId: 'cli-looker-verification-ledger' },
);
const slWrite = params.toolSet.sl_write_source;
if (!slWrite?.execute) {
@ -292,10 +292,13 @@ export class CliLookerSlWritingAgentRunner extends AgentRunnerService {
measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }],
},
},
{ toolCallId: 'cli-looker-sl-write', messages: [] },
{ toolCallId: 'cli-looker-sl-write' },
);
if (!result.structured.success) {
throw new Error(result.markdown);
const structured =
result && typeof result === 'object' && 'structured' in result ? result.structured : undefined;
if (!structured || typeof structured !== 'object' || !('success' in structured) || structured.success !== true) {
const message = result && typeof result === 'object' && 'markdown' in result ? result.markdown : String(result);
throw new Error(typeof message === 'string' ? message : 'sl_write_source failed');
}
}
return { stopReason: 'natural' as const };

View file

@ -61,6 +61,7 @@ async function writeReadyProject(projectDir: string, overrides: ReadyProjectOver
llm: {
provider: { backend: 'anthropic' },
models: { default: 'claude-sonnet-4-6' },
agentRunner: { backend: 'ai-sdk' },
},
ingest: {
...defaults.ingest,

View file

@ -975,6 +975,26 @@ describe('setup Anthropic model step', () => {
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
});
it('writes claude-code agent runner config when requested as the LLM backend', async () => {
const result = await runKtxSetupAnthropicModelStep(
{
projectDir: tempDir,
inputMode: 'disabled',
llmBackend: 'claude-code',
anthropicModel: 'claude-sonnet-4-6',
skipLlm: false,
},
makeIo().io,
{ claudeCodeAuthProbe: vi.fn(async () => ({ ok: true as const })) },
);
expect(result.status).toBe('ready');
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.llm.provider.backend).toBe('none');
expect(config.llm.agentRunner.backend).toBe('claude-code');
expect(config.llm.models.default).toBe('claude-sonnet-4-6');
});
it('returns back without writing config when Back is selected', async () => {
const prompts = makePromptAdapter({ credentialChoice: 'back' });
const result = await runKtxSetupAnthropicModelStep(

View file

@ -53,7 +53,7 @@ export interface AnthropicModelChoice {
recommended: boolean;
}
export type KtxSetupLlmBackend = 'anthropic' | 'vertex';
export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code';
export interface KtxSetupModelPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
@ -68,6 +68,7 @@ export interface KtxSetupModelDeps {
prompts?: KtxSetupModelPromptAdapter;
listModels?: (apiKey: string) => Promise<AnthropicModelChoice[]>;
healthCheck?: (config: KtxLlmConfig) => Promise<KtxLlmHealthCheckResult>;
claudeCodeAuthProbe?: () => Promise<{ ok: true } | { ok: false; message: string }>;
readGcloudProject?: () => Promise<string | undefined>;
listGcloudProjects?: () => Promise<GcloudProjectChoice[]>;
spinner?: () => KtxCliSpinner;
@ -238,6 +239,9 @@ export async function fetchAnthropicModels(
}
export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean {
if (config.agentRunner?.backend === 'claude-code') {
return Boolean(config.models.default);
}
let resolved: KtxLlmConfig | null;
try {
resolved = resolveLocalKtxLlmConfig(config, process.env);
@ -274,6 +278,7 @@ function buildProjectLlmConfig(
},
models: { ...existing.models, default: model },
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true, vertexFallbackTo5m: true },
agentRunner: { backend: 'ai-sdk' },
};
}
@ -284,6 +289,7 @@ function buildProjectLlmConfig(
},
models: { ...existing.models, default: model },
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true },
agentRunner: { backend: 'ai-sdk' },
};
}
@ -305,6 +311,30 @@ function buildVertexHealthConfig(vertex: { project?: string; location: string },
};
}
async function defaultClaudeCodeAuthProbe(): Promise<{ ok: true } | { ok: false; message: string }> {
const { query } = await import('@anthropic-ai/claude-agent-sdk');
const session = query({
prompt: '',
options: {
tools: [],
settingSources: [],
skills: [],
allowedTools: [],
disallowedTools: ['Bash', 'Read', 'Edit', 'Write', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Task'],
permissionMode: 'dontAsk',
maxTurns: 1,
},
});
try {
await session.accountInfo();
return { ok: true };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
} finally {
session.close();
}
}
type LlmHealthProvider = 'Anthropic API' | 'Vertex AI';
function llmHealthCheckStartText(provider: LlmHealthProvider, model: string): string {
@ -483,13 +513,18 @@ async function chooseBackend(
options: [
{ value: 'anthropic', label: 'Anthropic API' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'claude-code', label: 'Claude Code local session (agent runner only)' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };
}
return { status: 'ready', backend: choice === 'vertex' ? 'vertex' : 'anthropic', prompted: true };
return {
status: 'ready',
backend: choice === 'vertex' ? 'vertex' : choice === 'claude-code' ? 'claude-code' : 'anthropic',
prompted: true,
};
}
function resolveProvidedVertexRef(
@ -826,6 +861,21 @@ async function persistLlmConfig(
await markKtxSetupStateStepComplete(projectDir, 'llm');
}
async function persistClaudeCodeAgentRunnerConfig(projectDir: string, model: string): Promise<void> {
const project = await loadKtxProject({ projectDir });
const nextConfig: KtxProjectConfig = {
...project.config,
llm: {
...project.config.llm,
provider: { backend: 'none' },
models: { ...project.config.llm.models, default: model },
agentRunner: { backend: 'claude-code' },
},
};
await writeFile(project.configPath, serializeKtxProjectConfig(nextConfig), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'llm');
}
function buildInteractiveRetryArgs(args: KtxSetupModelArgs, backend?: KtxSetupLlmBackend): KtxSetupModelArgs {
return {
projectDir: args.projectDir,
@ -874,6 +924,18 @@ export async function runKtxSetupAnthropicModelStep(
? ({ ...attemptArgs, llmBackend: backendChoice.backend, showPromptInstructions: false } satisfies KtxSetupModelArgs)
: attemptArgs;
if (backendChoice.backend === 'claude-code') {
const model = backendArgs.anthropicModel ?? 'claude-sonnet-4-6';
const probe = await (deps.claudeCodeAuthProbe ?? defaultClaudeCodeAuthProbe)();
if (!probe.ok) {
io.stderr.write(`Claude Code authentication check failed: ${probe.message}\n`);
return { status: 'failed', projectDir: args.projectDir };
}
await persistClaudeCodeAgentRunnerConfig(args.projectDir, model);
io.stdout.write(`│ LLM ready: yes (Claude Code agent runner, ${model})\n`);
return { status: 'ready', projectDir: args.projectDir };
}
if (backendChoice.backend === 'vertex') {
const vertex = await chooseVertexConfig(backendArgs, io, deps);
if (vertex.status === 'back' && backendChoice.prompted) {

View file

@ -12,7 +12,6 @@ import {
agentToolOutputToText,
assertAgentToolSet,
type AgentToolDefinition,
type AgentToolSet,
} from './agent-tool.js';
import type { AgentRunnerPort, RunLoopParams, RunLoopResult, RunLoopStopReason } from './agent-runner.service.js';

View file

@ -5,7 +5,7 @@ import { AgentRunnerService } from '../agent/index.js';
import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../project/index.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeSourceAdapter } from './adapters/fake/fake.adapter.js';
import { createLocalBundleIngestRuntime } from './local-bundle-runtime.js';
import { createLocalBundleIngestRuntime, resolveAgentRunnerForTest } from './local-bundle-runtime.js';
type RuntimeWithConnectionDeps = {
deps: {
@ -55,7 +55,7 @@ describe('createLocalBundleIngestRuntime', () => {
}),
).toThrow(
[
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway; llm.agentRunner.backend: claude-code; or an injected agentRunner.',
`Configure an Anthropic provider, then rerun ingest:`,
` ktx setup --project-dir ${project.projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
].join('\n'),
@ -84,6 +84,18 @@ describe('createLocalBundleIngestRuntime', () => {
await mkdir(runtime.storage.resolveUploadDir('job-1'), { recursive: true });
});
it('constructs a Claude Agent SDK runner when llm.agentRunner.backend is claude-code', () => {
project.config.llm = {
provider: { backend: 'none' },
models: { default: 'claude-sonnet-4-6' },
agentRunner: { backend: 'claude-code' },
};
const resolved = resolveAgentRunnerForTest({ project, adapters: [] });
expect(resolved.agentRunner.constructor.name).toBe('ClaudeAgentSdkRunnerService');
});
it('exposes canonical warehouse connection types to local ingest SL tools', async () => {
project.config.connections.warehouse = {
driver: 'postgres',

View file

@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import type { KtxLlmProvider } from '@ktx/llm';
import YAML from 'yaml';
import type { AgentRunnerPort, AgentToolSet } from '../agent/index.js';
import { AgentRunnerService as DefaultAgentRunnerService } from '../agent/index.js';
import { AgentRunnerService as DefaultAgentRunnerService, ClaudeAgentSdkRunnerService } from '../agent/index.js';
import { localConnectionInfoFromConfig, type KtxSqlQueryExecutorPort } from '../connections/index.js';
import type { KtxEmbeddingPort, KtxLogger } from '../core/index.js';
import { noopLogger, SessionWorktreeService } from '../core/index.js';
@ -570,7 +570,7 @@ function nextLocalJobId(): string {
function localIngestLlmProviderGuardMessage(projectDir: string): string {
return [
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway; llm.agentRunner.backend: claude-code; or an injected agentRunner.',
'Configure an Anthropic provider, then rerun ingest:',
` ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
].join('\n');
@ -587,6 +587,17 @@ function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
return { agentRunner: options.agentRunner, ...(llmProvider ? { llmProvider } : {}) };
}
if (options.project.config.llm.agentRunner.backend === 'claude-code') {
return {
agentRunner: new ClaudeAgentSdkRunnerService({
projectDir: options.project.projectDir,
modelSlots: options.project.config.llm.models,
logger: options.logger ?? noopLogger,
}),
...(llmProvider ? { llmProvider } : {}),
};
}
if (!llmProvider) {
throw new Error(localIngestLlmProviderGuardMessage(options.project.projectDir));
}
@ -603,6 +614,8 @@ function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
};
}
export const resolveAgentRunnerForTest = resolveAgentRunner;
export function createLocalBundleIngestRuntime(
options: CreateLocalBundleIngestRuntimeOptions,
): LocalBundleIngestRuntime {

View file

@ -143,6 +143,17 @@ describe('createLocalProjectMemoryCapture', () => {
);
});
it('constructs local memory capture with Claude Agent SDK runner config', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.llm = {
provider: { backend: 'none' },
models: { default: 'claude-sonnet-4-6' },
agentRunner: { backend: 'claude-code' },
};
expect(() => createLocalProjectMemoryCapture(project)).not.toThrow();
});
it('captures a semantic-layer source for a named local connection id', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = { driver: 'postgres' };

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, type AgentRunnerPort } from '../agent/index.js';
import { AgentRunnerService, ClaudeAgentSdkRunnerService, 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';
@ -104,10 +104,16 @@ export function createLocalProjectMemoryCapture(
});
const agentRunner =
options.agentRunner ??
new AgentRunnerService({
llmProvider: requireLlmProvider(llmProvider),
logger,
});
(project.config.llm.agentRunner.backend === 'claude-code'
? new ClaudeAgentSdkRunnerService({
projectDir: project.projectDir,
modelSlots: project.config.llm.models,
logger,
})
: new AgentRunnerService({
llmProvider: requireLlmProvider(llmProvider),
logger,
}));
const memoryAgent = new MemoryAgentService({
settings: {
knowledge: { userScopedKnowledgeEnabled: false },
@ -145,7 +151,9 @@ export function createLocalProjectMemoryCapture(
function requireLlmProvider(provider: KtxLlmProvider | null | undefined): KtxLlmProvider {
if (!provider) {
throw new Error('createLocalProjectMemoryCapture requires llm.provider.backend or an injected agentRunner');
throw new Error(
'createLocalProjectMemoryCapture requires llm.provider.backend, llm.agentRunner.backend: claude-code, or an injected agentRunner',
);
}
return provider;
}

3
pnpm-lock.yaml generated
View file

@ -73,6 +73,9 @@ importers:
packages/cli:
dependencies:
'@anthropic-ai/claude-agent-sdk':
specifier: 0.3.142
version: 0.3.142(zod@4.4.3)
'@clack/prompts':
specifier: 1.4.0
version: 1.4.0