@onkeiki/agents 0.1.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 (111) hide show
  1. package/dist/Alert.d.ts +56 -0
  2. package/dist/Alert.d.ts.map +1 -0
  3. package/dist/Alert.js +8 -0
  4. package/dist/Alert.js.map +1 -0
  5. package/dist/Entity.d.ts +158 -0
  6. package/dist/Entity.d.ts.map +1 -0
  7. package/dist/Entity.js +289 -0
  8. package/dist/Entity.js.map +1 -0
  9. package/dist/Kit.d.ts +138 -0
  10. package/dist/Kit.d.ts.map +1 -0
  11. package/dist/Kit.js +517 -0
  12. package/dist/Kit.js.map +1 -0
  13. package/dist/OpenApi.d.ts +151 -0
  14. package/dist/OpenApi.d.ts.map +1 -0
  15. package/dist/OpenApi.js +222 -0
  16. package/dist/OpenApi.js.map +1 -0
  17. package/dist/Plugin.d.ts +183 -0
  18. package/dist/Plugin.d.ts.map +1 -0
  19. package/dist/Plugin.js +10 -0
  20. package/dist/Plugin.js.map +1 -0
  21. package/dist/Server.d.ts +71 -0
  22. package/dist/Server.d.ts.map +1 -0
  23. package/dist/Server.js +476 -0
  24. package/dist/Server.js.map +1 -0
  25. package/dist/Tool.d.ts +86 -0
  26. package/dist/Tool.d.ts.map +1 -0
  27. package/dist/Tool.js +27 -0
  28. package/dist/Tool.js.map +1 -0
  29. package/dist/bundle.d.ts +17 -0
  30. package/dist/bundle.d.ts.map +1 -0
  31. package/dist/bundle.js +101 -0
  32. package/dist/bundle.js.map +1 -0
  33. package/dist/index.d.ts +19 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +18 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/services/Blackboard.d.ts +18 -0
  38. package/dist/services/Blackboard.d.ts.map +1 -0
  39. package/dist/services/Blackboard.js +4 -0
  40. package/dist/services/Blackboard.js.map +1 -0
  41. package/dist/services/Observability.d.ts +102 -0
  42. package/dist/services/Observability.d.ts.map +1 -0
  43. package/dist/services/Observability.js +238 -0
  44. package/dist/services/Observability.js.map +1 -0
  45. package/dist/services/Platform.d.ts +112 -0
  46. package/dist/services/Platform.d.ts.map +1 -0
  47. package/dist/services/Platform.js +13 -0
  48. package/dist/services/Platform.js.map +1 -0
  49. package/dist/services/PluginDb.d.ts +44 -0
  50. package/dist/services/PluginDb.d.ts.map +1 -0
  51. package/dist/services/PluginDb.js +5 -0
  52. package/dist/services/PluginDb.js.map +1 -0
  53. package/dist/services/Sandbox.d.ts +68 -0
  54. package/dist/services/Sandbox.d.ts.map +1 -0
  55. package/dist/services/Sandbox.js +4 -0
  56. package/dist/services/Sandbox.js.map +1 -0
  57. package/dist/services/Steering.d.ts +46 -0
  58. package/dist/services/Steering.d.ts.map +1 -0
  59. package/dist/services/Steering.js +4 -0
  60. package/dist/services/Steering.js.map +1 -0
  61. package/dist/services/Storage.d.ts +63 -0
  62. package/dist/services/Storage.d.ts.map +1 -0
  63. package/dist/services/Storage.js +13 -0
  64. package/dist/services/Storage.js.map +1 -0
  65. package/dist/services/Transport.d.ts +19 -0
  66. package/dist/services/Transport.d.ts.map +1 -0
  67. package/dist/services/Transport.js +4 -0
  68. package/dist/services/Transport.js.map +1 -0
  69. package/dist/services/index.d.ts +9 -0
  70. package/dist/services/index.d.ts.map +1 -0
  71. package/dist/services/index.js +9 -0
  72. package/dist/services/index.js.map +1 -0
  73. package/dist/types/config.d.ts +526 -0
  74. package/dist/types/config.d.ts.map +1 -0
  75. package/dist/types/config.js +2 -0
  76. package/dist/types/config.js.map +1 -0
  77. package/dist/types/index.d.ts +4 -0
  78. package/dist/types/index.d.ts.map +1 -0
  79. package/dist/types/index.js +4 -0
  80. package/dist/types/index.js.map +1 -0
  81. package/dist/types/message.d.ts +54 -0
  82. package/dist/types/message.d.ts.map +1 -0
  83. package/dist/types/message.js +47 -0
  84. package/dist/types/message.js.map +1 -0
  85. package/dist/types/user.d.ts +16 -0
  86. package/dist/types/user.d.ts.map +1 -0
  87. package/dist/types/user.js +14 -0
  88. package/dist/types/user.js.map +1 -0
  89. package/package.json +39 -0
  90. package/src/Alert.ts +51 -0
  91. package/src/Entity.ts +543 -0
  92. package/src/Kit.ts +705 -0
  93. package/src/OpenApi.ts +343 -0
  94. package/src/Plugin.ts +209 -0
  95. package/src/Server.ts +618 -0
  96. package/src/Tool.ts +110 -0
  97. package/src/bundle.ts +114 -0
  98. package/src/index.ts +209 -0
  99. package/src/services/Blackboard.ts +19 -0
  100. package/src/services/Observability.ts +320 -0
  101. package/src/services/Platform.ts +125 -0
  102. package/src/services/PluginDb.ts +48 -0
  103. package/src/services/Sandbox.ts +74 -0
  104. package/src/services/Steering.ts +45 -0
  105. package/src/services/Storage.ts +76 -0
  106. package/src/services/Transport.ts +21 -0
  107. package/src/services/index.ts +8 -0
  108. package/src/types/config.ts +531 -0
  109. package/src/types/index.ts +3 -0
  110. package/src/types/message.ts +60 -0
  111. package/src/types/user.ts +17 -0
