ethagent 0.2.0 → 1.0.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +30 -8
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,858 @@
1
+ import type { Message, Provider, ProviderRetryStreamEvent, StreamEvent } from '../providers/contracts.js'
2
+ import type { ToolResult } from '../tools/contracts.js'
3
+ import { getTool } from '../tools/registry.js'
4
+ import {
5
+ looksLikeToolStateClaim,
6
+ unsupportedToolStateClaims,
7
+ type ToolEvidence,
8
+ } from './toolClaimGuards.js'
9
+
10
+ type ProviderTurnEvent =
11
+ | { type: 'text'; delta: string }
12
+ | { type: 'thinking'; delta: string }
13
+ | ProviderRetryStreamEvent
14
+ | { type: 'tool_use_start'; id: string; name: string }
15
+ | { type: 'tool_use_delta'; id: string; delta: string }
16
+ | { type: 'tool_use_stop'; id: string; name: string; input: Record<string, unknown> }
17
+ | { type: 'done'; stopReason?: TurnStopReason }
18
+ | { type: 'error'; message: string }
19
+ | { type: 'cancelled' }
20
+
21
+ type TurnStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown'
22
+
23
+ async function* runProviderTurn(
24
+ provider: Provider,
25
+ messages: Message[],
26
+ signal: AbortSignal,
27
+ ): AsyncIterable<ProviderTurnEvent> {
28
+ if (signal.aborted) {
29
+ yield { type: 'cancelled' }
30
+ return
31
+ }
32
+ for await (const ev of provider.complete(messages, signal)) {
33
+ if (signal.aborted) {
34
+ yield { type: 'cancelled' }
35
+ return
36
+ }
37
+ yield normalize(ev)
38
+ if (ev.type === 'done' || ev.type === 'error') return
39
+ }
40
+ if (signal.aborted) {
41
+ yield { type: 'cancelled' }
42
+ }
43
+ }
44
+
45
+ function normalize(event: StreamEvent): ProviderTurnEvent {
46
+ switch (event.type) {
47
+ case 'text': return { type: 'text', delta: event.delta }
48
+ case 'thinking': return { type: 'thinking', delta: event.delta }
49
+ case 'retry': return event
50
+ case 'tool_use_start': return event
51
+ case 'tool_use_delta': return event
52
+ case 'tool_use_stop': return event
53
+ case 'done': return { type: 'done', stopReason: event.stopReason }
54
+ case 'error': return { type: 'error', message: event.message }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * MAX_CONTINUATION_NUDGES: if the model stops a turn without emitting any
60
+ * tool_use AND the last assistant text signals intent to continue (e.g.
61
+ * "now I'll..."), we re-invoke the provider up to this many times with a
62
+ * small meta nudge appended.
63
+ */
64
+ export const MAX_CONTINUATION_NUDGES = 3
65
+
66
+ export type ContinuationNudgeReason =
67
+ | 'continuation'
68
+ | 'tool_capability'
69
+ | 'tool_state_claim'
70
+ | 'tool_protocol_fake'
71
+ | 'tool_delegation'
72
+ | 'private_continuity_tool'
73
+ | 'private_continuity_tool_repair'
74
+ | 'reasoning_only'
75
+
76
+ const CONTINUATION_NUDGE_TEXT =
77
+ 'Continue with the task. Use the appropriate tools to proceed.'
78
+
79
+ const TOOL_CAPABILITY_NUDGE_TEXT =
80
+ 'You do have access to the provided tools in this environment. Continue by making the appropriate tool call; do not ask the user to run commands or paste command output.'
81
+
82
+ const TOOL_STATE_CLAIM_NUDGE_TEXT =
83
+ 'Do not claim that files, directories, or workspace state changed unless you have executed the appropriate tool. Call the tool now.'
84
+
85
+ const TOOL_PROTOCOL_FAKE_NUDGE_TEXT =
86
+ 'The previous response printed tool names or a tool menu instead of calling a tool. Tool names are not text output. Make exactly one native tool call now.'
87
+
88
+ const TOOL_DELEGATION_NUDGE_TEXT =
89
+ 'Do not ask the user to run native tools. You have access to the tools in this environment. Make exactly one native tool call now.'
90
+
91
+ const PRIVATE_CONTINUITY_NUDGE_TEXT =
92
+ 'SOUL.md and MEMORY.md are existing private identity-vault scaffold files. Do not search workspace folders, read plans/, create files, or overwrite them. If exact private text is needed for a surgical removal or targeted replacement, call read_private_continuity_file with {"file":"MEMORY.md"} or {"file":"SOUL.md"}. If the user wants private continuity changed, call propose_private_continuity_edit. For memory/preferences use {"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or memory note."}. For persona use {"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior note."}.'
93
+
94
+ const PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT =
95
+ 'The previous propose_private_continuity_edit call had invalid or missing input. Retry the same native tool now with complete arguments. Do not answer in prose and do not search for markdown files. For memory/preferences use {"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or memory note."}. For persona use {"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior note."}.'
96
+
97
+ const REASONING_ONLY_NUDGE_TEXT =
98
+ 'You produced private reasoning but no user-visible answer. Answer the user now in visible text. Do not continue only with reasoning.'
99
+
100
+ /**
101
+ * TurnEvent - events emitted by the runtime turn loop. The UI layer subscribes
102
+ * and translates these into Ink rows, notes, permission prompts, and session
103
+ * writes. The shape is intentionally trimmed to what ethagent actually uses.
104
+ */
105
+ export type TurnEvent =
106
+ | { type: 'iteration_start'; index: number }
107
+ | { type: 'text'; delta: string }
108
+ | { type: 'thinking'; delta: string }
109
+ | ProviderRetryStreamEvent
110
+ | { type: 'tool_use_start'; id: string; name: string }
111
+ | { type: 'tool_use_delta'; id: string; delta: string }
112
+ | {
113
+ type: 'tool_use_stop'
114
+ id: string
115
+ name: string
116
+ input: Record<string, unknown>
117
+ }
118
+ | { type: 'assistant_message_committed'; text: string }
119
+ | {
120
+ type: 'tool_executed'
121
+ id: string
122
+ name: string
123
+ input: Record<string, unknown>
124
+ result: ToolResult
125
+ cwd: string
126
+ }
127
+ | { type: 'continuation_nudge'; attempt: number; reason: ContinuationNudgeReason }
128
+ | { type: 'local_tool_recovery' }
129
+ | { type: 'error'; message: string; discardAssistant?: boolean }
130
+ | { type: 'cancelled' }
131
+ | { type: 'done'; finishedNormally: boolean; stopReason?: TurnStopReason }
132
+
133
+ export type PendingToolUse = {
134
+ id: string
135
+ name: string
136
+ input: Record<string, unknown>
137
+ }
138
+
139
+ export type ExecutedToolUse = {
140
+ id: string
141
+ name: string
142
+ input: Record<string, unknown>
143
+ result: ToolResult
144
+ cwd: string
145
+ }
146
+
147
+ /**
148
+ * The runtime loop needs a way to hand pending tool_uses to the host (the UI
149
+ * adapter) so the host can:
150
+ * - render tool_use / tool_result rows,
151
+ * - persist tool_use / tool_result session messages,
152
+ * - route permission prompts,
153
+ * - and detect cancellation mid-execution.
154
+ *
155
+ * The host returns what actually happened so the loop can feed tool_results
156
+ * back to the provider for the next iteration.
157
+ */
158
+ export type ToolBatchRunner = (
159
+ pendingToolUses: PendingToolUse[],
160
+ ) => Promise<{ cancelled: boolean; completedTools: ExecutedToolUse[] }>
161
+
162
+ /**
163
+ * rebuildWorkingMessages: after every tool batch, the host recomputes the
164
+ * Message[] it wants to send to the provider. This keeps microcompact,
165
+ * system-prompt composition, and file-mention context completely outside the
166
+ * loop - the loop only cares about "give me the next prompt window".
167
+ */
168
+ export type RebuildMessages = () => Message[] | Promise<Message[]>
169
+
170
+ export type RuntimeTurnParams = {
171
+ provider: Provider
172
+ signal: AbortSignal
173
+ /** Initial Message[] to send. */
174
+ initialMessages: Message[]
175
+ /**
176
+ * Called after every tool execution round to rebuild the Message[] for the
177
+ * next provider call. The host is responsible for microcompact, system
178
+ * prompt, and any mention context - the loop is deliberately dumb here.
179
+ */
180
+ rebuildMessages: RebuildMessages
181
+ runToolBatch: ToolBatchRunner
182
+ /** Upper bound on continuation nudges per turn. Defaults to MAX_CONTINUATION_NUDGES. */
183
+ maxContinuationNudges?: number
184
+ }
185
+
186
+ /**
187
+ * runRuntimeTurn - the one and only turn loop.
188
+ *
189
+ * Shape:
190
+ * 1. Stream the provider.
191
+ * 2. Collect tool_use blocks from native `tool_use_stop` events.
192
+ * 3. If the model emitted tool_uses: execute them, feed results back, loop.
193
+ * 4. If it didn't: check if the last assistant text signals intent to
194
+ * continue ("now I'll..."). If yes and we're under the cap, append a
195
+ * soft meta nudge and loop. Otherwise exit.
196
+ *
197
+ * Intentionally absent:
198
+ * - No provider-family branching (no isLocalProvider specialization).
199
+ * - No broad regex fallback tool parsing. A narrow local-model
200
+ * compatibility parser handles standalone JSON tool payloads only.
201
+ * - No duplicate-tool-call suppression. The model is allowed to repeat.
202
+ * - No broad forced-repair retries on tool input validation errors - errors
203
+ * go back to the model as tool_result(is_error). Private continuity gets
204
+ * one narrow local-model repair nudge because bad JSON there is common and
205
+ * user-visible.
206
+ */
207
+ export async function* runRuntimeTurn(
208
+ params: RuntimeTurnParams,
209
+ ): AsyncGenerator<TurnEvent, void, void> {
210
+ const {
211
+ provider,
212
+ signal,
213
+ initialMessages,
214
+ rebuildMessages,
215
+ runToolBatch,
216
+ maxContinuationNudges = MAX_CONTINUATION_NUDGES,
217
+ } = params
218
+
219
+ let workingMessages = initialMessages
220
+ let continuationNudges = 0
221
+ let iterationIndex = 0
222
+ let priorIterationHadTools = false
223
+ const toolEvidenceThisTurn: ToolEvidence[] = []
224
+
225
+ // eslint-disable-next-line no-constant-condition
226
+ while (true) {
227
+ const hadToolsLastRound = priorIterationHadTools
228
+ priorIterationHadTools = false
229
+
230
+ if (signal.aborted) {
231
+ yield { type: 'cancelled' }
232
+ yield doneEvent(false)
233
+ return
234
+ }
235
+
236
+ yield { type: 'iteration_start', index: iterationIndex }
237
+ iterationIndex += 1
238
+
239
+ let assistantText = ''
240
+ const pendingToolUses: PendingToolUse[] = []
241
+ let errored = false
242
+ let cancelled = false
243
+ let thinkingSeen = false
244
+ let stopReason: TurnStopReason = 'unknown'
245
+
246
+ try {
247
+ for await (const ev of runProviderTurn(provider, workingMessages, signal)) {
248
+ if (ev.type === 'retry') {
249
+ yield ev
250
+ } else if (ev.type === 'text') {
251
+ assistantText += ev.delta
252
+ yield { type: 'text', delta: ev.delta }
253
+ } else if (ev.type === 'thinking') {
254
+ thinkingSeen = true
255
+ yield { type: 'thinking', delta: ev.delta }
256
+ } else if (ev.type === 'tool_use_start') {
257
+ yield { type: 'tool_use_start', id: ev.id, name: ev.name }
258
+ } else if (ev.type === 'tool_use_delta') {
259
+ yield { type: 'tool_use_delta', id: ev.id, delta: ev.delta }
260
+ } else if (ev.type === 'tool_use_stop') {
261
+ pendingToolUses.push({ id: ev.id, name: ev.name, input: ev.input })
262
+ yield {
263
+ type: 'tool_use_stop',
264
+ id: ev.id,
265
+ name: ev.name,
266
+ input: ev.input,
267
+ }
268
+ } else if (ev.type === 'error') {
269
+ errored = true
270
+ yield { type: 'error', message: ev.message }
271
+ break
272
+ } else if (ev.type === 'cancelled') {
273
+ cancelled = true
274
+ break
275
+ } else if (ev.type === 'done') {
276
+ stopReason = ev.stopReason ?? 'unknown'
277
+ break
278
+ }
279
+ }
280
+ } catch (err: unknown) {
281
+ if (signal.aborted) {
282
+ cancelled = true
283
+ } else {
284
+ errored = true
285
+ yield { type: 'error', message: (err as Error).message || 'stream error' }
286
+ }
287
+ }
288
+
289
+ if (signal.aborted || cancelled) {
290
+ yield { type: 'cancelled' }
291
+ yield doneEvent(false, stopReason)
292
+ return
293
+ }
294
+
295
+ if (errored) {
296
+ yield doneEvent(false, stopReason)
297
+ return
298
+ }
299
+
300
+ if (pendingToolUses.length === 0) {
301
+ const parsedToolUses = parseLocalModelTextToolUses(provider, assistantText, iterationIndex - 1)
302
+ if (parsedToolUses.length > 0) {
303
+ pendingToolUses.push(...parsedToolUses)
304
+ // Signal the orchestrator to discard any streamed assistant text
305
+ // rows that contained the JSON blob - they should not be persisted.
306
+ yield { type: 'local_tool_recovery' }
307
+ for (const parsedToolUse of parsedToolUses) {
308
+ yield {
309
+ type: 'tool_use_stop',
310
+ id: parsedToolUse.id,
311
+ name: parsedToolUse.name,
312
+ input: parsedToolUse.input,
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ if (pendingToolUses.length === 0 && provider.supportsTools && looksLikeFakeToolProtocolText(assistantText)) {
319
+ if (continuationNudges < maxContinuationNudges) {
320
+ continuationNudges += 1
321
+ yield {
322
+ type: 'continuation_nudge',
323
+ attempt: continuationNudges,
324
+ reason: 'tool_protocol_fake',
325
+ }
326
+ workingMessages = [
327
+ ...await rebuildMessages(),
328
+ { role: 'user', content: TOOL_PROTOCOL_FAKE_NUDGE_TEXT },
329
+ ]
330
+ continue
331
+ }
332
+ yield {
333
+ type: 'error',
334
+ message: 'model printed tool names instead of making a tool call',
335
+ discardAssistant: true,
336
+ }
337
+ yield doneEvent(false, stopReason)
338
+ return
339
+ }
340
+
341
+ if (pendingToolUses.length === 0 && provider.supportsTools && looksLikeToolDelegationText(assistantText)) {
342
+ if (continuationNudges < maxContinuationNudges) {
343
+ continuationNudges += 1
344
+ yield {
345
+ type: 'continuation_nudge',
346
+ attempt: continuationNudges,
347
+ reason: 'tool_delegation',
348
+ }
349
+ workingMessages = [
350
+ ...await rebuildMessages(),
351
+ { role: 'user', content: TOOL_DELEGATION_NUDGE_TEXT },
352
+ ]
353
+ continue
354
+ }
355
+ yield {
356
+ type: 'error',
357
+ message: 'model asked the user to run a tool instead of making a tool call',
358
+ discardAssistant: true,
359
+ }
360
+ yield doneEvent(false, stopReason)
361
+ return
362
+ }
363
+
364
+ if (pendingToolUses.length === 0) {
365
+ const unsupportedClaims = unsupportedToolStateClaims(assistantText, toolEvidenceThisTurn)
366
+ if (unsupportedClaims.length > 0) {
367
+ if (continuationNudges < maxContinuationNudges) {
368
+ continuationNudges += 1
369
+ yield {
370
+ type: 'continuation_nudge',
371
+ attempt: continuationNudges,
372
+ reason: 'tool_state_claim',
373
+ }
374
+ // Rebuild from scratch, inject a correction context message to
375
+ // demote prior unsupported assistant claims, then append the nudge.
376
+ // This prevents the model from reinforcing its own false claims
377
+ // on subsequent iterations within the same turn.
378
+ workingMessages = [
379
+ ...await rebuildMessages(),
380
+ {
381
+ role: 'user',
382
+ content:
383
+ 'The previous assistant response claimed workspace state without executing a tool. '
384
+ + 'Treat that claim as unreliable. '
385
+ + TOOL_STATE_CLAIM_NUDGE_TEXT,
386
+ },
387
+ ]
388
+ continue
389
+ }
390
+ yield {
391
+ type: 'error',
392
+ message: 'model claimed workspace state without matching tool evidence',
393
+ discardAssistant: true,
394
+ }
395
+ yield doneEvent(false, stopReason)
396
+ return
397
+ }
398
+ }
399
+
400
+ // No tool work: model decided this turn is over (modulo continuation nudge).
401
+ if (pendingToolUses.length === 0) {
402
+ if (!assistantText && thinkingSeen) {
403
+ if (continuationNudges < maxContinuationNudges) {
404
+ continuationNudges += 1
405
+ yield {
406
+ type: 'continuation_nudge',
407
+ attempt: continuationNudges,
408
+ reason: 'reasoning_only',
409
+ }
410
+ workingMessages = [
411
+ ...await rebuildMessages(),
412
+ { role: 'user', content: REASONING_ONLY_NUDGE_TEXT },
413
+ ]
414
+ continue
415
+ }
416
+ yield {
417
+ type: 'error',
418
+ message: 'model produced reasoning but no visible answer',
419
+ }
420
+ yield doneEvent(false, stopReason)
421
+ return
422
+ }
423
+
424
+ const nudge = nextNudge(provider, assistantText)
425
+ if (assistantText && continuationNudges < maxContinuationNudges && nudge) {
426
+ // After a tool batch, the model's summary text often accidentally
427
+ // matches the continuation-intent heuristic ("I've updated...").
428
+ // Commit the text and end the turn instead of nudging again.
429
+ if (hadToolsLastRound && nudge.reason === 'continuation') {
430
+ yield { type: 'assistant_message_committed', text: assistantText }
431
+ yield doneEvent(true, stopReason)
432
+ return
433
+ }
434
+ continuationNudges += 1
435
+ yield {
436
+ type: 'continuation_nudge',
437
+ attempt: continuationNudges,
438
+ reason: nudge.reason,
439
+ }
440
+ workingMessages = [
441
+ ...await rebuildMessages(),
442
+ ...(nudge.keepAssistantContext ? [{ role: 'assistant' as const, content: assistantText }] : []),
443
+ { role: 'user', content: nudge.text },
444
+ ]
445
+ continue
446
+ }
447
+ if (assistantText && nudge?.reason === 'tool_capability') {
448
+ yield {
449
+ type: 'error',
450
+ message: 'model refused available tools after corrective nudges',
451
+ }
452
+ yield doneEvent(false, stopReason)
453
+ return
454
+ }
455
+
456
+ if (assistantText) {
457
+ yield { type: 'assistant_message_committed', text: assistantText }
458
+ }
459
+
460
+ yield doneEvent(true, stopReason)
461
+ return
462
+ }
463
+
464
+ // Tool work: hand the batch to the host. The host renders rows, persists
465
+ // the tool_use/tool_result session messages, and routes permission prompts.
466
+ // We then emit tool_executed events so UI adapters that care (e.g., tests)
467
+ // can observe each completed tool before we loop back to the provider.
468
+ const batch = await runToolBatch(pendingToolUses)
469
+ for (const completed of batch.completedTools) {
470
+ toolEvidenceThisTurn.push({
471
+ name: completed.name,
472
+ result: { ok: completed.result.ok },
473
+ })
474
+ }
475
+
476
+ for (const completed of batch.completedTools) {
477
+ yield {
478
+ type: 'tool_executed',
479
+ id: completed.id,
480
+ name: completed.name,
481
+ input: completed.input,
482
+ result: completed.result,
483
+ cwd: completed.cwd,
484
+ }
485
+ }
486
+
487
+ if (batch.cancelled || signal.aborted) {
488
+ yield { type: 'cancelled' }
489
+ yield doneEvent(false, stopReason)
490
+ return
491
+ }
492
+
493
+ const repairNudge = nextToolResultRepairNudge(provider, batch.completedTools)
494
+ if (repairNudge) {
495
+ if (continuationNudges < maxContinuationNudges) {
496
+ continuationNudges += 1
497
+ yield {
498
+ type: 'continuation_nudge',
499
+ attempt: continuationNudges,
500
+ reason: 'private_continuity_tool_repair',
501
+ }
502
+ workingMessages = [
503
+ ...await rebuildMessages(),
504
+ { role: 'user', content: repairNudge },
505
+ ]
506
+ continue
507
+ }
508
+ yield {
509
+ type: 'error',
510
+ message: 'model called propose_private_continuity_edit with invalid input after corrective nudges',
511
+ discardAssistant: true,
512
+ }
513
+ yield doneEvent(false, stopReason)
514
+ return
515
+ }
516
+
517
+ priorIterationHadTools = true
518
+ workingMessages = await rebuildMessages()
519
+ }
520
+ }
521
+
522
+ function doneEvent(finishedNormally: boolean, stopReason?: TurnStopReason): Extract<TurnEvent, { type: 'done' }> {
523
+ if (stopReason && stopReason !== 'end_turn' && stopReason !== 'unknown') {
524
+ return { type: 'done', finishedNormally, stopReason }
525
+ }
526
+ return { type: 'done', finishedNormally }
527
+ }
528
+
529
+ function nextToolResultRepairNudge(
530
+ provider: Pick<Provider, 'id' | 'supportsTools'>,
531
+ completedTools: ExecutedToolUse[],
532
+ ): string | null {
533
+ if (!provider.supportsTools) return null
534
+ if (provider.id !== 'llamacpp') return null
535
+ const failedPrivateEdit = completedTools.some(completed =>
536
+ completed.name === 'propose_private_continuity_edit'
537
+ && !completed.result.ok
538
+ && completed.result.summary === 'propose_private_continuity_edit rejected input',
539
+ )
540
+ if (failedPrivateEdit) return PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT
541
+
542
+ const failedWorkspacePrivateRead = completedTools.some(completed =>
543
+ completed.name === 'read_file'
544
+ && !completed.result.ok
545
+ && /read_private_continuity_file/.test(completed.result.content),
546
+ )
547
+ return failedWorkspacePrivateRead
548
+ ? 'The previous read_file call targeted private identity continuity markdown. Retry now with read_private_continuity_file and complete input such as {"file":"MEMORY.md"} or {"file":"SOUL.md"}. Do not search workspace folders.'
549
+ : null
550
+ }
551
+
552
+ export function parseLocalModelTextToolUse(
553
+ provider: Pick<Provider, 'id'>,
554
+ assistantText: string,
555
+ iterationIndex = 0,
556
+ ): PendingToolUse | null {
557
+ const parsed = parseLocalModelTextToolUses(provider, assistantText, iterationIndex)
558
+ return parsed.length === 1 ? parsed[0]! : null
559
+ }
560
+
561
+ export function parseLocalModelTextToolUses(
562
+ provider: Pick<Provider, 'id'>,
563
+ assistantText: string,
564
+ iterationIndex = 0,
565
+ ): PendingToolUse[] {
566
+ if (provider.id !== 'llamacpp') return []
567
+
568
+ const calls = extractTextToolCalls(assistantText)
569
+ if (calls.length === 0) return []
570
+
571
+ return calls.map((call, index) => ({
572
+ id: calls.length === 1 ? `local-text-tool-${iterationIndex}` : `local-text-tool-${iterationIndex}-${index}`,
573
+ name: call.name,
574
+ input: call.input,
575
+ }))
576
+ }
577
+
578
+ function extractTextToolCalls(text: string): Array<{ name: string; input: Record<string, unknown> }> {
579
+ const payloads = extractToolPayloadCandidates(text)
580
+ const calls = payloads.flatMap(parseTextToolPayloads)
581
+ return calls.filter(call => typeof call.name === 'string' && isRecord(call.input) && Boolean(getTool(call.name)))
582
+ }
583
+
584
+ function extractToolPayloadCandidates(text: string): string[] {
585
+ const trimmed = text.trim()
586
+ if (!trimmed) return []
587
+
588
+ const exact = normalizeToolPayloadCandidate(trimmed)
589
+ if (exact.startsWith('{') && exact.endsWith('}')) return [exact]
590
+ if (exact.startsWith('[') && exact.endsWith(']')) return [exact]
591
+
592
+ const fencedOnlyMatch = trimmed.match(/^```[^\r\n]*\r?\n([\s\S]*?)\r?\n```$/i)
593
+ if (fencedOnlyMatch) return [normalizeToolPayloadCandidate(fencedOnlyMatch[1]!)]
594
+
595
+ const embedded = [
596
+ ...[...trimmed.matchAll(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi)].map(match => match[1]!),
597
+ ...[...trimmed.matchAll(/```[^\r\n]*\r?\n([\s\S]*?)\r?\n```/g)].map(match => match[1]!),
598
+ ...extractStandaloneJsonPayloads(trimmed),
599
+ ].map(normalizeToolPayloadCandidate)
600
+
601
+ return [...new Set(embedded)]
602
+ }
603
+
604
+ function extractStandaloneJsonPayloads(text: string): string[] {
605
+ const lines = text.split(/\r?\n/)
606
+ const out: string[] = []
607
+
608
+ for (let i = 0; i < lines.length; i += 1) {
609
+ const line = lines[i] ?? ''
610
+ const first = normalizeToolPayloadCandidate(line)
611
+ if (!first.startsWith('{') && !first.startsWith('[')) continue
612
+
613
+ let candidate = line
614
+ for (let j = i; j < lines.length; j += 1) {
615
+ if (j > i) candidate += `\n${lines[j] ?? ''}`
616
+ const normalized = normalizeToolPayloadCandidate(candidate)
617
+ if (canParseJson(normalized)) {
618
+ out.push(normalized)
619
+ i = j
620
+ break
621
+ }
622
+ if (candidate.length > 20_000) break
623
+ }
624
+ }
625
+
626
+ return out
627
+ }
628
+
629
+ function canParseJson(value: string): boolean {
630
+ try {
631
+ JSON.parse(value)
632
+ return true
633
+ } catch {
634
+ return false
635
+ }
636
+ }
637
+
638
+ function normalizeToolPayloadCandidate(candidate: string): string {
639
+ let normalized = candidate
640
+ .trim()
641
+ .split(/\r?\n/)
642
+ .map(line => line.replace(/^\s*\d+\s+(?=[{\[<"])/, ''))
643
+ .join('\n')
644
+ .trim()
645
+
646
+ const toolCallMatch = normalized.match(/^<tool_call>\s*([\s\S]*?)\s*<\/tool_call>$/i)
647
+ if (toolCallMatch) normalized = toolCallMatch[1]!.trim()
648
+ return normalized
649
+ }
650
+
651
+ function parseTextToolPayloads(payload: string): Array<{ name: string; input: Record<string, unknown> }> {
652
+ let parsed: unknown
653
+ try {
654
+ parsed = JSON.parse(payload)
655
+ } catch {
656
+ return []
657
+ }
658
+
659
+ return normalizeParsedToolPayloads(parsed)
660
+ }
661
+
662
+ function parseTextToolPayload(payload: string): { name: string; input: Record<string, unknown> } | null {
663
+ const calls = parseTextToolPayloads(payload)
664
+ return calls.length === 1 ? calls[0]! : null
665
+ }
666
+
667
+ function normalizeParsedToolPayloads(value: unknown): Array<{ name: string; input: Record<string, unknown> }> {
668
+ if (Array.isArray(value)) {
669
+ return value.flatMap(normalizeParsedToolPayloads)
670
+ }
671
+ if (!isRecord(value)) return []
672
+
673
+ const toolCalls = value.tool_calls
674
+ if (Array.isArray(toolCalls)) {
675
+ return toolCalls.flatMap(normalizeParsedToolPayloads)
676
+ }
677
+
678
+ const fn = value.function
679
+ if (isRecord(fn)) {
680
+ const call = normalizeNameAndInput(fn.name, fn.arguments)
681
+ return call ? [call] : []
682
+ }
683
+
684
+ const name = value.name ?? value.tool ?? value.tool_name ?? value.function_name
685
+ const rawInput = value.arguments ?? value.input ?? value.parameters ?? value.args ?? {}
686
+ const call = normalizeNameAndInput(name, rawInput)
687
+ return call ? [call] : []
688
+ }
689
+
690
+ function normalizeParsedToolPayload(value: unknown): { name: string; input: Record<string, unknown> } | null {
691
+ const calls = normalizeParsedToolPayloads(value)
692
+ return calls.length === 1 ? calls[0]! : null
693
+ }
694
+
695
+ function normalizeNameAndInput(
696
+ name: unknown,
697
+ rawInput: unknown,
698
+ ): { name: string; input: Record<string, unknown> } | null {
699
+ if (typeof name !== 'string') return null
700
+ const input = parseToolInput(rawInput)
701
+ if (!input) return null
702
+ return { name, input }
703
+ }
704
+
705
+ function parseToolInput(rawInput: unknown): Record<string, unknown> | null {
706
+ if (isRecord(rawInput)) return rawInput
707
+ if (typeof rawInput !== 'string') return null
708
+ try {
709
+ const parsed = JSON.parse(rawInput)
710
+ return isRecord(parsed) ? parsed : null
711
+ } catch {
712
+ return null
713
+ }
714
+ }
715
+
716
+ function isRecord(value: unknown): value is Record<string, unknown> {
717
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
718
+ }
719
+
720
+ function nextNudge(
721
+ provider: Pick<Provider, 'supportsTools'>,
722
+ assistantText: string,
723
+ ): { text: string; reason: ContinuationNudgeReason; keepAssistantContext: boolean } | null {
724
+ if (provider.supportsTools && looksLikePrivateContinuityWorkspaceCreationIntent(assistantText)) {
725
+ return {
726
+ text: PRIVATE_CONTINUITY_NUDGE_TEXT,
727
+ reason: 'private_continuity_tool',
728
+ keepAssistantContext: false,
729
+ }
730
+ }
731
+ if (provider.supportsTools && looksLikeToolCapabilityConfusion(assistantText)) {
732
+ return {
733
+ text: TOOL_CAPABILITY_NUDGE_TEXT,
734
+ reason: 'tool_capability',
735
+ keepAssistantContext: false,
736
+ }
737
+ }
738
+ if (looksLikeContinuationIntent(assistantText)) {
739
+ return {
740
+ text: CONTINUATION_NUDGE_TEXT,
741
+ reason: 'continuation',
742
+ keepAssistantContext: true,
743
+ }
744
+ }
745
+ return null
746
+ }
747
+
748
+ export function looksLikePrivateContinuityWorkspaceCreationIntent(text: string): boolean {
749
+ const lower = text.toLowerCase()
750
+ if (!/\b(soul|memory)\.md\b/.test(lower)) return false
751
+ return [
752
+ /\b(create|write|make|generate|scaffold|overwrite|replace|locate|find|search|read|check|inspect)\b.{0,100}\b(soul|memory)\.md\b/,
753
+ /\b(soul|memory)\.md\b.{0,100}\b(create|write|make|generate|scaffold|overwrite|replace|locate|find|search|read|check|inspect)\b/,
754
+ /\bplans?[\\/][^\s]*\b(soul|memory)\b/,
755
+ ].some(pattern => pattern.test(lower))
756
+ }
757
+
758
+ export function looksLikeToolCapabilityConfusion(text: string): boolean {
759
+ const lower = text.toLowerCase()
760
+ const limitation =
761
+ /\b(i (do not|don't|cannot|can't) (have|access|run|execute|inspect|read|list|use)|no direct access|unable to|not able to|currently operating under|limitations and restrictions)\b/
762
+ const toolTask =
763
+ /\b(run|execute|shell command|command output|local machine|terminal|files?|directories|workspace|paste|share the contents)\b/
764
+ return limitation.test(lower) && toolTask.test(lower)
765
+ }
766
+
767
+ export function looksLikeToolStateClaimWithoutTool(text: string): boolean {
768
+ return looksLikeToolStateClaim(text)
769
+ }
770
+
771
+ export function looksLikeFakeToolProtocolText(text: string): boolean {
772
+ const lower = text.toLowerCase()
773
+ if (!lower.trim()) return false
774
+
775
+ const toolNames = new Set(
776
+ [...lower.matchAll(/\b(change_directory|edit_file|propose_private_continuity_edit|read_private_continuity_file|list_directory|read_file|run_bash|write_file|delete_file)\b/g)]
777
+ .map(match => match[1]),
778
+ )
779
+ if (toolNames.size < 2) return false
780
+
781
+ const codeBlock = /```|code\s*(?:-|:)?\s*block/.test(lower)
782
+ const toolMenu = /\b(available tools|tool functions|functions are|tools are|native tools)\b/.test(lower)
783
+ const actionIntent = /\b(let'?s|let me|i'?ll|i will|first|next)\b.{0,80}\b(list|read|inspect|execute|run|change|edit|write)\b/.test(lower)
784
+ const commaSeparatedTools = /(?:change_directory|edit_file|propose_private_continuity_edit|read_private_continuity_file|list_directory|read_file|run_bash|write_file|delete_file)(?:\s*,\s*|\s+){1,}/.test(lower)
785
+
786
+ return (codeBlock || toolMenu || actionIntent) && commaSeparatedTools
787
+ }
788
+
789
+ export function looksLikeToolDelegationText(text: string): boolean {
790
+ const lower = text.toLowerCase()
791
+ if (!lower.trim()) return false
792
+
793
+ const toolName = '(?:change_directory|edit_file|propose_private_continuity_edit|read_private_continuity_file|list_directory|read_file|run_bash|write_file|delete_file)'
794
+ if (!new RegExp(`\\b${toolName}\\b`).test(lower)) return false
795
+
796
+ const directToolRef = `(?:\`?${toolName}\`?|the\\s+\`?${toolName}\`?\\s+tool)`
797
+ const action = '(?:run|execute|call|use|invoke)'
798
+ const askPrefix = "(?:please|kindly|can you|could you|would you|you can|you should|you need to|you'll need to|try to|go ahead and)"
799
+ const selfPrefix = "(?:i'll|i will|let me|let's|we should|we need to|before proceeding|first|next|now)"
800
+
801
+ const askUser = new RegExp(`\\b${askPrefix}\\b.{0,100}\\b${action}\\b.{0,50}${directToolRef}`).test(lower)
802
+ const selfIntent = new RegExp(`\\b${selfPrefix}\\b.{0,100}\\b${action}\\b.{0,50}${directToolRef}`).test(lower)
803
+ const commandForm = new RegExp(`\\b${action}\\s+${directToolRef}\\b`).test(lower)
804
+ && /\b(please|before proceeding|first|next|now|to proceed)\b/.test(lower)
805
+ const asksForOutput = new RegExp(`${directToolRef}.{0,120}\\b(output|result|files?|directory structure|working directory)\\b`).test(lower)
806
+ && /\b(please|you|run|paste|share|provide)\b/.test(lower)
807
+
808
+ return askUser || selfIntent || commandForm || asksForOutput
809
+ }
810
+
811
+ /**
812
+ * looksLikeContinuationIntent - heuristic for continuation nudge detection.
813
+ *
814
+ * Two rules:
815
+ * 1. If the text contains an explicit completion marker ("done", "all set",
816
+ * "let me know if"), never nudge.
817
+ * 2. Otherwise, nudge iff at least one action-intent pattern matches
818
+ * ("now I'll edit", "let me create", "time to run", etc.).
819
+ *
820
+ * Deliberately narrow. We never rewrite the model's output; we only decide
821
+ * whether to append a short meta user message and re-stream.
822
+ */
823
+ export function looksLikeContinuationIntent(text: string): boolean {
824
+ const lower = text.toLowerCase()
825
+
826
+ const completionMarkers =
827
+ /\b(done|finished|completed|complete|summary|that's all|that is all|all set|hope this helps|let me know if)\b/
828
+ if (completionMarkers.test(lower)) return false
829
+
830
+ const actionVerbs =
831
+ '(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|go|proceed|begin)'
832
+
833
+ const shortMessage = lower.length < 80
834
+
835
+ const patterns: RegExp[] = [
836
+ new RegExp(
837
+ `\\bso now (i|let me|we) (need to|have to|should|must|will) ${actionVerbs}\\b`,
838
+ ),
839
+ new RegExp(`\\bnow i('ll| will) ${actionVerbs}\\b`),
840
+ new RegExp(
841
+ `\\blet me (go ahead and |now )?${actionVerbs}\\b`,
842
+ ),
843
+ new RegExp(`\\btime to ${actionVerbs}\\b`),
844
+ ]
845
+
846
+ if (shortMessage) {
847
+ patterns.push(
848
+ new RegExp(
849
+ `\\bi('ll| will| need to| have to| must) (now )?${actionVerbs}\\b`,
850
+ ),
851
+ new RegExp(
852
+ `\\bnext,?\\s+(i('ll| will)|let me|i need to) ${actionVerbs}\\b`,
853
+ ),
854
+ )
855
+ }
856
+
857
+ return patterns.some(re => re.test(lower))
858
+ }