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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,152 @@
1
+ import { getKey } from '../storage/secrets.js'
2
+ import type { Message, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
3
+ import { ProviderError } from './contracts.js'
4
+ import { providerErrorFromResponse } from './errors.js'
5
+ import { fetchWithRetryStreamEvents } from './retry.js'
6
+ import { iterSseFrames } from './sse.js'
7
+ import { messageTextContent } from '../utils/messages.js'
8
+
9
+ type GeminiChunk = {
10
+ candidates?: Array<{
11
+ content?: {
12
+ parts?: Array<{
13
+ text?: string
14
+ }>
15
+ }
16
+ finishReason?: string
17
+ }>
18
+ promptFeedback?: {
19
+ blockReason?: string
20
+ }
21
+ usageMetadata?: {
22
+ promptTokenCount?: number
23
+ candidatesTokenCount?: number
24
+ }
25
+ }
26
+
27
+ const READ_TIMEOUT_MS = 45_000
28
+
29
+ export class GeminiProvider implements Provider {
30
+ readonly id = 'gemini' as const
31
+ readonly model: string
32
+ readonly supportsTools = false
33
+
34
+ constructor(opts: { model: string }) {
35
+ this.model = opts.model
36
+ }
37
+
38
+ async *complete(
39
+ messages: Message[],
40
+ signal: AbortSignal,
41
+ options: ProviderCompleteOptions = {},
42
+ ): AsyncIterable<StreamEvent> {
43
+ const apiKey = await getKey('gemini')
44
+ if (!apiKey) {
45
+ const error = new ProviderError('missing API key for gemini (/doctor to verify)')
46
+ yield { type: 'error', message: error.message }
47
+ return
48
+ }
49
+
50
+ const payload = buildGeminiPayload(messages, options)
51
+ const modelName = this.model.replace(/^models\//, '')
52
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelName)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`
53
+
54
+ let response: Response
55
+ try {
56
+ response = yield* fetchWithRetryStreamEvents(url, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'content-type': 'application/json',
60
+ accept: 'text/event-stream',
61
+ },
62
+ body: JSON.stringify(payload),
63
+ }, { signal })
64
+ } catch (err: unknown) {
65
+ if (signal.aborted) return
66
+ yield { type: 'error', message: (err as Error).message || 'network error' }
67
+ return
68
+ }
69
+
70
+ if (!response.ok) {
71
+ const error = await providerErrorFromResponse(this.id, response)
72
+ yield { type: 'error', message: error.message }
73
+ return
74
+ }
75
+ if (!response.body) {
76
+ yield { type: 'error', message: 'empty response body' }
77
+ return
78
+ }
79
+
80
+ let inputTokens: number | undefined
81
+ let outputTokens: number | undefined
82
+
83
+ try {
84
+ for await (const frame of iterSseFrames(response.body, signal, READ_TIMEOUT_MS)) {
85
+ let parsed: GeminiChunk
86
+ try {
87
+ parsed = JSON.parse(frame) as GeminiChunk
88
+ } catch {
89
+ continue
90
+ }
91
+
92
+ const blockedReason = parsed.promptFeedback?.blockReason
93
+ if (blockedReason) {
94
+ throw new ProviderError(`prompt blocked: ${blockedReason.toLowerCase()}`)
95
+ }
96
+
97
+ const parts = parsed.candidates?.[0]?.content?.parts ?? []
98
+ for (const part of parts) {
99
+ if (part.text) yield { type: 'text', delta: part.text }
100
+ }
101
+
102
+ inputTokens = parsed.usageMetadata?.promptTokenCount ?? inputTokens
103
+ outputTokens = parsed.usageMetadata?.candidatesTokenCount ?? outputTokens
104
+ }
105
+ } catch (err: unknown) {
106
+ if (signal.aborted) return
107
+ yield { type: 'error', message: (err as Error).message || 'stream error' }
108
+ return
109
+ }
110
+
111
+ if (signal.aborted) return
112
+ yield { type: 'done', inputTokens, outputTokens }
113
+ }
114
+ }
115
+
116
+ function buildGeminiPayload(messages: Message[], options: ProviderCompleteOptions = {}): {
117
+ contents: Array<{
118
+ role: 'user' | 'model'
119
+ parts: Array<{ text: string }>
120
+ }>
121
+ systemInstruction?: {
122
+ parts: Array<{ text: string }>
123
+ }
124
+ generationConfig?: {
125
+ maxOutputTokens?: number
126
+ }
127
+ } {
128
+ const systemParts: string[] = []
129
+ const contents: Array<{
130
+ role: 'user' | 'model'
131
+ parts: Array<{ text: string }>
132
+ }> = []
133
+
134
+ for (const message of messages) {
135
+ const text = messageTextContent(message).trim()
136
+ if (!text) continue
137
+ if (message.role === 'system') {
138
+ systemParts.push(text)
139
+ continue
140
+ }
141
+ contents.push({
142
+ role: message.role === 'assistant' ? 'model' : 'user',
143
+ parts: [{ text }],
144
+ })
145
+ }
146
+
147
+ return {
148
+ contents,
149
+ systemInstruction: systemParts.length > 0 ? { parts: [{ text: systemParts.join('\n\n') }] } : undefined,
150
+ generationConfig: options.maxTokens ? { maxOutputTokens: options.maxTokens } : undefined,
151
+ }
152
+ }
@@ -0,0 +1,472 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+ import type { Message, MessageContentBlock, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
3
+ import { ProviderError } from './contracts.js'
4
+ import { providerErrorFromResponse } from './errors.js'
5
+ import { fetchWithRetryStreamEvents } from './retry.js'
6
+ import { iterSseFrames } from './sse.js'
7
+ import { messageTextContent } from '../utils/messages.js'
8
+
9
+ export type OpenAIToolDefinition = {
10
+ type: 'function'
11
+ function: {
12
+ name: string
13
+ description: string
14
+ parameters: {
15
+ type: 'object'
16
+ properties?: Record<string, unknown>
17
+ required?: string[]
18
+ }
19
+ }
20
+ }
21
+
22
+ type Options = {
23
+ id: ProviderId
24
+ model: string
25
+ baseUrl: string
26
+ apiKey?: string
27
+ loadApiKey?: () => Promise<string | null>
28
+ tools?: OpenAIToolDefinition[]
29
+ maxRetries?: number
30
+ }
31
+
32
+ type ChatChunk = {
33
+ choices?: Array<{
34
+ delta?: {
35
+ content?: string | null
36
+ reasoning_content?: string | null
37
+ reasoning?: string | null
38
+ thinking?: string | null
39
+ tool_calls?: Array<{
40
+ index?: number
41
+ id?: string | null
42
+ type?: 'function'
43
+ function?: {
44
+ name?: string | null
45
+ arguments?: string | null
46
+ }
47
+ }>
48
+ }
49
+ finish_reason?: string | null
50
+ }>
51
+ usage?: {
52
+ prompt_tokens?: number
53
+ completion_tokens?: number
54
+ } | null
55
+ }
56
+
57
+ type ToolCallDelta = NonNullable<NonNullable<NonNullable<ChatChunk['choices']>[number]['delta']>['tool_calls']>[number]
58
+
59
+ type StreamingToolCall = {
60
+ id: string
61
+ name: string
62
+ inputJson: string
63
+ started: boolean
64
+ }
65
+
66
+ const READ_TIMEOUT_MS = 45_000
67
+ type DoneStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown'
68
+
69
+ export class OpenAIChatProvider implements Provider {
70
+ readonly id: ProviderId
71
+ readonly model: string
72
+ readonly supportsTools: boolean
73
+ private readonly baseUrl: string
74
+ private readonly apiKey: string
75
+ private readonly loadApiKey?: () => Promise<string | null>
76
+ private readonly tools: OpenAIToolDefinition[]
77
+ private readonly maxRetries?: number
78
+
79
+ constructor(opts: Options) {
80
+ this.id = opts.id
81
+ this.model = opts.model
82
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '')
83
+ this.apiKey = opts.apiKey ?? ''
84
+ this.loadApiKey = opts.loadApiKey
85
+ this.tools = opts.tools ?? []
86
+ this.maxRetries = opts.maxRetries
87
+ this.supportsTools = this.tools.length > 0
88
+ }
89
+
90
+ async *complete(
91
+ messages: Message[],
92
+ signal: AbortSignal,
93
+ options: ProviderCompleteOptions = {},
94
+ ): AsyncIterable<StreamEvent> {
95
+ const apiKey = await this.resolveApiKey()
96
+ if (!apiKey && this.id !== 'llamacpp') {
97
+ const error = new ProviderError(`missing API key for ${this.id} (/doctor to verify)`)
98
+ yield { type: 'error', message: error.message }
99
+ return
100
+ }
101
+
102
+ const headers: Record<string, string> = {
103
+ 'Content-Type': 'application/json',
104
+ Accept: 'text/event-stream',
105
+ }
106
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
107
+
108
+ let response: Response
109
+ try {
110
+ response = yield* fetchWithRetryStreamEvents(`${this.baseUrl}/chat/completions`, {
111
+ method: 'POST',
112
+ headers,
113
+ body: JSON.stringify({
114
+ model: this.model,
115
+ messages: toWireMessages(messages),
116
+ tools: this.tools.length > 0 ? this.tools : undefined,
117
+ tool_choice: this.tools.length > 0 ? 'auto' : undefined,
118
+ stream: true,
119
+ stream_options: { include_usage: true },
120
+ max_tokens: options.maxTokens,
121
+ }),
122
+ }, { signal, maxRetries: this.maxRetries, rateLimitResetProvider: 'openai-compatible' })
123
+ } catch (err: unknown) {
124
+ if (signal.aborted) return
125
+ const message = providerNetworkErrorMessage(this.id, this.baseUrl, err)
126
+ yield { type: 'error', message }
127
+ return
128
+ }
129
+
130
+ if (!response.ok) {
131
+ const error = await providerErrorFromResponse(this.id, response)
132
+ yield { type: 'error', message: error.message }
133
+ return
134
+ }
135
+ if (!response.body) {
136
+ yield { type: 'error', message: 'empty response body' }
137
+ return
138
+ }
139
+
140
+ let inputTokens: number | undefined
141
+ let outputTokens: number | undefined
142
+ let stopReason: DoneStopReason = 'unknown'
143
+ const toolCalls = new Map<number, StreamingToolCall>()
144
+ const contentThinkingParser = new ContentThinkingParser(this.id)
145
+
146
+ try {
147
+ for await (const frame of iterSseFrames(response.body, signal, READ_TIMEOUT_MS)) {
148
+ if (frame === '[DONE]') break
149
+ let parsed: ChatChunk
150
+ try {
151
+ parsed = JSON.parse(frame) as ChatChunk
152
+ } catch {
153
+ continue
154
+ }
155
+
156
+ const choice = parsed.choices?.[0]
157
+ const delta = choice?.delta
158
+ const text = typeof delta?.content === 'string' ? delta.content : ''
159
+ const reasoning =
160
+ typeof delta?.reasoning_content === 'string'
161
+ ? delta.reasoning_content
162
+ : typeof delta?.reasoning === 'string'
163
+ ? delta.reasoning
164
+ : typeof delta?.thinking === 'string'
165
+ ? delta.thinking
166
+ : ''
167
+
168
+ if (reasoning.length > 0) yield { type: 'thinking', delta: reasoning }
169
+ if (text.length > 0) {
170
+ for (const event of contentThinkingParser.push(text)) {
171
+ yield event
172
+ }
173
+ }
174
+
175
+ for (const event of applyStreamingToolCallDelta(toolCalls, delta?.tool_calls ?? [])) {
176
+ yield event
177
+ }
178
+
179
+ if (choice?.finish_reason) {
180
+ stopReason = normalizeFinishReason(choice.finish_reason)
181
+ }
182
+ if (parsed.usage) {
183
+ inputTokens = parsed.usage.prompt_tokens ?? inputTokens
184
+ outputTokens = parsed.usage.completion_tokens ?? outputTokens
185
+ }
186
+ }
187
+ } catch (err: unknown) {
188
+ if (signal.aborted) return
189
+ yield { type: 'error', message: providerNetworkErrorMessage(this.id, this.baseUrl, err, 'stream error') }
190
+ return
191
+ }
192
+
193
+ if (signal.aborted) return
194
+ for (const event of contentThinkingParser.flush()) {
195
+ yield event
196
+ }
197
+
198
+ let streamEmittedToolUses = 0
199
+ if (stopReason === 'tool_use' || toolCalls.size > 0) {
200
+ for (const [, toolCall] of [...toolCalls.entries()].sort((a, b) => a[0] - b[0])) {
201
+ if (!toolCall.name) continue
202
+ streamEmittedToolUses += 1
203
+ yield {
204
+ type: 'tool_use_stop',
205
+ id: toolCall.id,
206
+ name: toolCall.name,
207
+ input: parseToolArguments(toolCall.inputJson),
208
+ }
209
+ }
210
+ }
211
+
212
+ yield { type: 'done', inputTokens, outputTokens, stopReason }
213
+ }
214
+
215
+ private async resolveApiKey(): Promise<string> {
216
+ if (this.apiKey) return this.apiKey
217
+ if (!this.loadApiKey) return ''
218
+ return (await this.loadApiKey()) ?? ''
219
+ }
220
+
221
+ }
222
+
223
+ export function toWireMessages(messages: Message[]): Array<Record<string, unknown>> {
224
+ const out: Array<Record<string, unknown>> = []
225
+
226
+ for (const message of messages) {
227
+ if (typeof message.content === 'string') {
228
+ out.push({ role: message.role, content: message.content })
229
+ continue
230
+ }
231
+
232
+ if (message.role === 'assistant') {
233
+ const textParts = message.content.filter(isTextBlock).map(block => block.text)
234
+ const toolCalls = message.content.filter(isToolUseBlock).map(block => ({
235
+ id: block.id,
236
+ type: 'function',
237
+ function: {
238
+ name: block.name,
239
+ arguments: JSON.stringify(block.input),
240
+ },
241
+ }))
242
+ out.push({
243
+ role: 'assistant',
244
+ content: textParts.join(''),
245
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
246
+ })
247
+ continue
248
+ }
249
+
250
+ const toolResults = message.content.filter(isToolResultBlock)
251
+ if (toolResults.length > 0) {
252
+ for (const block of toolResults) {
253
+ out.push({
254
+ role: 'tool',
255
+ tool_call_id: block.toolUseId,
256
+ content: block.content,
257
+ })
258
+ }
259
+ continue
260
+ }
261
+
262
+ out.push({ role: message.role, content: messageTextContent(message) })
263
+ }
264
+
265
+ return normalizeSystemMessages(out)
266
+ }
267
+
268
+ function normalizeSystemMessages(messages: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
269
+ const systemContents: string[] = []
270
+ const nonSystem: Array<Record<string, unknown>> = []
271
+
272
+ for (const message of messages) {
273
+ if (message.role === 'system') {
274
+ if (typeof message.content === 'string' && message.content.length > 0) {
275
+ systemContents.push(message.content)
276
+ }
277
+ continue
278
+ }
279
+ nonSystem.push(message)
280
+ }
281
+
282
+ if (systemContents.length === 0) return nonSystem
283
+ return [
284
+ {
285
+ role: 'system',
286
+ content: systemContents.join('\n\n'),
287
+ },
288
+ ...nonSystem,
289
+ ]
290
+ }
291
+
292
+ function isTextBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'text' }> {
293
+ return block.type === 'text'
294
+ }
295
+
296
+ function isToolUseBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_use' }> {
297
+ return block.type === 'tool_use'
298
+ }
299
+
300
+ function isToolResultBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_result' }> {
301
+ return block.type === 'tool_result'
302
+ }
303
+
304
+ function parseToolArguments(inputJson: string): Record<string, unknown> {
305
+ if (!inputJson.trim()) return {}
306
+ try {
307
+ return JSON.parse(inputJson) as Record<string, unknown>
308
+ } catch {
309
+ const repaired = repairJsonObject(inputJson)
310
+ if (!repaired) return {}
311
+ try {
312
+ return JSON.parse(repaired) as Record<string, unknown>
313
+ } catch {
314
+ return {}
315
+ }
316
+ }
317
+ }
318
+
319
+ function* applyStreamingToolCallDelta(
320
+ toolCalls: Map<number, StreamingToolCall>,
321
+ deltas: ToolCallDelta[] | undefined,
322
+ ): Iterable<StreamEvent> {
323
+ for (const toolCallDelta of deltas ?? []) {
324
+ const index = toolCallDelta.index ?? 0
325
+ const existing = toolCalls.get(index) ?? createStreamingToolCall(index, toolCallDelta)
326
+
327
+ if (toolCallDelta.id) existing.id = toolCallDelta.id
328
+ if (toolCallDelta.function?.name) existing.name = toolCallDelta.function.name
329
+ if (toolCallDelta.function?.arguments) {
330
+ existing.inputJson += toolCallDelta.function.arguments
331
+ }
332
+ if (!existing.started && existing.name) {
333
+ existing.started = true
334
+ yield { type: 'tool_use_start', id: existing.id, name: existing.name }
335
+ }
336
+ if (toolCallDelta.function?.arguments) {
337
+ yield { type: 'tool_use_delta', id: existing.id, delta: toolCallDelta.function.arguments }
338
+ }
339
+
340
+ toolCalls.set(index, existing)
341
+ }
342
+ }
343
+
344
+ function createStreamingToolCall(
345
+ index: number,
346
+ delta: ToolCallDelta,
347
+ ): StreamingToolCall {
348
+ return {
349
+ id: delta.id ?? `tool-${index}`,
350
+ name: delta.function?.name ?? '',
351
+ inputJson: '',
352
+ started: false,
353
+ }
354
+ }
355
+
356
+ function normalizeFinishReason(reason: string): DoneStopReason {
357
+ if (reason === 'stop') return 'end_turn'
358
+ if (reason === 'tool_calls') return 'tool_use'
359
+ if (reason === 'length') return 'max_tokens'
360
+ if (reason === 'stop_sequence') return 'stop_sequence'
361
+ return 'unknown'
362
+ }
363
+
364
+ function providerNetworkErrorMessage(
365
+ provider: ProviderId,
366
+ baseUrl: string,
367
+ err: unknown,
368
+ fallback = 'network error',
369
+ ): string {
370
+ const message = (err as Error).message || fallback
371
+ if (provider !== 'llamacpp') return message
372
+ return `${provider} request failed at ${baseUrl}: ${message}`
373
+ }
374
+
375
+ class ContentThinkingParser {
376
+ private state: 'text' | 'thinking' = 'text'
377
+ private buffer = ''
378
+
379
+ constructor(private readonly provider: ProviderId) {}
380
+
381
+ *push(delta: string): Iterable<StreamEvent> {
382
+ if (!this.shouldParse()) {
383
+ yield { type: 'text', delta }
384
+ return
385
+ }
386
+
387
+ this.buffer += delta
388
+ yield* this.drain(false)
389
+ }
390
+
391
+ *flush(): Iterable<StreamEvent> {
392
+ if (!this.shouldParse() || this.buffer.length === 0) return
393
+ const content = this.buffer
394
+ this.buffer = ''
395
+ yield { type: this.state === 'thinking' ? 'thinking' : 'text', delta: content }
396
+ }
397
+
398
+ private *drain(flush: boolean): Iterable<StreamEvent> {
399
+ while (this.buffer.length > 0) {
400
+ const tag = this.state === 'text' ? '<think>' : '</think>'
401
+ const tagIndex = indexOfIgnoreCase(this.buffer, tag)
402
+
403
+ if (tagIndex !== -1) {
404
+ const before = this.buffer.slice(0, tagIndex)
405
+ if (before.length > 0) {
406
+ yield { type: this.state === 'thinking' ? 'thinking' : 'text', delta: before }
407
+ }
408
+ this.buffer = this.buffer.slice(tagIndex + tag.length)
409
+ this.state = this.state === 'text' ? 'thinking' : 'text'
410
+ continue
411
+ }
412
+
413
+ const keep = flush ? 0 : partialTagPrefixLength(this.buffer, tag)
414
+ const emit = this.buffer.slice(0, this.buffer.length - keep)
415
+ this.buffer = this.buffer.slice(this.buffer.length - keep)
416
+ if (emit.length > 0) {
417
+ yield { type: this.state === 'thinking' ? 'thinking' : 'text', delta: emit }
418
+ }
419
+ return
420
+ }
421
+ }
422
+
423
+ private shouldParse(): boolean {
424
+ return this.provider === 'llamacpp'
425
+ }
426
+ }
427
+
428
+ function indexOfIgnoreCase(value: string, search: string): number {
429
+ return value.toLowerCase().indexOf(search.toLowerCase())
430
+ }
431
+
432
+ function partialTagPrefixLength(value: string, tag: string): number {
433
+ const max = Math.min(value.length, tag.length - 1)
434
+ const lowerValue = value.toLowerCase()
435
+ const lowerTag = tag.toLowerCase()
436
+ for (let size = max; size > 0; size -= 1) {
437
+ if (lowerValue.endsWith(lowerTag.slice(0, size))) return size
438
+ }
439
+ return 0
440
+ }
441
+
442
+ function repairJsonObject(input: string): string | undefined {
443
+ const start = input.indexOf('{')
444
+ if (start === -1) return undefined
445
+
446
+ let depth = 0
447
+ let inString = false
448
+ let escaped = false
449
+ for (let index = start; index < input.length; index += 1) {
450
+ const char = input[index]!
451
+ if (escaped) {
452
+ escaped = false
453
+ continue
454
+ }
455
+ if (char === '\\') {
456
+ escaped = true
457
+ continue
458
+ }
459
+ if (char === '"') {
460
+ inString = !inString
461
+ continue
462
+ }
463
+ if (inString) continue
464
+ if (char === '{') depth += 1
465
+ if (char === '}') {
466
+ depth -= 1
467
+ if (depth === 0) return input.slice(start, index + 1)
468
+ }
469
+ }
470
+
471
+ return depth > 0 ? `${input.slice(start)}${'}'.repeat(depth)}` : undefined
472
+ }
@@ -0,0 +1,42 @@
1
+ import type { EthagentConfig } from '../storage/config.js'
2
+ import { localProviderBaseUrlFor } from '../storage/config.js'
3
+ import { getKey } from '../storage/secrets.js'
4
+ import type { Provider } from './contracts.js'
5
+ import type { SessionMode } from '../runtime/sessionMode.js'
6
+ import { AnthropicProvider } from './anthropic.js'
7
+ import { GeminiProvider } from './gemini.js'
8
+ import { OpenAIChatProvider } from './openai-chat.js'
9
+ import { anthropicTools, openAITools } from '../tools/registry.js'
10
+ import { openAIBaseUrlFor } from '../models/catalog.js'
11
+ import type { Tool } from '../tools/contracts.js'
12
+
13
+ export function isLocalProvider(provider: string): boolean {
14
+ return provider === 'llamacpp'
15
+ }
16
+
17
+ export function createProvider(config: EthagentConfig, options: { mode?: SessionMode; dynamicTools?: Tool[] } = {}): Provider {
18
+ const mode = options.mode ?? 'chat'
19
+ const toolContext = { hasIdentity: Boolean(config.identity), dynamicTools: options.dynamicTools }
20
+ switch (config.provider) {
21
+ case 'llamacpp':
22
+ return new OpenAIChatProvider({
23
+ id: 'llamacpp',
24
+ model: config.model,
25
+ baseUrl: localProviderBaseUrlFor('llamacpp', config.baseUrl),
26
+ apiKey: 'llamacpp',
27
+ tools: openAITools(mode, toolContext),
28
+ })
29
+ case 'openai':
30
+ return new OpenAIChatProvider({
31
+ id: 'openai',
32
+ model: config.model,
33
+ baseUrl: openAIBaseUrlFor(config),
34
+ loadApiKey: () => getKey('openai'),
35
+ tools: openAITools(mode, toolContext),
36
+ })
37
+ case 'anthropic':
38
+ return new AnthropicProvider({ model: config.model, tools: anthropicTools(mode, toolContext) })
39
+ case 'gemini':
40
+ return new GeminiProvider({ model: config.model })
41
+ }
42
+ }