package/src/Server.ts ADDED
@@ -0,0 +1,618 @@
1
+ import { Effect, Schema, JSONSchema } from 'effect'
2
+ import type { AgentKitConfig } from './types/config.js'
3
+ import type { InboundMessage, DeliveryStatus } from './types/index.js'
4
+ import type { ToolDefinition } from './Tool.js'
5
+ import type { AlertStateService, AlertContext, AlertMedia, AlertDefinition } from './Alert.js'
6
+ import type { UserStateService, SecretsService } from './Tool.js'
7
+ import { mergedAlerts, type ResolvedPlugins } from './Kit.js'
8
+ import type { TurnEvent, TurnResult, RouteContext } from './Plugin.js'
9
+ import type { PluginDbService } from './services/PluginDb.js'
10
+ import type { SandboxHandle, SandboxExecResult } from './services/Sandbox.js'
11
+ import { StorageError } from './services/Storage.js'
12
+
13
+ /**
14
+ * HTTP server that receives turn requests from the Orchid platform.
15
+ *
16
+ * Routes:
17
+ * - POST /turn — Platform sends inbound messages for processing
18
+ * - POST /delivery — Platform proxies delivery status webhooks
19
+ * - POST /events/:type — Platform proxies event webhooks
20
+ * - GET /health — Health check
21
+ * - ...plugin routes — Contributed by plugins
22
+ */
23
+
24
+ export type TurnRequest = {
25
+ readonly message: InboundMessage
26
+ readonly context: {
27
+ readonly memories: ReadonlyArray<{ content: string; category: string }>
28
+ readonly history: ReadonlyArray<{ content: string; direction: 'inbound' | 'outbound' }>
29
+ }
30
+ }
31
+
32
+ export type TurnResponse = {
33
+ readonly reply: string
34
+ readonly media?: { url: string; mimeType: string }
35
+ readonly toolCalls?: ReadonlyArray<{ name: string; input: unknown; output: unknown }>
36
+ readonly shouldLearn?: boolean
37
+ }
38
+
39
+ /** One outbound message produced by an alert run, as returned to the platform. */
40
+ export type AlertDelivery = {
41
+ readonly phone: string
42
+ readonly text: string
43
+ readonly media?: AlertMedia
44
+ /** Recipient-scoped claim key: the platform skips a delivery whose key was already claimed. */
45
+ readonly dedupKey?: string
46
+ }
47
+
48
+ export function createServer(config: AgentKitConfig, resolved: ResolvedPlugins) {
49
+ const routeCtx: RouteContext = { config, request: null as any }
50
+
51
+ return {
52
+ fetch: async (req: Request): Promise<Response> => {
53
+ // Run plugin onRequest interceptors
54
+ for (const onReq of resolved.onRequest) {
55
+ const result = await Effect.runPromise(onReq(req).pipe(Effect.orDie))
56
+ if (result instanceof Response) return result
57
+ if (result instanceof Request) req = result
58
+ }
59
+
60
+ // Run plugin middleware
61
+ for (const mw of resolved.middleware) {
62
+ const url = new URL(req.url)
63
+ if (url.pathname.startsWith(mw.path)) {
64
+ const mwResult = await Effect.runPromise(
65
+ mw.handler(req, { ...routeCtx, request: req }).pipe(Effect.orDie)
66
+ )
67
+ if (mwResult instanceof Response) return mwResult
68
+ }
69
+ }
70
+
71
+ const url = new URL(req.url)
72
+
73
+ // Check plugin routes first
74
+ const pluginRoute = resolved.routes[url.pathname]
75
+ if (pluginRoute) {
76
+ const response = await pluginRoute(req, { ...routeCtx, request: req })
77
+ return applyOnResponse(response, resolved)
78
+ }
79
+
80
+ let response: Response
81
+ switch (url.pathname) {
82
+ case '/health':
83
+ response = new Response(JSON.stringify({ status: 'ok', version: '0.1.0' }), {
84
+ headers: { 'Content-Type': 'application/json' },
85
+ })
86
+ break
87
+
88
+ case '/turn':
89
+ response = await handleTurn(req, config, resolved)
90
+ break
91
+
92
+ case '/alerts/execute':
93
+ response = await handleAlertExecute(req, config, resolved)
94
+ break
95
+
96
+ case '/delivery':
97
+ response = await handleDelivery(req, config)
98
+ break
99
+
100
+ default:
101
+ if (url.pathname.startsWith('/events/')) {
102
+ response = await handleEvent(req, config, url.pathname.slice(8))
103
+ } else {
104
+ response = new Response('Not found', { status: 404 })
105
+ }
106
+ }
107
+
108
+ return applyOnResponse(response, resolved)
109
+ },
110
+ }
111
+ }
112
+
113
+ async function applyOnResponse(res: Response, resolved: ResolvedPlugins): Promise<Response> {
114
+ for (const onRes of resolved.onResponse) {
115
+ res = await Effect.runPromise(onRes(res).pipe(Effect.orDie))
116
+ }
117
+ return res
118
+ }
119
+
120
+ async function handleTurn(req: Request, config: AgentKitConfig, resolved: ResolvedPlugins): Promise<Response> {
121
+ const authHeader = req.headers.get('Authorization')
122
+ const isPlatformForward = req.headers.get('X-Orchid-Platform') === 'true'
123
+ if (!isPlatformForward && (!authHeader?.startsWith('Bearer ') || authHeader.slice(7) !== config.apiKey)) {
124
+ return new Response('Unauthorized', { status: 401 })
125
+ }
126
+
127
+ const body = (await req.json()) as TurnRequest
128
+
129
+ // Run beforeTurn hooks
130
+ let turnEvent: TurnEvent = {
131
+ phone: body.message.phone,
132
+ messages: [
133
+ ...body.context.history.map((h) => ({ content: h.content, direction: h.direction })),
134
+ { content: body.message.text, direction: 'inbound' as const },
135
+ ],
136
+ context: {},
137
+ }
138
+ for (const hook of resolved.hooks.beforeTurn) {
139
+ turnEvent = await Effect.runPromise(hook(turnEvent).pipe(Effect.orDie))
140
+ }
141
+
142
+ const startTime = Date.now()
143
+ const result = await Effect.runPromise(runAgentTurn(config, body, resolved))
144
+
145
+ // Run afterTurn hooks
146
+ const turnResult: TurnResult = {
147
+ phone: body.message.phone,
148
+ response: result.reply,
149
+ toolCalls: result.toolCalls ?? [],
150
+ tokensUsed: { in: (result as any).tokensIn ?? 0, out: (result as any).tokensOut ?? 0 },
151
+ durationMs: Date.now() - startTime,
152
+ }
153
+ for (const hook of resolved.hooks.afterTurn) {
154
+ await Effect.runPromise(hook(turnResult).pipe(Effect.orDie))
155
+ }
156
+
157
+ return new Response(JSON.stringify(result), {
158
+ headers: { 'Content-Type': 'application/json' },
159
+ })
160
+ }
161
+
162
+ async function handleAlertExecute(
163
+ req: Request,
164
+ config: AgentKitConfig,
165
+ resolved: ResolvedPlugins
166
+ ): Promise<Response> {
167
+ const isPlatformForward = req.headers.get('X-Orchid-Platform') === 'true'
168
+ if (!isPlatformForward) return new Response('Unauthorized', { status: 401 })
169
+
170
+ const body = await req.json() as AlertExecuteBody
171
+ const alert = mergedAlerts(config, resolved).find((a) => a.name === body.alertName)
172
+ if (!alert) return new Response(`Alert not found: ${body.alertName}`, { status: 404 })
173
+
174
+ const pluginDb = config.storage && 'pluginDb' in config.storage ? config.storage.pluginDb : undefined
175
+ const result = await executeAlert(alert, body, pluginDb)
176
+
177
+ return new Response(JSON.stringify(result), {
178
+ headers: { 'Content-Type': 'application/json' },
179
+ })
180
+ }
181
+
182
+ export type AlertExecuteBody = {
183
+ alertName: string
184
+ subscribers: Array<{ phone: string; preferences?: Record<string, unknown> }>
185
+ secrets?: Record<string, string>
186
+ }
187
+
188
+ /**
189
+ * Run one alert against its subscribers and flatten the results into ordered
190
+ * {@link AlertDelivery} entries. Shared by the built-in `/alerts/execute`
191
+ * route and the alerts plugin, so the two paths cannot diverge.
192
+ */
193
+ export async function executeAlert(
194
+ alert: AlertDefinition,
195
+ body: AlertExecuteBody,
196
+ pluginDb?: PluginDbService
197
+ ): Promise<{ sent: number; skipped: number; deliveries: AlertDelivery[] }> {
198
+ let sent = 0
199
+ let skipped = 0
200
+ const deliveries: AlertDelivery[] = []
201
+
202
+ for (const subscriber of body.subscribers ?? []) {
203
+ const ctx: AlertContext = {
204
+ subscriber: {
205
+ phone: subscriber.phone,
206
+ chatId: `sdk-alert:${subscriber.phone}`,
207
+ preferences: subscriber.preferences ?? {},
208
+ },
209
+ now: new Date(),
210
+ secrets: body.secrets ?? {},
211
+ state: makeAlertState(pluginDb, body.alertName, subscriber.phone),
212
+ }
213
+
214
+ const ok = alert.shouldSend ? await Effect.runPromise(alert.shouldSend(ctx)) : true
215
+ if (!ok) {
216
+ skipped++
217
+ continue
218
+ }
219
+
220
+ const content = await Effect.runPromise(alert.generate(ctx))
221
+ if (!content || (!content.text && !content.media && !(content.messages?.length))) {
222
+ skipped++
223
+ continue
224
+ }
225
+
226
+ sent++
227
+ const subscriberDeliveries: AlertDelivery[] = []
228
+ if (content.text || content.media) {
229
+ subscriberDeliveries.push({ phone: subscriber.phone, text: content.text, media: content.media })
230
+ }
231
+ for (const extra of content.messages ?? []) {
232
+ if (!extra.text && !(extra.media?.length)) continue
233
+ subscriberDeliveries.push({ phone: subscriber.phone, text: extra.text, media: extra.media?.[0] })
234
+ for (const m of extra.media?.slice(1) ?? []) {
235
+ subscriberDeliveries.push({ phone: subscriber.phone, text: '', media: m })
236
+ }
237
+ }
238
+ // The claim key rides on the first delivery that will actually be sent,
239
+ // so a content shape with an empty primary message still gets claimed.
240
+ const dedupKey = alert.dedupKey?.(ctx)
241
+ if (dedupKey && subscriberDeliveries[0]) {
242
+ subscriberDeliveries[0] = { ...subscriberDeliveries[0], dedupKey }
243
+ }
244
+ deliveries.push(...subscriberDeliveries)
245
+ }
246
+
247
+ return { sent, skipped, deliveries }
248
+ }
249
+
250
+ async function handleDelivery(req: Request, config: AgentKitConfig): Promise<Response> {
251
+ const body = (await req.json()) as DeliveryStatus
252
+ if (config.events?.onDeliveryStatus) {
253
+ await Effect.runPromise(config.events.onDeliveryStatus(body))
254
+ }
255
+ return new Response('ok')
256
+ }
257
+
258
+ async function handleEvent(req: Request, config: AgentKitConfig, eventType: string): Promise<Response> {
259
+ const body = await req.json()
260
+
261
+ switch (eventType) {
262
+ case 'new-user':
263
+ if (config.events?.onNewUser) {
264
+ await Effect.runPromise(config.events.onNewUser(body))
265
+ }
266
+ break
267
+ case 'opt-out':
268
+ if (config.events?.onOptOut) {
269
+ await Effect.runPromise(config.events.onOptOut(body))
270
+ }
271
+ break
272
+ }
273
+
274
+ return new Response('ok')
275
+ }
276
+
277
+ const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? ''
278
+ const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
279
+
280
+ /** Most reply-review rejections ({@link TurnHooks.reviewReply}) honored per turn. */
281
+ const MAX_REPLY_REVIEW_RETRIES = 2
282
+
283
+ type LLMMessage = {
284
+ role: string
285
+ content?: string | null
286
+ tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>
287
+ tool_call_id?: string
288
+ }
289
+
290
+ function runAgentTurn(config: AgentKitConfig, turn: TurnRequest, resolved: ResolvedPlugins): Effect.Effect<TurnResponse> {
291
+ return Effect.tryPromise({
292
+ try: async () => {
293
+ const promptCtx = {
294
+ user: { phone: turn.message.phone, timezone: null, currency: null, metadata: {}, firstSeenAt: new Date() },
295
+ now: new Date(),
296
+ phone: turn.message.phone,
297
+ context: {
298
+ memorySummary: turn.context.memories.map((m) => `[${m.category}] ${m.content}`).join('\n') || null,
299
+ relevantFacts: turn.context.memories.map((m) => ({ fact: m.content, category: m.category })),
300
+ },
301
+ isFirstInteraction: turn.context.history.length === 0,
302
+ history: turn.context.history,
303
+ }
304
+ const hooks = config.agent.hooks
305
+ const finishReply = (reply: string) =>
306
+ hooks?.transformReply ? hooks.transformReply(reply, promptCtx) : reply
307
+
308
+ const systemPrompt = config.agent.systemPrompt(promptCtx)
309
+
310
+ // Merge agent tools + plugin tools
311
+ const allTools = [...config.agent.tools, ...resolved.tools]
312
+
313
+ // Convert SDK tools to OpenAI format
314
+ const openaiTools = allTools.map((t) => ({
315
+ type: 'function' as const,
316
+ function: {
317
+ name: t.name,
318
+ description: t.description,
319
+ parameters: JSONSchema.make(t.input),
320
+ },
321
+ }))
322
+
323
+ // Build LLM messages
324
+ const llmMessages: LLMMessage[] = [
325
+ { role: 'system', content: systemPrompt },
326
+ ...turn.context.history.map((h) => ({
327
+ role: h.direction === 'inbound' ? 'user' : 'assistant',
328
+ content: h.content,
329
+ })),
330
+ { role: 'user', content: turn.message.text },
331
+ ]
332
+
333
+ const model = config.agent.model.startsWith('openrouter/')
334
+ ? config.agent.model.slice('openrouter/'.length)
335
+ : config.agent.model
336
+
337
+ // Agentic loop
338
+ let totalTokensIn = 0
339
+ let totalTokensOut = 0
340
+ let lastToolName: string | undefined
341
+ let lastToolError: string | undefined
342
+ let lastToolInput: string | undefined
343
+ let identicalFailureStreak = 0
344
+ let stoppedByHook = false
345
+ let reviewAttempts = 0
346
+ const rejectedReplies = new Set<string>()
347
+ const toolCallResults: Array<{ name: string; input: unknown; output: unknown }> = []
348
+
349
+ for (let step = 0; step < config.agent.maxSteps; step++) {
350
+ const hookState = { step, toolCalls: toolCallResults.length, lastToolName, lastToolError, lastToolInput, identicalFailureStreak }
351
+ const prepared = hooks?.prepareStep?.(hookState, promptCtx)
352
+ const stepMessages = prepared?.appendSystem
353
+ ? [...llmMessages, { role: 'system', content: prepared.appendSystem }]
354
+ : llmMessages
355
+ const body: Record<string, unknown> = {
356
+ model,
357
+ messages: stepMessages,
358
+ reasoning: { effort: 'low', exclude: true },
359
+ }
360
+ if (openaiTools.length > 0) body.tools = openaiTools
361
+
362
+ const res = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
363
+ method: 'POST',
364
+ headers: {
365
+ 'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
366
+ 'Content-Type': 'application/json',
367
+ 'HTTP-Referer': 'https://onkeiki.com',
368
+ 'X-Title': 'Keiki SDK',
369
+ },
370
+ body: JSON.stringify(body),
371
+ })
372
+
373
+ if (!res.ok) throw new Error(`OpenRouter ${res.status}: ${await res.text()}`)
374
+
375
+ const data = await res.json() as {
376
+ choices: Array<{ message: { content?: string | null; tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> } }>
377
+ usage?: { prompt_tokens: number; completion_tokens: number }
378
+ }
379
+
380
+ totalTokensIn += data.usage?.prompt_tokens ?? 0
381
+ totalTokensOut += data.usage?.completion_tokens ?? 0
382
+
383
+ const msg = data.choices?.[0]?.message
384
+ if (!msg) throw new Error('No response from LLM')
385
+
386
+ // No tool calls — final response. Review gates the delivered
387
+ // (post-transformReply) text and fails closed: a rejected candidate is
388
+ // never delivered, even when retries run out.
389
+ if (!msg.tool_calls || msg.tool_calls.length === 0) {
390
+ const candidate = finishReply(msg.content ?? '')
391
+ const review = hooks?.reviewReply?.(candidate, promptCtx, { toolResults: toolCallResults, attempt: reviewAttempts })
392
+ if (review?.retryWith) {
393
+ rejectedReplies.add(msg.content ?? '')
394
+ if (reviewAttempts < MAX_REPLY_REVIEW_RETRIES) {
395
+ reviewAttempts++
396
+ llmMessages.push({ role: 'assistant', content: msg.content ?? '' })
397
+ llmMessages.push({ role: 'system', content: review.retryWith })
398
+ continue
399
+ }
400
+ return {
401
+ reply: '',
402
+ toolCalls: toolCallResults.length > 0 ? toolCallResults : undefined,
403
+ shouldLearn: config.agent.memory?.enabled ?? false,
404
+ tokensIn: totalTokensIn,
405
+ tokensOut: totalTokensOut,
406
+ } as TurnResponse
407
+ }
408
+ return {
409
+ reply: candidate,
410
+ toolCalls: toolCallResults.length > 0 ? toolCallResults : undefined,
411
+ shouldLearn: config.agent.memory?.enabled ?? false,
412
+ tokensIn: totalTokensIn,
413
+ tokensOut: totalTokensOut,
414
+ } as TurnResponse
415
+ }
416
+
417
+ // Add assistant message with tool_calls
418
+ llmMessages.push({
419
+ role: 'assistant',
420
+ content: msg.content || null,
421
+ tool_calls: msg.tool_calls,
422
+ })
423
+
424
+ // Execute each tool call via Effect
425
+ for (const toolCall of msg.tool_calls) {
426
+ const toolDef = allTools.find((t) => t.name === toolCall.function.name)
427
+ if (!toolDef) {
428
+ llmMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: `Error: Unknown tool ${toolCall.function.name}` })
429
+ continue
430
+ }
431
+
432
+ // Run beforeToolCall hooks
433
+ let toolCallEvent = { phone: turn.message.phone, toolName: toolCall.function.name, input: null as unknown }
434
+ for (const hook of resolved.hooks.beforeToolCall) {
435
+ toolCallEvent = await Effect.runPromise(hook(toolCallEvent).pipe(Effect.orDie))
436
+ }
437
+
438
+ const toolStart = Date.now()
439
+ try {
440
+ const args = JSON.parse(toolCall.function.arguments)
441
+ toolCallEvent.input = args
442
+ const decoded = Schema.decodeUnknownSync(toolDef.input)(args)
443
+ const result = await Effect.runPromise(
444
+ (toolDef.execute as (input: any, ctx: any) => Effect.Effect<any>)(decoded, {
445
+ phone: turn.message.phone,
446
+ user: promptCtx.user,
447
+ blackboard: {} as any,
448
+ platform: {} as any,
449
+ userState: makeNoopUserState(),
450
+ secrets: makeNoopSecrets(),
451
+ db: (config.storage && 'pluginDb' in config.storage ? config.storage.pluginDb : undefined) ?? makeNoopPluginDb(),
452
+ sandbox: makeNoopSandbox(),
453
+ })
454
+ )
455
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result)
456
+ toolCallResults.push({ name: toolCall.function.name, input: args, output: result })
457
+ lastToolName = toolCall.function.name
458
+ lastToolError = undefined
459
+ lastToolInput = toolCall.function.arguments
460
+ identicalFailureStreak = 0
461
+ llmMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: resultStr })
462
+
463
+ // Run afterToolCall hooks
464
+ for (const hook of resolved.hooks.afterToolCall) {
465
+ await Effect.runPromise(hook({
466
+ phone: turn.message.phone,
467
+ toolName: toolCall.function.name,
468
+ input: args,
469
+ output: result,
470
+ durationMs: Date.now() - toolStart,
471
+ }).pipe(Effect.orDie))
472
+ }
473
+ } catch (err) {
474
+ const errStr = `Error: ${err instanceof Error ? err.message : String(err)}`
475
+ llmMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: errStr })
476
+ const sameFailure =
477
+ lastToolName === toolCall.function.name &&
478
+ lastToolInput === toolCall.function.arguments &&
479
+ lastToolError === errStr
480
+ identicalFailureStreak = sameFailure ? identicalFailureStreak + 1 : 1
481
+ lastToolName = toolCall.function.name
482
+ lastToolError = errStr
483
+ lastToolInput = toolCall.function.arguments
484
+
485
+ // Run afterToolCall hooks with error
486
+ for (const hook of resolved.hooks.afterToolCall) {
487
+ await Effect.runPromise(hook({
488
+ phone: turn.message.phone,
489
+ toolName: toolCall.function.name,
490
+ input: toolCallEvent.input,
491
+ output: null,
492
+ durationMs: Date.now() - toolStart,
493
+ error: errStr,
494
+ }).pipe(Effect.orDie))
495
+ }
496
+ }
497
+ }
498
+
499
+ // Developer stop condition: end the turn with the last assistant text.
500
+ if (hooks?.stopWhen?.({ step, toolCalls: toolCallResults.length, lastToolName, lastToolError, lastToolInput, identicalFailureStreak }, promptCtx)) {
501
+ stoppedByHook = true
502
+ break
503
+ }
504
+ }
505
+
506
+ // Loop ended without a tool-free reply: use the last assistant text the
507
+ // model produced. Only real step exhaustion gets the apology — a
508
+ // developer stop condition is intentional and must not read as a failure.
509
+ const lastAssistantText = llmMessages
510
+ .filter((m) => m.role === 'assistant' && typeof m.content === 'string' && m.content && !rejectedReplies.has(m.content))
511
+ .pop()?.content as string | undefined
512
+ return {
513
+ reply: finishReply(lastAssistantText ?? (stoppedByHook ? '' : 'I apologize, I ran out of steps processing your request.')),
514
+ toolCalls: toolCallResults.length > 0 ? toolCallResults : undefined,
515
+ shouldLearn: config.agent.memory?.enabled ?? false,
516
+ tokensIn: totalTokensIn,
517
+ tokensOut: totalTokensOut,
518
+ } as TurnResponse
519
+ },
520
+ catch: (e) => e as Error,
521
+ }).pipe(Effect.orDie)
522
+ }
523
+
524
+ /** Whether the durable alert-state table has been ensured this process. */
525
+ let alertStateTableEnsured = false
526
+
527
+ /**
528
+ * Durable per-(alert, subscriber) KV state, persisted through the configured
529
+ * PluginDb. Falls back to per-run in-memory state when no PluginDb is
530
+ * configured, so alerts still run (just without cross-run durability).
531
+ */
532
+ function makeAlertState(pluginDb: PluginDbService | undefined, alertName: string, phone: string): AlertStateService {
533
+ if (!pluginDb) {
534
+ const mem = new Map<string, unknown>()
535
+ return {
536
+ get: (key) => Effect.succeed(mem.get(key) ?? null),
537
+ set: (key, value) => Effect.sync(() => { mem.set(key, value) }),
538
+ }
539
+ }
540
+
541
+ const ensureTable = Effect.suspend(() => {
542
+ if (alertStateTableEnsured) return Effect.void
543
+ return pluginDb.execute(
544
+ `CREATE TABLE IF NOT EXISTS smskit_alert_state (
545
+ alert_name TEXT NOT NULL,
546
+ phone TEXT NOT NULL,
547
+ key TEXT NOT NULL,
548
+ value JSONB,
549
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
550
+ PRIMARY KEY (alert_name, phone, key)
551
+ )`
552
+ ).pipe(
553
+ Effect.tap(() => Effect.sync(() => { alertStateTableEnsured = true })),
554
+ // Concurrent CREATE TABLE IF NOT EXISTS can race itself in Postgres
555
+ // (duplicate-relation error); the table exists either way, so swallow
556
+ // the DDL failure and let the actual read/write surface real errors.
557
+ Effect.catchAll(() => Effect.void)
558
+ )
559
+ })
560
+
561
+ return {
562
+ get: (key) =>
563
+ ensureTable.pipe(
564
+ Effect.andThen(pluginDb.queryOne<{ value: unknown }>(
565
+ `SELECT value FROM smskit_alert_state WHERE alert_name = $1 AND phone = $2 AND key = $3`,
566
+ [alertName, phone, key]
567
+ )),
568
+ Effect.map((row) => row?.value ?? null),
569
+ Effect.orDie
570
+ ),
571
+ set: (key, value) =>
572
+ ensureTable.pipe(
573
+ Effect.andThen(pluginDb.execute(
574
+ `INSERT INTO smskit_alert_state (alert_name, phone, key, value, updated_at)
575
+ VALUES ($1, $2, $3, $4, now())
576
+ ON CONFLICT (alert_name, phone, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
577
+ [alertName, phone, key, JSON.stringify(value ?? null)]
578
+ )),
579
+ Effect.orDie
580
+ ),
581
+ }
582
+ }
583
+
584
+ function makeNoopUserState(): UserStateService {
585
+ const store = new Map<string, unknown>()
586
+ return {
587
+ get: (key) => Effect.succeed(store.get(key) ?? null),
588
+ set: (key, value) => Effect.sync(() => { store.set(key, value) }),
589
+ delete: (key) => Effect.succeed(store.delete(key)),
590
+ getAll: () => Effect.succeed(Object.fromEntries(store)),
591
+ setMany: (entries) => Effect.sync(() => { for (const [k, v] of Object.entries(entries)) store.set(k, v) }),
592
+ }
593
+ }
594
+
595
+ function makeNoopSecrets(): SecretsService {
596
+ return {
597
+ get: () => Effect.succeed(null),
598
+ getAll: () => Effect.succeed({}),
599
+ }
600
+ }
601
+
602
+ function makeNoopPluginDb(): PluginDbService {
603
+ const err = new StorageError('PluginDb not configured. Use PostgresStorage() instead of PostgresStorageLive().')
604
+ return {
605
+ query: () => Effect.fail(err),
606
+ queryOne: () => Effect.fail(err),
607
+ execute: () => Effect.fail(err),
608
+ }
609
+ }
610
+
611
+ function makeNoopSandbox(): SandboxHandle {
612
+ const err = () => { throw new Error('SandboxProvider not configured. Enable the Sandbox capability via the sandbox() plugin.') }
613
+ return {
614
+ exec: err as () => Promise<SandboxExecResult>,
615
+ getPublicUrl: err as () => Promise<string>,
616
+ getHomeDir: err as () => Promise<string>,
617
+ }
618
+ }