@ouraihub/aether-worker 0.1.0 → 0.1.2

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.
@@ -0,0 +1,298 @@
1
+ import type { ImagePart, RunRequest } from '@ouraihub/aether-contracts'
2
+ import { badRequest, cancelled, KitFailure, safeError } from '@ouraihub/aether-core/error'
3
+ import { charCount } from '@ouraihub/aether-core/guard'
4
+ import { ImageFailure } from '@ouraihub/aether-core/images'
5
+ import type { McpSession } from '@ouraihub/aether-core/mcp'
6
+ import type { ResolvedSearchConfig } from '@ouraihub/aether-core/search'
7
+ import type { ToolCall, ToolExecution, ToolSpec, Connection } from '@ouraihub/aether-core/types'
8
+ import type { Actor, WorkerContext } from './context.js'
9
+ import type { AcceptedHandle, ConversationService, PreparedAction } from './conversations/index.js'
10
+ import type { WorkerHost } from './host.js'
11
+ import type { ImageService } from './images/index.js'
12
+ import type { McpService } from './mcp/index.js'
13
+ import type { ProviderService } from './providers/index.js'
14
+ import type { SearchService } from './search/index.js'
15
+
16
+ export interface Candidate {
17
+ id: string
18
+ image: ImagePart
19
+ origin: string
20
+ }
21
+
22
+ export interface ToolPreparationOptions {
23
+ host: WorkerHost
24
+ providers: ProviderService
25
+ search?: SearchService
26
+ mcp?: McpService
27
+ images?: ImageService
28
+ }
29
+
30
+ export interface PreparedTools {
31
+ tools: ToolSpec[]
32
+ mcp: McpSession | null
33
+ painting: Connection | null
34
+ candidates: Candidate[]
35
+ search: ResolvedSearchConfig | null
36
+ nativeWebSearch: boolean
37
+ }
38
+
39
+ export interface ToolRuntimeState {
40
+ input: { tools: readonly ToolSpec[] }
41
+ context: WorkerContext
42
+ mcp: McpSession | null
43
+ painting: Connection | null
44
+ candidates: readonly Candidate[]
45
+ paintingCalls: number
46
+ }
47
+
48
+ export const assertActive = (signal: AbortSignal): void => {
49
+ if (signal.aborted) throw cancelled()
50
+ }
51
+
52
+ const missing = (field: string): KitFailure => badRequest(field, 'missing_instance')
53
+
54
+ function candidatesFor(action: PreparedAction, enabled: boolean): Candidate[] {
55
+ if (!enabled) return []
56
+ const result: Candidate[] = []
57
+ const add = (images: readonly { mime: string; data: string | null }[], origin: string): void => {
58
+ for (let i = images.length - 1; i >= 0 && result.length < 8; i--) {
59
+ const image = images[i]
60
+ if (image && image.data !== null)
61
+ result.push({
62
+ id: `image-${result.length + 1}`,
63
+ image: { mime: image.mime, data: image.data },
64
+ origin,
65
+ })
66
+ }
67
+ }
68
+ add(action.images, 'current attachment')
69
+ for (let i = action.historyPath.length - 1; i >= 0 && result.length < 8; i--) {
70
+ const turn = action.historyPath[i]
71
+ if (turn) {
72
+ add(turn.generated, 'historical generated image')
73
+ add(turn.images, 'historical attachment')
74
+ }
75
+ }
76
+ return result
77
+ }
78
+
79
+ function paintingTool(candidates: readonly Candidate[]): ToolSpec {
80
+ const properties: Record<string, unknown> = {
81
+ prompt: { type: 'string', minLength: 1, maxLength: 4000 },
82
+ size: { type: 'string', enum: ['1024x1024', '1024x1536', '1536x1024'] },
83
+ }
84
+ if (candidates.length)
85
+ properties.image_ids = {
86
+ type: 'array',
87
+ items: { type: 'string', enum: candidates.map((item) => item.id) },
88
+ minItems: 1,
89
+ maxItems: 4,
90
+ }
91
+ return {
92
+ name: 'generate_image',
93
+ description: `Generate or edit an image. At most two calls per run.${candidates.length ? ` Images you can pass in image_ids (newest first): ${candidates.map((item) => `${item.id}: ${item.origin}`).join('; ')}` : ''}`,
94
+ parameters: { type: 'object', properties, required: ['prompt'], additionalProperties: false },
95
+ approval: 'ask',
96
+ effect: 'other',
97
+ source: { kind: 'host' },
98
+ }
99
+ }
100
+
101
+ export async function prepareTools(
102
+ options: ToolPreparationOptions,
103
+ effective: RunRequest,
104
+ runId: string,
105
+ preparationContext: WorkerContext,
106
+ signal: AbortSignal,
107
+ actor: Actor,
108
+ action: PreparedAction,
109
+ connection: Connection,
110
+ ): Promise<PreparedTools> {
111
+ const tools = structuredClone(await options.host.tools(effective, runId, preparationContext)).map(
112
+ (tool) => {
113
+ if (tool.source.kind !== 'host') throw badRequest('tools', 'host_source')
114
+ return tool
115
+ },
116
+ )
117
+ assertActive(signal)
118
+ let mcp: McpSession | null = null
119
+ if (options.mcp && (options.host.mcpTools?.(effective, preparationContext) ?? true)) {
120
+ assertActive(signal)
121
+ mcp = await options.mcp.client(preparationContext).session(signal)
122
+ }
123
+ assertActive(signal)
124
+ if (mcp)
125
+ tools.push(
126
+ ...mcp.tools().map(
127
+ (tool) =>
128
+ ({
129
+ name: tool.prefixed,
130
+ description: tool.description,
131
+ parameters: { ...tool.schema },
132
+ approval: tool.approval,
133
+ effect: 'other',
134
+ source: { kind: 'mcp', server: tool.server },
135
+ }) satisfies ToolSpec,
136
+ ),
137
+ )
138
+ let painting: Connection | null = null
139
+ if (effective.generateImage) {
140
+ if (!options.images) throw missing('images')
141
+ const reference = await options.host.paintingModel?.(effective, preparationContext)
142
+ assertActive(signal)
143
+ if (!reference)
144
+ throw new KitFailure('needs_config', 'Painting model unavailable', {
145
+ reason: 'painting_model',
146
+ })
147
+ painting = structuredClone(await options.providers.resolve(actor, reference, 'image'))
148
+ assertActive(signal)
149
+ }
150
+ const candidates = candidatesFor(action, painting?.canVision ?? false)
151
+ if (painting && connection.supportsTools !== false) tools.push(paintingTool(candidates))
152
+ let search: ResolvedSearchConfig | null = null
153
+ let nativeWebSearch = false
154
+ if (effective.webSearch) {
155
+ if (!options.search) throw missing('search')
156
+ const preferences = await options.search.preferences(actor)
157
+ assertActive(signal)
158
+ nativeWebSearch =
159
+ preferences.modelToolsPreferred &&
160
+ connection.nativeWebSearch === true &&
161
+ connection.protocol !== 'openai-chat'
162
+ if (!nativeWebSearch) {
163
+ search = {
164
+ ...structuredClone(await options.search.resolve(actor, 'search')),
165
+ preferences: structuredClone(preferences),
166
+ }
167
+ assertActive(signal)
168
+ }
169
+ }
170
+ return { tools, mcp, painting, candidates, search, nativeWebSearch }
171
+ }
172
+
173
+ export async function callTool(
174
+ handle: AcceptedHandle | null,
175
+ state: ToolRuntimeState,
176
+ call: ToolCall,
177
+ signal: AbortSignal,
178
+ options: ToolPreparationOptions & { conversations: ConversationService | null },
179
+ ): Promise<ToolExecution> {
180
+ assertActive(signal)
181
+ const tool = state.input.tools.find((item) => item.name === call.name)
182
+ if (!tool) return { kind: 'error', code: 'not_found', message: 'Tool not in this run' }
183
+ if (tool.source.kind === 'mcp') {
184
+ if (!state.mcp) return { kind: 'error', code: 'not_found', message: 'MCP tool not in this run' }
185
+ const result = await state.mcp.call(call.name, call.args, signal)
186
+ if (result.kind === 'unknown') return result
187
+ if (result.kind === 'failed')
188
+ return { kind: 'error', code: result.code, message: result.message }
189
+ const text = result.content
190
+ .map((item) =>
191
+ item.type === 'text' && typeof item.text === 'string' ? item.text : JSON.stringify(item),
192
+ )
193
+ .join('\n')
194
+ const payload = {
195
+ content: result.content,
196
+ ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}),
197
+ }
198
+ return result.isError
199
+ ? { kind: 'error', code: 'mcp_error', message: text, payload }
200
+ : { kind: 'ok', text, payload }
201
+ }
202
+ if (call.name === 'generate_image' && state.painting)
203
+ return paint(handle, state, call, signal, options)
204
+ return options.host.callTool(call, signal, { ...state.context, signal })
205
+ }
206
+
207
+ async function paint(
208
+ handle: AcceptedHandle | null,
209
+ state: ToolRuntimeState,
210
+ call: ToolCall,
211
+ signal: AbortSignal,
212
+ options: Pick<ToolPreparationOptions, 'images'> & { conversations: ConversationService | null },
213
+ ): Promise<ToolExecution> {
214
+ try {
215
+ const { prompt, size = '1024x1024', image_ids: ids } = call.args
216
+ if (
217
+ Object.keys(call.args).some((key) => !['prompt', 'size', 'image_ids'].includes(key)) ||
218
+ typeof prompt !== 'string' ||
219
+ !prompt.trim() ||
220
+ charCount(prompt) > 4000
221
+ )
222
+ throw badRequest('prompt', 'invalid')
223
+ if (size !== '1024x1024' && size !== '1024x1536' && size !== '1536x1024')
224
+ throw badRequest('size', 'invalid')
225
+ const images: ImagePart[] = []
226
+ if (ids !== undefined) {
227
+ if (!state.painting?.canVision || !state.candidates.length)
228
+ throw badRequest('image_ids', 'unsupported')
229
+ if (!Array.isArray(ids) || ids.length === 0 || ids.length > 4)
230
+ throw badRequest('image_ids', 'too_many')
231
+ for (const id of ids) {
232
+ const candidate = state.candidates.find((item) => item.id === id)
233
+ if (!candidate) throw badRequest('image_ids', 'unknown')
234
+ images.push(candidate.image)
235
+ }
236
+ }
237
+ if (state.paintingCalls >= 2) throw badRequest('generateImage', 'call_limit')
238
+ assertActive(signal)
239
+ if (!state.painting || !options.images) throw missing('images')
240
+ state.paintingCalls++
241
+ const result = await options.images.generate(
242
+ state.painting,
243
+ { prompt, size, images },
244
+ { ...state.context, signal },
245
+ )
246
+ let references
247
+ try {
248
+ references =
249
+ handle && options.conversations
250
+ ? await options.conversations.addGenerated(handle, [result.image], state.context.actor)
251
+ : [result.reference]
252
+ } catch {
253
+ return {
254
+ kind: 'error',
255
+ code: 'generated_save_failed',
256
+ message:
257
+ 'Image was generated, but its persistence could not be confirmed. Do not automatically regenerate.',
258
+ payload: {
259
+ generated: true,
260
+ persisted: false,
261
+ usage: result.usage,
262
+ totalTokens: result.totalTokens,
263
+ revisedPrompt: result.revisedPrompt,
264
+ },
265
+ }
266
+ }
267
+ return {
268
+ kind: 'ok',
269
+ text: 'Generated 1 image(s).',
270
+ payload: {
271
+ images: references,
272
+ usage: result.usage,
273
+ totalTokens: result.totalTokens,
274
+ revisedPrompt: result.revisedPrompt,
275
+ },
276
+ }
277
+ } catch (error) {
278
+ if (error instanceof ImageFailure && error.dispatch === 'unknown')
279
+ return {
280
+ kind: 'unknown',
281
+ reason:
282
+ 'Image generation was dispatched; its result could not be confirmed. Do not automatically retry.',
283
+ }
284
+ if (error instanceof ImageFailure && error.dispatch === 'generated')
285
+ return {
286
+ kind: 'error',
287
+ code: 'generated_save_failed',
288
+ message: 'Image was generated, but its persistence could not be confirmed.',
289
+ payload: { generated: true, persisted: false },
290
+ }
291
+ const failure = safeError(error)
292
+ return {
293
+ kind: 'error',
294
+ code: failure.code,
295
+ message: error instanceof KitFailure ? error.message : 'Image operation failed',
296
+ }
297
+ }
298
+ }