feat: add skills system, hooks integration, and OpenAI-compatible provider

- Add skill system with types, registry, SkillTool, and 5 bundled skills
  (simplify, commit, review, debug, test)
- Integrate hooks into QueryEngine at 9 lifecycle points (SessionStart,
  UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure,
  PreCompact, PostCompact, Stop, SessionEnd)
- Add LLM provider abstraction supporting both Anthropic Messages API
  and OpenAI Chat Completions API (works with GPT, DeepSeek, Qwen, etc.)
- Add CODEANY_API_TYPE env var ('anthropic-messages' | 'openai-completions')
  with auto-detection from model name
- Remove all ANTHROPIC_* env var references, only support CODEANY_* prefix
- Add model pricing and context windows for OpenAI/DeepSeek models
- Remove direct @anthropic-ai/sdk dependency from all files except the
  Anthropic provider (types.ts, engine.ts, etc. are now provider-agnostic)
- Add PermissionBehavior type export
- Bump version to 0.2.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
idoubi
2026-04-03 23:29:29 +08:00
parent 67e120b2ed
commit 85dff47d74
30 changed files with 1964 additions and 158 deletions
+140 -27
View File
@@ -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:
+88
View File
@@ -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<SkillContentBlock[]> {
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)
+88
View File
@@ -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)
+71
View File
@@ -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)
+1 -1
View File
@@ -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",
+106 -15
View File
@@ -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<void>
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.
*/
+167 -60
View File
@@ -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<string> {
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<HookInput>,
): Promise<HookOutput[]> {
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<SDKMessage> {
// 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<ToolResult & { tool_name?: string }> {
@@ -469,7 +541,7 @@ export class QueryEngine {
}
}
if (permission.updatedInput !== undefined) {
block = { ...block, input: permission.updatedInput as Record<string, unknown> }
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]
}
+51 -1
View File
@@ -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,
+60
View File
@@ -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<CreateMessageResponse> {
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,
},
}
}
}
+34
View File
@@ -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'.`)
}
}
+315
View File
@@ -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<string, any>
}
}
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<CreateMessageResponse> {
// Convert to OpenAI format
const messages = this.convertMessages(params.system, params.messages)
const tools = params.tools ? this.convertTools(params.tools) : undefined
const body: Record<string, any> = {
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
}
}
}
+85
View File
@@ -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<string, any>
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<CreateMessageResponse>
}
+5 -5
View File
@@ -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<SessionMetadata>,
): Promise<void> {
const dir = getSessionPath(sessionId)
@@ -146,7 +146,7 @@ export async function forkSession(
*/
export async function getSessionMessages(
sessionId: string,
): Promise<Anthropic.MessageParam[]> {
): Promise<NormalizedMessageParam[]> {
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<void> {
const data = await loadSession(sessionId)
if (!data) return
+38
View File
@@ -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<SkillContentBlock[]> {
let prompt = COMMIT_PROMPT
if (args.trim()) {
prompt += `\n\nAdditional instructions: ${args}`
}
return [{ type: 'text', text: prompt }]
},
})
}
+48
View File
@@ -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<SkillContentBlock[]> {
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 }]
},
})
}
+28
View File
@@ -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()
}
+41
View File
@@ -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<SkillContentBlock[]> {
let prompt = REVIEW_PROMPT
if (args.trim()) {
prompt += `\n\nFocus area: ${args}`
}
return [{ type: 'text', text: prompt }]
},
})
}
+51
View File
@@ -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<SkillContentBlock[]> {
let prompt = SIMPLIFY_PROMPT
if (args.trim()) {
prompt += `\n\n## Additional Focus\n${args}`
}
return [{ type: 'text', text: prompt }]
},
})
}
+43
View File
@@ -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<SkillContentBlock[]> {
let prompt = TEST_PROMPT
if (args.trim()) {
prompt += `\n\nSpecific test target: ${args}`
}
return [{ type: 'text', text: prompt }]
},
})
}
+25
View File
@@ -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'
+133
View File
@@ -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<string, SkillDefinition> = new Map()
/** Alias -> skill name mapping */
const aliases: Map<string, string> = 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')
}
+99
View File
@@ -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<SkillContentBlock[]>
}
/**
* 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
}
+11 -2
View File
@@ -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<string, AgentDefinition> = {}
@@ -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,
+8
View File
@@ -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
+133
View File
@@ -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<string> {
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<ToolResult> {
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<string, unknown> = {
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,
}
}
},
}
+7 -3
View File
@@ -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,
}
}
+29 -8
View File
@@ -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<string, AgentDefinition>
/** Hook registry for lifecycle events */
hookRegistry?: import('./hooks.js').HookRegistry
/** Session ID for hook context */
sessionId?: string
}
+18 -17
View File
@@ -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
+12 -13
View File
@@ -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.]',
+29 -6
View File
@@ -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<string, { input: number; output: number }> = {
// 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<string, { input: number; output: number }> =
'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 },
}
/**