@codeany/open-agent-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.env.example +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +388 -0
  4. package/examples/01-simple-query.ts +43 -0
  5. package/examples/02-multi-tool.ts +44 -0
  6. package/examples/03-multi-turn.ts +39 -0
  7. package/examples/04-prompt-api.ts +29 -0
  8. package/examples/05-custom-system-prompt.ts +26 -0
  9. package/examples/06-mcp-server.ts +49 -0
  10. package/examples/07-custom-tools.ts +87 -0
  11. package/examples/08-official-api-compat.ts +38 -0
  12. package/examples/09-subagents.ts +48 -0
  13. package/examples/10-permissions.ts +40 -0
  14. package/examples/11-custom-mcp-tools.ts +101 -0
  15. package/examples/web/index.html +365 -0
  16. package/examples/web/server.ts +157 -0
  17. package/package.json +60 -0
  18. package/src/agent.ts +425 -0
  19. package/src/engine.ts +520 -0
  20. package/src/hooks.ts +261 -0
  21. package/src/index.ts +376 -0
  22. package/src/mcp/client.ts +150 -0
  23. package/src/sdk-mcp-server.ts +78 -0
  24. package/src/session.ts +227 -0
  25. package/src/tool-helper.ts +127 -0
  26. package/src/tools/agent-tool.ts +153 -0
  27. package/src/tools/ask-user.ts +79 -0
  28. package/src/tools/bash.ts +75 -0
  29. package/src/tools/config-tool.ts +89 -0
  30. package/src/tools/cron-tools.ts +153 -0
  31. package/src/tools/edit.ts +74 -0
  32. package/src/tools/glob.ts +77 -0
  33. package/src/tools/grep.ts +168 -0
  34. package/src/tools/index.ts +232 -0
  35. package/src/tools/lsp-tool.ts +163 -0
  36. package/src/tools/mcp-resource-tools.ts +125 -0
  37. package/src/tools/notebook-edit.ts +93 -0
  38. package/src/tools/plan-tools.ts +88 -0
  39. package/src/tools/read.ts +73 -0
  40. package/src/tools/send-message.ts +96 -0
  41. package/src/tools/task-tools.ts +290 -0
  42. package/src/tools/team-tools.ts +128 -0
  43. package/src/tools/todo-tool.ts +112 -0
  44. package/src/tools/tool-search.ts +87 -0
  45. package/src/tools/types.ts +62 -0
  46. package/src/tools/web-fetch.ts +66 -0
  47. package/src/tools/web-search.ts +86 -0
  48. package/src/tools/worktree-tools.ts +140 -0
  49. package/src/tools/write.ts +42 -0
  50. package/src/types.ts +459 -0
  51. package/src/utils/compact.ts +206 -0
  52. package/src/utils/context.ts +191 -0
  53. package/src/utils/fileCache.ts +148 -0
  54. package/src/utils/messages.ts +196 -0
  55. package/src/utils/retry.ts +140 -0
  56. package/src/utils/tokens.ts +122 -0
  57. package/tsconfig.json +19 -0
