lonny-agent 0.2.2 → 0.2.4

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 (64) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/project.d.ts +26 -0
  5. package/dist/agent/project.d.ts.map +1 -0
  6. package/dist/agent/project.js +303 -0
  7. package/dist/agent/project.js.map +1 -0
  8. package/dist/agent/prompt-builder.d.ts +1 -1
  9. package/dist/agent/prompt-builder.d.ts.map +1 -1
  10. package/dist/agent/prompt-builder.js +10 -5
  11. package/dist/agent/prompt-builder.js.map +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +20 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.js +1 -1
  16. package/dist/agent/providers/google.js.map +1 -1
  17. package/dist/agent/providers/ollama.d.ts.map +1 -1
  18. package/dist/agent/providers/ollama.js +2 -1
  19. package/dist/agent/providers/ollama.js.map +1 -1
  20. package/dist/agent/providers/openai.d.ts.map +1 -1
  21. package/dist/agent/providers/openai.js +23 -3
  22. package/dist/agent/providers/openai.js.map +1 -1
  23. package/dist/agent/session.d.ts +4 -2
  24. package/dist/agent/session.d.ts.map +1 -1
  25. package/dist/agent/session.js +206 -147
  26. package/dist/agent/session.js.map +1 -1
  27. package/dist/tools/__tests__/edit.test.js +14 -14
  28. package/dist/tools/__tests__/edit.test.js.map +1 -1
  29. package/dist/tools/edit.d.ts +17 -0
  30. package/dist/tools/edit.d.ts.map +1 -1
  31. package/dist/tools/edit.js +81 -34
  32. package/dist/tools/edit.js.map +1 -1
  33. package/dist/tools/read.d.ts +6 -0
  34. package/dist/tools/read.d.ts.map +1 -1
  35. package/dist/tools/read.js +42 -15
  36. package/dist/tools/read.js.map +1 -1
  37. package/dist/tools/registry.d.ts.map +1 -1
  38. package/dist/tools/registry.js +0 -4
  39. package/dist/tools/registry.js.map +1 -1
  40. package/dist/tools/write_plan.d.ts +1 -1
  41. package/dist/tools/write_plan.d.ts.map +1 -1
  42. package/dist/tools/write_plan.js +14 -4
  43. package/dist/tools/write_plan.js.map +1 -1
  44. package/dist/tui/index.d.ts.map +1 -1
  45. package/dist/tui/index.js +5 -4
  46. package/dist/tui/index.js.map +1 -1
  47. package/dist/web/index.js +5 -5
  48. package/dist/web/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/agent/index.ts +1 -1
  51. package/src/agent/project.ts +366 -0
  52. package/src/agent/prompt-builder.ts +11 -5
  53. package/src/agent/providers/anthropic.ts +21 -2
  54. package/src/agent/providers/google.ts +1 -1
  55. package/src/agent/providers/ollama.ts +5 -1
  56. package/src/agent/providers/openai.ts +27 -3
  57. package/src/agent/session.ts +216 -150
  58. package/src/tools/__tests__/edit.test.ts +14 -14
  59. package/src/tools/edit.ts +103 -36
  60. package/src/tools/read.ts +61 -25
  61. package/src/tools/registry.ts +260 -265
  62. package/src/tools/write_plan.ts +21 -5
  63. package/src/tui/index.ts +5 -4
  64. package/src/web/index.ts +5 -5
