mirror of
https://github.com/codeany-ai/open-agent-sdk-typescript.git
synced 2026-04-25 07:00:49 +09:00
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:
@@ -4,7 +4,7 @@
|
||||
[](https://nodejs.org)
|
||||
[](./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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'.`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 }]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 }]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 }]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 },
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user