package/src/engine.ts ADDED
@@ -0,0 +1,520 @@
1
+ /**
2
+ * QueryEngine - Core agentic loop
3
+ *
4
+ * Manages the full conversation lifecycle:
5
+ * 1. Take user prompt
6
+ * 2. Build system prompt with context (git status, project context, tools)
7
+ * 3. Call LLM API with tools
8
+ * 4. Stream response
9
+ * 5. Execute tool calls (concurrent for read-only, serial for mutations)
10
+ * 6. Send results back, repeat until done
11
+ * 7. Auto-compact when context exceeds threshold
12
+ * 8. Retry with exponential backoff on transient errors
13
+ */
14
+
15
+ import Anthropic from '@anthropic-ai/sdk'
16
+ import type {
17
+ SDKMessage,
18
+ QueryEngineConfig,
19
+ ToolDefinition,
20
+ ToolResult,
21
+ ToolContext,
22
+ TokenUsage,
23
+ } from './types.js'
24
+ import { toApiTool } from './tools/types.js'
25
+ import {
26
+ estimateMessagesTokens,
27
+ estimateCost,
28
+ getAutoCompactThreshold,
29
+ } from './utils/tokens.js'
30
+ import {
31
+ shouldAutoCompact,
32
+ compactConversation,
33
+ microCompactMessages,
34
+ createAutoCompactState,
35
+ type AutoCompactState,
36
+ } from './utils/compact.js'
37
+ import {
38
+ withRetry,
39
+ isPromptTooLongError,
40
+ formatApiError,
41
+ } from './utils/retry.js'
42
+ import { getSystemContext, getUserContext } from './utils/context.js'
43
+ import { normalizeMessagesForAPI } from './utils/messages.js'
44
+
45
+ // ============================================================================
46
+ // System Prompt Builder
47
+ // ============================================================================
48
+
49
+ async function buildSystemPrompt(config: QueryEngineConfig): Promise<string> {
50
+ if (config.systemPrompt) {
51
+ const base = config.systemPrompt
52
+ return config.appendSystemPrompt
53
+ ? base + '\n\n' + config.appendSystemPrompt
54
+ : base
55
+ }
56
+
57
+ const parts: string[] = []
58
+
59
+ parts.push(
60
+ 'You are an AI assistant with access to tools. Use the tools provided to help the user accomplish their tasks.',
61
+ 'You should use tools when they would help you complete the task more accurately or efficiently.',
62
+ )
63
+
64
+ // List available tools with descriptions
65
+ parts.push('\n# Available Tools\n')
66
+ for (const tool of config.tools) {
67
+ parts.push(`- **${tool.name}**: ${tool.description}`)
68
+ }
69
+
70
+ // Add agent definitions
71
+ if (config.agents && Object.keys(config.agents).length > 0) {
72
+ parts.push('\n# Available Subagents\n')
73
+ for (const [name, def] of Object.entries(config.agents)) {
74
+ parts.push(`- **${name}**: ${def.description}`)
75
+ }
76
+ }
77
+
78
+ // System context (git status, etc.)
79
+ try {
80
+ const sysCtx = await getSystemContext(config.cwd)
81
+ if (sysCtx) {
82
+ parts.push('\n# Environment\n')
83
+ parts.push(sysCtx)
84
+ }
85
+ } catch {
86
+ // Context is best-effort
87
+ }
88
+
89
+ // User context (AGENT.md, date)
90
+ try {
91
+ const userCtx = await getUserContext(config.cwd)
92
+ if (userCtx) {
93
+ parts.push('\n# Project Context\n')
94
+ parts.push(userCtx)
95
+ }
96
+ } catch {
97
+ // Context is best-effort
98
+ }
99
+
100
+ // Working directory
101
+ parts.push(`\n# Working Directory\n${config.cwd}`)
102
+
103
+ if (config.appendSystemPrompt) {
104
+ parts.push('\n' + config.appendSystemPrompt)
105
+ }
106
+
107
+ return parts.join('\n')
108
+ }
109
+
110
+ // ============================================================================
111
+ // QueryEngine
112
+ // ============================================================================
113
+
114
+ export class QueryEngine {
115
+ private config: QueryEngineConfig
116
+ private client: Anthropic
117
+ public messages: Anthropic.MessageParam[] = []
118
+ private totalUsage: TokenUsage = { input_tokens: 0, output_tokens: 0 }
119
+ private totalCost = 0
120
+ private turnCount = 0
121
+ private compactState: AutoCompactState
122
+ private sessionId: string
123
+ private apiTimeMs = 0
124
+
125
+ constructor(config: QueryEngineConfig) {
126
+ this.config = config
127
+ this.client = new Anthropic({
128
+ apiKey: config.apiKey,
129
+ baseURL: config.baseURL,
130
+ })
131
+ this.compactState = createAutoCompactState()
132
+ this.sessionId = crypto.randomUUID()
133
+ }
134
+
135
+ /**
136
+ * Submit a user message and run the agentic loop.
137
+ * Yields SDKMessage events as the agent works.
138
+ */
139
+ async *submitMessage(
140
+ prompt: string | Anthropic.ContentBlockParam[],
141
+ ): AsyncGenerator<SDKMessage> {
142
+ // Add user message
143
+ this.messages.push({ role: 'user', content: prompt })
144
+
145
+ // Build tool definitions for API
146
+ const tools = this.config.tools.map(toApiTool)
147
+
148
+ // Build system prompt
149
+ const systemPrompt = await buildSystemPrompt(this.config)
150
+
151
+ // Emit init system message
152
+ yield {
153
+ type: 'system',
154
+ subtype: 'init',
155
+ session_id: this.sessionId,
156
+ tools: this.config.tools.map(t => t.name),
157
+ model: this.config.model,
158
+ cwd: this.config.cwd,
159
+ mcp_servers: [],
160
+ permission_mode: 'bypassPermissions',
161
+ } as SDKMessage
162
+
163
+ // Agentic loop
164
+ let turnsRemaining = this.config.maxTurns
165
+ let budgetExceeded = false
166
+ let maxOutputRecoveryAttempts = 0
167
+ const MAX_OUTPUT_RECOVERY = 3
168
+
169
+ while (turnsRemaining > 0) {
170
+ if (this.config.abortSignal?.aborted) break
171
+
172
+ // Check budget
173
+ if (this.config.maxBudgetUsd && this.totalCost >= this.config.maxBudgetUsd) {
174
+ budgetExceeded = true
175
+ break
176
+ }
177
+
178
+ // Auto-compact if context is too large
179
+ if (shouldAutoCompact(this.messages, this.config.model, this.compactState)) {
180
+ try {
181
+ const result = await compactConversation(
182
+ this.client,
183
+ this.config.model,
184
+ this.messages,
185
+ this.compactState,
186
+ )
187
+ this.messages = result.compactedMessages
188
+ this.compactState = result.state
189
+ } catch {
190
+ // Continue with uncompacted messages
191
+ }
192
+ }
193
+
194
+ // Micro-compact: truncate large tool results
195
+ const apiMessages = microCompactMessages(
196
+ normalizeMessagesForAPI(this.messages),
197
+ )
198
+
199
+ this.turnCount++
200
+ turnsRemaining--
201
+
202
+ // Make API call with retry
203
+ let response: Anthropic.Message
204
+ const apiStart = performance.now()
205
+ try {
206
+ response = await withRetry(
207
+ async () => {
208
+ const requestParams: Anthropic.MessageCreateParamsNonStreaming = {
209
+ model: this.config.model,
210
+ max_tokens: this.config.maxTokens,
211
+ system: systemPrompt,
212
+ messages: apiMessages,
213
+ tools: tools.length > 0 ? tools : undefined,
214
+ }
215
+
216
+ // Add thinking if configured
217
+ if (
218
+ this.config.thinking?.type === 'enabled' &&
219
+ this.config.thinking.budgetTokens
220
+ ) {
221
+ (requestParams as any).thinking = {
222
+ type: 'enabled',
223
+ budget_tokens: this.config.thinking.budgetTokens,
224
+ }
225
+ }
226
+
227
+ return this.client.messages.create(requestParams)
228
+ },
229
+ undefined,
230
+ this.config.abortSignal,
231
+ )
232
+ } catch (err: any) {
233
+ // Handle prompt-too-long by compacting
234
+ if (isPromptTooLongError(err) && !this.compactState.compacted) {
235
+ try {
236
+ const result = await compactConversation(
237
+ this.client,
238
+ this.config.model,
239
+ this.messages,
240
+ this.compactState,
241
+ )
242
+ this.messages = result.compactedMessages
243
+ this.compactState = result.state
244
+ turnsRemaining++ // Retry this turn
245
+ this.turnCount--
246
+ continue
247
+ } catch {
248
+ // Can't compact, give up
249
+ }
250
+ }
251
+
252
+ yield {
253
+ type: 'result',
254
+ subtype: 'error',
255
+ usage: this.totalUsage,
256
+ num_turns: this.turnCount,
257
+ cost: this.totalCost,
258
+ }
259
+ return
260
+ }
261
+
262
+ // Track API timing
263
+ this.apiTimeMs += performance.now() - apiStart
264
+
265
+ // Track usage
266
+ if (response.usage) {
267
+ this.totalUsage.input_tokens += response.usage.input_tokens
268
+ this.totalUsage.output_tokens += response.usage.output_tokens
269
+ if ('cache_creation_input_tokens' in response.usage) {
270
+ this.totalUsage.cache_creation_input_tokens =
271
+ (this.totalUsage.cache_creation_input_tokens || 0) +
272
+ ((response.usage as any).cache_creation_input_tokens || 0)
273
+ }
274
+ if ('cache_read_input_tokens' in response.usage) {
275
+ this.totalUsage.cache_read_input_tokens =
276
+ (this.totalUsage.cache_read_input_tokens || 0) +
277
+ ((response.usage as any).cache_read_input_tokens || 0)
278
+ }
279
+ this.totalCost += estimateCost(this.config.model, response.usage as TokenUsage)
280
+ }
281
+
282
+ // Add assistant message to conversation
283
+ this.messages.push({ role: 'assistant', content: response.content })
284
+
285
+ // Yield assistant message
286
+ yield {
287
+ type: 'assistant',
288
+ message: {
289
+ role: 'assistant',
290
+ content: response.content,
291
+ },
292
+ }
293
+
294
+ // Handle max_output_tokens recovery
295
+ if (
296
+ response.stop_reason === 'max_tokens' &&
297
+ maxOutputRecoveryAttempts < MAX_OUTPUT_RECOVERY
298
+ ) {
299
+ maxOutputRecoveryAttempts++
300
+ // Add continuation prompt
301
+ this.messages.push({
302
+ role: 'user',
303
+ content: 'Please continue from where you left off.',
304
+ })
305
+ continue
306
+ }
307
+
308
+ // Check for tool use
309
+ const toolUseBlocks = response.content.filter(
310
+ (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use',
311
+ )
312
+
313
+ if (toolUseBlocks.length === 0) {
314
+ break // No tool calls - agent is done
315
+ }
316
+
317
+ // Reset max_output recovery counter on successful tool use
318
+ maxOutputRecoveryAttempts = 0
319
+
320
+ // Execute tools (concurrent read-only, serial mutations)
321
+ const toolResults = await this.executeTools(toolUseBlocks)
322
+
323
+ // Yield tool results
324
+ for (const result of toolResults) {
325
+ yield {
326
+ type: 'tool_result',
327
+ result: {
328
+ tool_use_id: result.tool_use_id,
329
+ tool_name: result.tool_name || '',
330
+ output:
331
+ typeof result.content === 'string'
332
+ ? result.content
333
+ : JSON.stringify(result.content),
334
+ },
335
+ }
336
+ }
337
+
338
+ // Add tool results to conversation
339
+ this.messages.push({
340
+ role: 'user',
341
+ content: toolResults.map((r) => ({
342
+ type: 'tool_result' as const,
343
+ tool_use_id: r.tool_use_id,
344
+ content:
345
+ typeof r.content === 'string'
346
+ ? r.content
347
+ : JSON.stringify(r.content),
348
+ is_error: r.is_error,
349
+ })),
350
+ })
351
+
352
+ if (response.stop_reason === 'end_turn') break
353
+ }
354
+
355
+ // Yield enriched final result
356
+ const endSubtype = budgetExceeded
357
+ ? 'error_max_budget_usd'
358
+ : turnsRemaining <= 0
359
+ ? 'error_max_turns'
360
+ : 'success'
361
+
362
+ yield {
363
+ type: 'result',
364
+ subtype: endSubtype,
365
+ session_id: this.sessionId,
366
+ is_error: endSubtype !== 'success',
367
+ num_turns: this.turnCount,
368
+ total_cost_usd: this.totalCost,
369
+ duration_api_ms: Math.round(this.apiTimeMs),
370
+ usage: this.totalUsage,
371
+ model_usage: { [this.config.model]: { input_tokens: this.totalUsage.input_tokens, output_tokens: this.totalUsage.output_tokens } },
372
+ cost: this.totalCost,
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Execute tool calls with concurrency control.
378
+ *
379
+ * Read-only tools run concurrently (up to 10 at a time).
380
+ * Mutation tools run sequentially.
381
+ */
382
+ private async executeTools(
383
+ toolUseBlocks: Anthropic.ToolUseBlock[],
384
+ ): Promise<(ToolResult & { tool_name?: string })[]> {
385
+ const context: ToolContext = {
386
+ cwd: this.config.cwd,
387
+ abortSignal: this.config.abortSignal,
388
+ }
389
+
390
+ const MAX_CONCURRENCY = parseInt(
391
+ process.env.AGENT_SDK_MAX_TOOL_CONCURRENCY || '10',
392
+ )
393
+
394
+ // Partition into read-only (concurrent) and mutation (serial)
395
+ const readOnly: Array<{ block: Anthropic.ToolUseBlock; tool?: ToolDefinition }> = []
396
+ const mutations: Array<{ block: Anthropic.ToolUseBlock; tool?: ToolDefinition }> = []
397
+
398
+ for (const block of toolUseBlocks) {
399
+ const tool = this.config.tools.find((t) => t.name === block.name)
400
+ if (tool?.isReadOnly?.()) {
401
+ readOnly.push({ block, tool })
402
+ } else {
403
+ mutations.push({ block, tool })
404
+ }
405
+ }
406
+
407
+ const results: (ToolResult & { tool_name?: string })[] = []
408
+
409
+ // Execute read-only tools concurrently (batched by MAX_CONCURRENCY)
410
+ for (let i = 0; i < readOnly.length; i += MAX_CONCURRENCY) {
411
+ const batch = readOnly.slice(i, i + MAX_CONCURRENCY)
412
+ const batchResults = await Promise.all(
413
+ batch.map((item) =>
414
+ this.executeSingleTool(item.block, item.tool, context),
415
+ ),
416
+ )
417
+ results.push(...batchResults)
418
+ }
419
+
420
+ // Execute mutation tools sequentially
421
+ for (const item of mutations) {
422
+ const result = await this.executeSingleTool(item.block, item.tool, context)
423
+ results.push(result)
424
+ }
425
+
426
+ return results
427
+ }
428
+
429
+ /**
430
+ * Execute a single tool with permission checking.
431
+ */
432
+ private async executeSingleTool(
433
+ block: Anthropic.ToolUseBlock,
434
+ tool: ToolDefinition | undefined,
435
+ context: ToolContext,
436
+ ): Promise<ToolResult & { tool_name?: string }> {
437
+ if (!tool) {
438
+ return {
439
+ type: 'tool_result',
440
+ tool_use_id: block.id,
441
+ content: `Error: Unknown tool "${block.name}"`,
442
+ is_error: true,
443
+ tool_name: block.name,
444
+ }
445
+ }
446
+
447
+ // Check enabled
448
+ if (tool.isEnabled && !tool.isEnabled()) {
449
+ return {
450
+ type: 'tool_result',
451
+ tool_use_id: block.id,
452
+ content: `Error: Tool "${block.name}" is not enabled`,
453
+ is_error: true,
454
+ tool_name: block.name,
455
+ }
456
+ }
457
+
458
+ // Check permissions
459
+ if (this.config.canUseTool) {
460
+ try {
461
+ const permission = await this.config.canUseTool(tool, block.input)
462
+ if (permission.behavior === 'deny') {
463
+ return {
464
+ type: 'tool_result',
465
+ tool_use_id: block.id,
466
+ content: permission.message || `Permission denied for tool "${block.name}"`,
467
+ is_error: true,
468
+ tool_name: block.name,
469
+ }
470
+ }
471
+ if (permission.updatedInput !== undefined) {
472
+ block = { ...block, input: permission.updatedInput as Record<string, unknown> }
473
+ }
474
+ } catch (err: any) {
475
+ return {
476
+ type: 'tool_result',
477
+ tool_use_id: block.id,
478
+ content: `Permission check error: ${err.message}`,
479
+ is_error: true,
480
+ tool_name: block.name,
481
+ }
482
+ }
483
+ }
484
+
485
+ // Execute the tool
486
+ try {
487
+ const result = await tool.call(block.input, context)
488
+ return { ...result, tool_use_id: block.id, tool_name: block.name }
489
+ } catch (err: any) {
490
+ return {
491
+ type: 'tool_result',
492
+ tool_use_id: block.id,
493
+ content: `Tool execution error: ${err.message}`,
494
+ is_error: true,
495
+ tool_name: block.name,
496
+ }
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Get current messages for session persistence.
502
+ */
503
+ getMessages(): Anthropic.MessageParam[] {
504
+ return [...this.messages]
505
+ }
506
+
507
+ /**
508
+ * Get total usage across all turns.
509
+ */
510
+ getUsage(): TokenUsage {
511
+ return { ...this.totalUsage }
512
+ }
513
+
514
+ /**
515
+ * Get total cost.
516
+ */
517
+ getCost(): number {
518
+ return this.totalCost
519
+ }
520
+ }