@@ -0,0 +1,366 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as path from 'node:path'
3
+
4
+ // ── Project type detection ─────────────────────────────────────────────────
5
+
6
+ export type ProjectType = 'node' | 'python' | 'rust' | 'go' | 'java' | 'cpp' | 'unknown'
7
+
8
+ export interface ProjectInfo {
9
+ type: ProjectType
10
+ entryPoints: string[]
11
+ dependencies: Record<string, string>
12
+ devDependencies: Record<string, string>
13
+ testFiles: string[]
14
+ configFiles: string[]
15
+ srcDirs: string[]
16
+ hasTests: boolean
17
+ packageManager: 'npm' | 'yarn' | 'pnpm' | 'bun' | null
18
+ }
19
+
20
+ interface PackageJson {
21
+ name?: string
22
+ version?: string
23
+ main?: string
24
+ module?: string
25
+ exports?: string | Record<string, string>
26
+ dependencies?: Record<string, string>
27
+ devDependencies?: Record<string, string>
28
+ scripts?: Record<string, string>
29
+ type?: string
30
+ }
31
+
32
+ // Common entry point patterns
33
+ const ENTRY_PATTERNS: Record<ProjectType, string[]> = {
34
+ node: [
35
+ 'src/index.ts',
36
+ 'src/main.ts',
37
+ 'src/app.ts',
38
+ 'src/server.ts',
39
+ 'index.ts',
40
+ 'app.ts',
41
+ 'server.ts',
42
+ 'main.ts',
43
+ ],
44
+ python: ['main.py', 'app.py', 'src/main.py', 'src/app.py'],
45
+ rust: ['src/main.rs', 'src/lib.rs'],
46
+ go: ['main.go', 'cmd/main.go'],
47
+ java: ['src/main/java/Main.java'],
48
+ cpp: ['src/main.cpp', 'main.cpp'],
49
+ unknown: [],
50
+ }
51
+
52
+ const TEST_PATTERNS: Record<ProjectType, string[]> = {
53
+ node: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**', 'tests/**/*.ts'],
54
+ python: ['test_*.py', '*_test.py', 'tests/**/*.py'],
55
+ rust: ['tests/**/*.rs', 'src/**/*.rs'],
56
+ go: ['*_test.go'],
57
+ java: ['**/*Test.java', '**/*Tests.java'],
58
+ cpp: ['**/*test*.cpp', 'tests/**/*.cpp'],
59
+ unknown: [],
60
+ }
61
+
62
+ const CONFIG_FILES: Record<ProjectType, string[]> = {
63
+ node: [
64
+ 'package.json',
65
+ 'tsconfig.json',
66
+ 'biome.json',
67
+ '.biomerc',
68
+ 'jest.config.*',
69
+ 'vitest.config.*',
70
+ '.eslintrc*',
71
+ '.prettierrc*',
72
+ '.npmrc',
73
+ ],
74
+ python: [
75
+ 'pyproject.toml',
76
+ 'setup.py',
77
+ 'setup.cfg',
78
+ 'requirements.txt',
79
+ 'Pipfile',
80
+ 'poetry.lock',
81
+ 'pytest.ini',
82
+ 'pyproject.toml',
83
+ ],
84
+ rust: ['Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml'],
85
+ go: ['go.mod', 'go.sum'],
86
+ java: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
87
+ cpp: ['CMakeLists.txt', 'Makefile', '*.cmake'],
88
+ unknown: [],
89
+ }
90
+
91
+ const SRC_DIRS: Record<ProjectType, string[]> = {
92
+ node: ['src', 'lib', 'app', 'packages'],
93
+ python: ['src', 'lib', 'app'],
94
+ rust: ['src'],
95
+ go: ['cmd', 'internal', 'pkg'],
96
+ java: ['src/main/java', 'src'],
97
+ cpp: ['src', 'lib', 'include'],
98
+ unknown: [],
99
+ }
100
+
101
+ // ── Detection functions ───────────────────────────────────────────────────
102
+
103
+ /** Detect project type from root directory */
104
+ async function detectProjectType(cwd: string): Promise<ProjectType> {
105
+ const files = await fs.readdir(cwd, { withFileTypes: true })
106
+ const fileNames = files.map(f => f.name.toLowerCase())
107
+
108
+ // Priority order matters
109
+ if (fileNames.includes('cargo.toml')) return 'rust'
110
+ if (fileNames.includes('go.mod')) return 'go'
111
+ if (fileNames.includes('pom.xml') || fileNames.includes('build.gradle')) return 'java'
112
+ if (fileNames.includes('package.json')) return 'node'
113
+ if (
114
+ fileNames.includes('pyproject.toml') ||
115
+ fileNames.includes('setup.py') ||
116
+ fileNames.includes('requirements.txt')
117
+ )
118
+ return 'python'
119
+ if (fileNames.includes('cmakelists.txt')) return 'cpp'
120
+
121
+ return 'unknown'
122
+ }
123
+
124
+ /** Find entry points for the project */
125
+ async function findEntryPoints(cwd: string, type: ProjectType): Promise<string[]> {
126
+ if (type === 'unknown') return []
127
+
128
+ const entryPoints: string[] = []
129
+ const patterns = ENTRY_PATTERNS[type]
130
+
131
+ for (const pattern of patterns) {
132
+ const fullPath = path.join(cwd, pattern)
133
+ try {
134
+ const stat = await fs.stat(fullPath)
135
+ if (stat.isFile()) {
136
+ entryPoints.push(pattern)
137
+ }
138
+ } catch {
139
+ // File doesn't exist, skip
140
+ }
141
+ }
142
+
143
+ // For Node.js, also check package.json main/module
144
+ if (type === 'node') {
145
+ try {
146
+ const pkgPath = path.join(cwd, 'package.json')
147
+ const content = await fs.readFile(pkgPath, 'utf-8')
148
+ const pkg: PackageJson = JSON.parse(content)
149
+ if (pkg.main && !entryPoints.includes(pkg.main)) {
150
+ entryPoints.push(pkg.main)
151
+ }
152
+ if (pkg.module && !entryPoints.includes(pkg.module)) {
153
+ entryPoints.push(pkg.module)
154
+ }
155
+ } catch {
156
+ // No package.json
157
+ }
158
+ }
159
+
160
+ return entryPoints
161
+ }
162
+
163
+ /** Find test files */
164
+ async function findTestFiles(cwd: string, type: ProjectType): Promise<string[]> {
165
+ if (type === 'unknown') return []
166
+
167
+ const testFiles: string[] = []
168
+ const patterns = TEST_PATTERNS[type]
169
+
170
+ // Simple glob simulation - just check common locations
171
+ const dirs =
172
+ type === 'node'
173
+ ? ['src', 'tests', '__tests__', '']
174
+ : type === 'python'
175
+ ? ['tests', '']
176
+ : type === 'rust'
177
+ ? ['tests', 'src']
178
+ : ['']
179
+
180
+ for (const dir of dirs) {
181
+ const searchDir = dir ? path.join(cwd, dir) : cwd
182
+ try {
183
+ const files = await fs.readdir(searchDir, { withFileTypes: true })
184
+ for (const file of files) {
185
+ if (file.isFile()) {
186
+ const name = file.name.toLowerCase()
187
+ if (name.includes('test') || name.includes('spec')) {
188
+ const relPath = dir ? `${dir}/${file.name}` : file.name
189
+ testFiles.push(relPath)
190
+ }
191
+ }
192
+ }
193
+ } catch {
194
+ // Directory doesn't exist
195
+ }
196
+ }
197
+
198
+ return testFiles.slice(0, 10) // Limit to 10
199
+ }
200
+
201
+ /** Find config files */
202
+ async function findConfigFiles(cwd: string, type: ProjectType): Promise<string[]> {
203
+ const configs: string[] = []
204
+ const patterns = CONFIG_FILES[type] || []
205
+
206
+ for (const pattern of patterns) {
207
+ const fullPath = path.join(cwd, pattern)
208
+ try {
209
+ const stat = await fs.stat(fullPath)
210
+ if (stat.isFile()) {
211
+ configs.push(pattern)
212
+ }
213
+ } catch {
214
+ // File doesn't exist
215
+ }
216
+ }
217
+
218
+ return configs
219
+ }
220
+
221
+ /** Find source directories */
222
+ async function findSrcDirs(cwd: string, type: ProjectType): Promise<string[]> {
223
+ const srcDirs: string[] = []
224
+ const patterns = SRC_DIRS[type] || []
225
+
226
+ for (const pattern of patterns) {
227
+ const fullPath = path.join(cwd, pattern)
228
+ try {
229
+ const stat = await fs.stat(fullPath)
230
+ if (stat.isDirectory()) {
231
+ srcDirs.push(pattern)
232
+ }
233
+ } catch {
234
+ // Directory doesn't exist
235
+ }
236
+ }
237
+
238
+ return srcDirs
239
+ }
240
+
241
+ /** Detect package manager */
242
+ async function detectPackageManager(cwd: string): Promise<ProjectInfo['packageManager']> {
243
+ const files = await fs.readdir(cwd, { withFileTypes: true })
244
+ const fileNames = files.map(f => f.name)
245
+
246
+ if (fileNames.includes('pnpm-lock.yaml')) return 'pnpm'
247
+ if (fileNames.includes('yarn.lock')) return 'yarn'
248
+ if (fileNames.includes('bun.lockb')) return 'bun'
249
+ if (fileNames.includes('package-lock.json')) return 'npm'
250
+
251
+ return null
252
+ }
253
+
254
+ /** Load dependencies from package.json */
255
+ async function loadDependencies(
256
+ cwd: string,
257
+ ): Promise<{ deps: Record<string, string>; devDeps: Record<string, string> }> {
258
+ try {
259
+ const pkgPath = path.join(cwd, 'package.json')
260
+ const content = await fs.readFile(pkgPath, 'utf-8')
261
+ const pkg: PackageJson = JSON.parse(content)
262
+ return {
263
+ deps: pkg.dependencies || {},
264
+ devDeps: pkg.devDependencies || {},
265
+ }
266
+ } catch {
267
+ return { deps: {}, devDeps: {} }
268
+ }
269
+ }
270
+
271
+ // ── Main discovery function ───────────────────────────────────────────────
272
+
273
+ // Cache for project info
274
+ const projectCache = new Map<string, { info: ProjectInfo; timestamp: number }>()
275
+ const CACHE_TTL = 60_000 // 1 minute
276
+
277
+ /**
278
+ * Discover project information for a given directory.
279
+ * Results are cached for 1 minute to avoid repeated disk I/O.
280
+ */
281
+ export async function discoverProject(cwd: string): Promise<ProjectInfo> {
282
+ // Check cache first
283
+ const cached = projectCache.get(cwd)
284
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
285
+ return cached.info
286
+ }
287
+
288
+ const type = await detectProjectType(cwd)
289
+ const [entryPoints, testFiles, configFiles, srcDirs, packageManager, deps] = await Promise.all([
290
+ findEntryPoints(cwd, type),
291
+ findTestFiles(cwd, type),
292
+ findConfigFiles(cwd, type),
293
+ findSrcDirs(cwd, type),
294
+ detectPackageManager(cwd),
295
+ type === 'node' ? loadDependencies(cwd) : Promise.resolve({ deps: {}, devDeps: {} }),
296
+ ])
297
+
298
+ const info: ProjectInfo = {
299
+ type,
300
+ entryPoints,
301
+ dependencies: deps.deps,
302
+ devDependencies: deps.devDeps,
303
+ testFiles,
304
+ configFiles,
305
+ srcDirs,
306
+ hasTests: testFiles.length > 0,
307
+ packageManager,
308
+ }
309
+
310
+ // Cache the result
311
+ projectCache.set(cwd, { info, timestamp: Date.now() })
312
+
313
+ return info
314
+ }
315
+
316
+ /** Clear project cache */
317
+ export function clearProjectCache(): void {
318
+ projectCache.clear()
319
+ }
320
+
321
+ /** Invalidate cache for a specific directory */
322
+ export function invalidateProjectCache(cwd: string): void {
323
+ projectCache.delete(cwd)
324
+ }
325
+
326
+ /**
327
+ * Format project info as a string for inclusion in prompts.
328
+ */
329
+ export function formatProjectContext(info: ProjectInfo): string {
330
+ const lines: string[] = ['## Project Context']
331
+
332
+ if (info.type === 'unknown') {
333
+ lines.push('- Project type: Unknown')
334
+ return lines.join('\n')
335
+ }
336
+
337
+ lines.push(`- Project type: ${info.type}`)
338
+ lines.push(`- Package manager: ${info.packageManager || 'unknown'}`)
339
+
340
+ if (info.entryPoints.length > 0) {
341
+ lines.push(`- Entry point(s): ${info.entryPoints.join(', ')}`)
342
+ }
343
+
344
+ if (info.srcDirs.length > 0) {
345
+ lines.push(`- Source directories: ${info.srcDirs.join(', ')}`)
346
+ }
347
+
348
+ if (info.configFiles.length > 0) {
349
+ lines.push(`- Config files: ${info.configFiles.join(', ')}`)
350
+ }
351
+
352
+ if (info.hasTests) {
353
+ const testCount = info.testFiles.length
354
+ lines.push(`- Test files: ${testCount} found`)
355
+ }
356
+
357
+ // Show main dependencies
358
+ const mainDeps = Object.keys(info.dependencies).slice(0, 10)
359
+ if (mainDeps.length > 0) {
360
+ lines.push(
361
+ `- Dependencies: ${mainDeps.join(', ')}${Object.keys(info.dependencies).length > 10 ? '...' : ''}`,
362
+ )
363
+ }
364
+
365
+ return lines.join('\n')
366
+ }
@@ -1,12 +1,13 @@
1
1
  import * as os from 'node:os'
