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.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- 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,701 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { Message, Provider } from '../providers/contracts.js'
|
|
4
|
+
import { directToolUsesForUserText } from '../runtime/toolIntent.js'
|
|
5
|
+
import { toPermissionMode, type SessionMode } from '../runtime/sessionMode.js'
|
|
6
|
+
import { runPendingToolUses } from '../runtime/toolExecution.js'
|
|
7
|
+
import { runRuntimeTurn, type TurnEvent } from '../runtime/turn.js'
|
|
8
|
+
import type { EthagentConfig } from '../storage/config.js'
|
|
9
|
+
import type { SessionMessage } from '../storage/sessions.js'
|
|
10
|
+
import type { SessionPermissionRule, ToolResult } from '../tools/contracts.js'
|
|
11
|
+
import { readContinuityFiles } from '../identity/continuity/storage.js'
|
|
12
|
+
import type { MessageRow } from './MessageList.js'
|
|
13
|
+
import {
|
|
14
|
+
buildBaseMessages,
|
|
15
|
+
createTurnCheckpoint,
|
|
16
|
+
type TurnCheckpoint,
|
|
17
|
+
} from './chatScreenUtils.js'
|
|
18
|
+
|
|
19
|
+
type MutableRef<T> = { current: T }
|
|
20
|
+
|
|
21
|
+
type ExecuteToolResult = {
|
|
22
|
+
result: ToolResult
|
|
23
|
+
sessionRule?: SessionPermissionRule
|
|
24
|
+
persistRule?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TurnOrchestratorContext = {
|
|
28
|
+
provider: Provider
|
|
29
|
+
mode: SessionMode
|
|
30
|
+
sessionId: string
|
|
31
|
+
userText: string
|
|
32
|
+
streamFlushMs: number
|
|
33
|
+
controller: AbortController
|
|
34
|
+
nextRowId: () => string
|
|
35
|
+
nowIso: () => string
|
|
36
|
+
getConfig: () => EthagentConfig
|
|
37
|
+
getCwd: () => string
|
|
38
|
+
getDisplayCwd: () => string
|
|
39
|
+
getSessionMessages: () => SessionMessage[]
|
|
40
|
+
setActiveCheckpoint: (checkpoint: TurnCheckpoint | undefined) => void
|
|
41
|
+
setStreaming: (streaming: boolean) => void
|
|
42
|
+
updateRows: (updater: (prev: MessageRow[]) => MessageRow[]) => void
|
|
43
|
+
pushNote: (text: string, kind?: 'info' | 'error' | 'dim') => void
|
|
44
|
+
persistTurnMessage: (message: SessionMessage) => Promise<void>
|
|
45
|
+
executeTool: (
|
|
46
|
+
name: string,
|
|
47
|
+
input: Record<string, unknown>,
|
|
48
|
+
mode: ReturnType<typeof toPermissionMode>,
|
|
49
|
+
) => Promise<ExecuteToolResult>
|
|
50
|
+
applySessionRule: (rule?: SessionPermissionRule, persistRule?: boolean) => Promise<void>
|
|
51
|
+
preflightProvider?: () => Promise<{ ok: true } | { ok: false; message: string }>
|
|
52
|
+
onPlanReady?: (plan: string) => void
|
|
53
|
+
pendingAssistantTextRef: MutableRef<string | null>
|
|
54
|
+
pendingThinkingTextRef: MutableRef<string | null>
|
|
55
|
+
streamFlushTimerRef: MutableRef<ReturnType<typeof setTimeout> | null>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type StreamingTurnResult = {
|
|
59
|
+
finishedNormally: boolean
|
|
60
|
+
cancelled: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* runStreamingTurn - the UI adapter over runRuntimeTurn.
|
|
65
|
+
*
|
|
66
|
+
* Responsibilities (UI-only; logic lives in runtime/turn.ts):
|
|
67
|
+
* - translate runtime events into Ink MessageRow updates,
|
|
68
|
+
* - flush streaming text to rows on a debounce,
|
|
69
|
+
* - persist SessionMessages on commit boundaries,
|
|
70
|
+
* - drive the tool batch (permission prompts, row pushes, persistence),
|
|
71
|
+
* - surface plan-mode output to the caller,
|
|
72
|
+
* - return a summary of what happened (finishedNormally / cancelled)
|
|
73
|
+
* for the caller to act on.
|
|
74
|
+
*/
|
|
75
|
+
export async function runStreamingTurn(
|
|
76
|
+
context: TurnOrchestratorContext,
|
|
77
|
+
): Promise<StreamingTurnResult> {
|
|
78
|
+
const {
|
|
79
|
+
provider,
|
|
80
|
+
mode,
|
|
81
|
+
sessionId,
|
|
82
|
+
userText,
|
|
83
|
+
streamFlushMs,
|
|
84
|
+
controller,
|
|
85
|
+
nextRowId,
|
|
86
|
+
nowIso,
|
|
87
|
+
getConfig,
|
|
88
|
+
getCwd,
|
|
89
|
+
getSessionMessages,
|
|
90
|
+
setActiveCheckpoint,
|
|
91
|
+
setStreaming,
|
|
92
|
+
updateRows,
|
|
93
|
+
pushNote,
|
|
94
|
+
persistTurnMessage,
|
|
95
|
+
executeTool,
|
|
96
|
+
applySessionRule,
|
|
97
|
+
preflightProvider,
|
|
98
|
+
onPlanReady,
|
|
99
|
+
pendingAssistantTextRef,
|
|
100
|
+
pendingThinkingTextRef,
|
|
101
|
+
streamFlushTimerRef,
|
|
102
|
+
} = context
|
|
103
|
+
|
|
104
|
+
if (mode === 'accept-edits') {
|
|
105
|
+
pushNote(
|
|
106
|
+
provider.supportsTools
|
|
107
|
+
? 'accept-edits mode: workspace reads/edits auto-allow. private continuity edits and bash still prompt.'
|
|
108
|
+
: 'accept-edits mode selected, but the current provider does not support tools yet.',
|
|
109
|
+
'dim',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setStreaming(true)
|
|
114
|
+
const activeCheckpoint = createTurnCheckpoint(sessionId, userText)
|
|
115
|
+
setActiveCheckpoint(activeCheckpoint)
|
|
116
|
+
|
|
117
|
+
updateRows(prev => [...prev, { role: 'user', id: nextRowId(), content: userText }])
|
|
118
|
+
await persistTurnMessage({
|
|
119
|
+
role: 'user',
|
|
120
|
+
content: userText,
|
|
121
|
+
createdAt: nowIso(),
|
|
122
|
+
turnId: activeCheckpoint.turnId,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const mentionContextMessages = await buildFileMentionContextMessages(userText, getCwd())
|
|
126
|
+
|
|
127
|
+
const buildWorking = async (): Promise<Message[]> => {
|
|
128
|
+
const baseMessages = buildWorkingMessages(context, activeCheckpoint.turnId)
|
|
129
|
+
const [baseSystem, ...conversationMessages] = baseMessages
|
|
130
|
+
return [
|
|
131
|
+
...(baseSystem ? [baseSystem] : []),
|
|
132
|
+
...await buildIdentityContinuityContextMessages(getConfig()),
|
|
133
|
+
...conversationMessages,
|
|
134
|
+
...mentionContextMessages,
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Per-iteration UI scratch. These are reset each time the runtime loop
|
|
139
|
+
// re-enters streaming (new provider call = new assistant row, new accumulator).
|
|
140
|
+
let accumulated = ''
|
|
141
|
+
let thinkingContent = ''
|
|
142
|
+
let thinkingRowId: string | null = null
|
|
143
|
+
let thinkingCursorActive = false
|
|
144
|
+
let assistantId: string | null = null
|
|
145
|
+
let hasPendingToolUse = false
|
|
146
|
+
|
|
147
|
+
const resetIteration = () => {
|
|
148
|
+
accumulated = ''
|
|
149
|
+
thinkingContent = ''
|
|
150
|
+
thinkingRowId = null
|
|
151
|
+
thinkingCursorActive = false
|
|
152
|
+
assistantId = null
|
|
153
|
+
hasPendingToolUse = false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const stopThinkingCursor = () => {
|
|
157
|
+
if (!thinkingRowId || !thinkingCursorActive) return
|
|
158
|
+
thinkingCursorActive = false
|
|
159
|
+
updateRows(prev => prev.map(row =>
|
|
160
|
+
row.id === thinkingRowId && row.role === 'thinking'
|
|
161
|
+
? { ...row, showCursor: false }
|
|
162
|
+
: row,
|
|
163
|
+
))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ensureAssistantRow = (): string => {
|
|
167
|
+
if (assistantId) return assistantId
|
|
168
|
+
assistantId = nextRowId()
|
|
169
|
+
updateRows(prev => [
|
|
170
|
+
...prev,
|
|
171
|
+
{ role: 'assistant', id: assistantId!, content: '', liveTail: '', streaming: true },
|
|
172
|
+
])
|
|
173
|
+
return assistantId
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const flushStreamRows = (immediate = false) => {
|
|
177
|
+
const commit = () => {
|
|
178
|
+
streamFlushTimerRef.current = null
|
|
179
|
+
const nextAssistant = pendingAssistantTextRef.current
|
|
180
|
+
const nextThinking = pendingThinkingTextRef.current
|
|
181
|
+
if (nextAssistant === null && nextThinking === null) return
|
|
182
|
+
updateRows(prev => updateStreamingRows(
|
|
183
|
+
prev,
|
|
184
|
+
assistantId,
|
|
185
|
+
thinkingRowId,
|
|
186
|
+
nextAssistant,
|
|
187
|
+
nextThinking,
|
|
188
|
+
))
|
|
189
|
+
pendingAssistantTextRef.current = null
|
|
190
|
+
pendingThinkingTextRef.current = null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (immediate) {
|
|
194
|
+
if (streamFlushTimerRef.current) {
|
|
195
|
+
clearTimeout(streamFlushTimerRef.current)
|
|
196
|
+
streamFlushTimerRef.current = null
|
|
197
|
+
}
|
|
198
|
+
commit()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (streamFlushTimerRef.current) return
|
|
203
|
+
streamFlushTimerRef.current = setTimeout(commit, streamFlushMs)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const finalizeStreamingRows = () => {
|
|
207
|
+
flushStreamRows(true)
|
|
208
|
+
updateRows(prev => {
|
|
209
|
+
let next = finalizeStreamingRowsById(prev, assistantId, thinkingRowId, accumulated, thinkingContent)
|
|
210
|
+
// If we emitted tool_uses, strip the empty assistant text row - tool_use
|
|
211
|
+
// rows replace it. If the assistant emitted no text at all (pure tool
|
|
212
|
+
// turn), drop the empty row.
|
|
213
|
+
if (assistantId && (hasPendingToolUse || accumulated.length === 0)) {
|
|
214
|
+
next = next.filter(r => r.id !== assistantId)
|
|
215
|
+
}
|
|
216
|
+
return next
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const discardStreamingRows = () => {
|
|
221
|
+
flushStreamRows(true)
|
|
222
|
+
updateRows(prev => prev.filter(row =>
|
|
223
|
+
!(assistantId && row.id === assistantId)
|
|
224
|
+
&& !(thinkingRowId && row.id === thinkingRowId),
|
|
225
|
+
))
|
|
226
|
+
pendingAssistantTextRef.current = null
|
|
227
|
+
pendingThinkingTextRef.current = null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let finishedNormally = false
|
|
231
|
+
let cancelled = false
|
|
232
|
+
|
|
233
|
+
const runToolBatch = async (pendingToolUses: Array<{
|
|
234
|
+
id: string
|
|
235
|
+
name: string
|
|
236
|
+
input: Record<string, unknown>
|
|
237
|
+
}>) => {
|
|
238
|
+
// Persist the assistant tool_use blocks into the session before execution
|
|
239
|
+
// so microcompact / rebuild has them on the next provider call. The actual
|
|
240
|
+
// Message[] sent to the provider is rebuilt from session messages.
|
|
241
|
+
// (This mirrors the pre-Wave-2 behavior, just routed from the event loop.)
|
|
242
|
+
// NOTE: some provider loops keep tool_use blocks inside the assistant
|
|
243
|
+
// message; ethagent stores them as discrete tool_use SessionMessages via
|
|
244
|
+
// runPendingToolUses, which keeps the microcompact model simpler.
|
|
245
|
+
const step = await runPendingToolUses({
|
|
246
|
+
pendingToolUses,
|
|
247
|
+
nextRowId,
|
|
248
|
+
nowIso,
|
|
249
|
+
mode,
|
|
250
|
+
getCwd,
|
|
251
|
+
getConfig,
|
|
252
|
+
turnId: activeCheckpoint.turnId,
|
|
253
|
+
controller,
|
|
254
|
+
updateRows,
|
|
255
|
+
pushNote,
|
|
256
|
+
persistTurnMessage,
|
|
257
|
+
executeTool,
|
|
258
|
+
applySessionRule,
|
|
259
|
+
})
|
|
260
|
+
return step
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const directToolUses = provider.supportsTools
|
|
264
|
+
? directToolUsesForUserText(userText)
|
|
265
|
+
: []
|
|
266
|
+
if (directToolUses.length > 0) {
|
|
267
|
+
const step = await runToolBatch(directToolUses)
|
|
268
|
+
const directCancelled = step.cancelled || controller.signal.aborted
|
|
269
|
+
if (directCancelled) pushNote('(cancelled)', 'dim')
|
|
270
|
+
setStreaming(false)
|
|
271
|
+
setActiveCheckpoint(undefined)
|
|
272
|
+
return {
|
|
273
|
+
finishedNormally: !directCancelled,
|
|
274
|
+
cancelled: directCancelled,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (preflightProvider) {
|
|
279
|
+
let preflight: { ok: true } | { ok: false; message: string }
|
|
280
|
+
try {
|
|
281
|
+
preflight = await preflightProvider()
|
|
282
|
+
} catch (err: unknown) {
|
|
283
|
+
preflight = {
|
|
284
|
+
ok: false,
|
|
285
|
+
message: `provider preflight failed: ${(err as Error).message || 'unknown error'}`,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!preflight.ok) {
|
|
289
|
+
pushNote(preflight.message, 'error')
|
|
290
|
+
setStreaming(false)
|
|
291
|
+
setActiveCheckpoint(undefined)
|
|
292
|
+
return {
|
|
293
|
+
finishedNormally: false,
|
|
294
|
+
cancelled: controller.signal.aborted,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
for await (const ev of runRuntimeTurn({
|
|
301
|
+
provider,
|
|
302
|
+
signal: controller.signal,
|
|
303
|
+
initialMessages: await buildWorking(),
|
|
304
|
+
rebuildMessages: buildWorking,
|
|
305
|
+
runToolBatch,
|
|
306
|
+
})) {
|
|
307
|
+
cancelled = cancelled || isCancelledEvent(ev)
|
|
308
|
+
await handleEvent(ev, {
|
|
309
|
+
ensureAssistantRow,
|
|
310
|
+
flushStreamRows,
|
|
311
|
+
finalizeStreamingRows,
|
|
312
|
+
discardStreamingRows,
|
|
313
|
+
resetIteration,
|
|
314
|
+
stopThinkingCursor,
|
|
315
|
+
setAccumulated: text => { accumulated = text },
|
|
316
|
+
getAccumulated: () => accumulated,
|
|
317
|
+
setThinkingContent: text => { thinkingContent = text },
|
|
318
|
+
getThinkingContent: () => thinkingContent,
|
|
319
|
+
setThinkingRowId: id => { thinkingRowId = id },
|
|
320
|
+
markThinkingCursorActive: () => { thinkingCursorActive = true },
|
|
321
|
+
getThinkingRowId: () => thinkingRowId,
|
|
322
|
+
markPendingToolUse: () => { hasPendingToolUse = true },
|
|
323
|
+
updateRows,
|
|
324
|
+
pushNote,
|
|
325
|
+
nextRowId,
|
|
326
|
+
pendingAssistantTextRef,
|
|
327
|
+
pendingThinkingTextRef,
|
|
328
|
+
persistTurnMessage,
|
|
329
|
+
nowIso,
|
|
330
|
+
mode,
|
|
331
|
+
onPlanReady,
|
|
332
|
+
turnId: activeCheckpoint.turnId,
|
|
333
|
+
model: getConfig().model,
|
|
334
|
+
onFinishedNormally: () => { finishedNormally = true },
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
} catch (err: unknown) {
|
|
338
|
+
if (!controller.signal.aborted) {
|
|
339
|
+
pushNote((err as Error).message || 'stream error', 'error')
|
|
340
|
+
}
|
|
341
|
+
finalizeStreamingRows()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (cancelled || controller.signal.aborted) pushNote('(cancelled)', 'dim')
|
|
345
|
+
setStreaming(false)
|
|
346
|
+
setActiveCheckpoint(undefined)
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
finishedNormally,
|
|
350
|
+
cancelled,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Event handling: per-event UI translation
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
type EventHandlerContext = {
|
|
359
|
+
ensureAssistantRow: () => string
|
|
360
|
+
flushStreamRows: (immediate?: boolean) => void
|
|
361
|
+
finalizeStreamingRows: () => void
|
|
362
|
+
discardStreamingRows: () => void
|
|
363
|
+
resetIteration: () => void
|
|
364
|
+
stopThinkingCursor: () => void
|
|
365
|
+
setAccumulated: (text: string) => void
|
|
366
|
+
getAccumulated: () => string
|
|
367
|
+
setThinkingContent: (text: string) => void
|
|
368
|
+
getThinkingContent: () => string
|
|
369
|
+
setThinkingRowId: (id: string | null) => void
|
|
370
|
+
getThinkingRowId: () => string | null
|
|
371
|
+
markThinkingCursorActive: () => void
|
|
372
|
+
markPendingToolUse: () => void
|
|
373
|
+
updateRows: (updater: (prev: MessageRow[]) => MessageRow[]) => void
|
|
374
|
+
pushNote: (text: string, kind?: 'info' | 'error' | 'dim') => void
|
|
375
|
+
nextRowId: () => string
|
|
376
|
+
pendingAssistantTextRef: MutableRef<string | null>
|
|
377
|
+
pendingThinkingTextRef: MutableRef<string | null>
|
|
378
|
+
persistTurnMessage: (message: SessionMessage) => Promise<void>
|
|
379
|
+
nowIso: () => string
|
|
380
|
+
mode: SessionMode
|
|
381
|
+
onPlanReady?: (plan: string) => void
|
|
382
|
+
turnId: string
|
|
383
|
+
model: string
|
|
384
|
+
onFinishedNormally: () => void
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isCancelledEvent(ev: TurnEvent): boolean {
|
|
388
|
+
return ev.type === 'cancelled'
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<void> {
|
|
392
|
+
switch (ev.type) {
|
|
393
|
+
case 'iteration_start': {
|
|
394
|
+
// Reset per-iteration scratch so each provider call gets a fresh
|
|
395
|
+
// assistant row, accumulator, and hasPendingToolUse flag. Iteration 0
|
|
396
|
+
// is the initial stream - resetting before anything runs is a no-op,
|
|
397
|
+
// which is fine.
|
|
398
|
+
ctx.resetIteration()
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
case 'text': {
|
|
402
|
+
ctx.stopThinkingCursor()
|
|
403
|
+
ctx.ensureAssistantRow()
|
|
404
|
+
const next = ctx.getAccumulated() + ev.delta
|
|
405
|
+
ctx.setAccumulated(next)
|
|
406
|
+
ctx.pendingAssistantTextRef.current = next
|
|
407
|
+
ctx.flushStreamRows()
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
case 'thinking': {
|
|
411
|
+
const current = ctx.getThinkingContent()
|
|
412
|
+
const appended = current + ev.delta
|
|
413
|
+
ctx.setThinkingContent(appended)
|
|
414
|
+
if (ctx.getThinkingRowId() === null) {
|
|
415
|
+
const id = ctx.nextRowId()
|
|
416
|
+
ctx.setThinkingRowId(id)
|
|
417
|
+
ctx.markThinkingCursorActive()
|
|
418
|
+
ctx.updateRows(prev => [
|
|
419
|
+
...prev,
|
|
420
|
+
{
|
|
421
|
+
role: 'thinking',
|
|
422
|
+
id,
|
|
423
|
+
content: '',
|
|
424
|
+
liveTail: appended,
|
|
425
|
+
streaming: true,
|
|
426
|
+
expanded: false,
|
|
427
|
+
showCursor: true,
|
|
428
|
+
},
|
|
429
|
+
])
|
|
430
|
+
}
|
|
431
|
+
ctx.pendingThinkingTextRef.current = appended
|
|
432
|
+
ctx.flushStreamRows()
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
case 'retry': {
|
|
436
|
+
ctx.pushNote(formatRetryStatus(ev), 'dim')
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
case 'tool_use_stop': {
|
|
440
|
+
ctx.markPendingToolUse()
|
|
441
|
+
ctx.finalizeStreamingRows()
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
case 'assistant_message_committed': {
|
|
445
|
+
// End of a streaming round with no tool_use - finalize rows, persist
|
|
446
|
+
// the assistant text, and hand it to the plan hook if in plan mode.
|
|
447
|
+
ctx.finalizeStreamingRows()
|
|
448
|
+
if (ev.text) {
|
|
449
|
+
await ctx.persistTurnMessage({
|
|
450
|
+
role: 'assistant',
|
|
451
|
+
content: ev.text,
|
|
452
|
+
createdAt: ctx.nowIso(),
|
|
453
|
+
model: ctx.model,
|
|
454
|
+
turnId: ctx.turnId,
|
|
455
|
+
})
|
|
456
|
+
if (ctx.mode === 'plan') ctx.onPlanReady?.(ev.text)
|
|
457
|
+
}
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
case 'tool_executed': {
|
|
461
|
+
// Row + session persistence happened inside runPendingToolUses; the
|
|
462
|
+
// event is informational for observers that need it (tests, future
|
|
463
|
+
// instrumentation). No UI side-effect here.
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
case 'local_tool_recovery': {
|
|
467
|
+
// The runtime recovered tool calls from local model text output.
|
|
468
|
+
// Discard the streamed assistant rows that contained the JSON blob
|
|
469
|
+
// so they are not persisted or displayed as prose.
|
|
470
|
+
ctx.discardStreamingRows()
|
|
471
|
+
ctx.markPendingToolUse()
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
case 'continuation_nudge': {
|
|
475
|
+
// Clean break between provider calls. Corrective nudges suppress the
|
|
476
|
+
// unverified assistant row so it cannot become durable context.
|
|
477
|
+
if (
|
|
478
|
+
ev.reason === 'tool_state_claim' ||
|
|
479
|
+
ev.reason === 'tool_capability' ||
|
|
480
|
+
ev.reason === 'tool_protocol_fake' ||
|
|
481
|
+
ev.reason === 'tool_delegation'
|
|
482
|
+
) {
|
|
483
|
+
ctx.discardStreamingRows()
|
|
484
|
+
} else {
|
|
485
|
+
ctx.finalizeStreamingRows()
|
|
486
|
+
}
|
|
487
|
+
ctx.resetIteration()
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
case 'error': {
|
|
491
|
+
ctx.pushNote(ev.message, 'error')
|
|
492
|
+
if (ev.discardAssistant) {
|
|
493
|
+
ctx.discardStreamingRows()
|
|
494
|
+
} else {
|
|
495
|
+
ctx.finalizeStreamingRows()
|
|
496
|
+
}
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
case 'cancelled': {
|
|
500
|
+
ctx.finalizeStreamingRows()
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
case 'done': {
|
|
504
|
+
// If we ended mid-iteration (no assistant_message_committed yet) the
|
|
505
|
+
// finalize call from error/cancelled already ran. If we ended after a
|
|
506
|
+
// tool_executed batch, finalize here so the UI settles.
|
|
507
|
+
ctx.finalizeStreamingRows()
|
|
508
|
+
if (ev.finishedNormally) ctx.onFinishedNormally()
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
case 'tool_use_start':
|
|
512
|
+
case 'tool_use_delta':
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function formatRetryStatus(ev: Extract<TurnEvent, { type: 'retry' }>): string {
|
|
518
|
+
const totalAttempts = ev.maxRetries + 1
|
|
519
|
+
const reason = ev.status !== undefined ? `HTTP ${ev.status}` : ev.code ?? ev.reason
|
|
520
|
+
return `provider retry ${ev.nextAttempt}/${totalAttempts} in ${formatRetryDelay(ev.delayMs)} (${reason})`
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function formatRetryDelay(delayMs: number): string {
|
|
524
|
+
if (delayMs < 1000) return `${Math.max(0, Math.round(delayMs))}ms`
|
|
525
|
+
const seconds = delayMs / 1000
|
|
526
|
+
if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`
|
|
527
|
+
if (seconds < 60) return `${Math.round(seconds)}s`
|
|
528
|
+
const minutes = seconds / 60
|
|
529
|
+
return `${minutes.toFixed(minutes < 10 ? 1 : 0).replace(/\.0$/, '')}m`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function updateStreamingRows(
|
|
533
|
+
rows: MessageRow[],
|
|
534
|
+
assistantId: string | null,
|
|
535
|
+
thinkingRowId: string | null,
|
|
536
|
+
assistantText: string | null,
|
|
537
|
+
thinkingText: string | null,
|
|
538
|
+
): MessageRow[] {
|
|
539
|
+
let next: MessageRow[] | null = null
|
|
540
|
+
if (assistantId && assistantText !== null) {
|
|
541
|
+
const index = findRowIndexById(rows, assistantId)
|
|
542
|
+
const row = rows[index]
|
|
543
|
+
if (row?.role === 'assistant') {
|
|
544
|
+
next = next ?? rows.slice()
|
|
545
|
+
next[index] = { ...row, content: assistantText, liveTail: '' }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const source = next ?? rows
|
|
549
|
+
if (thinkingRowId && thinkingText !== null) {
|
|
550
|
+
const index = findRowIndexById(source, thinkingRowId)
|
|
551
|
+
const row = source[index]
|
|
552
|
+
if (row?.role === 'thinking') {
|
|
553
|
+
next = next ?? rows.slice()
|
|
554
|
+
next[index] = { ...row, content: thinkingText, liveTail: '' }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return next ?? rows
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function finalizeStreamingRowsById(
|
|
561
|
+
rows: MessageRow[],
|
|
562
|
+
assistantId: string | null,
|
|
563
|
+
thinkingRowId: string | null,
|
|
564
|
+
assistantText: string,
|
|
565
|
+
thinkingText: string,
|
|
566
|
+
): MessageRow[] {
|
|
567
|
+
let next: MessageRow[] | null = null
|
|
568
|
+
if (assistantId) {
|
|
569
|
+
const index = findRowIndexById(rows, assistantId)
|
|
570
|
+
const row = rows[index]
|
|
571
|
+
if (row?.role === 'assistant') {
|
|
572
|
+
next = next ?? rows.slice()
|
|
573
|
+
next[index] = { ...row, content: assistantText || row.content, liveTail: undefined, streaming: false }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const source = next ?? rows
|
|
577
|
+
if (thinkingRowId) {
|
|
578
|
+
const index = findRowIndexById(source, thinkingRowId)
|
|
579
|
+
const row = source[index]
|
|
580
|
+
if (row?.role === 'thinking') {
|
|
581
|
+
next = next ?? rows.slice()
|
|
582
|
+
next[index] = { ...row, content: thinkingText || row.content, liveTail: undefined, streaming: false, showCursor: false }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return next ?? rows
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function findRowIndexById(rows: MessageRow[], id: string): number {
|
|
589
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
590
|
+
if (rows[index]?.id === id) return index
|
|
591
|
+
}
|
|
592
|
+
return -1
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// File-mention context (unchanged from pre-Wave-2 - pure helper)
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
async function buildFileMentionContextMessages(
|
|
600
|
+
userText: string,
|
|
601
|
+
cwd: string,
|
|
602
|
+
): Promise<Message[]> {
|
|
603
|
+
const mentions = extractFileMentions(userText)
|
|
604
|
+
if (mentions.length === 0) return []
|
|
605
|
+
|
|
606
|
+
const lines: string[] = []
|
|
607
|
+
for (const mention of mentions) {
|
|
608
|
+
const resolved = path.resolve(cwd, mention)
|
|
609
|
+
const rel = path.relative(cwd, resolved)
|
|
610
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
611
|
+
lines.push(
|
|
612
|
+
`@${mention} -> outside current workspace; do not use unless the user changes directory or names an allowed path.`,
|
|
613
|
+
)
|
|
614
|
+
continue
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const stats = await fs.stat(resolved)
|
|
618
|
+
lines.push(`@${mention} -> ${mention} (${stats.isDirectory() ? 'directory' : 'file'})`)
|
|
619
|
+
} catch {
|
|
620
|
+
lines.push(`@${mention} -> unresolved`)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return [
|
|
625
|
+
{
|
|
626
|
+
role: 'user',
|
|
627
|
+
content: [
|
|
628
|
+
'Resolved file mentions for this request:',
|
|
629
|
+
...lines,
|
|
630
|
+
'Treat these mentions as authoritative filenames from the user request. Read referenced context files when needed, and edit only the file requested by the user or the target file you have inspected.',
|
|
631
|
+
].join('\n'),
|
|
632
|
+
},
|
|
633
|
+
]
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function buildIdentityContinuityContextMessages(
|
|
637
|
+
config: EthagentConfig,
|
|
638
|
+
): Promise<Message[]> {
|
|
639
|
+
const identity = config.identity
|
|
640
|
+
if (!identity) return []
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
const privateFiles = await readContinuityFiles(identity)
|
|
644
|
+
return [{
|
|
645
|
+
role: 'system',
|
|
646
|
+
content: [
|
|
647
|
+
'<identity_continuity_files>',
|
|
648
|
+
'The active identity continuity files have been loaded automatically for this turn.',
|
|
649
|
+
'SOUL.md is private owner continuity and is the authoritative persona, voice, and standing-behavior layer for this active identity.',
|
|
650
|
+
'MEMORY.md is private owner continuity for durable preferences, facts, and project context.',
|
|
651
|
+
'Apply SOUL.md and MEMORY.md over generic ethagent identity/style unless they conflict with safety, tool correctness, developer instructions, or the user\'s latest explicit request. Do not quote private continuity unless necessary.',
|
|
652
|
+
'<SOUL.md visibility="private">',
|
|
653
|
+
privateFiles['SOUL.md'].trimEnd(),
|
|
654
|
+
'</SOUL.md>',
|
|
655
|
+
'',
|
|
656
|
+
'<MEMORY.md visibility="private">',
|
|
657
|
+
privateFiles['MEMORY.md'].trimEnd(),
|
|
658
|
+
'</MEMORY.md>',
|
|
659
|
+
'</identity_continuity_files>',
|
|
660
|
+
].join('\n'),
|
|
661
|
+
}]
|
|
662
|
+
} catch (err: unknown) {
|
|
663
|
+
return [{
|
|
664
|
+
role: 'system',
|
|
665
|
+
content: [
|
|
666
|
+
'<identity_continuity_files>',
|
|
667
|
+
`Automatic identity continuity load failed: ${(err as Error).message}`,
|
|
668
|
+
'If the user asks about continuity, surface this failure and route them to the identity hub.',
|
|
669
|
+
'</identity_continuity_files>',
|
|
670
|
+
].join('\n'),
|
|
671
|
+
}]
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function extractFileMentions(text: string): string[] {
|
|
676
|
+
const mentions = new Set<string>()
|
|
677
|
+
for (const match of text.matchAll(/@([^\s]+)/g)) {
|
|
678
|
+
const raw = match[1]?.replace(/[),.;:!?]+$/g, '')
|
|
679
|
+
if (!raw || raw.length === 0) continue
|
|
680
|
+
mentions.add(raw.replace(/\\/g, '/'))
|
|
681
|
+
}
|
|
682
|
+
return [...mentions]
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function buildWorkingMessages(
|
|
686
|
+
context: Pick<
|
|
687
|
+
TurnOrchestratorContext,
|
|
688
|
+
'getSessionMessages' | 'getConfig' | 'provider' | 'getCwd' | 'mode'
|
|
689
|
+
>,
|
|
690
|
+
preserveTurnId?: string,
|
|
691
|
+
): Message[] {
|
|
692
|
+
const config = context.getConfig()
|
|
693
|
+
return buildBaseMessages(
|
|
694
|
+
[...context.getSessionMessages()],
|
|
695
|
+
config,
|
|
696
|
+
context.provider.supportsTools,
|
|
697
|
+
context.getCwd(),
|
|
698
|
+
context.mode,
|
|
699
|
+
{ preserveTurnId },
|
|
700
|
+
)
|
|
701
|
+
}
|