@ixo/oracle-runtime-workers 0.4.1 → 0.10.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 (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +2 -2
  3. package/src/attachments/download.ts +43 -22
  4. package/src/attachments/index.ts +1 -0
  5. package/src/core/env.ts +28 -0
  6. package/src/core/main-agent.ts +56 -4
  7. package/src/core/middlewares/dangling-tool-calls.ts +99 -0
  8. package/src/core/middlewares/index.ts +5 -0
  9. package/src/core/runtime-context.ts +14 -5
  10. package/src/core/subagent-as-tool.ts +12 -1
  11. package/src/do/ambient.ts +13 -4
  12. package/src/do/boot-dirty.ts +38 -0
  13. package/src/do/contracts.ts +113 -18
  14. package/src/do/matrix-turn-ledger.ts +126 -0
  15. package/src/do/reauth-prompt.ts +76 -0
  16. package/src/do/room-mirror.ts +111 -0
  17. package/src/do/sse-stream.ts +104 -2
  18. package/src/do/transcript.ts +15 -2
  19. package/src/do/turn-config.ts +24 -0
  20. package/src/do/user-oracle-do.ts +484 -91
  21. package/src/matrix/gateway-do.ts +233 -42
  22. package/src/matrix/inbox-store.ts +188 -0
  23. package/src/matrix/txn-id.ts +35 -0
  24. package/src/matrix/work-status.ts +15 -3
  25. package/src/owner-store/ixo-vfs-store.ts +12 -5
  26. package/src/owner-store/matrix-media-store.ts +65 -40
  27. package/src/owner-store/measure.ts +39 -0
  28. package/src/owner-store/migrating-store.ts +29 -34
  29. package/src/owner-store/types.ts +0 -0
  30. package/src/plugin-api/types.ts +25 -1
  31. package/src/plugins/portal/action-log.ts +36 -9
  32. package/src/plugins/tasks/tasks-tools.ts +7 -7
  33. package/src/shell/app.ts +92 -7
  34. package/src/shell/turn-body-cap.ts +19 -0
  35. package/src/sqlite/cache-config.ts +27 -6
  36. package/src/sqlite/chunk-layout.ts +14 -0
  37. package/src/sqlite/database.ts +188 -19
  38. package/src/sqlite/do-vfs.ts +963 -99
  39. package/src/sqlite/index.ts +13 -0
  40. package/src/sqlite/page-tier.ts +668 -0
  41. package/src/tasks/schedule-input.ts +102 -0
  42. package/src/tasks/scheduler.ts +351 -54
  43. package/src/tasks/store.ts +198 -3
@@ -15,6 +15,7 @@ import {
15
15
  redactOperatorFault,
16
16
  } from '../llm/provider-error';
17
17
  import type { AIMessageChunk, ToolMessage } from '@langchain/core/messages';
18
+ import { isToolMessage } from '@langchain/core/messages';
18
19
 
19
20
  export interface SseTurnRunnerInput {
20
21
  /** `agent.streamEvents(stateInput, config)` async iterable. */
@@ -75,9 +76,30 @@ interface ToolCallPayload {
75
76
  requestId: string;
76
77
  toolName: string;
77
78
  args: Record<string, unknown>;
78
- status: 'isRunning' | 'done';
79
+ status: 'isRunning' | 'done' | 'error';
79
80
  output?: string;
80
81
  eventId?: string;
82
+ /** Set with `status: 'error'`: what the tool reported (schema rejection, retries exhausted). */
83
+ error?: string;
84
+ }
85
+
86
+ /**
87
+ * Model calls the agent makes for itself — the summarization middleware
88
+ * condensing the thread, a sub-agent's inner turn — stream through the same
89
+ * `streamEvents` pipe as the user-facing reply. Only the outermost agent's
90
+ * model output belongs on the wire; everything else is bookkeeping the user
91
+ * never asked to read (the "Here is a summary of the conversation to date"
92
+ * leak). The summarizer tags its invoke with `lc_source: 'summarization'`;
93
+ * sub-agents run under their own run name below the main agent.
94
+ */
95
+ export function isInternalModelEvent(evt: {
96
+ metadata?: Record<string, unknown>;
97
+ tags?: string[];
98
+ }): boolean {
99
+ const source = evt.metadata?.['lc_source'];
100
+ if (source === 'summarization') return true;
101
+ const tags = evt.tags ?? [];
102
+ return tags.some((t) => t === 'summarization' || t === 'internal');
81
103
  }
82
104
 
83
105
  interface ActionCallPayload {
@@ -230,6 +252,32 @@ export function createSseTurnStream(
230
252
  toolCallMap.clear();
231
253
  };
232
254
 
255
+ // Tool calls the main agent's model produced, by call id, until their
256
+ // tool run starts. A call whose arguments fail the tool's schema never
257
+ // starts (LangChain validates before the tool's start callback), so
258
+ // `on_tool_start` / `on_tool_end` never fire for it; the error tool
259
+ // message the tools node returns is the only trace. Anything still
260
+ // recorded when that node ends is reported from that message.
261
+ const pendingCalls = new Map<
262
+ string,
263
+ { toolName: string; args: Record<string, unknown> }
264
+ >();
265
+ const sameArgs = (a: unknown, b: unknown) => {
266
+ try {
267
+ return JSON.stringify(a) === JSON.stringify(b);
268
+ } catch {
269
+ return false;
270
+ }
271
+ };
272
+ const forgetStartedCall = (toolName: string, args: unknown) => {
273
+ for (const [id, call] of pendingCalls) {
274
+ if (call.toolName === toolName && sameArgs(call.args, args)) {
275
+ pendingCalls.delete(id);
276
+ return;
277
+ }
278
+ }
279
+ };
280
+
233
281
  const run = async () => {
234
282
  let fullContent = '';
235
283
  try {
@@ -240,12 +288,59 @@ export function createSseTurnStream(
240
288
  run_id: string;
241
289
  name?: string;
242
290
  data: unknown;
291
+ metadata?: Record<string, unknown>;
292
+ tags?: string[];
243
293
  };
294
+ if (evt.event === 'on_chat_model_end') {
295
+ if (isInternalModelEvent(evt)) continue;
296
+ const output = (evt.data as { output?: AIMessageChunk })?.output;
297
+ for (const call of output?.tool_calls ?? []) {
298
+ if (call.id)
299
+ pendingCalls.set(call.id, {
300
+ toolName: call.name,
301
+ args: (call.args ?? {}) as Record<string, unknown>,
302
+ });
303
+ }
304
+ continue;
305
+ }
306
+ if (evt.event === 'on_chain_end' && evt.name === 'tools') {
307
+ if (pendingCalls.size === 0) continue;
308
+ const output = (evt.data as { output?: unknown })?.output;
309
+ const messages: unknown[] = Array.isArray(output)
310
+ ? output
311
+ : Array.isArray((output as { messages?: unknown[] })?.messages)
312
+ ? ((output as { messages: unknown[] }).messages ?? [])
313
+ : [];
314
+ for (const message of messages) {
315
+ if (!isToolMessage(message)) continue;
316
+ const call = pendingCalls.get(message.tool_call_id);
317
+ if (!call) continue;
318
+ pendingCalls.delete(message.tool_call_id);
319
+ const text = toText(message.content);
320
+ const failed = message.status === 'error';
321
+ write('tool_call', {
322
+ sessionId,
323
+ requestId,
324
+ toolName: message.name ?? call.toolName,
325
+ args: {
326
+ ...call.args,
327
+ toolName: message.name ?? call.toolName,
328
+ },
329
+ status: failed ? 'error' : 'done',
330
+ output: text,
331
+ ...(failed && { error: text || 'Tool call rejected' }),
332
+ eventId: message.tool_call_id,
333
+ });
334
+ }
335
+ pendingCalls.clear();
336
+ continue;
337
+ }
244
338
  if (evt.event === 'on_tool_start') {
245
339
  const toolName = evt.name ?? 'tool';
246
340
  const args = extractToolArgs(
247
341
  (evt.data as { input?: unknown })?.input,
248
342
  );
343
+ forgetStartedCall(toolName, args);
249
344
  if (input.agActionNames?.has(toolName)) {
250
345
  const payload: ActionCallPayload = {
251
346
  requestId,
@@ -295,10 +390,16 @@ export function createSseTurnStream(
295
390
  }
296
391
  const tool = toolCallMap.get(evt.run_id);
297
392
  if (tool) {
393
+ // A tool whose call was rejected (schema mismatch) or whose
394
+ // retries ran out comes back as a ToolMessage with
395
+ // `status: 'error'`, not as `on_tool_error`; the client must
396
+ // see it as failed, not as a finished call with odd output.
397
+ const failed = output?.status === 'error';
298
398
  write('tool_call', {
299
399
  ...tool,
300
- status: 'done',
400
+ status: failed ? 'error' : 'done',
301
401
  output: text,
402
+ ...(failed && { error: text || 'Tool failed' }),
302
403
  eventId: evt.run_id,
303
404
  args: {
304
405
  ...tool.args,
@@ -342,6 +443,7 @@ export function createSseTurnStream(
342
443
  continue;
343
444
  }
344
445
  if (evt.event === 'on_chat_model_stream') {
446
+ if (isInternalModelEvent(evt)) continue;
345
447
  const chunk = (evt.data as { chunk?: AIMessageChunk })?.chunk;
346
448
  if (!chunk) continue;
347
449
  const raw = chunk.additional_kwargs?.__raw_response as
@@ -12,6 +12,7 @@ import {
12
12
  isAttachmentPlaceholderText,
13
13
  isAttachmentViewMessage,
14
14
  } from '../attachments/retention';
15
+ import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
15
16
 
16
17
  export interface ToolCallDto {
17
18
  name: string;
@@ -103,11 +104,23 @@ export function contentToText(content: BaseMessage['content']): string {
103
104
  return String(content);
104
105
  }
105
106
 
106
- /** The summarization middleware's bookkeeping message — never shown to users. */
107
+ /**
108
+ * The summarization middleware's bookkeeping message — never shown to users.
109
+ * LangChain 1.4 writes it as a HUMAN message tagged
110
+ * `additional_kwargs.lc_source: 'summarization'` whose text starts with the
111
+ * summary prefix; older versions used a system message and other tags. All
112
+ * of them match here, so a condensed thread lists as the kept tail only.
113
+ */
107
114
  export function isSummarizationMessage(message: BaseMessage): boolean {
108
115
  const kw = message.additional_kwargs as Record<string, unknown> | undefined;
109
- if (kw?.lc_summary_message === true || kw?.summary === true) return true;
116
+ if (
117
+ kw?.lc_source === 'summarization' ||
118
+ kw?.lc_summary_message === true ||
119
+ kw?.summary === true
120
+ )
121
+ return true;
110
122
  const text = contentToText(message.content);
123
+ if (text.startsWith(SUMMARY_PREFIX)) return true;
111
124
  return message.type === 'system' && /summary of the conversation/i.test(text);
112
125
  }
113
126
 
@@ -0,0 +1,24 @@
1
+ import { baseEnvSchema, TURN_RECURSION_LIMIT_DEFAULT } from '../core/env';
2
+ import type { OracleWorkerEnv } from './contracts';
3
+
4
+ /**
5
+ * The LangGraph `recursionLimit` every turn runs with (HTTP/SSE turns, room
6
+ * turns and the task runner's background turns all build their config in
7
+ * one place). `TURN_RECURSION_LIMIT` is validated by the base env schema at
8
+ * boot; this re-parses the raw Worker var through the same field so the
9
+ * turn never sees anything but a positive integer, and falls back to the
10
+ * default (with a warning) if the var is somehow malformed.
11
+ */
12
+ export function turnRecursionLimit(
13
+ env: Pick<OracleWorkerEnv, 'TURN_RECURSION_LIMIT'>,
14
+ log: Pick<Console, 'warn'> = console,
15
+ ): number {
16
+ const parsed = baseEnvSchema.shape.TURN_RECURSION_LIMIT.safeParse(
17
+ env.TURN_RECURSION_LIMIT,
18
+ );
19
+ if (parsed.success) return parsed.data;
20
+ log.warn(
21
+ `[turn] TURN_RECURSION_LIMIT=${JSON.stringify(env.TURN_RECURSION_LIMIT)} is not a positive integer; using ${TURN_RECURSION_LIMIT_DEFAULT}`,
22
+ );
23
+ return TURN_RECURSION_LIMIT_DEFAULT;
24
+ }