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