2
2
  import type { Config } from '../config/index.js'
3
+ import { discoverProject, formatProjectContext } from './project.js'
3
4
  import { formatSkillsForPrompt, loadSkills } from './skills.js'
4
5
 
5
6
  /**
6
7
  * Build the system prompt for the current configuration.
7
8
  * Extracted from session.ts to keep module size manageable (<500 LoC target).
8
9
  */
9
- export function buildSystemPrompt(config: Config): string {
10
+ export async function buildSystemPrompt(config: Config): Promise<string> {
10
11
  const platform = os.platform()
11
12
  const release = os.release()
12
13
  const shell = process.env.SHELL || process.env.ComSpec || 'unknown'
@@ -18,6 +19,10 @@ export function buildSystemPrompt(config: Config): string {
18
19
  const skills = loadSkills(cwd)
19
20
  const skillsSection = formatSkillsForPrompt(skills)
20
21
 
22
+ // ── Load project context ─────────────────────────────────────────────────
23
+ const projectInfo = await discoverProject(cwd)
24
+ const projectSection = formatProjectContext(projectInfo)
25
+
21
26
  // ── Mode-specific tool list ───────────────────────────────────────────
22
27
  function getToolListForMode(mode: string): string {
23
28
  if (mode === 'ask') {
@@ -36,7 +41,7 @@ export function buildSystemPrompt(config: Config): string {
36
41
  - \`find\`: Find files by name pattern (pattern: string, path?: string, maxResults?: number)
37
42
  - \`git\`: Run read-only git commands (command: string)
38
43
  - \`search\`: Search the web using Tavily (query: string, search_depth?: string, include_answer?: boolean, max_results?: number, topic?: string, days?: number)
39
- - \`write_plan\`: Save plan/todo markdown into .lonny/ folder
44
+ - \`write_plan\`: Save plan/todo markdown into .lonny/ folder (use descriptive names like backend-api.md, frontend-ui.md if splitting into multiple files)
40
45
  `
41
46
  }
42
47
  return `Available tools:
@@ -45,7 +50,7 @@ export function buildSystemPrompt(config: Config): string {
45
50
  - \`grep\`: Search file content by regex (pattern: string, include?: string, path?: string)
46
51
  - \`ls\`: List directory (path?: string)
47
52
  - \`bash\`: Execute a shell command
48
- - \`edit\`: Replace text in files — call with {"edits": [{"file_path", "old_string", "new_string"}]} (array required)
53
+ - \`edit\`: Replace text in files — call with {edits: [{file_path: string, old_string: string, new_string: string}]} (array required)
49
54
  - \`install_skill\`: Install an npm package as a skill — fetches package info from npm, runs npm install, and creates a .lonny/skills/ file with usage instructions for the AI
50
55
  - \`find\`: Find files by name pattern (pattern: string, path?: string, maxResults?: number)
51
56
  - \`git\`: Run read-only git commands (command: string)
@@ -78,7 +83,8 @@ RULES (plan-specific):
78
83
  3. Use \`bash\` for investigation only — NEVER to modify files, install packages, or run write operations.
79
84
  4. Your ONLY output is a plan file saved via \`write_plan\`. You CANNOT modify the codebase directly.
80
85
  5. You MUST persist the final plan AND todo list to a file in \`.lonny/\` using \`write_plan\`. The \`write_plan\` content MUST include both ## Plan and ## Todo List sections.
81
- 6. You MUST also include the todo list in your text response to the user (not just in the file).
86
+ 6. If the plan is very long (or the todo list has many items), split into multiple files with descriptive names like \`backend-api.md\`, \`frontend-ui.md\`, \`database.md\`, etc.
87
+ 7. You MUST also include the todo list in your text response to the user (not just in the file).
82
88
  7. If the user asks you to modify files, run write commands, or install packages — refuse and explain they need to switch to code mode (\`/mode code\`).
83
89
 
84
90
  OUTPUT FORMAT (you MUST include both in write_plan AND in your response text):
@@ -171,5 +177,5 @@ ${isWindows ? ' - Use `type` instead of `cat`, `dir` instead of `ls`, `where` i
171
177
  ${sharedRules}`
172
178
  return `${modeInstructions}
173
179
 
174
- ${envSection}${rulesSection}${methodologySection}${skillsSection}`
180
+ ${envSection}${rulesSection}${methodologySection}${projectSection}${skillsSection}`
175
181
  }
@@ -116,12 +116,23 @@ export class AnthropicProvider implements LLMProvider {
116
116
  }
117
117
  } else if (event.type === 'content_block_stop') {
118
118
  if (currentToolUse) {
119
+ let input: Record<string, unknown>
120
+ const rawInput = currentToolUse.input || ''
121
+ try {
122
+ input = JSON.parse(rawInput || '{}')
123
+ } catch {
124
+ console.error(
125
+ '[anthropic] Failed to parse tool_use input (content_block_stop):',
126
+ rawInput,
127
+ )
128
+ input = {}
129
+ }
119
130
  yield {
120
131
  type: 'tool_use',
121
132
  tool_call: {
122
133
  id: currentToolUse.id,
123
134
  name: currentToolUse.name,
124
- input: JSON.parse(currentToolUse.input || '{}'),
135
+ input,
125
136
  },
126
137
  }
127
138
  currentToolUse = null
@@ -138,12 +149,20 @@ export class AnthropicProvider implements LLMProvider {
138
149
  }
139
150
  if (event.delta.stop_reason === 'end_turn' || event.delta.stop_reason === 'stop_sequence') {
140
151
  if (currentToolUse) {
152
+ let input: Record<string, unknown>
153
+ const rawInput = currentToolUse.input || ''
154
+ try {
155
+ input = JSON.parse(rawInput || '{}')
156
+ } catch {
157
+ console.error('[anthropic] Failed to parse tool_use input (message_delta):', rawInput)
158
+ input = {}
159
+ }
141
160
  yield {
142
161
  type: 'tool_use',
143
162
  tool_call: {
144
163
  id: currentToolUse.id,
145
164
  name: currentToolUse.name,
146
- input: JSON.parse(currentToolUse.input || '{}'),
165
+ input,
147
166
  },
148
167
  }
149
168
  currentToolUse = null
@@ -203,7 +203,7 @@ export class GoogleProvider implements LLMProvider {
203
203
  }
204
204
  }
205
205
  } catch {
206
- // skip malformed JSON
206
+ console.error('[google] Failed to parse stream chunk:', jsonStr)
207
207
  }
208
208
  }
209
209
  }
@@ -150,6 +150,10 @@ export class OllamaProvider implements LLMProvider {
150
150
  try {
151
151
  input = JSON.parse(tc.function.arguments || '{}')
152
152
  } catch {
153
+ console.error(
154
+ '[ollama] Failed to parse tool_call arguments:',
155
+ tc.function.arguments,
156
+ )
153
157
  input = {}
154
158
  }
155
159
  yield {
@@ -174,7 +178,7 @@ export class OllamaProvider implements LLMProvider {
174
178
  }
175
179
  }
176
180
  } catch {
177
- // skip malformed JSON
181
+ console.error('[ollama] Failed to parse stream line:', line)
178
182
  }
179
183
  }
180
184
  }
@@ -200,12 +200,23 @@ export class OpenAIProvider implements LLMProvider {
200
200
  for (const tc of delta.tool_calls) {
201
201
  if (tc.id) {
202
202
  if (currentToolCall) {
203
+ let input: Record<string, unknown>
204
+ const rawArgs = currentToolCall.arguments || ''
205
+ try {
206
+ input = JSON.parse(rawArgs || '{}')
207
+ } catch {
208
+ console.error(
209
+ '[openai] Failed to parse tool_call arguments (flush on new tool):',
210
+ rawArgs,
211
+ )
212
+ input = {}
213
+ }
203
214
  yield {
204
215
  type: 'tool_use',
205
216
  tool_call: {
206
217
  id: currentToolCall.id,
207
218
  name: currentToolCall.name,
208
- input: JSON.parse(currentToolCall.arguments || '{}'),
219
+ input,
209
220
  },
210
221
  reasoning_content: reasoningContent,
211
222
  }
@@ -224,17 +235,22 @@ export class OpenAIProvider implements LLMProvider {
224
235
 
225
236
  if (chunk.choices?.[0]?.finish_reason) {
226
237
  if (currentToolCall) {
238
+ const finalArgs = currentToolCall.arguments || ''
227
239
  try {
228
240
  yield {
229
241
  type: 'tool_use',
230
242
  tool_call: {
231
243
  id: currentToolCall.id,
232
244
  name: currentToolCall.name,
233
- input: JSON.parse(currentToolCall.arguments || '{}'),
245
+ input: JSON.parse(finalArgs || '{}'),
234
246
  },
235
247
  reasoning_content: reasoningContent,
236
248
  }
237
249
  } catch {
250
+ console.error(
251
+ '[openai] Failed to parse tool_call arguments (finish_reason):',
252
+ finalArgs,
253
+ )
238
254
  yield {
239
255
  type: 'tool_use',
240
256
  tool_call: {
@@ -296,12 +312,20 @@ export class OpenAIProvider implements LLMProvider {
296
312
  output_tokens: lastUsage.completion_tokens ?? 0,
297
313
  }
298
314
  : undefined
315
+ let input: Record<string, unknown>
316
+ const rawFinalArgs = currentToolCall.arguments || ''
317
+ try {
318
+ input = JSON.parse(rawFinalArgs || '{}')
319
+ } catch {
320
+ console.error('[openai] Failed to parse tool_call arguments (final flush):', rawFinalArgs)
321
+ input = {}
322
+ }
299
323
  yield {
300
324
  type: 'tool_use',
301
325
  tool_call: {
302
326
  id: currentToolCall.id,
303
327
  name: currentToolCall.name,
304
- input: JSON.parse(currentToolCall.arguments || '{}'),
328
+ input,
305
329
  },
306
330
  reasoning_content: reasoningContent,
307
331
  usage,