diff --git a/README.md b/README.md index 70ed1c0..a0f7492 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) -Open-source Agent SDK that runs the full agent loop **in-process** — no subprocess or CLI required. Deploy anywhere: cloud, serverless, Docker, CI/CD. +Open-source Agent SDK that runs the full agent loop **in-process** — no subprocess or CLI required. Supports both **Anthropic** and **OpenAI-compatible** APIs. Deploy anywhere: cloud, serverless, Docker, CI/CD. Also available in **Go**: [open-agent-sdk-go](https://github.com/codeany-ai/open-agent-sdk-go) @@ -20,7 +20,18 @@ Set your API key: export CODEANY_API_KEY=your-api-key ``` -Third-party providers (e.g. OpenRouter) are supported via `CODEANY_BASE_URL`: +### OpenAI-compatible models + +Works with OpenAI, DeepSeek, Qwen, Mistral, or any OpenAI-compatible endpoint: + +```bash +export CODEANY_API_TYPE=openai-completions +export CODEANY_API_KEY=sk-... +export CODEANY_BASE_URL=https://api.openai.com/v1 +export CODEANY_MODEL=gpt-4o +``` + +### Third-party Anthropic-compatible providers ```bash export CODEANY_BASE_URL=https://openrouter.ai/api @@ -64,6 +75,24 @@ console.log( ); ``` +### OpenAI / GPT models + +```typescript +import { createAgent } from "@codeany/open-agent-sdk"; + +const agent = createAgent({ + apiType: "openai-completions", + model: "gpt-4o", + apiKey: "sk-...", + baseURL: "https://api.openai.com/v1", +}); + +const result = await agent.prompt("What files are in this project?"); +console.log(result.text); +``` + +The `apiType` is auto-detected from model name — models containing `gpt-`, `o1`, `o3`, `deepseek`, `qwen`, `mistral`, etc. automatically use `openai-completions`. + ### Multi-turn conversation ```typescript @@ -137,6 +166,66 @@ const r = await agent.prompt("Calculate 2**10 * 3"); console.log(r.text); ``` +### Skills + +Skills are reusable prompt templates that extend agent capabilities. Five bundled skills are included: `simplify`, `commit`, `review`, `debug`, `test`. + +```typescript +import { + createAgent, + registerSkill, + getAllSkills, +} from "@codeany/open-agent-sdk"; + +// Register a custom skill +registerSkill({ + name: "explain", + description: "Explain a concept in simple terms", + userInvocable: true, + async getPrompt(args) { + return [ + { + type: "text", + text: `Explain in simple terms: ${args || "Ask what to explain."}`, + }, + ]; + }, +}); + +console.log(`${getAllSkills().length} skills registered`); + +// The model can invoke skills via the Skill tool +const agent = createAgent(); +const result = await agent.prompt('Use the "explain" skill to explain git rebase'); +console.log(result.text); +``` + +### Hooks (lifecycle events) + +```typescript +import { createAgent, createHookRegistry } from "@codeany/open-agent-sdk"; + +const hooks = createHookRegistry({ + PreToolUse: [ + { + handler: async (input) => { + console.log(`About to use: ${input.toolName}`); + // Return { block: true } to prevent tool execution + }, + }, + ], + PostToolUse: [ + { + handler: async (input) => { + console.log(`Tool ${input.toolName} completed`); + }, + }, + ], +}); +``` + +20 lifecycle events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `SessionStart`, `SessionEnd`, `Stop`, `SubagentStart`, `SubagentStop`, `UserPromptSubmit`, `PermissionRequest`, `PermissionDenied`, `TaskCreated`, `TaskCompleted`, `ConfigChange`, `CwdChanged`, `FileChanged`, `Notification`, `PreCompact`, `PostCompact`, `TeammateIdle`. + ### MCP server integration ```typescript @@ -214,9 +303,12 @@ npx tsx examples/web/server.ts | `tool(name, desc, schema, handler)` | Create a tool with Zod schema validation | | `createSdkMcpServer({ name, tools })` | Bundle tools into an in-process MCP server | | `defineTool(config)` | Low-level tool definition helper | -| `getAllBaseTools()` | Get all 34 built-in tools | +| `getAllBaseTools()` | Get all 35+ built-in tools | +| `registerSkill(definition)` | Register a custom skill | +| `getAllSkills()` | Get all registered skills | +| `createProvider(apiType, opts)` | Create an LLM provider directly | +| `createHookRegistry(config)` | Create a hook registry for lifecycle events | | `listSessions()` | List persisted sessions | -| `getSessionMessages(id)` | Retrieve messages from a session | | `forkSession(id)` | Fork a session for branching | ### Agent methods @@ -230,12 +322,14 @@ npx tsx examples/web/server.ts | `agent.interrupt()` | Abort current query | | `agent.setModel(model)` | Change model mid-session | | `agent.setPermissionMode(mode)` | Change permission mode | +| `agent.getApiType()` | Get current API type | | `agent.close()` | Close MCP connections, persist session | ### Options | Option | Type | Default | Description | | -------------------- | --------------------------------------- | ---------------------- | -------------------------------------------------------------------- | +| `apiType` | `string` | auto-detected | `'anthropic-messages'` or `'openai-completions'` | | `model` | `string` | `claude-sonnet-4-6` | LLM model ID | | `apiKey` | `string` | `CODEANY_API_KEY` | API key | | `baseURL` | `string` | — | Custom API endpoint | @@ -266,12 +360,13 @@ npx tsx examples/web/server.ts ### Environment variables -| Variable | Description | -| -------------------- | ---------------------- | -| `CODEANY_API_KEY` | API key (required) | -| `CODEANY_MODEL` | Default model override | -| `CODEANY_BASE_URL` | Custom API endpoint | -| `CODEANY_AUTH_TOKEN` | Alternative auth token | +| Variable | Description | +| -------------------- | -------------------------------------------------------- | +| `CODEANY_API_KEY` | API key (required) | +| `CODEANY_API_TYPE` | `anthropic-messages` (default) or `openai-completions` | +| `CODEANY_MODEL` | Default model override | +| `CODEANY_BASE_URL` | Custom API endpoint | +| `CODEANY_AUTH_TOKEN` | Alternative auth token | ## Built-in tools @@ -287,6 +382,7 @@ npx tsx examples/web/server.ts | **WebSearch** | Search the web | | **NotebookEdit** | Edit Jupyter notebook cells | | **Agent** | Spawn subagents for parallel work | +| **Skill** | Invoke registered skills | | **TaskCreate/List/Update/Get/Stop/Output** | Task management system | | **TeamCreate/Delete** | Multi-agent team coordination | | **SendMessage** | Inter-agent messaging | @@ -301,6 +397,18 @@ npx tsx examples/web/server.ts | **Config** | Dynamic configuration | | **TodoWrite** | Session todo list | +## Bundled skills + +| Skill | Description | +| ------------ | -------------------------------------------------------------- | +| `simplify` | Review changed code for reuse, quality, and efficiency | +| `commit` | Create a git commit with a well-crafted message | +| `review` | Review code changes for correctness, security, and performance | +| `debug` | Systematic debugging using structured investigation | +| `test` | Run tests and analyze failures | + +Register custom skills with `registerSkill()`. + ## Architecture ``` @@ -312,7 +420,7 @@ npx tsx examples/web/server.ts │ ┌──────────▼──────────┐ │ Agent │ Session state, tool pool, - │ query() / prompt() │ MCP connections + │ query() / prompt() │ MCP connections, hooks └──────────┬──────────┘ │ ┌──────────▼──────────┐ @@ -323,26 +431,28 @@ npx tsx examples/web/server.ts ┌───────────────┼───────────────┐ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ - │ LLM API │ │ 34 Tools │ │ MCP │ - │ Client │ │ Bash,Read │ │ Servers │ - │ (streaming)│ │ Edit,... │ │ stdio/SSE/ │ - └───────────┘ └───────────┘ │ HTTP/SDK │ - └───────────┘ + │ Provider │ │ 35 Tools │ │ MCP │ + │ Anthropic │ │ Bash,Read │ │ Servers │ + │ OpenAI │ │ Edit,... │ │ stdio/SSE/ │ + │ DeepSeek │ │ + Skills │ │ HTTP/SDK │ + └───────────┘ └───────────┘ └───────────┘ ``` **Key internals:** -| Component | Description | -| --------------------- | ---------------------------------------------------------------- | -| **QueryEngine** | Core agentic loop with auto-compact, retry, tool orchestration | -| **Auto-compact** | Summarizes conversation when context window fills up | -| **Micro-compact** | Truncates oversized tool results | -| **Retry** | Exponential backoff for rate limits and transient errors | -| **Token estimation** | Rough token counting for budget and compaction thresholds | -| **File cache** | LRU cache (100 entries, 25 MB) for file reads | -| **Hook system** | 20 lifecycle events (PreToolUse, PostToolUse, SessionStart, ...) | -| **Session storage** | Persist / resume / fork sessions on disk | -| **Context injection** | Git status + AGENT.md automatically injected into system prompt | +| Component | Description | +| --------------------- | ------------------------------------------------------------------ | +| **Provider layer** | Abstracts Anthropic / OpenAI API differences | +| **QueryEngine** | Core agentic loop with auto-compact, retry, tool orchestration | +| **Skill system** | Reusable prompt templates with 5 bundled skills | +| **Hook system** | 20 lifecycle events integrated into the engine | +| **Auto-compact** | Summarizes conversation when context window fills up | +| **Micro-compact** | Truncates oversized tool results | +| **Retry** | Exponential backoff for rate limits and transient errors | +| **Token estimation** | Rough token counting with pricing for Claude, GPT, DeepSeek models | +| **File cache** | LRU cache (100 entries, 25 MB) for file reads | +| **Session storage** | Persist / resume / fork sessions on disk | +| **Context injection** | Git status + AGENT.md automatically injected into system prompt | ## Examples @@ -359,6 +469,9 @@ npx tsx examples/web/server.ts | 09 | `examples/09-subagents.ts` | Subagent delegation | | 10 | `examples/10-permissions.ts` | Read-only agent with tool restrictions | | 11 | `examples/11-custom-mcp-tools.ts` | `tool()` + `createSdkMcpServer()` | +| 12 | `examples/12-skills.ts` | Skill system usage | +| 13 | `examples/13-hooks.ts` | Lifecycle hooks | +| 14 | `examples/14-openai-compat.ts` | OpenAI / DeepSeek models | | web | `examples/web/` | Web chat UI for testing | Run any example: diff --git a/examples/12-skills.ts b/examples/12-skills.ts new file mode 100644 index 0000000..b373e94 --- /dev/null +++ b/examples/12-skills.ts @@ -0,0 +1,88 @@ +/** + * Example 12: Skills + * + * Shows how to use the skill system: bundled skills, custom skills, + * and invoking skills programmatically. + * + * Run: npx tsx examples/12-skills.ts + */ +import { + createAgent, + registerSkill, + getAllSkills, + getUserInvocableSkills, + getSkill, + initBundledSkills, +} from '../src/index.js' +import type { SkillContentBlock } from '../src/index.js' + +async function main() { + console.log('--- Example 12: Skills ---\n') + + // Bundled skills are auto-initialized when creating an Agent, + // but you can also init them explicitly: + initBundledSkills() + + // List all registered skills + const all = getAllSkills() + console.log(`Registered skills (${all.length}):`) + for (const skill of all) { + console.log(` - ${skill.name}: ${skill.description.slice(0, 80)}...`) + } + + // Register a custom skill + registerSkill({ + name: 'explain', + description: 'Explain a concept or piece of code in simple terms.', + aliases: ['eli5'], + userInvocable: true, + async getPrompt(args): Promise { + return [{ + type: 'text', + text: `Explain the following in simple, clear terms that a beginner could understand. Use analogies where helpful.\n\nTopic: ${args || 'Ask the user what they want explained.'}`, + }] + }, + }) + + console.log(`\nAfter registering custom skill: ${getAllSkills().length} total`) + console.log(`User-invocable: ${getUserInvocableSkills().length}`) + + // Get a specific skill + const commitSkill = getSkill('commit') + if (commitSkill) { + const blocks = await commitSkill.getPrompt('', { cwd: process.cwd() }) + console.log(`\nCommit skill prompt (first 200 chars):`) + console.log(blocks[0]?.type === 'text' ? blocks[0].text.slice(0, 200) + '...' : '(non-text)') + } + + // Use skills with an agent - the model can invoke them via the Skill tool + console.log('\n--- Using skills with an agent ---\n') + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 5, + }) + + for await (const event of agent.query( + 'Use the "explain" skill to explain what git rebase does.', + )) { + const msg = event as any + if (msg.type === 'assistant') { + for (const block of msg.message?.content || []) { + if (block.type === 'tool_use') { + console.log(`[Tool: ${block.name}] ${JSON.stringify(block.input)}`) + } + if (block.type === 'text' && block.text.trim()) { + console.log(block.text) + } + } + } + if (msg.type === 'result') { + console.log(`\n--- ${msg.subtype} ---`) + } + } + + await agent.close() +} + +main().catch(console.error) diff --git a/examples/13-hooks.ts b/examples/13-hooks.ts new file mode 100644 index 0000000..46068c8 --- /dev/null +++ b/examples/13-hooks.ts @@ -0,0 +1,88 @@ +/** + * Example 13: Hooks + * + * Shows how to use lifecycle hooks to intercept agent behavior. + * Hooks fire at key points: session start/end, before/after tool use, + * compaction, etc. + * + * Run: npx tsx examples/13-hooks.ts + */ +import { createAgent, createHookRegistry } from '../src/index.js' +import type { HookInput } from '../src/index.js' + +async function main() { + console.log('--- Example 13: Hooks ---\n') + + // Create a hook registry with custom handlers + const registry = createHookRegistry({ + SessionStart: [{ + handler: async (input: HookInput) => { + console.log(`[Hook] Session started: ${input.sessionId}`) + }, + }], + PreToolUse: [{ + handler: async (input: HookInput) => { + console.log(`[Hook] About to use tool: ${input.toolName}`) + // You can block a tool by returning { block: true } + // return { block: true, message: 'Tool blocked by hook' } + }, + }], + PostToolUse: [{ + handler: async (input: HookInput) => { + const output = typeof input.toolOutput === 'string' + ? input.toolOutput.slice(0, 100) + : JSON.stringify(input.toolOutput).slice(0, 100) + console.log(`[Hook] Tool ${input.toolName} completed: ${output}...`) + }, + }], + PostToolUseFailure: [{ + handler: async (input: HookInput) => { + console.log(`[Hook] Tool ${input.toolName} FAILED: ${input.error}`) + }, + }], + Stop: [{ + handler: async () => { + console.log('[Hook] Agent loop completed') + }, + }], + SessionEnd: [{ + handler: async () => { + console.log('[Hook] Session ended') + }, + }], + }) + + // Create agent with hook registry + // Note: For direct HookRegistry usage, we pass hooks via the engine config. + // The AgentOptions.hooks format also works (see below). + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 5, + // Alternative: use AgentOptions.hooks format + hooks: { + PreToolUse: [{ + hooks: [async (input: any, toolUseId: string) => { + console.log(`[AgentHook] PreToolUse: ${input.toolName} (${toolUseId})`) + }], + }], + }, + }) + + for await (const event of agent.query('What files are in the current directory? Be brief.')) { + const msg = event as any + if (msg.type === 'assistant') { + for (const block of msg.message?.content || []) { + if (block.type === 'text' && block.text.trim()) { + console.log(`\nAssistant: ${block.text.slice(0, 200)}`) + } + } + } + if (msg.type === 'result') { + console.log(`\n--- ${msg.subtype} ---`) + } + } + + await agent.close() +} + +main().catch(console.error) diff --git a/examples/14-openai-compat.ts b/examples/14-openai-compat.ts new file mode 100644 index 0000000..70e618a --- /dev/null +++ b/examples/14-openai-compat.ts @@ -0,0 +1,71 @@ +/** + * Example 14: OpenAI-Compatible Models + * + * Shows how to use the SDK with OpenAI's API or any OpenAI-compatible + * endpoint (e.g., DeepSeek, Qwen, vLLM, Ollama). + * + * Environment variables: + * CODEANY_API_KEY=sk-... # Your OpenAI API key + * CODEANY_BASE_URL=https://api.openai.com/v1 # Optional, defaults to OpenAI + * CODEANY_API_TYPE=openai-completions # Optional, auto-detected from model name + * + * Run: npx tsx examples/14-openai-compat.ts + */ +import { createAgent } from '../src/index.js' + +async function main() { + console.log('--- Example 14: OpenAI-Compatible Models ---\n') + + // Option 1: Explicit apiType + const agent = createAgent({ + apiType: 'openai-completions', + model: process.env.CODEANY_MODEL || 'gpt-4o', + apiKey: process.env.CODEANY_API_KEY, + baseURL: process.env.CODEANY_BASE_URL || 'https://api.openai.com/v1', + maxTurns: 5, + }) + + console.log(`API Type: ${agent.getApiType()}`) + console.log(`Model: ${process.env.CODEANY_MODEL || 'gpt-4o'}\n`) + + // Option 2: Auto-detected from model name (uncomment to try) + // const agent = createAgent({ + // model: 'gpt-4o', // Auto-detects 'openai-completions' + // apiKey: process.env.CODEANY_API_KEY, + // }) + + // Option 3: DeepSeek example (uncomment to try) + // const agent = createAgent({ + // model: 'deepseek-chat', + // apiKey: process.env.CODEANY_API_KEY, + // baseURL: 'https://api.deepseek.com/v1', + // }) + + // Option 4: Via environment variables only + // CODEANY_API_TYPE=openai-completions + // CODEANY_MODEL=gpt-4o + // CODEANY_API_KEY=sk-... + // CODEANY_BASE_URL=https://api.openai.com/v1 + // const agent = createAgent() + + for await (const event of agent.query('What is 2+2? Reply in one sentence.')) { + const msg = event as any + if (msg.type === 'assistant') { + for (const block of msg.message?.content || []) { + if (block.type === 'text' && block.text.trim()) { + console.log(`Assistant: ${block.text}`) + } + if (block.type === 'tool_use') { + console.log(`[Tool: ${block.name}] ${JSON.stringify(block.input)}`) + } + } + } + if (msg.type === 'result') { + console.log(`\n--- ${msg.subtype} (${msg.usage?.input_tokens}+${msg.usage?.output_tokens} tokens) ---`) + } + } + + await agent.close() +} + +main().catch(console.error) diff --git a/package.json b/package.json index 55ad05a..5ebe3ed 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "bugs": { "url": "https://github.com/codeany-ai/open-agent-sdk-typescript/issues" }, - "version": "0.1.0", + "version": "0.2.0", "description": "Open-source Agent SDK. Runs the full agent loop in-process — no local CLI required. Deploy anywhere: cloud, serverless, Docker, CI/CD.", "type": "module", "main": "./dist/index.js", diff --git a/src/agent.ts b/src/agent.ts index 8e93de7..dcf38ce 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -8,6 +8,14 @@ * import { createAgent } from 'open-agent-sdk' * const agent = createAgent({ model: 'claude-sonnet-4-6' }) * for await (const event of agent.query('Hello')) { ... } + * + * // OpenAI-compatible models + * const agent = createAgent({ + * apiType: 'openai-completions', + * model: 'gpt-4o', + * apiKey: 'sk-...', + * baseURL: 'https://api.openai.com/v1', + * }) */ import type { @@ -17,7 +25,6 @@ import type { ToolDefinition, CanUseToolFn, Message, - TokenUsage, PermissionMode, } from './types.js' import { QueryEngine } from './engine.js' @@ -29,7 +36,10 @@ import { saveSession, loadSession, } from './session.js' -import type Anthropic from '@anthropic-ai/sdk' +import { createHookRegistry, type HookRegistry } from './hooks.js' +import { initBundledSkills } from './skills/index.js' +import { createProvider, type LLMProvider, type ApiType } from './providers/index.js' +import type { NormalizedMessageParam } from './providers/types.js' // -------------------------------------------------------------------------- // Agent class @@ -39,14 +49,17 @@ export class Agent { private cfg: AgentOptions private toolPool: ToolDefinition[] private modelId: string + private apiType: ApiType private apiCredentials: { key?: string; baseUrl?: string } + private provider: LLMProvider private mcpLinks: MCPConnection[] = [] - private history: Anthropic.MessageParam[] = [] + private history: NormalizedMessageParam[] = [] private messageLog: Message[] = [] private setupDone: Promise private sid: string private abortCtrl: AbortController | null = null private currentEngine: QueryEngine | null = null + private hookRegistry: HookRegistry constructor(options: AgentOptions = {}) { // Shallow copy to avoid mutating caller's object @@ -57,13 +70,38 @@ export class Agent { this.modelId = this.cfg.model ?? this.readEnv('CODEANY_MODEL') ?? 'claude-sonnet-4-6' this.sid = this.cfg.sessionId ?? crypto.randomUUID() - // The underlying @anthropic-ai/sdk reads ANTHROPIC_API_KEY from process.env, - // so we bridge our resolved credentials into it. - if (this.apiCredentials.key) { - process.env.ANTHROPIC_API_KEY = this.apiCredentials.key - } - if (this.apiCredentials.baseUrl) { - process.env.ANTHROPIC_BASE_URL = this.apiCredentials.baseUrl + // Resolve API type + this.apiType = this.resolveApiType() + + // Create LLM provider + this.provider = createProvider(this.apiType, { + apiKey: this.apiCredentials.key, + baseURL: this.apiCredentials.baseUrl, + }) + + // Initialize bundled skills + initBundledSkills() + + // Build hook registry from options + this.hookRegistry = createHookRegistry() + if (this.cfg.hooks) { + // Convert AgentOptions hooks format to HookConfig + for (const [event, defs] of Object.entries(this.cfg.hooks)) { + for (const def of defs) { + for (const handler of def.hooks) { + this.hookRegistry.register(event as any, { + matcher: def.matcher, + timeout: def.timeout, + handler: async (input) => { + const result = await handler(input, input.toolUseId || '', { + signal: this.abortCtrl?.signal || new AbortController().signal, + }) + return result || undefined + }, + }) + } + } + } } // Build tool pool from options (supports ToolDefinition[], string[], or preset) @@ -73,6 +111,41 @@ export class Agent { this.setupDone = this.setup() } + /** + * Resolve API type from options, env, or model name heuristic. + */ + private resolveApiType(): ApiType { + // Explicit option + if (this.cfg.apiType) return this.cfg.apiType + + // Env var + const envType = + this.cfg.env?.CODEANY_API_TYPE ?? + this.readEnv('CODEANY_API_TYPE') + if (envType === 'openai-completions' || envType === 'anthropic-messages') { + return envType + } + + // Heuristic from model name + const model = this.modelId.toLowerCase() + if ( + model.includes('gpt-') || + model.includes('o1') || + model.includes('o3') || + model.includes('o4') || + model.includes('deepseek') || + model.includes('qwen') || + model.includes('yi-') || + model.includes('glm') || + model.includes('mistral') || + model.includes('gemma') + ) { + return 'openai-completions' + } + + return 'anthropic-messages' + } + /** Pick API key and base URL from options or CODEANY_* env vars. */ private pickCredentials(): { key?: string; baseUrl?: string } { const envMap = this.cfg.env @@ -208,12 +281,21 @@ export class Agent { } } + // Recreate provider if overrides change credentials or apiType + let provider = this.provider + if (overrides?.apiType || overrides?.apiKey || overrides?.baseURL) { + const resolvedApiType = overrides.apiType ?? this.apiType + provider = createProvider(resolvedApiType, { + apiKey: overrides.apiKey ?? this.apiCredentials.key, + baseURL: overrides.baseURL ?? this.apiCredentials.baseUrl, + }) + } + // Create query engine with current conversation state const engine = new QueryEngine({ cwd, model: opts.model || this.modelId, - apiKey: this.apiCredentials.key, - baseURL: this.apiCredentials.baseUrl, + provider, tools, systemPrompt, appendSystemPrompt, @@ -226,6 +308,8 @@ export class Agent { includePartialMessages: opts.includePartialMessages ?? false, abortSignal: this.abortCtrl.signal, agents: opts.agents, + hookRegistry: this.hookRegistry, + sessionId: this.sid, }) this.currentEngine = engine @@ -279,9 +363,9 @@ export class Agent { switch (ev.type) { case 'assistant': { // Extract the last assistant text (multi-turn: only final answer matters) - const fragments = ev.message.content - .filter((c): c is Anthropic.TextBlock => c.type === 'text') - .map((c) => c.text) + const fragments = (ev.message.content as any[]) + .filter((c: any) => c.type === 'text') + .map((c: any) => c.text) if (fragments.length) collected.text = fragments.join('') break } @@ -359,6 +443,13 @@ export class Agent { return this.sid } + /** + * Get the current API type. + */ + getApiType(): ApiType { + return this.apiType + } + /** * Stop a background task. */ diff --git a/src/engine.ts b/src/engine.ts index 1fa8573..ca03ceb 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -4,7 +4,7 @@ * Manages the full conversation lifecycle: * 1. Take user prompt * 2. Build system prompt with context (git status, project context, tools) - * 3. Call LLM API with tools + * 3. Call LLM API with tools (via provider abstraction) * 4. Stream response * 5. Execute tool calls (concurrent for read-only, serial for mutations) * 6. Send results back, repeat until done @@ -12,7 +12,6 @@ * 8. Retry with exponential backoff on transient errors */ -import Anthropic from '@anthropic-ai/sdk' import type { SDKMessage, QueryEngineConfig, @@ -21,7 +20,12 @@ import type { ToolContext, TokenUsage, } from './types.js' -import { toApiTool } from './tools/types.js' +import type { + LLMProvider, + CreateMessageResponse, + NormalizedMessageParam, + NormalizedTool, +} from './providers/types.js' import { estimateMessagesTokens, estimateCost, @@ -37,10 +41,34 @@ import { import { withRetry, isPromptTooLongError, - formatApiError, } from './utils/retry.js' import { getSystemContext, getUserContext } from './utils/context.js' import { normalizeMessagesForAPI } from './utils/messages.js' +import type { HookRegistry, HookInput, HookOutput } from './hooks.js' + +// ============================================================================ +// Tool format conversion +// ============================================================================ + +/** Convert a ToolDefinition to the normalized provider tool format. */ +function toProviderTool(tool: ToolDefinition): NormalizedTool { + return { + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema, + } +} + +// ============================================================================ +// ToolUseBlock (internal type for extracted tool_use blocks) +// ============================================================================ + +interface ToolUseBlock { + type: 'tool_use' + id: string + name: string + input: any +} // ============================================================================ // System Prompt Builder @@ -113,23 +141,43 @@ async function buildSystemPrompt(config: QueryEngineConfig): Promise { export class QueryEngine { private config: QueryEngineConfig - private client: Anthropic - public messages: Anthropic.MessageParam[] = [] + private provider: LLMProvider + public messages: NormalizedMessageParam[] = [] private totalUsage: TokenUsage = { input_tokens: 0, output_tokens: 0 } private totalCost = 0 private turnCount = 0 private compactState: AutoCompactState private sessionId: string private apiTimeMs = 0 + private hookRegistry?: HookRegistry constructor(config: QueryEngineConfig) { this.config = config - this.client = new Anthropic({ - apiKey: config.apiKey, - baseURL: config.baseURL, - }) + this.provider = config.provider this.compactState = createAutoCompactState() - this.sessionId = crypto.randomUUID() + this.sessionId = config.sessionId || crypto.randomUUID() + this.hookRegistry = config.hookRegistry + } + + /** + * Execute hooks for a lifecycle event. + * Returns hook outputs; never throws. + */ + private async executeHooks( + event: import('./hooks.js').HookEvent, + extra?: Partial, + ): Promise { + if (!this.hookRegistry?.hasHooks(event)) return [] + try { + return await this.hookRegistry.execute(event, { + event, + sessionId: this.sessionId, + cwd: this.config.cwd, + ...extra, + }) + } catch { + return [] + } } /** @@ -137,13 +185,34 @@ export class QueryEngine { * Yields SDKMessage events as the agent works. */ async *submitMessage( - prompt: string | Anthropic.ContentBlockParam[], + prompt: string | any[], ): AsyncGenerator { - // Add user message - this.messages.push({ role: 'user', content: prompt }) + // Hook: SessionStart + await this.executeHooks('SessionStart') - // Build tool definitions for API - const tools = this.config.tools.map(toApiTool) + // Hook: UserPromptSubmit + const userHookResults = await this.executeHooks('UserPromptSubmit', { + toolInput: prompt, + }) + // Check if any hook blocks the submission + if (userHookResults.some((r) => r.block)) { + yield { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + usage: this.totalUsage, + num_turns: 0, + cost: 0, + errors: ['Blocked by UserPromptSubmit hook'], + } + return + } + + // Add user message + this.messages.push({ role: 'user', content: prompt as any }) + + // Build tool definitions for provider + const tools = this.config.tools.map(toProviderTool) // Build system prompt const systemPrompt = await buildSystemPrompt(this.config) @@ -176,16 +245,18 @@ export class QueryEngine { } // Auto-compact if context is too large - if (shouldAutoCompact(this.messages, this.config.model, this.compactState)) { + if (shouldAutoCompact(this.messages as any[], this.config.model, this.compactState)) { + await this.executeHooks('PreCompact') try { const result = await compactConversation( - this.client, + this.provider, this.config.model, - this.messages, + this.messages as any[], this.compactState, ) - this.messages = result.compactedMessages + this.messages = result.compactedMessages as NormalizedMessageParam[] this.compactState = result.state + await this.executeHooks('PostCompact') } catch { // Continue with uncompacted messages } @@ -193,38 +264,33 @@ export class QueryEngine { // Micro-compact: truncate large tool results const apiMessages = microCompactMessages( - normalizeMessagesForAPI(this.messages), - ) + normalizeMessagesForAPI(this.messages as any[]), + ) as NormalizedMessageParam[] this.turnCount++ turnsRemaining-- - // Make API call with retry - let response: Anthropic.Message + // Make API call with retry via provider + let response: CreateMessageResponse const apiStart = performance.now() try { response = await withRetry( async () => { - const requestParams: Anthropic.MessageCreateParamsNonStreaming = { + return this.provider.createMessage({ model: this.config.model, - max_tokens: this.config.maxTokens, + maxTokens: this.config.maxTokens, system: systemPrompt, messages: apiMessages, tools: tools.length > 0 ? tools : undefined, - } - - // Add thinking if configured - if ( - this.config.thinking?.type === 'enabled' && - this.config.thinking.budgetTokens - ) { - (requestParams as any).thinking = { - type: 'enabled', - budget_tokens: this.config.thinking.budgetTokens, - } - } - - return this.client.messages.create(requestParams) + thinking: + this.config.thinking?.type === 'enabled' && + this.config.thinking.budgetTokens + ? { + type: 'enabled', + budget_tokens: this.config.thinking.budgetTokens, + } + : undefined, + }) }, undefined, this.config.abortSignal, @@ -234,12 +300,12 @@ export class QueryEngine { if (isPromptTooLongError(err) && !this.compactState.compacted) { try { const result = await compactConversation( - this.client, + this.provider, this.config.model, - this.messages, + this.messages as any[], this.compactState, ) - this.messages = result.compactedMessages + this.messages = result.compactedMessages as NormalizedMessageParam[] this.compactState = result.state turnsRemaining++ // Retry this turn this.turnCount-- @@ -262,38 +328,38 @@ export class QueryEngine { // Track API timing this.apiTimeMs += performance.now() - apiStart - // Track usage + // Track usage (normalized by provider) if (response.usage) { this.totalUsage.input_tokens += response.usage.input_tokens this.totalUsage.output_tokens += response.usage.output_tokens - if ('cache_creation_input_tokens' in response.usage) { + if (response.usage.cache_creation_input_tokens) { this.totalUsage.cache_creation_input_tokens = (this.totalUsage.cache_creation_input_tokens || 0) + - ((response.usage as any).cache_creation_input_tokens || 0) + response.usage.cache_creation_input_tokens } - if ('cache_read_input_tokens' in response.usage) { + if (response.usage.cache_read_input_tokens) { this.totalUsage.cache_read_input_tokens = (this.totalUsage.cache_read_input_tokens || 0) + - ((response.usage as any).cache_read_input_tokens || 0) + response.usage.cache_read_input_tokens } - this.totalCost += estimateCost(this.config.model, response.usage as TokenUsage) + this.totalCost += estimateCost(this.config.model, response.usage) } // Add assistant message to conversation - this.messages.push({ role: 'assistant', content: response.content }) + this.messages.push({ role: 'assistant', content: response.content as any }) // Yield assistant message yield { type: 'assistant', message: { role: 'assistant', - content: response.content, + content: response.content as any, }, } // Handle max_output_tokens recovery if ( - response.stop_reason === 'max_tokens' && + response.stopReason === 'max_tokens' && maxOutputRecoveryAttempts < MAX_OUTPUT_RECOVERY ) { maxOutputRecoveryAttempts++ @@ -307,7 +373,7 @@ export class QueryEngine { // Check for tool use const toolUseBlocks = response.content.filter( - (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use', + (block): block is ToolUseBlock => block.type === 'tool_use', ) if (toolUseBlocks.length === 0) { @@ -349,9 +415,15 @@ export class QueryEngine { })), }) - if (response.stop_reason === 'end_turn') break + if (response.stopReason === 'end_turn') break } + // Hook: Stop (end of agentic loop) + await this.executeHooks('Stop') + + // Hook: SessionEnd + await this.executeHooks('SessionEnd') + // Yield enriched final result const endSubtype = budgetExceeded ? 'error_max_budget_usd' @@ -380,7 +452,7 @@ export class QueryEngine { * Mutation tools run sequentially. */ private async executeTools( - toolUseBlocks: Anthropic.ToolUseBlock[], + toolUseBlocks: ToolUseBlock[], ): Promise<(ToolResult & { tool_name?: string })[]> { const context: ToolContext = { cwd: this.config.cwd, @@ -392,8 +464,8 @@ export class QueryEngine { ) // Partition into read-only (concurrent) and mutation (serial) - const readOnly: Array<{ block: Anthropic.ToolUseBlock; tool?: ToolDefinition }> = [] - const mutations: Array<{ block: Anthropic.ToolUseBlock; tool?: ToolDefinition }> = [] + const readOnly: Array<{ block: ToolUseBlock; tool?: ToolDefinition }> = [] + const mutations: Array<{ block: ToolUseBlock; tool?: ToolDefinition }> = [] for (const block of toolUseBlocks) { const tool = this.config.tools.find((t) => t.name === block.name) @@ -430,7 +502,7 @@ export class QueryEngine { * Execute a single tool with permission checking. */ private async executeSingleTool( - block: Anthropic.ToolUseBlock, + block: ToolUseBlock, tool: ToolDefinition | undefined, context: ToolContext, ): Promise { @@ -469,7 +541,7 @@ export class QueryEngine { } } if (permission.updatedInput !== undefined) { - block = { ...block, input: permission.updatedInput as Record } + block = { ...block, input: permission.updatedInput } } } catch (err: any) { return { @@ -482,11 +554,46 @@ export class QueryEngine { } } + // Hook: PreToolUse + const preHookResults = await this.executeHooks('PreToolUse', { + toolName: block.name, + toolInput: block.input, + toolUseId: block.id, + }) + // Check if any hook blocks this tool + if (preHookResults.some((r) => r.block)) { + const msg = preHookResults.find((r) => r.message)?.message || 'Blocked by PreToolUse hook' + return { + type: 'tool_result', + tool_use_id: block.id, + content: msg, + is_error: true, + tool_name: block.name, + } + } + // Execute the tool try { const result = await tool.call(block.input, context) + + // Hook: PostToolUse + await this.executeHooks('PostToolUse', { + toolName: block.name, + toolInput: block.input, + toolOutput: typeof result.content === 'string' ? result.content : JSON.stringify(result.content), + toolUseId: block.id, + }) + return { ...result, tool_use_id: block.id, tool_name: block.name } } catch (err: any) { + // Hook: PostToolUseFailure + await this.executeHooks('PostToolUseFailure', { + toolName: block.name, + toolInput: block.input, + toolUseId: block.id, + error: err.message, + }) + return { type: 'tool_result', tool_use_id: block.id, @@ -500,7 +607,7 @@ export class QueryEngine { /** * Get current messages for session persistence. */ - getMessages(): Anthropic.MessageParam[] { + getMessages(): NormalizedMessageParam[] { return [...this.messages] } diff --git a/src/index.ts b/src/index.ts index ccea65e..13a83f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ * * Features: * - 30+ built-in tools (file I/O, shell, web, agents, tasks, teams, etc.) + * - Skill system (reusable prompt templates with bundled skills) * - MCP server integration (stdio, SSE, HTTP) * - Context compression (auto-compact, micro-compact) * - Retry with exponential backoff @@ -14,7 +15,7 @@ * - Permission system (allow/deny/bypass modes) * - Subagent spawning & team coordination * - Task management & scheduling - * - Hook system (pre/post tool use, lifecycle events) + * - Hook system with lifecycle integration (pre/post tool use, session, compact) * - Token estimation & cost tracking * - File state LRU caching * - Plan mode for structured workflows @@ -50,6 +51,26 @@ export type { McpSdkServerConfig } from './sdk-mcp-server.js' export { QueryEngine } from './engine.js' +// -------------------------------------------------------------------------- +// LLM Providers (Anthropic + OpenAI) +// -------------------------------------------------------------------------- + +export { + createProvider, + AnthropicProvider, + OpenAIProvider, +} from './providers/index.js' +export type { + ApiType, + LLMProvider, + CreateMessageParams, + CreateMessageResponse, + NormalizedMessageParam, + NormalizedContentBlock, + NormalizedTool, + NormalizedResponseBlock, +} from './providers/index.js' + // -------------------------------------------------------------------------- // Tool System (30+ tools) // -------------------------------------------------------------------------- @@ -123,6 +144,9 @@ export { // Todo TodoWriteTool, + + // Skill + SkillTool, } from './tools/index.js' // -------------------------------------------------------------------------- @@ -132,6 +156,27 @@ export { export { connectMCPServer, closeAllConnections } from './mcp/client.js' export type { MCPConnection } from './mcp/client.js' +// -------------------------------------------------------------------------- +// Skill System +// -------------------------------------------------------------------------- + +export { + registerSkill, + getSkill, + getAllSkills, + getUserInvocableSkills, + hasSkill, + unregisterSkill, + clearSkills, + formatSkillsForPrompt, + initBundledSkills, +} from './skills/index.js' +export type { + SkillDefinition, + SkillContentBlock, + SkillResult, +} from './skills/index.js' + // -------------------------------------------------------------------------- // Hook System // -------------------------------------------------------------------------- @@ -341,6 +386,7 @@ export type { // Permission types PermissionMode, + PermissionBehavior, CanUseToolFn, CanUseToolResult, @@ -360,6 +406,10 @@ export type { // Engine types QueryEngineConfig, + // Content block types + ContentBlockParam, + ContentBlock, + // Sandbox types SandboxSettings, SandboxNetworkConfig, diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts new file mode 100644 index 0000000..b95f306 --- /dev/null +++ b/src/providers/anthropic.ts @@ -0,0 +1,60 @@ +/** + * Anthropic Messages API Provider + * + * Wraps the @anthropic-ai/sdk client. Since our internal format is + * Anthropic-like, this is mostly a thin pass-through. + */ + +import Anthropic from '@anthropic-ai/sdk' +import type { + LLMProvider, + CreateMessageParams, + CreateMessageResponse, +} from './types.js' + +export class AnthropicProvider implements LLMProvider { + readonly apiType = 'anthropic-messages' as const + private client: Anthropic + + constructor(opts: { apiKey?: string; baseURL?: string }) { + this.client = new Anthropic({ + apiKey: opts.apiKey, + baseURL: opts.baseURL, + }) + } + + async createMessage(params: CreateMessageParams): Promise { + const requestParams: Anthropic.MessageCreateParamsNonStreaming = { + model: params.model, + max_tokens: params.maxTokens, + system: params.system, + messages: params.messages as Anthropic.MessageParam[], + tools: params.tools + ? (params.tools as Anthropic.Tool[]) + : undefined, + } + + // Add extended thinking if configured + if (params.thinking?.type === 'enabled' && params.thinking.budget_tokens) { + (requestParams as any).thinking = { + type: 'enabled', + budget_tokens: params.thinking.budget_tokens, + } + } + + const response = await this.client.messages.create(requestParams) + + return { + content: response.content as CreateMessageResponse['content'], + stopReason: response.stop_reason || 'end_turn', + usage: { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: + (response.usage as any).cache_creation_input_tokens, + cache_read_input_tokens: + (response.usage as any).cache_read_input_tokens, + }, + } + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..28d6768 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,34 @@ +/** + * LLM Provider Factory + * + * Creates the appropriate provider based on API type configuration. + */ + +export type { ApiType, LLMProvider, CreateMessageParams, CreateMessageResponse, NormalizedMessageParam, NormalizedContentBlock, NormalizedTool, NormalizedResponseBlock } from './types.js' + +export { AnthropicProvider } from './anthropic.js' +export { OpenAIProvider } from './openai.js' + +import type { ApiType, LLMProvider } from './types.js' +import { AnthropicProvider } from './anthropic.js' +import { OpenAIProvider } from './openai.js' + +/** + * Create an LLM provider based on the API type. + * + * @param apiType - 'anthropic-messages' or 'openai-completions' + * @param opts - API credentials + */ +export function createProvider( + apiType: ApiType, + opts: { apiKey?: string; baseURL?: string }, +): LLMProvider { + switch (apiType) { + case 'anthropic-messages': + return new AnthropicProvider(opts) + case 'openai-completions': + return new OpenAIProvider(opts) + default: + throw new Error(`Unsupported API type: ${apiType}. Use 'anthropic-messages' or 'openai-completions'.`) + } +} diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 0000000..81aa68b --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,315 @@ +/** + * OpenAI Chat Completions API Provider + * + * Converts between the SDK's internal Anthropic-like message format + * and OpenAI's Chat Completions API format. + * + * Uses native fetch (no openai SDK dependency required). + */ + +import type { + LLMProvider, + CreateMessageParams, + CreateMessageResponse, + NormalizedMessageParam, + NormalizedContentBlock, + NormalizedTool, + NormalizedResponseBlock, +} from './types.js' + +// -------------------------------------------------------------------------- +// OpenAI-specific types (minimal, just what we need) +// -------------------------------------------------------------------------- + +interface OpenAIChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool' + content?: string | null + tool_calls?: OpenAIToolCall[] + tool_call_id?: string +} + +interface OpenAIToolCall { + id: string + type: 'function' + function: { + name: string + arguments: string + } +} + +interface OpenAITool { + type: 'function' + function: { + name: string + description: string + parameters: Record + } +} + +interface OpenAIChatResponse { + id: string + choices: Array<{ + index: number + message: { + role: 'assistant' + content: string | null + tool_calls?: OpenAIToolCall[] + } + finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | string + }> + usage?: { + prompt_tokens: number + completion_tokens: number + total_tokens: number + } +} + +// -------------------------------------------------------------------------- +// Provider +// -------------------------------------------------------------------------- + +export class OpenAIProvider implements LLMProvider { + readonly apiType = 'openai-completions' as const + private apiKey: string + private baseURL: string + + constructor(opts: { apiKey?: string; baseURL?: string }) { + this.apiKey = opts.apiKey || '' + this.baseURL = (opts.baseURL || 'https://api.openai.com/v1').replace(/\/$/, '') + } + + async createMessage(params: CreateMessageParams): Promise { + // Convert to OpenAI format + const messages = this.convertMessages(params.system, params.messages) + const tools = params.tools ? this.convertTools(params.tools) : undefined + + const body: Record = { + model: params.model, + max_tokens: params.maxTokens, + messages, + } + + if (tools && tools.length > 0) { + body.tools = tools + } + + // Make API call + const response = await fetch(`${this.baseURL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + const errBody = await response.text().catch(() => '') + const err: any = new Error( + `OpenAI API error: ${response.status} ${response.statusText}: ${errBody}`, + ) + err.status = response.status + throw err + } + + const data = (await response.json()) as OpenAIChatResponse + + // Convert response back to normalized format + return this.convertResponse(data) + } + + // -------------------------------------------------------------------------- + // Message Conversion: Internal → OpenAI + // -------------------------------------------------------------------------- + + private convertMessages( + system: string, + messages: NormalizedMessageParam[], + ): OpenAIChatMessage[] { + const result: OpenAIChatMessage[] = [] + + // System prompt as first message + if (system) { + result.push({ role: 'system', content: system }) + } + + for (const msg of messages) { + if (msg.role === 'user') { + this.convertUserMessage(msg, result) + } else if (msg.role === 'assistant') { + this.convertAssistantMessage(msg, result) + } + } + + return result + } + + private convertUserMessage( + msg: NormalizedMessageParam, + result: OpenAIChatMessage[], + ): void { + if (typeof msg.content === 'string') { + result.push({ role: 'user', content: msg.content }) + return + } + + // Content blocks may contain text and/or tool_result blocks + const textParts: string[] = [] + const toolResults: Array<{ tool_use_id: string; content: string }> = [] + + for (const block of msg.content) { + if (block.type === 'text') { + textParts.push(block.text) + } else if (block.type === 'tool_result') { + toolResults.push({ + tool_use_id: block.tool_use_id, + content: block.content, + }) + } + } + + // Tool results become separate tool messages + for (const tr of toolResults) { + result.push({ + role: 'tool', + tool_call_id: tr.tool_use_id, + content: tr.content, + }) + } + + // Text parts become a user message + if (textParts.length > 0) { + result.push({ role: 'user', content: textParts.join('\n') }) + } + } + + private convertAssistantMessage( + msg: NormalizedMessageParam, + result: OpenAIChatMessage[], + ): void { + if (typeof msg.content === 'string') { + result.push({ role: 'assistant', content: msg.content }) + return + } + + // Extract text and tool_use blocks + const textParts: string[] = [] + const toolCalls: OpenAIToolCall[] = [] + + for (const block of msg.content) { + if (block.type === 'text') { + textParts.push(block.text) + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: typeof block.input === 'string' + ? block.input + : JSON.stringify(block.input), + }, + }) + } + } + + const assistantMsg: OpenAIChatMessage = { + role: 'assistant', + content: textParts.length > 0 ? textParts.join('\n') : null, + } + + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls + } + + result.push(assistantMsg) + } + + // -------------------------------------------------------------------------- + // Tool Conversion: Internal → OpenAI + // -------------------------------------------------------------------------- + + private convertTools(tools: NormalizedTool[]): OpenAITool[] { + return tools.map((t) => ({ + type: 'function' as const, + function: { + name: t.name, + description: t.description, + parameters: t.input_schema, + }, + })) + } + + // -------------------------------------------------------------------------- + // Response Conversion: OpenAI → Internal + // -------------------------------------------------------------------------- + + private convertResponse(data: OpenAIChatResponse): CreateMessageResponse { + const choice = data.choices[0] + if (!choice) { + return { + content: [{ type: 'text', text: '' }], + stopReason: 'end_turn', + usage: { input_tokens: 0, output_tokens: 0 }, + } + } + + const content: NormalizedResponseBlock[] = [] + + // Add text content + if (choice.message.content) { + content.push({ type: 'text', text: choice.message.content }) + } + + // Add tool calls + if (choice.message.tool_calls) { + for (const tc of choice.message.tool_calls) { + let input: any + try { + input = JSON.parse(tc.function.arguments) + } catch { + input = tc.function.arguments + } + + content.push({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input, + }) + } + } + + // If no content at all, add empty text + if (content.length === 0) { + content.push({ type: 'text', text: '' }) + } + + // Map finish_reason to our normalized stop reasons + const stopReason = this.mapFinishReason(choice.finish_reason) + + return { + content, + stopReason, + usage: { + input_tokens: data.usage?.prompt_tokens || 0, + output_tokens: data.usage?.completion_tokens || 0, + }, + } + } + + private mapFinishReason( + reason: string, + ): 'end_turn' | 'max_tokens' | 'tool_use' | string { + switch (reason) { + case 'stop': + return 'end_turn' + case 'length': + return 'max_tokens' + case 'tool_calls': + return 'tool_use' + default: + return reason + } + } +} diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..d71eae5 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,85 @@ +/** + * LLM Provider Abstraction Types + * + * Defines a provider interface that normalizes API differences between + * Anthropic Messages API and OpenAI Chat Completions API. + * + * Internally the SDK uses Anthropic-like message format as the canonical + * representation. Providers convert to/from their native API format. + */ + +// -------------------------------------------------------------------------- +// API Type +// -------------------------------------------------------------------------- + +export type ApiType = 'anthropic-messages' | 'openai-completions' + +// -------------------------------------------------------------------------- +// Normalized Request +// -------------------------------------------------------------------------- + +export interface CreateMessageParams { + model: string + maxTokens: number + system: string + messages: NormalizedMessageParam[] + tools?: NormalizedTool[] + thinking?: { type: string; budget_tokens?: number } +} + +/** + * Normalized message format (Anthropic-like). + * This is the internal representation used throughout the SDK. + */ +export interface NormalizedMessageParam { + role: 'user' | 'assistant' + content: string | NormalizedContentBlock[] +} + +export type NormalizedContentBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: any } + | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean } + | { type: 'image'; source: any } + | { type: 'thinking'; thinking: string } + +export interface NormalizedTool { + name: string + description: string + input_schema: { + type: 'object' + properties: Record + required?: string[] + } +} + +// -------------------------------------------------------------------------- +// Normalized Response +// -------------------------------------------------------------------------- + +export interface CreateMessageResponse { + content: NormalizedResponseBlock[] + stopReason: 'end_turn' | 'max_tokens' | 'tool_use' | string + usage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } +} + +export type NormalizedResponseBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: any } + +// -------------------------------------------------------------------------- +// Provider Interface +// -------------------------------------------------------------------------- + +export interface LLMProvider { + /** The API type this provider implements. */ + readonly apiType: ApiType + + /** Send a message and get a response. */ + createMessage(params: CreateMessageParams): Promise +} diff --git a/src/session.ts b/src/session.ts index 523a3c9..2c72980 100644 --- a/src/session.ts +++ b/src/session.ts @@ -8,7 +8,7 @@ import { readFile, writeFile, mkdir, readdir, stat } from 'fs/promises' import { join } from 'path' import type { Message } from './types.js' -import type Anthropic from '@anthropic-ai/sdk' +import type { NormalizedMessageParam } from './providers/types.js' /** * Session metadata. @@ -28,7 +28,7 @@ export interface SessionMetadata { */ export interface SessionData { metadata: SessionMetadata - messages: Anthropic.MessageParam[] + messages: NormalizedMessageParam[] } /** @@ -51,7 +51,7 @@ function getSessionPath(sessionId: string): string { */ export async function saveSession( sessionId: string, - messages: Anthropic.MessageParam[], + messages: NormalizedMessageParam[], metadata: Partial, ): Promise { const dir = getSessionPath(sessionId) @@ -146,7 +146,7 @@ export async function forkSession( */ export async function getSessionMessages( sessionId: string, -): Promise { +): Promise { const data = await loadSession(sessionId) return data?.messages || [] } @@ -156,7 +156,7 @@ export async function getSessionMessages( */ export async function appendToSession( sessionId: string, - message: Anthropic.MessageParam, + message: NormalizedMessageParam, ): Promise { const data = await loadSession(sessionId) if (!data) return diff --git a/src/skills/bundled/commit.ts b/src/skills/bundled/commit.ts new file mode 100644 index 0000000..ec720b8 --- /dev/null +++ b/src/skills/bundled/commit.ts @@ -0,0 +1,38 @@ +/** + * Bundled Skill: commit + * + * Create a git commit with a well-crafted message based on staged changes. + */ + +import { registerSkill } from '../registry.js' +import type { SkillContentBlock } from '../types.js' + +const COMMIT_PROMPT = `Create a git commit for the current changes. Follow these steps: + +1. Run \`git status\` and \`git diff --cached\` to understand what's staged +2. If nothing is staged, run \`git diff\` to see unstaged changes and suggest what to stage +3. Analyze the changes and draft a concise commit message that: + - Uses imperative mood ("Add feature" not "Added feature") + - Summarizes the "why" not just the "what" + - Keeps the first line under 72 characters + - Adds a body with details if the change is complex +4. Create the commit + +Do NOT push to remote unless explicitly asked.` + +export function registerCommitSkill(): void { + registerSkill({ + name: 'commit', + description: 'Create a git commit with a well-crafted message based on staged changes.', + aliases: ['ci'], + allowedTools: ['Bash', 'Read', 'Glob', 'Grep'], + userInvocable: true, + async getPrompt(args): Promise { + let prompt = COMMIT_PROMPT + if (args.trim()) { + prompt += `\n\nAdditional instructions: ${args}` + } + return [{ type: 'text', text: prompt }] + }, + }) +} diff --git a/src/skills/bundled/debug.ts b/src/skills/bundled/debug.ts new file mode 100644 index 0000000..ee2c887 --- /dev/null +++ b/src/skills/bundled/debug.ts @@ -0,0 +1,48 @@ +/** + * Bundled Skill: debug + * + * Systematic debugging of an issue using structured investigation. + */ + +import { registerSkill } from '../registry.js' +import type { SkillContentBlock } from '../types.js' + +const DEBUG_PROMPT = `Debug the described issue using a systematic approach: + +1. **Reproduce**: Understand and reproduce the issue + - Read relevant error messages or logs + - Identify the failing component + +2. **Investigate**: Trace the root cause + - Read the relevant source code + - Add logging or use debugging tools if needed + - Check recent changes that might have introduced the issue (\`git log --oneline -20\`) + +3. **Hypothesize**: Form a theory about the cause + - State your hypothesis clearly before attempting a fix + +4. **Fix**: Implement the minimal fix + - Make the smallest change that resolves the issue + - Don't refactor unrelated code + +5. **Verify**: Confirm the fix works + - Run relevant tests + - Check for regressions` + +export function registerDebugSkill(): void { + registerSkill({ + name: 'debug', + description: 'Systematic debugging of an issue using structured investigation.', + aliases: ['investigate', 'diagnose'], + userInvocable: true, + async getPrompt(args): Promise { + let prompt = DEBUG_PROMPT + if (args.trim()) { + prompt += `\n\n## Issue Description\n${args}` + } else { + prompt += `\n\nAsk the user to describe the issue they're experiencing.` + } + return [{ type: 'text', text: prompt }] + }, + }) +} diff --git a/src/skills/bundled/index.ts b/src/skills/bundled/index.ts new file mode 100644 index 0000000..a044219 --- /dev/null +++ b/src/skills/bundled/index.ts @@ -0,0 +1,28 @@ +/** + * Bundled Skills Initialization + * + * Registers all built-in skills at SDK startup. + */ + +import { registerSimplifySkill } from './simplify.js' +import { registerCommitSkill } from './commit.js' +import { registerReviewSkill } from './review.js' +import { registerDebugSkill } from './debug.js' +import { registerTestSkill } from './test.js' + +let initialized = false + +/** + * Initialize all bundled skills. + * Safe to call multiple times (idempotent). + */ +export function initBundledSkills(): void { + if (initialized) return + initialized = true + + registerSimplifySkill() + registerCommitSkill() + registerReviewSkill() + registerDebugSkill() + registerTestSkill() +} diff --git a/src/skills/bundled/review.ts b/src/skills/bundled/review.ts new file mode 100644 index 0000000..628ed62 --- /dev/null +++ b/src/skills/bundled/review.ts @@ -0,0 +1,41 @@ +/** + * Bundled Skill: review + * + * Review code changes (PR or local diff) for issues and improvements. + */ + +import { registerSkill } from '../registry.js' +import type { SkillContentBlock } from '../types.js' + +const REVIEW_PROMPT = `Review the current code changes for potential issues. Follow these steps: + +1. Run \`git diff\` to see uncommitted changes, or \`git diff main...HEAD\` for branch changes +2. For each changed file, analyze: + - **Correctness**: Logic errors, edge cases, off-by-one errors + - **Security**: Injection vulnerabilities, auth issues, data exposure + - **Performance**: N+1 queries, unnecessary allocations, blocking I/O + - **Style**: Naming, consistency with surrounding code, readability + - **Testing**: Are the changes adequately tested? +3. Provide a summary with: + - Critical issues (must fix) + - Suggestions (nice to have) + - Questions (need clarification) + +Be specific: reference file names, line numbers, and suggest fixes.` + +export function registerReviewSkill(): void { + registerSkill({ + name: 'review', + description: 'Review code changes for correctness, security, performance, and style issues.', + aliases: ['review-pr', 'cr'], + allowedTools: ['Bash', 'Read', 'Glob', 'Grep'], + userInvocable: true, + async getPrompt(args): Promise { + let prompt = REVIEW_PROMPT + if (args.trim()) { + prompt += `\n\nFocus area: ${args}` + } + return [{ type: 'text', text: prompt }] + }, + }) +} diff --git a/src/skills/bundled/simplify.ts b/src/skills/bundled/simplify.ts new file mode 100644 index 0000000..398146d --- /dev/null +++ b/src/skills/bundled/simplify.ts @@ -0,0 +1,51 @@ +/** + * Bundled Skill: simplify + * + * Reviews changed code for reuse opportunities, code quality issues, + * and efficiency improvements, then fixes any issues found. + */ + +import { registerSkill } from '../registry.js' +import type { SkillContentBlock } from '../types.js' + +const SIMPLIFY_PROMPT = `Review the recently changed code for three categories of improvements. Launch 3 parallel Agent sub-tasks: + +## Task 1: Reuse Analysis +Look for: +- Duplicated code that could be consolidated +- Existing utilities or helpers that could replace new code +- Patterns that should be extracted into shared functions +- Re-implementations of functionality that already exists elsewhere + +## Task 2: Code Quality +Look for: +- Overly complex logic that could be simplified +- Poor naming or unclear intent +- Missing edge case handling +- Unnecessary abstractions or over-engineering +- Dead code or unused imports + +## Task 3: Efficiency +Look for: +- Unnecessary allocations or copies +- N+1 query patterns or redundant I/O +- Blocking operations that could be async +- Inefficient data structures for the access pattern +- Unnecessary re-computation + +After all three analyses complete, fix any issues found. Prioritize by impact.` + +export function registerSimplifySkill(): void { + registerSkill({ + name: 'simplify', + description: 'Review changed code for reuse, quality, and efficiency, then fix any issues found.', + userInvocable: true, + async getPrompt(args): Promise { + let prompt = SIMPLIFY_PROMPT + if (args.trim()) { + prompt += `\n\n## Additional Focus\n${args}` + } + return [{ type: 'text', text: prompt }] + }, + }) +} diff --git a/src/skills/bundled/test.ts b/src/skills/bundled/test.ts new file mode 100644 index 0000000..516fb57 --- /dev/null +++ b/src/skills/bundled/test.ts @@ -0,0 +1,43 @@ +/** + * Bundled Skill: test + * + * Run tests and analyze failures. + */ + +import { registerSkill } from '../registry.js' +import type { SkillContentBlock } from '../types.js' + +const TEST_PROMPT = `Run the project's test suite and analyze the results: + +1. **Discover**: Find the test runner configuration + - Look for package.json scripts, jest.config, vitest.config, pytest.ini, etc. + - Identify the appropriate test command + +2. **Execute**: Run the tests + - Run the full test suite or specific tests if specified + - Capture output including failures and errors + +3. **Analyze**: If tests fail: + - Read the failing test to understand what it expects + - Read the source code being tested + - Identify why the test is failing + - Fix the issue (in tests or source as appropriate) + +4. **Re-verify**: Run the failing tests again to confirm the fix` + +export function registerTestSkill(): void { + registerSkill({ + name: 'test', + description: 'Run tests and analyze failures, fixing any issues found.', + aliases: ['run-tests'], + allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'], + userInvocable: true, + async getPrompt(args): Promise { + let prompt = TEST_PROMPT + if (args.trim()) { + prompt += `\n\nSpecific test target: ${args}` + } + return [{ type: 'text', text: prompt }] + }, + }) +} diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 0000000..87f1a9b --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,25 @@ +/** + * Skills Module - Public API + */ + +// Types +export type { + SkillDefinition, + SkillContentBlock, + SkillResult, +} from './types.js' + +// Registry +export { + registerSkill, + getSkill, + getAllSkills, + getUserInvocableSkills, + hasSkill, + unregisterSkill, + clearSkills, + formatSkillsForPrompt, +} from './registry.js' + +// Bundled skills +export { initBundledSkills } from './bundled/index.js' diff --git a/src/skills/registry.ts b/src/skills/registry.ts new file mode 100644 index 0000000..0d78c96 --- /dev/null +++ b/src/skills/registry.ts @@ -0,0 +1,133 @@ +/** + * Skill Registry + * + * Central registry for managing skill definitions. + * Skills can be registered programmatically or loaded from bundled definitions. + */ + +import type { SkillDefinition } from './types.js' + +/** Internal skill store */ +const skills: Map = new Map() + +/** Alias -> skill name mapping */ +const aliases: Map = new Map() + +/** + * Register a skill definition. + */ +export function registerSkill(definition: SkillDefinition): void { + skills.set(definition.name, definition) + + // Register aliases + if (definition.aliases) { + for (const alias of definition.aliases) { + aliases.set(alias, definition.name) + } + } +} + +/** + * Get a skill by name or alias. + */ +export function getSkill(name: string): SkillDefinition | undefined { + // Direct lookup + const direct = skills.get(name) + if (direct) return direct + + // Alias lookup + const resolved = aliases.get(name) + if (resolved) return skills.get(resolved) + + return undefined +} + +/** + * Get all registered skills. + */ +export function getAllSkills(): SkillDefinition[] { + return Array.from(skills.values()) +} + +/** + * Get all user-invocable skills (for /command listing). + */ +export function getUserInvocableSkills(): SkillDefinition[] { + return getAllSkills().filter( + (s) => s.userInvocable !== false && (!s.isEnabled || s.isEnabled()), + ) +} + +/** + * Check if a skill exists. + */ +export function hasSkill(name: string): boolean { + return skills.has(name) || aliases.has(name) +} + +/** + * Remove a skill. + */ +export function unregisterSkill(name: string): boolean { + const skill = skills.get(name) + if (!skill) return false + + // Remove aliases + if (skill.aliases) { + for (const alias of skill.aliases) { + aliases.delete(alias) + } + } + + return skills.delete(name) +} + +/** + * Clear all skills (for testing). + */ +export function clearSkills(): void { + skills.clear() + aliases.clear() +} + +/** + * Format skills listing for system prompt injection. + * + * Uses a budget system: skills listing gets a limited character budget + * to avoid bloating the context window. + */ +export function formatSkillsForPrompt( + contextWindowTokens?: number, +): string { + const invocable = getUserInvocableSkills() + if (invocable.length === 0) return '' + + // Budget: 1% of context window in characters (4 chars per token) + const CHARS_PER_TOKEN = 4 + const DEFAULT_BUDGET = 8000 + const MAX_DESC_CHARS = 250 + const budget = contextWindowTokens + ? Math.floor(contextWindowTokens * 0.01 * CHARS_PER_TOKEN) + : DEFAULT_BUDGET + + const lines: string[] = [] + let used = 0 + + for (const skill of invocable) { + const desc = skill.description.length > MAX_DESC_CHARS + ? skill.description.slice(0, MAX_DESC_CHARS) + '...' + : skill.description + + const trigger = skill.whenToUse + ? ` TRIGGER when: ${skill.whenToUse}` + : '' + + const line = `- ${skill.name}: ${desc}${trigger}` + + if (used + line.length > budget) break + lines.push(line) + used += line.length + } + + return lines.join('\n') +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..006aa6b --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,99 @@ +/** + * Skill System Types + * + * Skills are reusable prompt templates that extend agent capabilities. + * They can be invoked by the model via the Skill tool or by users via /skillname. + */ + +import type { ToolContext } from '../types.js' +import type { HookConfig } from '../hooks.js' + +/** + * Content block for skill prompts (compatible with Anthropic API). + */ +export type SkillContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } } + +/** + * Bundled skill definition. + * + * Inspired by Claude Code's skill system. Skills provide specialized + * capabilities by injecting context-specific prompts with optional + * tool restrictions and model overrides. + */ +export interface SkillDefinition { + /** Unique skill name (e.g., 'simplify', 'commit') */ + name: string + + /** Human-readable description */ + description: string + + /** Alternative names for the skill */ + aliases?: string[] + + /** When the model should invoke this skill (used in system prompt) */ + whenToUse?: string + + /** Hint for expected arguments */ + argumentHint?: string + + /** Tools the skill is allowed to use (empty = all tools) */ + allowedTools?: string[] + + /** Model override for this skill */ + model?: string + + /** Whether the skill can be invoked by users via /command */ + userInvocable?: boolean + + /** Runtime check for availability */ + isEnabled?: () => boolean + + /** Hook overrides while skill is active */ + hooks?: HookConfig + + /** Execution context: 'inline' runs in current context, 'fork' spawns a subagent */ + context?: 'inline' | 'fork' + + /** Subagent type for forked execution */ + agent?: string + + /** + * Generate the prompt content blocks for this skill. + * + * @param args - User-provided arguments (e.g., from "/simplify focus on error handling") + * @param context - Tool execution context (cwd, etc.) + * @returns Content blocks to inject into the conversation + */ + getPrompt: ( + args: string, + context: ToolContext, + ) => Promise +} + +/** + * Result of executing a skill. + */ +export interface SkillResult { + /** Whether execution succeeded */ + success: boolean + + /** Skill name that was executed */ + skillName: string + + /** Execution status */ + status: 'inline' | 'forked' + + /** Allowed tools override (for inline execution) */ + allowedTools?: string[] + + /** Model override */ + model?: string + + /** Result text (for forked execution) */ + result?: string + + /** Error message */ + error?: string +} diff --git a/src/tools/agent-tool.ts b/src/tools/agent-tool.ts index c02c844..fa96f62 100644 --- a/src/tools/agent-tool.ts +++ b/src/tools/agent-tool.ts @@ -8,7 +8,7 @@ import type { ToolDefinition, ToolContext, ToolResult, AgentDefinition } from '../types.js' import { QueryEngine } from '../engine.js' import { getAllBaseTools, filterTools } from './index.js' -import { toApiTool } from './types.js' +import { createProvider, type ApiType } from '../providers/index.js' // Store for registered agent definitions let registeredAgents: Record = {} @@ -101,10 +101,19 @@ export const AgentTool: ToolDefinition = { const systemPrompt = agentDef?.prompt || 'You are a helpful assistant. Complete the given task using the available tools.' + // Resolve model and create provider for subagent + const subModel = input.model || process.env.CODEANY_MODEL || 'claude-sonnet-4-6' + const apiType = (process.env.CODEANY_API_TYPE as ApiType) || 'anthropic-messages' + const provider = createProvider(apiType, { + apiKey: process.env.CODEANY_API_KEY, + baseURL: process.env.CODEANY_BASE_URL, + }) + // Create subagent engine const engine = new QueryEngine({ cwd: context.cwd, - model: input.model || process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + model: subModel, + provider, tools, systemPrompt, maxTurns: agentDef?.maxTurns || 10, diff --git a/src/tools/index.ts b/src/tools/index.ts index 6829398..225630b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -62,6 +62,9 @@ import { ConfigTool } from './config-tool.js' // Todo import { TodoWriteTool } from './todo-tool.js' +// Skill +import { SkillTool } from './skill-tool.js' + /** * All built-in tools (30+). */ @@ -125,6 +128,9 @@ const ALL_TOOLS: ToolDefinition[] = [ // Todo TodoWriteTool, + + // Skill + SkillTool, ] /** @@ -226,6 +232,8 @@ export { ConfigTool, // Todo TodoWriteTool, + // Skill + SkillTool, } // Re-export helpers diff --git a/src/tools/skill-tool.ts b/src/tools/skill-tool.ts new file mode 100644 index 0000000..8033c81 --- /dev/null +++ b/src/tools/skill-tool.ts @@ -0,0 +1,133 @@ +/** + * Skill Tool + * + * Allows the model to invoke registered skills by name. + * Skills are prompt templates that provide specialized capabilities. + */ + +import type { ToolDefinition, ToolResult, ToolContext } from '../types.js' +import { getSkill, getUserInvocableSkills } from '../skills/registry.js' + +export const SkillTool: ToolDefinition = { + name: 'Skill', + description: + 'Execute a skill within the current conversation. ' + + 'Skills provide specialized capabilities and domain knowledge. ' + + 'Use this tool with the skill name and optional arguments. ' + + 'Available skills are listed in system-reminder messages.', + inputSchema: { + type: 'object', + properties: { + skill: { + type: 'string', + description: 'The skill name to execute (e.g., "commit", "review", "simplify")', + }, + args: { + type: 'string', + description: 'Optional arguments for the skill', + }, + }, + required: ['skill'], + }, + + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => getUserInvocableSkills().length > 0, + + async prompt(): Promise { + const skills = getUserInvocableSkills() + if (skills.length === 0) return '' + + const lines = skills.map((s) => { + const desc = + s.description.length > 200 + ? s.description.slice(0, 200) + '...' + : s.description + return `- ${s.name}: ${desc}` + }) + + return ( + 'Execute a skill within the main conversation.\n\n' + + 'Available skills:\n' + + lines.join('\n') + + '\n\nWhen a skill matches the user\'s request, invoke it using the Skill tool.' + ) + }, + + async call(input: any, context: ToolContext): Promise { + const skillName: string = input.skill + const args: string = input.args || '' + + if (!skillName) { + return { + type: 'tool_result', + tool_use_id: '', + content: 'Error: skill name is required', + is_error: true, + } + } + + const skill = getSkill(skillName) + if (!skill) { + const available = getUserInvocableSkills() + .map((s) => s.name) + .join(', ') + return { + type: 'tool_result', + tool_use_id: '', + content: `Error: Unknown skill "${skillName}". Available skills: ${available || 'none'}`, + is_error: true, + } + } + + // Check if skill is enabled + if (skill.isEnabled && !skill.isEnabled()) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Error: Skill "${skillName}" is currently disabled`, + is_error: true, + } + } + + try { + // Get skill prompt + const contentBlocks = await skill.getPrompt(args, context) + + // Convert content blocks to text + const promptText = contentBlocks + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map((b) => b.text) + .join('\n\n') + + // Build result with metadata + const result: Record = { + success: true, + commandName: skill.name, + status: skill.context === 'fork' ? 'forked' : 'inline', + prompt: promptText, + } + + if (skill.allowedTools) { + result.allowedTools = skill.allowedTools + } + + if (skill.model) { + result.model = skill.model + } + + return { + type: 'tool_result', + tool_use_id: '', + content: JSON.stringify(result), + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Error executing skill "${skillName}": ${err.message}`, + is_error: true, + } + } + }, +} diff --git a/src/tools/types.ts b/src/tools/types.ts index c2e4aa6..7e84ad7 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -3,7 +3,6 @@ */ import type { ToolDefinition, ToolInputSchema, ToolContext, ToolResult } from '../types.js' -import type Anthropic from '@anthropic-ai/sdk' /** * Helper to create a tool definition with sensible defaults. @@ -52,11 +51,16 @@ export function defineTool(config: { /** * Convert a ToolDefinition to API-compatible tool format. + * Returns the normalized tool format used by providers. */ -export function toApiTool(tool: ToolDefinition): Anthropic.Tool { +export function toApiTool(tool: ToolDefinition): { + name: string + description: string + input_schema: ToolInputSchema +} { return { name: tool.name, description: tool.description, - input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, + input_schema: tool.inputSchema, } } diff --git a/src/types.ts b/src/types.ts index d93ef52..aafa38f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,17 @@ * Core type definitions for the Agent SDK */ -import type Anthropic from '@anthropic-ai/sdk' +// Content block types (provider-agnostic, compatible with Anthropic format) +export type ContentBlockParam = + | { type: 'text'; text: string } + | { type: 'image'; source: any } + | { type: 'tool_use'; id: string; name: string; input: any } + | { type: 'tool_result'; tool_use_id: string; content: string | any[]; is_error?: boolean } + +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: any } + | { type: 'thinking'; thinking: string } // -------------------------------------------------------------------------- // Message Types @@ -12,7 +22,7 @@ export type MessageRole = 'user' | 'assistant' export interface ConversationMessage { role: MessageRole - content: string | Anthropic.ContentBlockParam[] + content: string | ContentBlockParam[] } export interface UserMessage { @@ -26,7 +36,7 @@ export interface AssistantMessage { type: 'assistant' message: { role: 'assistant' - content: Anthropic.ContentBlock[] + content: ContentBlock[] } uuid: string timestamp: string @@ -57,7 +67,7 @@ export interface SDKAssistantMessage { session_id?: string message: { role: 'assistant' - content: Anthropic.ContentBlock[] + content: ContentBlock[] } parent_tool_use_id?: string | null } @@ -186,7 +196,7 @@ export interface ToolContext { export interface ToolResult { type: 'tool_result' tool_use_id: string - content: string | Anthropic.ToolResultBlockParam['content'] + content: string | any[] is_error?: boolean } @@ -202,8 +212,10 @@ export type PermissionMode = | 'dontAsk' | 'auto' +export type PermissionBehavior = 'allow' | 'deny' + export type CanUseToolResult = { - behavior: 'allow' | 'deny' + behavior: PermissionBehavior updatedInput?: unknown message?: string } @@ -326,6 +338,11 @@ export interface ModelInfo { export interface AgentOptions { /** LLM model ID */ model?: string + /** + * API type: 'anthropic-messages' or 'openai-completions'. + * Falls back to CODEANY_API_TYPE env var. Default: 'anthropic-messages'. + */ + apiType?: import('./providers/types.js').ApiType /** API key. Falls back to CODEANY_API_KEY env var. */ apiKey?: string /** API base URL override */ @@ -442,8 +459,8 @@ export interface QueryResult { export interface QueryEngineConfig { cwd: string model: string - apiKey?: string - baseURL?: string + /** LLM provider instance (created from apiType) */ + provider: import('./providers/types.js').LLMProvider tools: ToolDefinition[] systemPrompt?: string appendSystemPrompt?: string @@ -456,4 +473,8 @@ export interface QueryEngineConfig { includePartialMessages: boolean abortSignal?: AbortSignal agents?: Record + /** Hook registry for lifecycle events */ + hookRegistry?: import('./hooks.js').HookRegistry + /** Session ID for hook context */ + sessionId?: string } diff --git a/src/utils/compact.ts b/src/utils/compact.ts index 13a2199..9306fc5 100644 --- a/src/utils/compact.ts +++ b/src/utils/compact.ts @@ -8,7 +8,8 @@ * 3. Session memory compaction: consolidates across sessions */ -import Anthropic from '@anthropic-ai/sdk' +import type { LLMProvider } from '../providers/types.js' +import type { NormalizedMessageParam } from '../providers/types.js' import { estimateMessagesTokens, getAutoCompactThreshold, @@ -38,7 +39,7 @@ export function createAutoCompactState(): AutoCompactState { * Check if auto-compaction should trigger. */ export function shouldAutoCompact( - messages: Anthropic.MessageParam[], + messages: any[], model: string, state: AutoCompactState, ): boolean { @@ -57,12 +58,12 @@ export function shouldAutoCompact( * then replaces the history with a compact summary. */ export async function compactConversation( - client: Anthropic, + provider: LLMProvider, model: string, - messages: Anthropic.MessageParam[], + messages: any[], state: AutoCompactState, ): Promise<{ - compactedMessages: Anthropic.MessageParam[] + compactedMessages: NormalizedMessageParam[] summary: string state: AutoCompactState }> { @@ -73,9 +74,9 @@ export async function compactConversation( // Build compaction prompt const compactionPrompt = buildCompactionPrompt(strippedMessages) - const response = await client.messages.create({ + const response = await provider.createMessage({ model, - max_tokens: 8192, + maxTokens: 8192, system: 'You are a conversation summarizer. Create a detailed summary of the conversation that preserves all important context, decisions made, files modified, tool outputs, and current state. The summary should allow the conversation to continue seamlessly.', messages: [ { @@ -86,12 +87,12 @@ export async function compactConversation( }) const summary = response.content - .filter((b): b is Anthropic.TextBlock => b.type === 'text') - .map((b) => b.text) + .filter((b) => b.type === 'text') + .map((b) => (b as { type: 'text'; text: string }).text) .join('\n') // Replace messages with summary - const compactedMessages: Anthropic.MessageParam[] = [ + const compactedMessages: NormalizedMessageParam[] = [ { role: 'user', content: `[Previous conversation summary]\n\n${summary}\n\n[End of summary - conversation continues below]`, @@ -127,9 +128,9 @@ export async function compactConversation( * Strip images from messages for compaction safety. */ function stripImagesFromMessages( - messages: Anthropic.MessageParam[], -): Anthropic.MessageParam[] { - return messages.map((msg) => { + messages: any[], +): any[] { + return messages.map((msg: any) => { if (typeof msg.content === 'string') return msg const filtered = (msg.content as any[]).filter((block: any) => { @@ -143,7 +144,7 @@ function stripImagesFromMessages( /** * Build compaction prompt from messages. */ -function buildCompactionPrompt(messages: Anthropic.MessageParam[]): string { +function buildCompactionPrompt(messages: any[]): string { const parts: string[] = ['Please summarize this conversation:\n'] for (const msg of messages) { @@ -179,10 +180,10 @@ function buildCompactionPrompt(messages: Anthropic.MessageParam[]): string { * to fit within token budgets. */ export function microCompactMessages( - messages: Anthropic.MessageParam[], + messages: any[], maxToolResultChars: number = 50000, -): Anthropic.MessageParam[] { - return messages.map((msg) => { +): any[] { + return messages.map((msg: any) => { if (typeof msg.content === 'string') return msg if (!Array.isArray(msg.content)) return msg diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 08252aa..749f068 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -5,14 +5,13 @@ * synthetic placeholders, and content processing. */ -import type Anthropic from '@anthropic-ai/sdk' import type { Message, UserMessage, AssistantMessage, TokenUsage } from '../types.js' /** * Create a user message. */ export function createUserMessage( - content: string | Anthropic.ContentBlockParam[], + content: string | any[], options?: { uuid?: string isMeta?: boolean @@ -34,7 +33,7 @@ export function createUserMessage( * Create an assistant message. */ export function createAssistantMessage( - content: Anthropic.ContentBlock[], + content: any[], usage?: TokenUsage, ): AssistantMessage { return { @@ -55,9 +54,9 @@ export function createAssistantMessage( * and fixes tool result pairing. */ export function normalizeMessagesForAPI( - messages: Anthropic.MessageParam[], -): Anthropic.MessageParam[] { - const normalized: Anthropic.MessageParam[] = [] + messages: Array<{ role: string; content: any }>, +): Array<{ role: string; content: any }> { + const normalized: Array<{ role: string; content: any }> = [] for (let i = 0; i < messages.length; i++) { const msg = messages[i] @@ -96,9 +95,9 @@ export function normalizeMessagesForAPI( * matching tool_use in the previous assistant message. */ function fixToolResultPairing( - messages: Anthropic.MessageParam[], -): Anthropic.MessageParam[] { - const result: Anthropic.MessageParam[] = [] + messages: Array<{ role: string; content: any }>, +): Array<{ role: string; content: any }> { + const result: Array<{ role: string; content: any }> = [] for (let i = 0; i < messages.length; i++) { const msg = messages[i] @@ -145,8 +144,8 @@ function fixToolResultPairing( * Strip images from messages (for compaction). */ export function stripImagesFromMessages( - messages: Anthropic.MessageParam[], -): Anthropic.MessageParam[] { + messages: Array<{ role: string; content: any }>, +): Array<{ role: string; content: any }> { return messages.map((msg) => { if (typeof msg.content === 'string') return msg if (!Array.isArray(msg.content)) return msg @@ -166,7 +165,7 @@ export function stripImagesFromMessages( * Extract text from message content blocks. */ export function extractTextFromContent( - content: Anthropic.ContentBlock[] | string, + content: any[] | string, ): string { if (typeof content === 'string') return content @@ -179,7 +178,7 @@ export function extractTextFromContent( /** * Create a system message for compact boundary. */ -export function createCompactBoundaryMessage(): Anthropic.MessageParam { +export function createCompactBoundaryMessage(): { role: string; content: string } { return { role: 'user', content: '[Previous context has been summarized above. Continuing conversation.]', diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 8b2b697..eec258f 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -5,8 +5,6 @@ * API-based exact counting when available. */ -import Anthropic from '@anthropic-ai/sdk' - /** * Rough token estimation: ~4 chars per token (conservative). */ @@ -18,7 +16,7 @@ export function estimateTokens(text: string): number { * Estimate tokens for a message array. */ export function estimateMessagesTokens( - messages: Anthropic.MessageParam[], + messages: Array<{ role: string; content: any }>, ): number { let total = 0 for (const msg of messages) { @@ -68,15 +66,26 @@ export function getTokenCountFromUsage(usage: { * Get the context window size for a model. */ export function getContextWindowSize(model: string): number { - // Model context windows + // Anthropic model context windows if (model.includes('opus-4') && model.includes('1m')) return 1_000_000 if (model.includes('opus-4')) return 200_000 if (model.includes('sonnet-4')) return 200_000 if (model.includes('haiku-4')) return 200_000 - - if (model.includes('claude-3')) return 200_000 + // OpenAI model context windows + if (model.includes('gpt-4o')) return 128_000 + if (model.includes('gpt-4-turbo')) return 128_000 + if (model.includes('gpt-4-1')) return 1_000_000 + if (model.includes('gpt-4')) return 128_000 + if (model.includes('gpt-3.5')) return 16_385 + if (model.includes('o1')) return 200_000 + if (model.includes('o3')) return 200_000 + if (model.includes('o4')) return 200_000 + + // DeepSeek models + if (model.includes('deepseek')) return 128_000 + // Default return 200_000 } @@ -97,6 +106,7 @@ export function getAutoCompactThreshold(model: string): number { * Model pricing (USD per token). */ export const MODEL_PRICING: Record = { + // Anthropic models 'claude-opus-4-6': { input: 15 / 1_000_000, output: 75 / 1_000_000 }, 'claude-opus-4-5': { input: 15 / 1_000_000, output: 75 / 1_000_000 }, 'claude-sonnet-4-6': { input: 3 / 1_000_000, output: 15 / 1_000_000 }, @@ -105,6 +115,19 @@ export const MODEL_PRICING: Record = 'claude-3-5-sonnet': { input: 3 / 1_000_000, output: 15 / 1_000_000 }, 'claude-3-5-haiku': { input: 0.8 / 1_000_000, output: 4 / 1_000_000 }, 'claude-3-opus': { input: 15 / 1_000_000, output: 75 / 1_000_000 }, + + // OpenAI models + 'gpt-4o': { input: 2.5 / 1_000_000, output: 10 / 1_000_000 }, + 'gpt-4o-mini': { input: 0.15 / 1_000_000, output: 0.6 / 1_000_000 }, + 'gpt-4-turbo': { input: 10 / 1_000_000, output: 30 / 1_000_000 }, + 'gpt-4-1': { input: 2 / 1_000_000, output: 8 / 1_000_000 }, + 'o1': { input: 15 / 1_000_000, output: 60 / 1_000_000 }, + 'o3': { input: 10 / 1_000_000, output: 40 / 1_000_000 }, + 'o4-mini': { input: 1.1 / 1_000_000, output: 4.4 / 1_000_000 }, + + // DeepSeek models + 'deepseek-chat': { input: 0.27 / 1_000_000, output: 1.1 / 1_000_000 }, + 'deepseek-reasoner': { input: 0.55 / 1_000_000, output: 2.19 / 1_000_000 }, } /**