From 8add97516bb77c13c1847a3dc8730a7956380385 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Fri, 3 Apr 2026 16:07:57 +1100 Subject: [PATCH 1/5] Add bidirectional communication between SDK and consumer --- apps/claude-sdk-cli/src/main.ts | 37 +++- .../src/tools/edit/editConfirmTool.ts | 2 +- .../claude-sdk-cli/src/tools/edit/editTool.ts | 2 +- packages/claude-sdk/src/index.ts | 11 +- .../claude-sdk/src/private/AgentChannel.ts | 22 +++ packages/claude-sdk/src/private/AgentRun.ts | 186 ++++++++++++++++++ .../claude-sdk/src/private/AnthropicAgent.ts | 18 ++ .../claude-sdk/src/private/ApprovalState.ts | 30 +++ packages/claude-sdk/src/private/types.ts | 5 + .../claude-sdk/src/public/AnthropicAgent.ts | 173 ---------------- .../src/public/createAnthropicAgent.ts | 8 + packages/claude-sdk/src/public/enums.ts | 9 + packages/claude-sdk/src/public/interfaces.ts | 7 + packages/claude-sdk/src/public/types.ts | 43 ++-- 14 files changed, 345 insertions(+), 208 deletions(-) create mode 100644 packages/claude-sdk/src/private/AgentChannel.ts create mode 100644 packages/claude-sdk/src/private/AgentRun.ts create mode 100644 packages/claude-sdk/src/private/AnthropicAgent.ts create mode 100644 packages/claude-sdk/src/private/ApprovalState.ts delete mode 100644 packages/claude-sdk/src/public/AnthropicAgent.ts create mode 100644 packages/claude-sdk/src/public/createAnthropicAgent.ts create mode 100644 packages/claude-sdk/src/public/enums.ts create mode 100644 packages/claude-sdk/src/public/interfaces.ts diff --git a/apps/claude-sdk-cli/src/main.ts b/apps/claude-sdk-cli/src/main.ts index 9869be2..fc4cef5 100644 --- a/apps/claude-sdk-cli/src/main.ts +++ b/apps/claude-sdk-cli/src/main.ts @@ -1,23 +1,20 @@ -import { AnthropicAgent, AnthropicBeta } from '@shellicar/claude-sdk'; +import { AnthropicBeta, createAnthropicAgent, type SdkMessage } from '@shellicar/claude-sdk'; import { logger } from './logger'; import { editConfirmTool } from './tools/edit/editConfirmTool'; import { editTool } from './tools/edit/editTool'; const main = async () => { - const agent = new AnthropicAgent({ + const agent = createAnthropicAgent({ apiKey: process.env.CLAUDE_CODE_API_KEY ?? 'no-key', logger, }); - agent.on('message_start', () => process.stdout.write('> ')); - agent.on('message_text', (x) => process.stdout.write(x)); - agent.on('message_end', () => process.stdout.write('\n')); - - await agent.runAgent({ + const { port, done } = agent.runAgent({ model: 'claude-sonnet-4-6', maxTokens: 8096, messages: ['Please add a comment "// hello world" on line 1344 of the file /Users/stephen/repos/@shellicar/claude-cli/node_modules/.pnpm/@anthropic-ai+sdk@0.80.0_zod@4.3.6/node_modules/@anthropic-ai/sdk/src/resources/messages/messages.ts'], tools: [editTool, editConfirmTool], + requireToolApproval: true, betas: { [AnthropicBeta.InterleavedThinking]: true, [AnthropicBeta.ContextManagement]: true, @@ -27,5 +24,31 @@ const main = async () => { [AnthropicBeta.TokenEfficientTools]: true, }, }); + + port.on('message', (msg: SdkMessage) => { + switch (msg.type) { + case 'message_start': + process.stdout.write('> '); + break; + case 'message_text': + process.stdout.write(msg.text); + break; + case 'message_end': + process.stdout.write('\n'); + break; + case 'tool_approval_request': + logger.info('tool_approval_request', { name: msg.name, input: msg.input }); + port.postMessage({ type: 'tool_approval_response', requestId: msg.requestId, approved: true }); + break; + case 'done': + logger.info('done', { stopReason: msg.stopReason }); + break; + case 'error': + logger.error('error', { message: msg.message }); + break; + } + }); + + await done; }; main(); diff --git a/apps/claude-sdk-cli/src/tools/edit/editConfirmTool.ts b/apps/claude-sdk-cli/src/tools/edit/editConfirmTool.ts index f9e1230..408ec3d 100644 --- a/apps/claude-sdk-cli/src/tools/edit/editConfirmTool.ts +++ b/apps/claude-sdk-cli/src/tools/edit/editConfirmTool.ts @@ -13,7 +13,7 @@ export const editConfirmTool: ToolDefinition { + handler: async ({ patchId }, store) => { const input = store.get(patchId); if (input == null) { throw new Error('edit_confirm requires a staged edit from the edit tool'); diff --git a/apps/claude-sdk-cli/src/tools/edit/editTool.ts b/apps/claude-sdk-cli/src/tools/edit/editTool.ts index ed291e4..aecb7ab 100644 --- a/apps/claude-sdk-cli/src/tools/edit/editTool.ts +++ b/apps/claude-sdk-cli/src/tools/edit/editTool.ts @@ -32,7 +32,7 @@ export const editTool: ToolDefinition = { ], }, ], - handler: (input, store) => { + handler: async (input, store) => { const originalContent = readFileSync(input.file, 'utf-8'); const originalHash = createHash('sha256').update(originalContent).digest('hex'); const originalLines = originalContent.split('\n'); diff --git a/packages/claude-sdk/src/index.ts b/packages/claude-sdk/src/index.ts index fb8edda..6995708 100644 --- a/packages/claude-sdk/src/index.ts +++ b/packages/claude-sdk/src/index.ts @@ -1,6 +1,7 @@ -import { AnthropicAgent } from './public/AnthropicAgent'; -import type { AgentEvents, AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ILogger, JsonObject, JsonValue, RunAgentQuery, ToolDefinition } from './public/types'; -import { AnthropicBeta } from './public/types'; +import { createAnthropicAgent } from './public/createAnthropicAgent'; +import { AnthropicBeta } from './public/enums'; +import { IAnthropicAgent } from './public/interfaces'; +import type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ConsumerMessage, ILogger, JsonObject, JsonValue, RunAgentQuery, RunAgentResult, SdkMessage, ToolDefinition } from './public/types'; -export type { AgentEvents, AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ILogger, JsonObject, JsonValue, RunAgentQuery, ToolDefinition }; -export { AnthropicAgent, AnthropicBeta }; +export type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ConsumerMessage, ILogger, JsonObject, JsonValue, RunAgentQuery, RunAgentResult, SdkMessage, ToolDefinition }; +export { createAnthropicAgent, IAnthropicAgent, AnthropicBeta }; diff --git a/packages/claude-sdk/src/private/AgentChannel.ts b/packages/claude-sdk/src/private/AgentChannel.ts new file mode 100644 index 0000000..dacbb08 --- /dev/null +++ b/packages/claude-sdk/src/private/AgentChannel.ts @@ -0,0 +1,22 @@ +import { MessageChannel, type MessagePort } from 'node:worker_threads'; +import type { ConsumerMessage, SdkMessage } from '../public/types'; + +export class AgentChannel { + readonly #port: MessagePort; + readonly consumerPort: MessagePort; + + constructor(onMessage: (msg: ConsumerMessage) => void) { + const { port1, port2 } = new MessageChannel(); + this.#port = port1; + this.consumerPort = port2; + port1.on('message', onMessage); + } + + send(msg: SdkMessage): void { + this.#port.postMessage(msg); + } + + close(): void { + this.#port.close(); + } +} diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts new file mode 100644 index 0000000..f2498ed --- /dev/null +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -0,0 +1,186 @@ +import { randomUUID } from 'node:crypto'; +import type { RequestOptions } from 'node:http'; +import type { MessagePort } from 'node:worker_threads'; +import { Anthropic } from '@anthropic-ai/sdk'; +import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js'; +import type { BetaCacheControlEphemeral } from '@anthropic-ai/sdk/resources/beta.mjs'; +import { z } from 'zod'; +import type { AnyToolDefinition, ChainedToolStore, ILogger, RunAgentQuery, SdkMessage } from '../public/types'; +import { AgentChannel } from './AgentChannel'; +import { ApprovalState } from './ApprovalState'; +import { AGENT_SDK_PREFIX } from './consts'; +import { MessageStream } from './MessageStream'; +import type { ToolUseResult } from './types'; + +export class AgentRun { + readonly #client: Anthropic; + readonly #logger: ILogger | undefined; + readonly #options: RunAgentQuery; + readonly #channel: AgentChannel; + readonly #approval: ApprovalState; + + constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery) { + this.#client = client; + this.#logger = logger; + this.#options = options; + this.#approval = new ApprovalState(); + this.#channel = new AgentChannel((msg) => this.#approval.handle(msg)); + } + + get port(): MessagePort { + return this.#channel.consumerPort; + } + + async execute(): Promise { + const messages: Anthropic.Beta.Messages.BetaMessageParam[] = this.#options.messages.map((content) => ({ + role: 'user', + content, + })); + const store: ChainedToolStore = new Map(); + + try { + while (!this.#approval.cancelled) { + this.#logger?.debug('messages', { messages }); + const stream = this.#getMessageStream(messages); + this.#logger?.info('Processing messages'); + + const messageStream = new MessageStream(this.#logger); + messageStream.on('message_start', () => this.#channel.send({ type: 'message_start' })); + messageStream.on('message_text', (text) => this.#channel.send({ type: 'message_text', text })); + messageStream.on('message_stop', () => this.#channel.send({ type: 'message_end' })); + + let result: Awaited>; + try { + result = await messageStream.process(stream); + } catch (err) { + if (err instanceof Error) { + this.#channel.send({ type: 'error', message: err.message }); + } + return; + } + + if (result.stopReason !== 'tool_use' || result.toolUses.length === 0) { + this.#channel.send({ type: 'done', stopReason: result.stopReason ?? 'end_turn' }); + break; + } + + const toolResults = await this.#handleTools(result.toolUses, store); + + messages.push({ + role: 'assistant', + content: [ + ...(result.text.length > 0 ? [{ type: 'text' as const, text: result.text }] : []), + ...result.toolUses.map((t) => ({ + type: 'tool_use' as const, + id: t.id, + name: t.name, + input: t.input, + })), + ], + }); + messages.push({ role: 'user', content: toolResults }); + } + } finally { + this.#channel.close(); + } + } + + #getMessageStream(messages: Anthropic.Beta.Messages.BetaMessageParam[]) { + const body = { + model: this.#options.model, + max_tokens: this.#options.maxTokens, + tools: this.#options.tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: z.toJSONSchema(t.input_schema) as Anthropic.Tool['input_schema'], + input_examples: t.input_examples, + })), + cache_control: { type: 'ephemeral', scope: 'global' } as BetaCacheControlEphemeral, + system: [{ type: 'text', text: AGENT_SDK_PREFIX }], + messages, + thinking: { type: 'adaptive' }, + stream: true, + } satisfies BetaMessageStreamParams; + + const betas = Object.entries(this.#options.betas ?? {}) + .filter(([, enabled]) => enabled) + .map(([beta]) => beta) + .join(','); + + const requestOptions = { + headers: { 'anthropic-beta': betas }, + } satisfies RequestOptions; + + this.#logger?.info('Sending request', { + model: this.#options.model, + max_tokens: this.#options.maxTokens, + tools: this.#options.tools.map((t) => ({ name: t.name, description: t.description })), + cache_control: { type: 'ephemeral', scope: 'global' } as BetaCacheControlEphemeral, + thinking: { type: 'adaptive' }, + stream: true, + headers: requestOptions.headers, + }); + + return this.#client.beta.messages.stream(body, requestOptions); + } + + async #handleTools(toolUses: ToolUseResult[], store: ChainedToolStore): Promise { + const tools: AnyToolDefinition[] = this.#options.tools; + const requireApproval = this.#options.requireToolApproval ?? false; + const toolResults: Anthropic.Beta.Messages.BetaToolResultBlockParam[] = []; + + for (const toolUse of toolUses) { + if (this.#approval.cancelled) break; + + if (requireApproval) { + const requestId = randomUUID(); + const response = await this.#approval.request(requestId, () => { + this.#channel.send({ type: 'tool_approval_request', requestId, name: toolUse.name, input: toolUse.input } satisfies SdkMessage); + }); + + if (!response.approved) { + const content = response.reason ?? 'Tool use rejected'; + this.#logger?.debug('tool_rejected', { name: toolUse.name, reason: content }); + toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content }); + continue; + } + } + + const tool = tools.find((t) => t.name === toolUse.name); + this.#logger?.debug('tool_call', { name: toolUse.name, input: toolUse.input, found: tool != null }); + if (tool == null) { + const content = `Tool not found: ${toolUse.name}`; + this.#logger?.debug('tool_result_error', { name: toolUse.name, content }); + toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content }); + continue; + } + + const parseResult = tool.input_schema.safeParse(toolUse.input); + if (!parseResult.success) { + this.#logger?.debug('tool_parse_error', { name: toolUse.name, error: parseResult.error }); + toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: `Invalid input: ${parseResult.error.message}` }); + continue; + } + + const handler = tool.handler as (input: unknown, store: Map) => Promise; + let toolOutput: unknown; + try { + toolOutput = await handler(parseResult.data, store); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.#logger?.debug('tool_handler_error', { name: toolUse.name, error: message }); + toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: message }); + continue; + } + + this.#logger?.debug('tool_result', { name: toolUse.name, output: toolOutput }); + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput), + }); + } + + return toolResults; + } +} diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts new file mode 100644 index 0000000..4eae192 --- /dev/null +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -0,0 +1,18 @@ +import { Anthropic } from '@anthropic-ai/sdk'; +import { AgentRun } from './AgentRun'; +import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } from '../public/types'; + +export class AnthropicAgent { + readonly #client: Anthropic; + readonly #logger: ILogger | undefined; + + public constructor(options: AnthropicAgentOptions) { + this.#logger = options.logger; + this.#client = new Anthropic({ apiKey: options.apiKey }); + } + + public runAgent(options: RunAgentQuery): RunAgentResult { + const run = new AgentRun(this.#client, this.#logger, options); + return { port: run.port, done: run.execute() }; + } +} diff --git a/packages/claude-sdk/src/private/ApprovalState.ts b/packages/claude-sdk/src/private/ApprovalState.ts new file mode 100644 index 0000000..cf94ad0 --- /dev/null +++ b/packages/claude-sdk/src/private/ApprovalState.ts @@ -0,0 +1,30 @@ +import type { ConsumerMessage } from '../public/types'; +import type { ApprovalResponse } from './types'; + +export class ApprovalState { + readonly #pending = new Map void>(); + #cancelled = false; + + get cancelled(): boolean { + return this.#cancelled; + } + + handle(msg: ConsumerMessage): void { + if (msg.type === 'tool_approval_response') { + const resolve = this.#pending.get(msg.requestId); + if (resolve != null) { + this.#pending.delete(msg.requestId); + resolve({ approved: msg.approved, reason: msg.reason }); + } + } else if (msg.type === 'cancel') { + this.#cancelled = true; + } + } + + request(requestId: string, onRequest: () => void): Promise { + return new Promise((resolve) => { + this.#pending.set(requestId, resolve); + onRequest(); + }); + } +} diff --git a/packages/claude-sdk/src/private/types.ts b/packages/claude-sdk/src/private/types.ts index a83cffc..9fe58a5 100644 --- a/packages/claude-sdk/src/private/types.ts +++ b/packages/claude-sdk/src/private/types.ts @@ -4,6 +4,11 @@ export type ToolUseAccumulator = { partialJson: string; }; +export type ApprovalResponse = { + approved: boolean; + reason?: string; +}; + export type ToolUseResult = { id: string; name: string; diff --git a/packages/claude-sdk/src/public/AnthropicAgent.ts b/packages/claude-sdk/src/public/AnthropicAgent.ts deleted file mode 100644 index 90aeb49..0000000 --- a/packages/claude-sdk/src/public/AnthropicAgent.ts +++ /dev/null @@ -1,173 +0,0 @@ -import EventEmitter from 'node:events'; -import type { RequestOptions } from 'node:http'; -import { Anthropic } from '@anthropic-ai/sdk'; -import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js'; -import type { BetaCacheControlEphemeral } from '@anthropic-ai/sdk/resources/beta.mjs'; -import { z } from 'zod'; -import { AGENT_SDK_PREFIX } from '../private/consts'; -import { MessageStream } from '../private/MessageStream'; -import type { ToolUseResult } from '../private/types'; -import type { AgentEvents, AnthropicAgentOptions, AnyToolDefinition, ChainedToolStore, ILogger, RunAgentQuery } from './types'; - -export class AnthropicAgent extends EventEmitter { - readonly #client: Anthropic; - readonly #logger: ILogger | undefined; - - public constructor(options: AnthropicAgentOptions) { - super(); - this.#logger = options.logger; - this.#client = new Anthropic({ apiKey: options.apiKey }); - } - - public async runAgent(options: RunAgentQuery): Promise { - const messages: Anthropic.Beta.Messages.BetaMessageParam[] = options.messages.map((content) => ({ - role: 'user', - content, - })); - - const store: ChainedToolStore = new Map(); - - while (true) { - this.#logger?.debug('messages', { messages }); - const stream = this.getMessageStream(options, messages); - this.#logger?.info('Processing messages'); - - const messageStream = new MessageStream(this.#logger); - messageStream.on('message_start', () => this.emit('message_start')); - messageStream.on('message_text', (text) => this.emit('message_text', text)); - messageStream.on('message_stop', () => this.emit('message_end')); - - let result: Awaited>; - try { - result = await messageStream.process(stream); - } catch (err) { - if (err instanceof Error) { - this.emit('error', err); - } - return; - } - - if (result.stopReason !== 'tool_use' || result.toolUses.length === 0) { - break; - } - - const toolResults = this.handleTools(options.tools, result.toolUses, store); - - messages.push({ - role: 'assistant', - content: [ - ...(result.text.length > 0 ? [{ type: 'text' as const, text: result.text }] : []), - ...result.toolUses.map((t) => ({ - type: 'tool_use' as const, - id: t.id, - name: t.name, - input: t.input, - })), - ], - }); - messages.push({ - role: 'user', - content: toolResults, - }); - } - } - - private getMessageStream(options: RunAgentQuery, messages: Anthropic.Beta.Messages.BetaMessageParam[]) { - const body = { - model: options.model, - max_tokens: options.maxTokens, - tools: options.tools.map((t) => ({ - name: t.name, - description: t.description, - input_schema: z.toJSONSchema(t.input_schema) as Anthropic.Tool['input_schema'], - input_examples: t.input_examples, - })), - cache_control: { type: 'ephemeral', scope: 'global' } as BetaCacheControlEphemeral, - system: [{ type: 'text', text: AGENT_SDK_PREFIX }], - messages, - thinking: { - type: 'adaptive', - }, - stream: true, - } satisfies BetaMessageStreamParams; - - const betas = Object.entries(options.betas ?? {}) - .filter(([, enabled]) => enabled) - .map(([beta]) => beta) - .join(','); - - const requestOptions = { - headers: { - 'anthropic-beta': betas, - }, - } satisfies RequestOptions; - - this.#logger?.info('Sending request', { - model: options.model, - max_tokens: options.maxTokens, - tools: options.tools.map((t) => ({ - name: t.name, - description: t.description, - })), - cache_control: { type: 'ephemeral', scope: 'global' } as BetaCacheControlEphemeral, - thinking: { - type: 'adaptive', - }, - stream: true, - headers: requestOptions.headers, - }); - return this.#client.beta.messages.stream(body, requestOptions); - } - - private handleTools(tools: AnyToolDefinition[], toolUses: ToolUseResult[], store: Map) { - const toolResults: Anthropic.Beta.Messages.BetaToolResultBlockParam[] = []; - for (const toolUse of toolUses) { - const tool = tools.find((t) => t.name === toolUse.name); - this.#logger?.debug('tool_call', { name: toolUse.name, input: toolUse.input, found: tool != null }); - if (tool == null) { - const content = `Tool not found: ${toolUse.name}`; - this.#logger?.debug('tool_result_error', { name: toolUse.name, content }); - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - is_error: true, - content, - }); - continue; - } - const parseResult = tool.input_schema.safeParse(toolUse.input); - if (!parseResult.success) { - this.#logger?.debug('tool_parse_error', { name: toolUse.name, error: parseResult.error }); - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - is_error: true, - content: `Invalid input: ${parseResult.error.message}`, - }); - continue; - } - const handler = tool.handler as (input: unknown, store: Map) => unknown; - let toolOutput: unknown; - try { - toolOutput = handler(parseResult.data, store); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.#logger?.debug('tool_handler_error', { name: toolUse.name, error: message }); - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - is_error: true, - content: message, - }); - continue; - } - this.#logger?.debug('tool_result', { name: toolUse.name, output: toolOutput }); - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - content: typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput), - }); - } - return toolResults; - } -} diff --git a/packages/claude-sdk/src/public/createAnthropicAgent.ts b/packages/claude-sdk/src/public/createAnthropicAgent.ts new file mode 100644 index 0000000..e57f204 --- /dev/null +++ b/packages/claude-sdk/src/public/createAnthropicAgent.ts @@ -0,0 +1,8 @@ +import { AnthropicAgent } from "../private/AnthropicAgent"; +import { IAnthropicAgent } from "./interfaces"; +import { AnthropicAgentOptions } from "./types"; + + +export const createAnthropicAgent = (options: AnthropicAgentOptions): IAnthropicAgent => { + return new AnthropicAgent(options); +}; diff --git a/packages/claude-sdk/src/public/enums.ts b/packages/claude-sdk/src/public/enums.ts new file mode 100644 index 0000000..98a597f --- /dev/null +++ b/packages/claude-sdk/src/public/enums.ts @@ -0,0 +1,9 @@ +export enum AnthropicBeta { + InterleavedThinking = 'interleaved-thinking-2025-05-14', + ContextManagement = 'context-management-2025-06-27', + PromptCachingScope = 'prompt-caching-scope-2026-01-05', + Effort = 'effort-2025-11-24', + AdvancedToolUse = 'advanced-tool-use-2025-11-20', + ToolSearchTool = 'tool-search-tool-2025-10-19', + TokenEfficientTools = 'token-efficient-tools-2026-03-28', +} \ No newline at end of file diff --git a/packages/claude-sdk/src/public/interfaces.ts b/packages/claude-sdk/src/public/interfaces.ts new file mode 100644 index 0000000..593b914 --- /dev/null +++ b/packages/claude-sdk/src/public/interfaces.ts @@ -0,0 +1,7 @@ +import { RunAgentQuery, RunAgentResult } from "./types"; + +export abstract class IAnthropicAgent { + public abstract runAgent(options: RunAgentQuery): RunAgentResult; +} + + diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index 8680034..2575302 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -1,6 +1,7 @@ -import type { UUID } from 'node:crypto'; +import type { MessagePort } from 'node:worker_threads'; import type { Model } from '@anthropic-ai/sdk/resources/messages'; import type { z } from 'zod'; +import { AnthropicBeta } from './enums'; export type ChainedToolStore = Map; @@ -9,7 +10,7 @@ export type ToolDefinition = { description: string; input_schema: z.ZodType; input_examples: TInput[]; - handler: (input: TInput, store: ChainedToolStore) => TOutput; + handler: (input: TInput, store: ChainedToolStore) => Promise; }; export type JsonValue = string | number | boolean | JsonObject | JsonValue[]; @@ -22,19 +23,9 @@ export type AnyToolDefinition = { description: string; input_schema: z.ZodType; input_examples: JsonObject[]; - handler: (input: never, store: ChainedToolStore) => unknown; + handler: (input: never, store: ChainedToolStore) => Promise; }; -export enum AnthropicBeta { - InterleavedThinking = 'interleaved-thinking-2025-05-14', - ContextManagement = 'context-management-2025-06-27', - PromptCachingScope = 'prompt-caching-scope-2026-01-05', - Effort = 'effort-2025-11-24', - AdvancedToolUse = 'advanced-tool-use-2025-11-20', - ToolSearchTool = 'tool-search-tool-2025-10-19', - TokenEfficientTools = 'token-efficient-tools-2026-03-28', -} - export type AnthropicBetaFlags = Partial>; export type RunAgentQuery = { @@ -43,17 +34,27 @@ export type RunAgentQuery = { messages: string[]; tools: AnyToolDefinition[]; betas?: AnthropicBetaFlags; + requireToolApproval?: boolean; }; -export type AgentEvents = { - message_start: []; - message_text: [text: string]; - message_end: []; +/** Messages sent from the SDK to the consumer via the MessagePort. */ +export type SdkMessage = + | { type: 'message_start' } + | { type: 'message_text'; text: string } + | { type: 'message_end' } + | { type: 'tool_approval_request'; requestId: string; name: string; input: Record } + | { type: 'done'; stopReason: string } + | { type: 'error'; message: string }; + +/** Messages sent from the consumer to the SDK via the MessagePort. */ +export type ConsumerMessage = + | { type: 'tool_approval_response'; requestId: string; approved: boolean; reason?: string } + | { type: 'cancel' }; - tool_use: [name: string, input: Record]; - session_id: [sessionId: UUID]; - done: [stopReason: string]; - error: [err: Error]; +/** Returned by runAgent: port2 for the consumer, done resolves when the agent finishes. */ +export type RunAgentResult = { + port: MessagePort; + done: Promise; }; export type ILogger = { From fcebb5ec5393cfa65cc94cdbfc4c23cb39dd738b Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Fri, 3 Apr 2026 16:57:27 +1100 Subject: [PATCH 2/5] Provide useful error --- apps/claude-sdk-cli/src/main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/claude-sdk-cli/src/main.ts b/apps/claude-sdk-cli/src/main.ts index fc4cef5..c0049db 100644 --- a/apps/claude-sdk-cli/src/main.ts +++ b/apps/claude-sdk-cli/src/main.ts @@ -4,8 +4,14 @@ import { editConfirmTool } from './tools/edit/editConfirmTool'; import { editTool } from './tools/edit/editTool'; const main = async () => { + const apiKey = process.env.CLAUDE_CODE_API_KEY; + if (!apiKey) { + logger.error('CLAUDE_CODE_API_KEY is not set'); + process.exit(1); + } + const agent = createAnthropicAgent({ - apiKey: process.env.CLAUDE_CODE_API_KEY ?? 'no-key', + apiKey, logger, }); From e44067d3d28af66a3527c6ec385d3e885c8c2f79 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Fri, 3 Apr 2026 17:03:48 +1100 Subject: [PATCH 3/5] Linting --- packages/claude-sdk/src/index.ts | 2 +- packages/claude-sdk/src/private/AgentRun.ts | 2 +- packages/claude-sdk/src/private/AnthropicAgent.ts | 2 +- .../claude-sdk/src/public/createAnthropicAgent.ts | 7 +++---- packages/claude-sdk/src/public/enums.ts | 2 +- packages/claude-sdk/src/public/interfaces.ts | 4 +--- packages/claude-sdk/src/public/types.ts | 14 +++----------- 7 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/claude-sdk/src/index.ts b/packages/claude-sdk/src/index.ts index 6995708..bd08e1d 100644 --- a/packages/claude-sdk/src/index.ts +++ b/packages/claude-sdk/src/index.ts @@ -4,4 +4,4 @@ import { IAnthropicAgent } from './public/interfaces'; import type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ConsumerMessage, ILogger, JsonObject, JsonValue, RunAgentQuery, RunAgentResult, SdkMessage, ToolDefinition } from './public/types'; export type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ConsumerMessage, ILogger, JsonObject, JsonValue, RunAgentQuery, RunAgentResult, SdkMessage, ToolDefinition }; -export { createAnthropicAgent, IAnthropicAgent, AnthropicBeta }; +export { AnthropicBeta, createAnthropicAgent, IAnthropicAgent }; diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index f2498ed..1d4da2e 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { RequestOptions } from 'node:http'; import type { MessagePort } from 'node:worker_threads'; -import { Anthropic } from '@anthropic-ai/sdk'; +import type { Anthropic } from '@anthropic-ai/sdk'; import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js'; import type { BetaCacheControlEphemeral } from '@anthropic-ai/sdk/resources/beta.mjs'; import { z } from 'zod'; diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index 4eae192..9c5979a 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -1,6 +1,6 @@ import { Anthropic } from '@anthropic-ai/sdk'; -import { AgentRun } from './AgentRun'; import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } from '../public/types'; +import { AgentRun } from './AgentRun'; export class AnthropicAgent { readonly #client: Anthropic; diff --git a/packages/claude-sdk/src/public/createAnthropicAgent.ts b/packages/claude-sdk/src/public/createAnthropicAgent.ts index e57f204..78ec811 100644 --- a/packages/claude-sdk/src/public/createAnthropicAgent.ts +++ b/packages/claude-sdk/src/public/createAnthropicAgent.ts @@ -1,7 +1,6 @@ -import { AnthropicAgent } from "../private/AnthropicAgent"; -import { IAnthropicAgent } from "./interfaces"; -import { AnthropicAgentOptions } from "./types"; - +import { AnthropicAgent } from '../private/AnthropicAgent'; +import type { IAnthropicAgent } from './interfaces'; +import type { AnthropicAgentOptions } from './types'; export const createAnthropicAgent = (options: AnthropicAgentOptions): IAnthropicAgent => { return new AnthropicAgent(options); diff --git a/packages/claude-sdk/src/public/enums.ts b/packages/claude-sdk/src/public/enums.ts index 98a597f..27c2ad1 100644 --- a/packages/claude-sdk/src/public/enums.ts +++ b/packages/claude-sdk/src/public/enums.ts @@ -6,4 +6,4 @@ export enum AnthropicBeta { AdvancedToolUse = 'advanced-tool-use-2025-11-20', ToolSearchTool = 'tool-search-tool-2025-10-19', TokenEfficientTools = 'token-efficient-tools-2026-03-28', -} \ No newline at end of file +} diff --git a/packages/claude-sdk/src/public/interfaces.ts b/packages/claude-sdk/src/public/interfaces.ts index 593b914..5604c5a 100644 --- a/packages/claude-sdk/src/public/interfaces.ts +++ b/packages/claude-sdk/src/public/interfaces.ts @@ -1,7 +1,5 @@ -import { RunAgentQuery, RunAgentResult } from "./types"; +import type { RunAgentQuery, RunAgentResult } from './types'; export abstract class IAnthropicAgent { public abstract runAgent(options: RunAgentQuery): RunAgentResult; } - - diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index 2575302..1417097 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -1,7 +1,7 @@ import type { MessagePort } from 'node:worker_threads'; import type { Model } from '@anthropic-ai/sdk/resources/messages'; import type { z } from 'zod'; -import { AnthropicBeta } from './enums'; +import type { AnthropicBeta } from './enums'; export type ChainedToolStore = Map; @@ -38,18 +38,10 @@ export type RunAgentQuery = { }; /** Messages sent from the SDK to the consumer via the MessagePort. */ -export type SdkMessage = - | { type: 'message_start' } - | { type: 'message_text'; text: string } - | { type: 'message_end' } - | { type: 'tool_approval_request'; requestId: string; name: string; input: Record } - | { type: 'done'; stopReason: string } - | { type: 'error'; message: string }; +export type SdkMessage = { type: 'message_start' } | { type: 'message_text'; text: string } | { type: 'message_end' } | { type: 'tool_approval_request'; requestId: string; name: string; input: Record } | { type: 'done'; stopReason: string } | { type: 'error'; message: string }; /** Messages sent from the consumer to the SDK via the MessagePort. */ -export type ConsumerMessage = - | { type: 'tool_approval_response'; requestId: string; approved: boolean; reason?: string } - | { type: 'cancel' }; +export type ConsumerMessage = { type: 'tool_approval_response'; requestId: string; approved: boolean; reason?: string } | { type: 'cancel' }; /** Returned by runAgent: port2 for the consumer, done resolves when the agent finishes. */ export type RunAgentResult = { From 712c99061da824aa35390991706aebadff855c38 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Fri, 3 Apr 2026 17:36:25 +1100 Subject: [PATCH 4/5] Fix linting and refactor tool approval flow --- .../claude-sdk/src/private/AgentChannel.ts | 8 +- packages/claude-sdk/src/private/AgentRun.ts | 97 ++++++++++++------- .../claude-sdk/src/private/AnthropicAgent.ts | 4 +- .../claude-sdk/src/private/ApprovalState.ts | 6 +- 4 files changed, 70 insertions(+), 45 deletions(-) diff --git a/packages/claude-sdk/src/private/AgentChannel.ts b/packages/claude-sdk/src/private/AgentChannel.ts index dacbb08..c26dea1 100644 --- a/packages/claude-sdk/src/private/AgentChannel.ts +++ b/packages/claude-sdk/src/private/AgentChannel.ts @@ -3,20 +3,20 @@ import type { ConsumerMessage, SdkMessage } from '../public/types'; export class AgentChannel { readonly #port: MessagePort; - readonly consumerPort: MessagePort; + public readonly consumerPort: MessagePort; - constructor(onMessage: (msg: ConsumerMessage) => void) { + public constructor(onMessage: (msg: ConsumerMessage) => void) { const { port1, port2 } = new MessageChannel(); this.#port = port1; this.consumerPort = port2; port1.on('message', onMessage); } - send(msg: SdkMessage): void { + public send(msg: SdkMessage): void { this.#port.postMessage(msg); } - close(): void { + public close(): void { this.#port.close(); } } diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index 1d4da2e..5de6d57 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -19,7 +19,7 @@ export class AgentRun { readonly #channel: AgentChannel; readonly #approval: ApprovalState; - constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery) { + public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery) { this.#client = client; this.#logger = logger; this.#options = options; @@ -27,11 +27,11 @@ export class AgentRun { this.#channel = new AgentChannel((msg) => this.#approval.handle(msg)); } - get port(): MessagePort { + public get port(): MessagePort { return this.#channel.consumerPort; } - async execute(): Promise { + public async execute(): Promise { const messages: Anthropic.Beta.Messages.BetaMessageParam[] = this.#options.messages.map((content) => ({ role: 'user', content, @@ -125,18 +125,42 @@ export class AgentRun { } async #handleTools(toolUses: ToolUseResult[], store: ChainedToolStore): Promise { - const tools: AnyToolDefinition[] = this.#options.tools; const requireApproval = this.#options.requireToolApproval ?? false; const toolResults: Anthropic.Beta.Messages.BetaToolResultBlockParam[] = []; + // Resolve tools first. Error immediately for any that don't exist. + const resolved = []; for (const toolUse of toolUses) { - if (this.#approval.cancelled) break; + const tool = this.#options.tools.find((t) => t.name === toolUse.name); + if (tool == null) { + const content = `Tool not found: ${toolUse.name}`; + this.#logger?.debug('tool_result_error', { name: toolUse.name, content }); + toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content }); + continue; + } + resolved.push({ toolUse, tool }); + } - if (requireApproval) { + if (requireApproval) { + // Send all approval requests to the consumer at once + const pending = resolved.map(({ toolUse, tool }) => { const requestId = randomUUID(); - const response = await this.#approval.request(requestId, () => { - this.#channel.send({ type: 'tool_approval_request', requestId, name: toolUse.name, input: toolUse.input } satisfies SdkMessage); - }); + return { + toolUse, + tool, + promise: this.#approval.request(requestId, () => { + this.#channel.send({ type: 'tool_approval_request', requestId, name: toolUse.name, input: toolUse.input } satisfies SdkMessage); + }), + }; + }); + + // Execute tools in the order approvals arrive + while (pending.length > 0) { + if (this.#approval.cancelled) { + break; + } + const { toolUse, tool, response, index } = await Promise.race(pending.map((item, idx) => item.promise.then((response) => ({ toolUse: item.toolUse, tool: item.tool, response, index: idx })))); + pending.splice(index, 1); if (!response.approved) { const content = response.reason ?? 'Tool use rejected'; @@ -144,43 +168,42 @@ export class AgentRun { toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content }); continue; } - } - const tool = tools.find((t) => t.name === toolUse.name); - this.#logger?.debug('tool_call', { name: toolUse.name, input: toolUse.input, found: tool != null }); - if (tool == null) { - const content = `Tool not found: ${toolUse.name}`; - this.#logger?.debug('tool_result_error', { name: toolUse.name, content }); - toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content }); - continue; + toolResults.push(await this.#executeTool(toolUse, tool, store)); } - - const parseResult = tool.input_schema.safeParse(toolUse.input); - if (!parseResult.success) { - this.#logger?.debug('tool_parse_error', { name: toolUse.name, error: parseResult.error }); - toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: `Invalid input: ${parseResult.error.message}` }); - continue; + } else { + for (const { toolUse, tool } of resolved) { + if (this.#approval.cancelled) { + break; + } + toolResults.push(await this.#executeTool(toolUse, tool, store)); } + } - const handler = tool.handler as (input: unknown, store: Map) => Promise; - let toolOutput: unknown; - try { - toolOutput = await handler(parseResult.data, store); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.#logger?.debug('tool_handler_error', { name: toolUse.name, error: message }); - toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: message }); - continue; - } + return toolResults; + } + + async #executeTool(toolUse: ToolUseResult, tool: AnyToolDefinition, store: ChainedToolStore): Promise { + this.#logger?.debug('tool_call', { name: toolUse.name, input: toolUse.input }); + const parseResult = tool.input_schema.safeParse(toolUse.input); + if (!parseResult.success) { + this.#logger?.debug('tool_parse_error', { name: toolUse.name, error: parseResult.error }); + return { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: `Invalid input: ${parseResult.error.message}` }; + } + const handler = tool.handler as (input: unknown, store: Map) => Promise; + try { + const toolOutput = await handler(parseResult.data, store); this.#logger?.debug('tool_result', { name: toolUse.name, output: toolOutput }); - toolResults.push({ + return { type: 'tool_result', tool_use_id: toolUse.id, content: typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput), - }); + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.#logger?.debug('tool_handler_error', { name: toolUse.name, error: message }); + return { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: message }; } - - return toolResults; } } diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index 9c5979a..4a73a12 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -1,12 +1,14 @@ import { Anthropic } from '@anthropic-ai/sdk'; +import { IAnthropicAgent } from '../public/interfaces'; import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } from '../public/types'; import { AgentRun } from './AgentRun'; -export class AnthropicAgent { +export class AnthropicAgent extends IAnthropicAgent { readonly #client: Anthropic; readonly #logger: ILogger | undefined; public constructor(options: AnthropicAgentOptions) { + super(); this.#logger = options.logger; this.#client = new Anthropic({ apiKey: options.apiKey }); } diff --git a/packages/claude-sdk/src/private/ApprovalState.ts b/packages/claude-sdk/src/private/ApprovalState.ts index cf94ad0..b856f19 100644 --- a/packages/claude-sdk/src/private/ApprovalState.ts +++ b/packages/claude-sdk/src/private/ApprovalState.ts @@ -5,11 +5,11 @@ export class ApprovalState { readonly #pending = new Map void>(); #cancelled = false; - get cancelled(): boolean { + public get cancelled(): boolean { return this.#cancelled; } - handle(msg: ConsumerMessage): void { + public handle(msg: ConsumerMessage): void { if (msg.type === 'tool_approval_response') { const resolve = this.#pending.get(msg.requestId); if (resolve != null) { @@ -21,7 +21,7 @@ export class ApprovalState { } } - request(requestId: string, onRequest: () => void): Promise { + public request(requestId: string, onRequest: () => void): Promise { return new Promise((resolve) => { this.#pending.set(requestId, resolve); onRequest(); From cdb98111cebfd70d9f3d8b6af0af306a54a7733f Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Fri, 3 Apr 2026 17:46:48 +1100 Subject: [PATCH 5/5] Validate tool input before requesting approval --- packages/claude-sdk/src/private/AgentRun.ts | 31 +++++++++++---------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index 5de6d57..72332dc 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -128,7 +128,7 @@ export class AgentRun { const requireApproval = this.#options.requireToolApproval ?? false; const toolResults: Anthropic.Beta.Messages.BetaToolResultBlockParam[] = []; - // Resolve tools first. Error immediately for any that don't exist. + // Resolve tools and validate input first. Error immediately without requesting approval. const resolved = []; for (const toolUse of toolUses) { const tool = this.#options.tools.find((t) => t.name === toolUse.name); @@ -138,16 +138,23 @@ export class AgentRun { toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content }); continue; } - resolved.push({ toolUse, tool }); + const parseResult = tool.input_schema.safeParse(toolUse.input); + if (!parseResult.success) { + this.#logger?.debug('tool_parse_error', { name: toolUse.name, error: parseResult.error }); + toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: `Invalid input: ${parseResult.error.message}` }); + continue; + } + resolved.push({ toolUse, tool, input: parseResult.data }); } if (requireApproval) { // Send all approval requests to the consumer at once - const pending = resolved.map(({ toolUse, tool }) => { + const pending = resolved.map(({ toolUse, tool, input }) => { const requestId = randomUUID(); return { toolUse, tool, + input, promise: this.#approval.request(requestId, () => { this.#channel.send({ type: 'tool_approval_request', requestId, name: toolUse.name, input: toolUse.input } satisfies SdkMessage); }), @@ -159,7 +166,7 @@ export class AgentRun { if (this.#approval.cancelled) { break; } - const { toolUse, tool, response, index } = await Promise.race(pending.map((item, idx) => item.promise.then((response) => ({ toolUse: item.toolUse, tool: item.tool, response, index: idx })))); + const { toolUse, tool, input, response, index } = await Promise.race(pending.map((item, idx) => item.promise.then((response) => ({ toolUse: item.toolUse, tool: item.tool, input: item.input, response, index: idx })))); pending.splice(index, 1); if (!response.approved) { @@ -169,31 +176,25 @@ export class AgentRun { continue; } - toolResults.push(await this.#executeTool(toolUse, tool, store)); + toolResults.push(await this.#executeTool(toolUse, tool, input, store)); } } else { - for (const { toolUse, tool } of resolved) { + for (const { toolUse, tool, input } of resolved) { if (this.#approval.cancelled) { break; } - toolResults.push(await this.#executeTool(toolUse, tool, store)); + toolResults.push(await this.#executeTool(toolUse, tool, input, store)); } } return toolResults; } - async #executeTool(toolUse: ToolUseResult, tool: AnyToolDefinition, store: ChainedToolStore): Promise { + async #executeTool(toolUse: ToolUseResult, tool: AnyToolDefinition, input: unknown, store: ChainedToolStore): Promise { this.#logger?.debug('tool_call', { name: toolUse.name, input: toolUse.input }); - const parseResult = tool.input_schema.safeParse(toolUse.input); - if (!parseResult.success) { - this.#logger?.debug('tool_parse_error', { name: toolUse.name, error: parseResult.error }); - return { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: `Invalid input: ${parseResult.error.message}` }; - } - const handler = tool.handler as (input: unknown, store: Map) => Promise; try { - const toolOutput = await handler(parseResult.data, store); + const toolOutput = await handler(input, store); this.#logger?.debug('tool_result', { name: toolUse.name, output: toolOutput }); return { type: 'tool_result',