ethagent 2.2.0 → 2.4.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 (168) hide show
  1. package/README.md +11 -0
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +3 -7
  4. package/src/app/FirstRunTimeline.tsx +1 -1
  5. package/src/chat/ChatBottomPane.tsx +29 -11
  6. package/src/chat/ChatScreen.tsx +169 -38
  7. package/src/chat/ConversationStack.tsx +1 -1
  8. package/src/chat/MessageList.tsx +185 -72
  9. package/src/chat/SessionStatus.tsx +3 -1
  10. package/src/chat/chatScreenUtils.ts +11 -15
  11. package/src/chat/chatSessionState.ts +5 -2
  12. package/src/chat/chatTurnOrchestrator.ts +7 -9
  13. package/src/chat/commands.ts +26 -26
  14. package/src/chat/display/DiffView.tsx +193 -0
  15. package/src/chat/display/SyntaxText.tsx +192 -0
  16. package/src/chat/display/toolCallDisplay.ts +103 -0
  17. package/src/chat/display/toolResultDisplay.ts +19 -0
  18. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +61 -25
  19. package/src/chat/input/imageRefs.ts +30 -0
  20. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  21. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  22. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  23. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  24. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  25. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  26. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  27. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  28. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +50 -41
  29. package/src/chat/views/RewindView.tsx +410 -0
  30. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  31. package/src/identity/hub/OperationalRoutes.tsx +21 -21
  32. package/src/identity/hub/Routes.tsx +13 -13
  33. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  34. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  35. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  36. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  37. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
  38. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  39. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  40. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  41. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  42. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  43. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
  44. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
  45. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  46. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  47. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  48. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  49. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
  50. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  51. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  52. package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
  53. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
  54. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
  55. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
  56. package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
  57. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
  58. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
  59. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
  60. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
  61. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  62. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
  63. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  64. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  65. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  66. package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
  67. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
  68. package/src/identity/hub/identityHubReducer.ts +3 -3
  69. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
  70. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
  71. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  72. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
  73. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
  74. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  75. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  76. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  77. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  78. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  79. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  80. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  81. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  82. package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
  83. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  84. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  85. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  86. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  87. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  88. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  89. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
  90. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
  91. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  92. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  93. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  94. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  95. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
  96. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  97. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  98. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
  99. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  100. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  101. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  102. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  103. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  104. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
  105. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  106. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
  107. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  108. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  109. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  110. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  111. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  112. package/src/identity/hub/useIdentityHubController.ts +11 -11
  113. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  114. package/src/models/ModelPicker.tsx +143 -9
  115. package/src/models/catalog.ts +2 -1
  116. package/src/models/huggingface.ts +180 -2
  117. package/src/models/llamacpp.ts +110 -15
  118. package/src/models/llamacppPreflight.ts +30 -11
  119. package/src/models/modelPickerOptions.ts +16 -15
  120. package/src/models/providerDisplay.ts +16 -0
  121. package/src/providers/anthropic.ts +36 -5
  122. package/src/providers/contracts.ts +9 -1
  123. package/src/providers/errors.ts +6 -4
  124. package/src/providers/gemini.ts +29 -3
  125. package/src/providers/openai-chat.ts +83 -3
  126. package/src/providers/openai-responses-format.ts +29 -8
  127. package/src/providers/openai-responses.ts +22 -7
  128. package/src/providers/registry.ts +1 -0
  129. package/src/runtime/sessionMode.ts +1 -1
  130. package/src/runtime/systemPrompt.ts +3 -1
  131. package/src/runtime/toolExecution.ts +9 -6
  132. package/src/runtime/turn.ts +29 -0
  133. package/src/storage/config.ts +1 -0
  134. package/src/storage/rewind.ts +20 -0
  135. package/src/storage/sessions.ts +16 -3
  136. package/src/tools/bashSafety.ts +7 -3
  137. package/src/tools/bashTool.ts +1 -1
  138. package/src/tools/contracts.ts +3 -0
  139. package/src/tools/deleteFileTool.ts +8 -3
  140. package/src/tools/editTool.ts +10 -5
  141. package/src/tools/fileDiff.ts +261 -0
  142. package/src/tools/privateContinuityEditTool.ts +5 -1
  143. package/src/tools/writeFileTool.ts +8 -3
  144. package/src/ui/Spinner.tsx +39 -5
  145. package/src/ui/TextInput.tsx +2 -2
  146. package/src/ui/theme.ts +19 -0
  147. package/src/utils/clipboard.ts +10 -7
  148. package/src/utils/images.ts +140 -0
  149. package/src/utils/messages.ts +2 -0
  150. package/src/chat/RewindView.tsx +0 -386
  151. package/src/chat/toolResultDisplay.ts +0 -8
  152. package/src/identity/hub/effects/index.ts +0 -73
  153. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  154. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  155. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  156. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  157. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  158. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  159. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  160. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  161. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  162. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  163. /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
  164. /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
  165. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  166. /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
  167. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  168. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -5,6 +5,8 @@ import { providerErrorFromResponse } from './errors.js'
