ethagent 0.2.1 → 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.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- 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
|
+
}
|