5
5
  import { fetchWithRetryStreamEvents } from './retry.js'
6
6
  import { iterSseFrames } from './sse.js'
7
7
  import { messageTextContent } from '../utils/messages.js'
8
+ import { hasImageBlocks, ImageLoadError, loadImageBlock } from '../utils/images.js'
9
+ import { providerDisplayName } from '../models/providerDisplay.js'
8
10
 
9
11
  export type OpenAIToolDefinition = {
10
12
  type: 'function'
@@ -27,6 +29,7 @@ type Options = {
27
29
  loadApiKey?: () => Promise<string | null>
28
30
  tools?: OpenAIToolDefinition[]
29
31
  maxRetries?: number
32
+ hasVisionProjector?: boolean
30
33
  }
31
34
 
32
35
  type ChatChunk = {
@@ -75,6 +78,7 @@ export class OpenAIChatProvider implements Provider {
75
78
  private readonly loadApiKey?: () => Promise<string | null>
76
79
  private readonly tools: OpenAIToolDefinition[]
77
80
  private readonly maxRetries?: number
81
+ private readonly hasVisionProjector: boolean
78
82
 
79
83
  constructor(opts: Options) {
80
84
  this.id = opts.id
@@ -85,6 +89,7 @@ export class OpenAIChatProvider implements Provider {
85
89
  this.tools = opts.tools ?? []
86
90
  this.maxRetries = opts.maxRetries
87
91
  this.supportsTools = this.tools.length > 0
92
+ this.hasVisionProjector = opts.hasVisionProjector ?? false
88
93
  }
89
94
 
90
95
  async *complete(
@@ -98,6 +103,19 @@ export class OpenAIChatProvider implements Provider {
98
103
  yield { type: 'error', message: error.message }
99
104
  return
100
105
  }
106
+ if (hasImageBlocks(messages)) {
107
+ if (this.id === 'llamacpp' && !this.hasVisionProjector) {
108
+ const hint = localModelNameHintsVision(this.model)
109
+ ? '; open alt+p and run "Add Vision Encoder" on this model to enable image input'
110
+ : ''
111
+ yield { type: 'error', message: `image input is not enabled for local model "${this.model}" (no vision projector loaded)${hint}` }
112
+ return
113
+ }
114
+ if (this.id === 'openai' && !supportsOpenAIImages(this.model)) {
115
+ yield { type: 'error', message: `image input is not enabled for ${this.model}` }
116
+ return
117
+ }
118
+ }
101
119
 
102
120
  const headers: Record<string, string> = {
103
121
  'Content-Type': 'application/json',
@@ -105,6 +123,17 @@ export class OpenAIChatProvider implements Provider {
105
123
  }
106
124
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`
107
125
 
126
+ let wireMessages: Array<Record<string, unknown>>
127
+ try {
128
+ wireMessages = await toWireMessages(messages)
129
+ } catch (err: unknown) {
130
+ if (err instanceof ImageLoadError) {
131
+ yield { type: 'error', message: err.message }
132
+ return
133
+ }
134
+ throw err
135
+ }
136
+
108
137
  let response: Response
109
138
  try {
110
139
  response = yield* fetchWithRetryStreamEvents(`${this.baseUrl}/chat/completions`, {
@@ -112,7 +141,7 @@ export class OpenAIChatProvider implements Provider {
112
141
  headers,
113
142
  body: JSON.stringify({
114
143
  model: this.model,
115
- messages: toWireMessages(messages),
144
+ messages: wireMessages,
116
145
  tools: this.tools.length > 0 ? this.tools : undefined,
117
146
  tool_choice: this.tools.length > 0 ? 'auto' : undefined,
118
147
  stream: true,
@@ -220,7 +249,7 @@ export class OpenAIChatProvider implements Provider {
220
249
 
221
250
  }
222
251
 
223
- export function toWireMessages(messages: Message[]): Array<Record<string, unknown>> {
252
+ export async function toWireMessages(messages: Message[]): Promise<Array<Record<string, unknown>>> {
224
253
  const out: Array<Record<string, unknown>> = []
225
254
 
226
255
  for (const message of messages) {
@@ -229,6 +258,26 @@ export function toWireMessages(messages: Message[]): Array<Record<string, unknow
229
258
  continue
230
259
  }
231
260
 
261
+ if (message.role === 'user') {
262
+ const toolResults = message.content.filter(isToolResultBlock)
263
+ if (toolResults.length > 0) {
264
+ for (const block of toolResults) {
265
+ out.push({
266
+ role: 'tool',
267
+ tool_call_id: block.toolUseId,
268
+ content: block.content,
269
+ })
270
+ }
271
+ const nonToolBlocks = message.content.filter(block => block.type !== 'tool_result')
272
+ if (nonToolBlocks.length > 0) {
273
+ out.push({ role: 'user', content: await toOpenAIUserContent(nonToolBlocks) })
274
+ }
275
+ continue
276
+ }
277
+ out.push({ role: 'user', content: await toOpenAIUserContent(message.content) })
278
+ continue
279
+ }
280
+
232
281
  if (message.role === 'assistant') {
233
282
  const textParts = message.content.filter(isTextBlock).map(block => block.text)
234
283
  const toolCalls = message.content.filter(isToolUseBlock).map(block => ({
@@ -265,6 +314,37 @@ export function toWireMessages(messages: Message[]): Array<Record<string, unknow
265
314
  return normalizeSystemMessages(out)
266
315
  }
267
316
 
317
+ async function toOpenAIUserContent(blocks: MessageContentBlock[]): Promise<Array<Record<string, unknown>>> {
318
+ const parts: Array<Record<string, unknown>> = []
319
+ for (const block of blocks) {
320
+ if (block.type === 'text') {
321
+ if (block.text.length > 0) parts.push({ type: 'text', text: block.text })
322
+ continue
323
+ }
324
+ if (block.type === 'image') {
325
+ const loaded = await loadImageBlock(block)
326
+ if (loaded.url) {
327
+ parts.push({ type: 'image_url', image_url: { url: loaded.url } })
328
+ } else if (loaded.dataBase64 && loaded.mimeType) {
329
+ parts.push({ type: 'image_url', image_url: { url: `data:${loaded.mimeType};base64,${loaded.dataBase64}` } })
330
+ }
331
+ continue
332
+ }
333
+ }
334
+ return parts.length > 0 ? parts : [{ type: 'text', text: '' }]
335
+ }
336
+
337
+ export function supportsOpenAIImages(model: string): boolean {
338
+ const normalized = model.toLowerCase()
339
+ if (normalized.includes('gpt-3.5')) return false
340
+ return /gpt-4o|gpt-4\.1|gpt-4-turbo|gpt-4-vision|gpt-5|o1|o3|o4|chatgpt-4/.test(normalized)
341
+ }
342
+
343
+ export function localModelNameHintsVision(model: string): boolean {
344
+ const normalized = model.toLowerCase()
345
+ return /llava|bakllava|qwen[-_.]?vl|qwen2[-_.]?vl|qwen2\.5[-_.]?vl|minicpm-?v|llama-3\.2.*vision|mllama|cogvlm|internvl|moondream|pixtral|phi-?3[\.-]?vision|phi-?3\.5[\.-]?vision|smolvlm/.test(normalized)
346
+ }
347
+
268
348
  function normalizeSystemMessages(messages: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
269
349
  const systemContents: string[] = []
270
350
  const nonSystem: Array<Record<string, unknown>> = []
@@ -369,7 +449,7 @@ function providerNetworkErrorMessage(
369
449
  ): string {
370
450
  const message = (err as Error).message || fallback
371
451
  if (provider !== 'llamacpp') return message
372
- return `${provider} request failed at ${baseUrl}: ${message}`
452
+ return `${providerDisplayName(provider)} request failed at ${baseUrl}: ${message}`
373
453
  }
374
454
 
375
455
  class ContentThinkingParser {
@@ -1,9 +1,11 @@
1
1
  import type { Message, MessageContentBlock } from './contracts.js'
2
2
  import { messageTextContent } from '../utils/messages.js'
3
3
  import type { OpenAIToolDefinition } from './openai-chat.js'
4
+ import { loadImageBlock } from '../utils/images.js'
4
5
 
5
6
  export type ResponsesInputContent =
6
7
  | { type: 'input_text'; text: string }
8
+ | { type: 'input_image'; image_url: string }
7
9
  | { type: 'output_text'; text: string }
8
10
 
9
11
  export type ResponsesInputItem =
@@ -30,13 +32,13 @@ export type ResponsesRequestBody = {
30
32
  max_output_tokens?: number
31
33
  }
32
34
 
33
- export function buildResponsesBody(args: {
35
+ export async function buildResponsesBody(args: {
34
36
  model: string
35
37
  messages: Message[]
36
38
  tools: OpenAIToolDefinition[]
37
39
  maxOutputTokens?: number
38
- }): ResponsesRequestBody {
39
- const { instructions, items } = splitMessages(args.messages)
40
+ }): Promise<ResponsesRequestBody> {
41
+ const { instructions, items } = await splitMessages(args.messages)
40
42
  const body: ResponsesRequestBody = {
41
43
  model: args.model,
42
44
  input: items,
@@ -60,10 +62,10 @@ export function buildResponsesBody(args: {
60
62
  return body
61
63
  }
62
64
 
63
- function splitMessages(messages: Message[]): {
65
+ async function splitMessages(messages: Message[]): Promise<{
64
66
  instructions?: string
65
67
  items: ResponsesInputItem[]
66
- } {
68
+ }> {
67
69
  const instructions: string[] = []
68
70
  const items: ResponsesInputItem[] = []
69
71
 
@@ -100,12 +102,12 @@ function splitMessages(messages: Message[]): {
100
102
  }
101
103
  continue
102
104
  }
103
- const text = blocks.filter(isTextBlock).map(block => block.text).join('')
104
- if (text) {
105
+ const content = await toOpenAIResponsesUserContent(blocks)
106
+ if (content.length > 0) {
105
107
  items.push({
106
108
  type: 'message',
107
109
  role: 'user',
108
- content: [{ type: 'input_text', text }],
110
+ content,
109
111
  })
110
112
  }
111
113
  continue
@@ -136,6 +138,25 @@ function splitMessages(messages: Message[]): {
136
138
  }
137
139
  }
138
140
 
141
+ async function toOpenAIResponsesUserContent(blocks: MessageContentBlock[]): Promise<ResponsesInputContent[]> {
142
+ const content: ResponsesInputContent[] = []
143
+ for (const block of blocks) {
144
+ if (block.type === 'text') {
145
+ if (block.text) content.push({ type: 'input_text', text: block.text })
146
+ continue
147
+ }
148
+ if (block.type === 'image') {
149
+ const loaded = await loadImageBlock(block)
150
+ if (loaded.url) {
151
+ content.push({ type: 'input_image', image_url: loaded.url })
152
+ } else if (loaded.dataBase64 && loaded.mimeType) {
153
+ content.push({ type: 'input_image', image_url: `data:${loaded.mimeType};base64,${loaded.dataBase64}` })
154
+ }
155
+ }
156
+ }
157
+ return content
158
+ }
159
+
139
160
  function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
140
161
  if (typeof content === 'string') {
141
162
  return content ? [{ type: 'text', text: content }] : []
@@ -5,7 +5,8 @@ import { providerErrorFromResponse } from './errors.js'
5
5
  import { fetchWithRetryStreamEvents } from './retry.js'
6
6
  import { iterSseEvents } from './sse.js'
7
7
  import { buildResponsesBody } from './openai-responses-format.js'
8
- import type { OpenAIToolDefinition } from './openai-chat.js'
8
+ import { supportsOpenAIImages, type OpenAIToolDefinition } from './openai-chat.js'
9
+ import { hasImageBlocks, ImageLoadError } from '../utils/images.js'
9
10
 
10
11
  const READ_TIMEOUT_MS = 45_000
11
12
 
@@ -64,15 +65,29 @@ export class OpenAIResponsesProvider implements Provider {
64
65
  return
65
66
  }
66
67
 
68
+ if (hasImageBlocks(messages) && !supportsOpenAIImages(this.model)) {
69
+ yield { type: 'error', message: `image input is not enabled for ${this.model}` }
70
+ return
71
+ }
72
+
67
73
  let attempt = 0
68
74
  while (true) {
69
75
  attempt += 1
70
- const body = JSON.stringify(buildResponsesBody({
71
- model: this.model,
72
- messages,
73
- tools: this.tools,
74
- maxOutputTokens: options.maxTokens,
75
- }))
76
+ let body: string
77
+ try {
78
+ body = JSON.stringify(await buildResponsesBody({
79
+ model: this.model,
80
+ messages,
81
+ tools: this.tools,
82
+ maxOutputTokens: options.maxTokens,
83
+ }))
84
+ } catch (err: unknown) {
85
+ if (err instanceof ImageLoadError) {
86
+ yield { type: 'error', message: err.message }
87
+ return
88
+ }
89
+ throw err
90
+ }
76
91
 
77
92
  let response: Response
78
93
  try {
@@ -34,6 +34,7 @@ export function createProvider(config: EthagentConfig, options: { mode?: Session
34
34
  baseUrl: localProviderBaseUrlFor('llamacpp', config.baseUrl),
35
35
  apiKey: 'llamacpp',
36
36
  tools: openAITools(mode, toolContext),
37
+ hasVisionProjector: Boolean(config.localMmprojPath),
37
38
  })
38
39
  case 'openai':
39
40
  return createOpenAIProvider(config, openAITools(mode, toolContext))
@@ -24,7 +24,7 @@ export function nextSessionMode(mode: SessionMode): SessionMode {
24
24
  }
25
25
 
26
26
  export function sessionModeLabel(mode: SessionMode): string {
27
- return mode === 'plan' ? 'Plan mode' : mode === 'accept-edits' ? 'Accept edits on' : ''
27
+ return mode === 'plan' ? 'plan mode' : mode === 'accept-edits' ? 'accept edits on' : ''
28
28
  }
29
29
 
30
30
  export function modePolicy(mode: PolicyMode): ModePolicy {
@@ -76,7 +76,7 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
76
76
  '**DIRECT REQUESTS**: If the user asks to change directory, list files, or read a file, respond with exactly one matching native tool call. Do not substitute prose or claim the action was taken.',
77
77
  '**EVIDENCE REQUIRED**: Do not claim a path is missing, a directory does not exist, or a file is absent unless you have a `list_directory` or `read_file` result from this conversation that confirms it.',
78
78
  '**TOOL TYPING**: Tool names are NOT shell commands. NEVER pass `list_directory`, `read_file`, `edit_file`, or `change_directory` directly to `run_bash`. Call the matching native tool.',
79
- 'Prefer targeted `read_file` and `edit_file` calls over general `run_bash` operations when both solve the task.',
79
+ '**PREFER NATIVE TOOLS**: If a request can be answered by `list_directory`, `read_file`, `edit_file`, `write_file`, `delete_file`, or `change_directory`, use that tool. Treat reaching for `run_bash` as a flag that you may be doing it wrong — only proceed if the action genuinely needs a real shell.',
80
80
  ...(ctx.mode === 'plan'
81
81
  ? [
82
82
  'Only read/list tools and permission-gated private continuity reads are available in plan mode.',
@@ -99,6 +99,8 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
99
99
  ]
100
100
  : ['No agent identity is linked in this session. Do not attempt private identity continuity edits; ask the user to create or load an agent first.']),
101
101
  'Use `run_bash` **only** when true shell execution is necessary.',
102
+ '**NO BASH EXPLORATION**: Never use `run_bash` to inspect the workspace. That means no `node -e`, no heredocs (`<<EOF`, `<<\'NODE\'`), no `find`, `grep`, `cat`, `head`, `tail`, `ls`, `dir`, `type`, `tree`, or shell loops to read or walk files. Use `list_directory` for directories and `read_file` for files. `run_bash` is reserved for actions that require a real shell: running tests, builds, git operations, launching processes. If you catch yourself writing a one-liner to read or list something, stop and call the native tool instead.',
103
+ '**DISCOVERY BUDGET**: For exploratory or "tell me about" questions, target ≤5 tool calls total. If 5 calls of the same kind have not given you enough to answer, the question does not need more depth — answer from what you have. Do not recursively scan, walk every subdirectory, or write deeper scripts to be more thorough than the user asked for. Stop and reply.',
102
104
  'Never use `run_bash` to produce conversational text. Do not call `echo`, `printf`, or similar to emit your reply — write it as your assistant text. Bash is for actions that need a real shell, not for generating words.',
103
105
  '**CWD CONTINUITY**: The working directory below is authoritative. After `change_directory` succeeds, use the new path as the base for subsequent actions.',
104
106
  'Do not lag behind the CWD. Edit/read relative to the *current* working directory.',
@@ -17,10 +17,7 @@ import type {
17
17
  import { setCwd as setRuntimeCwd } from './cwd.js'
18
18
  import type { EthagentConfig } from '../storage/config.js'
19
19
  import type { SessionMessage } from '../storage/sessions.js'
20
- import {
21
- summarizeToolInput,
22
- toolResultContentForRow,
23
- } from '../chat/chatScreenUtils.js'
20
+ import { toolResultContentForRow, toolResultDiffForRow } from '../chat/chatScreenUtils.js'
24
21
  import type { MessageRow } from '../chat/MessageList.js'
25
22
  import { modePolicy, toPermissionMode, type SessionMode } from './sessionMode.js'
26
23
 
@@ -239,7 +236,7 @@ export async function runPendingToolUses(args: {
239
236
  id: rowId,
240
237
  name: toolUse.name,
241
238
  summary: toolUse.name,
242
- input: summarizeToolInput(toolUse.input),
239
+ input: toolUse.input,
243
240
  },
244
241
  ])
245
242
  await args.persistTurnMessage({
@@ -282,9 +279,15 @@ async function recordToolResult(
282
279
  ): Promise<void> {
283
280
  const isError = !result.ok
284
281
  const resultContent = toolResultContentForRow(toolUse.name, result.content, isError)
282
+ const diff = toolResultDiffForRow(result.content, isError)
285
283
  args.updateRows(prev => prev.map(row =>
286
284
  row.role === 'tool_call' && row.id === rowId
287
- ? { ...row, result: { content: resultContent, summary: result.summary, isError } }
285
+ ? {
286
+ ...row,
287
+ result: diff
288
+ ? { content: resultContent, summary: result.summary, isError, diff }
289
+ : { content: resultContent, summary: result.summary, isError },
290
+ }
288
291
  : row,
289
292
  ))
290
293
  await args.persistTurnMessage({
@@ -56,6 +56,7 @@ function normalize(event: StreamEvent): ProviderTurnEvent {
56
56
  }
57
57
 
58
58
  export const MAX_CONTINUATION_NUDGES = 3
59
+ export const MAX_TOOL_USES_PER_TURN = 25
59
60
 
60
61
  export type ContinuationNudgeReason =
61
62
  | 'continuation'
@@ -63,6 +64,7 @@ export type ContinuationNudgeReason =
63
64
  | 'tool_state_claim'
64
65
  | 'tool_protocol_fake'
65
66
  | 'tool_delegation'
67
+ | 'tool_budget'
66
68
  | 'private_continuity_tool'
67
69
  | 'private_continuity_tool_repair'
68
70
  | 'reasoning_only'
@@ -82,6 +84,9 @@ const TOOL_PROTOCOL_FAKE_NUDGE_TEXT =
82
84
  const TOOL_DELEGATION_NUDGE_TEXT =
83
85
  '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.'
84
86
 
87
+ const TOOL_BUDGET_NUDGE_TEXT =
88
+ 'You have reached the tool-call budget for this turn. Do not call any more tools. Produce your final answer now using only what you already know from earlier tool results.'
89
+
85
90
  const PRIVATE_CONTINUITY_NUDGE_TEXT =
86
91
  '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."}.'
87
92
 
@@ -164,6 +169,7 @@ export async function* runRuntimeTurn(
164
169
  let continuationNudges = 0
165
170
  let iterationIndex = 0
166
171
  let priorIterationHadTools = false
172
+ let cumulativeToolUseCount = 0
167
173
  const toolEvidenceThisTurn: ToolEvidence[] = []
168
174
 
169
175
  // eslint-disable-next-line no-constant-condition
@@ -395,6 +401,29 @@ export async function* runRuntimeTurn(
395
401
  return
396
402
  }
397
403
 
404
+ if (cumulativeToolUseCount + pendingToolUses.length > MAX_TOOL_USES_PER_TURN) {
405
+ if (continuationNudges < maxContinuationNudges) {
406
+ continuationNudges += 1
407
+ yield {
408
+ type: 'continuation_nudge',
409
+ attempt: continuationNudges,
410
+ reason: 'tool_budget',
411
+ }
412
+ workingMessages = [
413
+ ...await rebuildMessages(),
414
+ { role: 'user', content: TOOL_BUDGET_NUDGE_TEXT },
415
+ ]
416
+ continue
417
+ }
418
+ yield {
419
+ type: 'error',
420
+ message: `tool budget exceeded (${MAX_TOOL_USES_PER_TURN} max per turn); ask again with a narrower request`,
421
+ }
422
+ yield doneEvent(false, stopReason)
423
+ return
424
+ }
425
+ cumulativeToolUseCount += pendingToolUses.length
426
+
398
427
  const batch = await runToolBatch(pendingToolUses)
399
428
  for (const completed of batch.completedTools) {
400
429
  toolEvidenceThisTurn.push({
@@ -80,6 +80,7 @@ const ConfigSchema = z.object({
80
80
  provider: z.enum(PROVIDERS),
81
81
  model: z.string().min(1),
82
82
  baseUrl: z.string().url().optional(),
83
+ localMmprojPath: z.string().min(1).optional(),
83
84
  firstRunAt: z.string(),
84
85
  identity: IdentitySchema.optional(),
85
86
  erc8004: z.object({
@@ -131,6 +131,26 @@ export async function listRewindEntries(
131
131
  .slice(offset, offset + limit)
132
132
  }
133
133
 
134
+ export async function groupRewindEntriesByTurn(
135
+ workspaceRoot: string,
136
+ sessionId: string,
137
+ ): Promise<Map<string, RewindEntry[]>> {
138
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
139
+ const snapshots = await loadSnapshots()
140
+ const grouped = new Map<string, RewindEntry[]>()
141
+ for (const snapshot of snapshots) {
142
+ if (isIdentityMarkdownSnapshot(snapshot)) continue
143
+ if (!isSnapshotWithinScope(snapshot, normalizedWorkspaceRoot)) continue
144
+ if (snapshot.sessionId !== sessionId) continue
145
+ if (!snapshot.turnId) continue
146
+ const entry = toEntry(snapshot)
147
+ const bucket = grouped.get(snapshot.turnId)
148
+ if (bucket) bucket.push(entry)
149
+ else grouped.set(snapshot.turnId, [entry])
150
+ }
151
+ return grouped
152
+ }
153
+
134
154
  export async function rewindWorkspaceEditsByEntryIds(
135
155
  workspaceRoot: string,
136
156
  entryIds: string[],
@@ -6,13 +6,15 @@ import type { Message } from '../providers/contracts.js'
6
6
  import { getCwd } from '../runtime/cwd.js'
7
7
  import type { SessionMode } from '../runtime/sessionMode.js'
8
8
  import { atomicWriteText } from './atomicWrite.js'
9
+ import { stripFileChangeResultDiff } from '../tools/fileDiff.js'
9
10
  import {
10
11
  isUserCorrectionOfToolState,
11
12
  looksLikeToolStateClaim,
12
13
  } from '../runtime/toolClaimGuards.js'
14
+ import { userTextToContentBlocks } from '../utils/images.js'
13
15
 
14
16
  export type SessionMessage =
15
- | { version?: 2; role: 'user'; content: string; createdAt: string; turnId?: string; synthetic?: boolean }
17
+ | { version?: 2; role: 'user'; content: string; providerContent?: Message['content']; createdAt: string; turnId?: string; synthetic?: boolean }
16
18
  | { version?: 2; role: 'assistant'; content: string; createdAt: string; model?: string; usage?: { in?: number; out?: number }; turnId?: string; synthetic?: boolean }
17
19
  | { version?: 2; role: 'system'; content: string; createdAt: string; turnId?: string; synthetic?: boolean }
18
20
  | { version: 2; role: 'tool_use'; toolUseId: string; name: string; input: Record<string, unknown>; createdAt: string; turnId?: string }
@@ -243,6 +245,17 @@ export type ProviderMessageProjectionOptions = {
243
245
  export const TOOL_CORRECTION_CONTEXT_MESSAGE =
244
246
  'The latest user message corrects a prior assistant claim about tool or filesystem state. Treat user correction and tool_result messages as authoritative. Ignore any recent assistant claim about files, directories, cwd, or tool execution unless it is backed by a tool_result, and retry with the appropriate tool.'
245
247
 
248
+ function resolveUserContent(
249
+ message: Extract<SessionMessage, { role: 'system' | 'user' | 'assistant' }>,
250
+ ): Message['content'] {
251
+ if (message.role !== 'user') return message.content
252
+ if (message.providerContent) return message.providerContent
253
+ if (message.content.includes('[image:')) {
254
+ return userTextToContentBlocks(message.content)
255
+ }
256
+ return message.content
257
+ }
258
+
246
259
  export function sessionMessagesToProviderMessages(
247
260
  messages: SessionMessage[],
248
261
  options: ProviderMessageProjectionOptions = {},
@@ -254,7 +267,7 @@ export function sessionMessagesToProviderMessages(
254
267
  for (const [index, message] of messages.entries()) {
255
268
  if (message.role === 'system' || message.role === 'user' || message.role === 'assistant') {
256
269
  if (message.role === 'assistant' && invalidatedAssistantMessages.has(index)) continue
257
- out.push({ role: message.role, content: message.content })
270
+ out.push({ role: message.role, content: resolveUserContent(message) })
258
271
  continue
259
272
  }
260
273
  if (message.role === 'tool_use') {
@@ -282,7 +295,7 @@ export function sessionMessagesToProviderMessages(
282
295
  content: [{
283
296
  type: 'tool_result',
284
297
  toolUseId: message.toolUseId,
285
- content: message.content,
298
+ content: stripFileChangeResultDiff(message.content),
286
299
  isError: message.isError,
287
300
  }],
288
301
  })
@@ -98,16 +98,16 @@ export function assessBashCommand(command: string): BashSafetyAssessment {
98
98
  const triggeredChecks = RISKY_PATTERN_CHECKS.filter(check => check.pattern.test(command)).map(check => check.message)
99
99
 
100
100
  const warning = triggeredChecks.length > 0
101
- ? `warning: ${triggeredChecks[0]}. reusable approval is limited for this command.`
101
+ ? `Warning: ${sentenceCase(triggeredChecks[0] ?? 'command is risky')}. Reusable approval is limited for this command.`
102
102
  : highRisk
103
- ? `warning: ${firstToken} is a high-impact command. reusable approval is limited for this command.`
103
+ ? `Warning: ${sentenceCase(firstToken ?? '')} is a high-impact command. Reusable approval is limited for this command.`
104
104
  : undefined
105
105
 
106
106
  return {
107
107
  warning,
108
108
  canPersistExact: triggeredChecks.length === 0 && !nonPersistable,
109
109
  canPersistPrefix: triggeredChecks.length === 0 && !highRisk && Boolean(firstToken),
110
- commandPrefix: firstToken,
110
+ commandPrefix: firstToken ?? '',
111
111
  }
112
112
  }
113
113
 
@@ -180,3 +180,7 @@ function normalizeCommandToken(token: string): string {
180
180
  .toLowerCase()
181
181
  .replace(/[^a-z0-9_.:-]/g, '') ?? ''
182
182
  }
183
+
184
+ function sentenceCase(value: string): string {
185
+ return value ? value[0]!.toUpperCase() + value.slice(1) : value
186
+ }
@@ -39,7 +39,7 @@ export const bashTool: Tool<typeof schema> = {
39
39
  command: input.command,
40
40
  commandPrefix: safety.commandPrefix,
41
41
  cwd,
42
- title: 'allow shell command?',
42
+ title: 'Allow shell command?',
43
43
  subtitle: `${input.command}\n${cwd}`,
44
44
  warning: safety.warning,
45
45
  canPersistExact: safety.canPersistExact,
@@ -23,6 +23,7 @@ export type PermissionRequest =
23
23
  subtitle: string
24
24
  before: string
25
25
  after: string
26
+ diff: string
26
27
  changeSummary: string
27
28
  }
28
29
  | {
@@ -34,6 +35,7 @@ export type PermissionRequest =
34
35
  subtitle: string
35
36
  before: string
36
37
  after: string
38
+ diff: string
37
39
  changeSummary: string
38
40
  }
39
41
  | {
@@ -68,6 +70,7 @@ export type PermissionRequest =
68
70
  subtitle: string
69
71
  before: string
70
72
  after: string
73
+ diff: string
71
74
  changeSummary: string
72
75
  }
73
76
  | {
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { recordRewindSnapshot } from '../storage/rewind.js'
5
5
  import type { Tool } from './contracts.js'
6
+ import { formatFileChangeResult, renderUnifiedFileDiff } from './fileDiff.js'
6
7
  import { resolveWorkspacePath } from './readTool.js'
7
8
 
8
9
  const schema = z.object({
@@ -35,6 +36,7 @@ export const deleteFileTool: Tool<typeof schema> = {
35
36
  subtitle: prepared.fullPath,
36
37
  before: preview(prepared.before),
37
38
  after: '(deleted)',
39
+ diff: renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: '' }),
38
40
  changeSummary: `delete ${prepared.relativePath}`,
39
41
  }
40
42
  },
@@ -58,9 +60,12 @@ export const deleteFileTool: Tool<typeof schema> = {
58
60
  return {
59
61
  ok: true,
60
62
  summary: `deleted ${prepared.relativePath}`,
61
- content: rewindWarning
62
- ? `deleted ${prepared.fullPath}\nwarning: ${rewindWarning}`
63
- : `deleted ${prepared.fullPath}`,
63
+ content: formatFileChangeResult(
64
+ rewindWarning
65
+ ? `deleted ${prepared.fullPath}\nwarning: ${rewindWarning}`
66
+ : `deleted ${prepared.fullPath}`,
67
+ renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: '' }),
68
+ ),
64
69
  }
65
70
  },
66
71
  }
@@ -5,6 +5,7 @@ import { recordRewindSnapshot } from '../storage/rewind.js'
5
5
  import type { EthagentConfig } from '../storage/config.js'
6
6
  import type { Tool } from './contracts.js'
7
7
  import { applyRequestedEdit } from './editUtils.js'
8
+ import { formatFileChangeResult, renderUnifiedFileDiff } from './fileDiff.js'
8
9
  import { resolveWorkspacePath } from './readTool.js'
9
10
 
10
11
  const schema = z.object({
@@ -44,15 +45,16 @@ export const editTool: Tool<typeof schema> = {
44
45
  subtitle: fullPath,
45
46
  before: applied.previewBefore,
46
47
  after: applied.previewAfter,
48
+ diff: renderUnifiedFileDiff({ filePath: relativePath, before: applied.before, after: applied.after }),
47
49
  changeSummary: applied.summary,
48
50
  }
49
51
  },
50
52
  async execute(input, context) {
51
- const { fullPath, applied, existedBefore, before } = await prepareEdit(input, context)
53
+ const { fullPath, relativePath, applied, existedBefore, before } = await prepareEdit(input, context)
52
54
  const rewindWarning = await tryRecordRewindSnapshot({
53
55
  workspaceRoot: context.workspaceRoot,
54
56
  filePath: fullPath,
55
- relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
57
+ relativePath,
56
58
  existedBefore,
57
59
  previousContent: before,
58
60
  changeSummary: applied.summary,
@@ -68,9 +70,12 @@ export const editTool: Tool<typeof schema> = {
68
70
  return {
69
71
  ok: true,
70
72
  summary: applied.summary,
71
- content: rewindWarning
72
- ? `updated ${fullPath}\nwarning: ${rewindWarning}`
73
- : `updated ${fullPath}`,
73
+ content: formatFileChangeResult(
74
+ rewindWarning
75
+ ? `updated ${fullPath}\nwarning: ${rewindWarning}`
76
+ : `updated ${fullPath}`,
77
+ renderUnifiedFileDiff({ filePath: relativePath, before: applied.before, after: applied.after }),
78
+ ),
74
79
  }
75
80
  },
76
81
  }