@ixo/oracle-runtime-workers 0.4.0 → 0.9.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/README.md +5 -1
- package/package.json +3 -3
- package/src/attachments/download.ts +43 -22
- package/src/attachments/index.ts +1 -0
- package/src/core/env.ts +19 -0
- package/src/core/main-agent.ts +56 -4
- package/src/core/middlewares/dangling-tool-calls.ts +99 -0
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/runtime-context.ts +14 -5
- package/src/core/subagent-as-tool.ts +12 -1
- package/src/do/ambient.ts +13 -4
- package/src/do/boot-dirty.ts +38 -0
- package/src/do/contracts.ts +91 -18
- package/src/do/matrix-turn-ledger.ts +126 -0
- package/src/do/reauth-prompt.ts +76 -0
- package/src/do/room-mirror.ts +111 -0
- package/src/do/sse-stream.ts +104 -2
- package/src/do/transcript.ts +15 -2
- package/src/do/turn-config.ts +24 -0
- package/src/do/user-oracle-do.ts +339 -67
- package/src/matrix/gateway-do.ts +233 -42
- package/src/matrix/inbox-store.ts +188 -0
- package/src/matrix/txn-id.ts +35 -0
- package/src/matrix/work-status.ts +15 -3
- package/src/owner-store/matrix-media-store.ts +59 -39
- package/src/owner-store/migrating-store.ts +22 -33
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/types.ts +25 -1
- package/src/plugins/portal/action-log.ts +36 -9
- package/src/plugins/tasks/tasks-tools.ts +7 -7
- package/src/shell/app.ts +80 -7
- package/src/shell/turn-body-cap.ts +19 -0
- package/src/tasks/schedule-input.ts +102 -0
- package/src/tasks/scheduler.ts +351 -54
- package/src/tasks/store.ts +198 -3
package/README.md
CHANGED
|
@@ -109,7 +109,11 @@ lifetime are in [operations](docs/operations.md).
|
|
|
109
109
|
for production (0.1.0–0.1.2 could send plaintext into an encrypted room
|
|
110
110
|
after a lazy-member sync delta, and queued session markers behind
|
|
111
111
|
background replays — both fixed in 0.1.3 and re-measured, see
|
|
112
|
-
[load-tests](docs/load-tests.md#session-creation-is-the-one-funnel))
|
|
112
|
+
[load-tests](docs/load-tests.md#session-creation-is-the-one-funnel));
|
|
113
|
+
0.3.1 brings the streamed media calls the legacy-copy import and the
|
|
114
|
+
attachments now use (a 50 MB Node-era checkpoint no longer resets the
|
|
115
|
+
user object's isolate) and the crypto-snapshot flush before every keys
|
|
116
|
+
upload.
|
|
113
117
|
- Open: the memory engine's recall ranking on accounts with a long history;
|
|
114
118
|
the storage cost of task-holding users at scale (R2 page tier, see
|
|
115
119
|
[architecture](docs/architecture.md#storage-cost-planning)).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@composio/core": "0.10.0",
|
|
34
34
|
"@composio/langchain": "0.9.2",
|
|
35
35
|
"@ixo/editor": "6.0.1",
|
|
36
|
-
"@ixo/matrix-bot-workers-sdk": "0.
|
|
36
|
+
"@ixo/matrix-bot-workers-sdk": "0.4.0",
|
|
37
37
|
"@ixo/matrix-crdt": "^1.2.5",
|
|
38
38
|
"@langchain/core": "1.1.48",
|
|
39
39
|
"@langchain/langgraph": "1.3.2",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"matrix-js-sdk": "^42.2.0",
|
|
53
53
|
"superjson": "1.13.3",
|
|
54
54
|
"wa-sqlite": "1.0.0",
|
|
55
|
-
"yjs": "
|
|
55
|
+
"yjs": "13.6.32",
|
|
56
56
|
"zod": "^4.4.3",
|
|
57
57
|
"@ixo/ucan": "2.2.0"
|
|
58
58
|
},
|
|
@@ -52,15 +52,20 @@ export function validateUrlTarget(targetUrl: string): void {
|
|
|
52
52
|
* Durable Object in the user object; tests pass an in-memory fake.
|
|
53
53
|
*/
|
|
54
54
|
export interface MatrixMediaSource {
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Raw bytes behind an `mxc://` URI (authenticated media download), the
|
|
57
|
+
* transfer cut off once it passes `maxBytes`.
|
|
58
|
+
*/
|
|
59
|
+
downloadMxc(mxc: string, maxBytes?: number): Promise<Uint8Array>;
|
|
57
60
|
/**
|
|
58
61
|
* Bytes of the media event `eventId` in `roomId`, decrypted when the event
|
|
59
|
-
* carries an encrypted `file
|
|
62
|
+
* carries an encrypted `file`, the transfer cut off once it passes
|
|
63
|
+
* `maxBytes`. Null when the event does not exist.
|
|
60
64
|
*/
|
|
61
65
|
downloadEvent(
|
|
62
66
|
roomId: string,
|
|
63
67
|
eventId: string,
|
|
68
|
+
maxBytes?: number,
|
|
64
69
|
): Promise<{
|
|
65
70
|
bytes: Uint8Array;
|
|
66
71
|
mimetype?: string;
|
|
@@ -104,6 +109,33 @@ function concat(chunks: Uint8Array[], total: number): Uint8Array {
|
|
|
104
109
|
return out;
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Collect a stream into bytes with a running size check: the moment the
|
|
114
|
+
* total passes `maxBytes` the stream is cancelled and an error thrown, so an
|
|
115
|
+
* oversized file never lands in memory whole.
|
|
116
|
+
*/
|
|
117
|
+
export async function readBytesCapped(
|
|
118
|
+
stream: ReadableStream<Uint8Array>,
|
|
119
|
+
maxBytes: number,
|
|
120
|
+
): Promise<Uint8Array> {
|
|
121
|
+
const reader = stream.getReader();
|
|
122
|
+
const chunks: Uint8Array[] = [];
|
|
123
|
+
let total = 0;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const { done, value } = await reader.read();
|
|
126
|
+
if (done) break;
|
|
127
|
+
total += value.byteLength;
|
|
128
|
+
if (total > maxBytes) {
|
|
129
|
+
void reader.cancel();
|
|
130
|
+
throw new Error(
|
|
131
|
+
`File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
chunks.push(value);
|
|
135
|
+
}
|
|
136
|
+
return concat(chunks, total);
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
/**
|
|
108
140
|
* Download an http(s) URL: every redirect hop is SSRF-validated, the body is
|
|
109
141
|
* streamed with a running size check so an oversized file never lands in
|
|
@@ -150,24 +182,9 @@ export async function downloadFromUrl(
|
|
|
150
182
|
`File too large: server reports ${Math.round(parseInt(contentLength, 10) / 1024 / 1024)} MB (limit: ${Math.round(maxBytes / 1024 / 1024)} MB)`,
|
|
151
183
|
);
|
|
152
184
|
}
|
|
153
|
-
|
|
154
|
-
if (!reader) throw new Error('Response body is not readable');
|
|
155
|
-
const chunks: Uint8Array[] = [];
|
|
156
|
-
let total = 0;
|
|
157
|
-
for (;;) {
|
|
158
|
-
const { done, value } = await reader.read();
|
|
159
|
-
if (done) break;
|
|
160
|
-
total += value.byteLength;
|
|
161
|
-
if (total > maxBytes) {
|
|
162
|
-
void reader.cancel();
|
|
163
|
-
throw new Error(
|
|
164
|
-
`File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
chunks.push(value);
|
|
168
|
-
}
|
|
185
|
+
if (!response.body) throw new Error('Response body is not readable');
|
|
169
186
|
return {
|
|
170
|
-
data:
|
|
187
|
+
data: await readBytesCapped(response.body, maxBytes),
|
|
171
188
|
contentType,
|
|
172
189
|
...(currentUrl !== url ? { finalUrl: currentUrl } : {}),
|
|
173
190
|
};
|
|
@@ -206,12 +223,16 @@ export async function loadAttachmentBytes(
|
|
|
206
223
|
throw new Error(
|
|
207
224
|
`Cannot fetch event ${attachment.eventId}: no Matrix room for this turn`,
|
|
208
225
|
);
|
|
209
|
-
const media = await source.downloadEvent(
|
|
226
|
+
const media = await source.downloadEvent(
|
|
227
|
+
roomId,
|
|
228
|
+
attachment.eventId,
|
|
229
|
+
maxBytes,
|
|
230
|
+
);
|
|
210
231
|
if (!media) throw new Error(`Matrix event ${attachment.eventId} not found`);
|
|
211
232
|
bytes = media.bytes;
|
|
212
233
|
httpType = media.mimetype;
|
|
213
234
|
} else if (attachment.mxcUri!.startsWith('mxc://')) {
|
|
214
|
-
bytes = await source.downloadMxc(attachment.mxcUri
|
|
235
|
+
bytes = await source.downloadMxc(attachment.mxcUri!, maxBytes);
|
|
215
236
|
} else {
|
|
216
237
|
const result = await downloadFromUrl(attachment.mxcUri!, {
|
|
217
238
|
...opts,
|
package/src/attachments/index.ts
CHANGED
package/src/core/env.ts
CHANGED
|
@@ -21,6 +21,14 @@ import { NOOP_LOGGER } from './utils';
|
|
|
21
21
|
* other non-string bindings on the Worker `env` object never reach plugins
|
|
22
22
|
* through `ctx.config`.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* Three times the Node runtime's hard-coded `recursionLimit: 200`. A tool-call
|
|
26
|
+
* round trip costs about 6 steps with the bundled middlewares, so 600 is
|
|
27
|
+
* roughly 100 chained tool calls — room for long research turns without an
|
|
28
|
+
* env override.
|
|
29
|
+
*/
|
|
30
|
+
export const TURN_RECURSION_LIMIT_DEFAULT = 600;
|
|
31
|
+
|
|
24
32
|
export const baseEnvSchema = z.object({
|
|
25
33
|
// --- identity -----------------------------------------------------------
|
|
26
34
|
ORACLE_NAME: z.string().min(1),
|
|
@@ -86,6 +94,17 @@ export const baseEnvSchema = z.object({
|
|
|
86
94
|
* token, at some cost to hard multi-step reasoning.
|
|
87
95
|
*/
|
|
88
96
|
MAIN_REASONING_EFFORT: z.enum(['low', 'medium', 'high']).default('medium'),
|
|
97
|
+
/**
|
|
98
|
+
* LangGraph `recursionLimit` for one turn — the super-steps (a model call,
|
|
99
|
+
* a batch of tool calls, a middleware hook) a turn may take before it fails
|
|
100
|
+
* with `GraphRecursionError`. Default 600 (the Node runtime hard-codes 200);
|
|
101
|
+
* see `TURN_RECURSION_LIMIT_DEFAULT`.
|
|
102
|
+
*/
|
|
103
|
+
TURN_RECURSION_LIMIT: z.coerce
|
|
104
|
+
.number()
|
|
105
|
+
.int()
|
|
106
|
+
.min(1)
|
|
107
|
+
.default(TURN_RECURSION_LIMIT_DEFAULT),
|
|
89
108
|
|
|
90
109
|
// --- misc ---------------------------------------------------------------
|
|
91
110
|
LOG_LEVEL: z.string().default('info'),
|
package/src/core/main-agent.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createAgent,
|
|
3
|
+
ToolInvocationError,
|
|
3
4
|
toolRetryMiddleware,
|
|
4
5
|
type StructuredTool,
|
|
5
6
|
} from 'langchain';
|
|
@@ -16,12 +17,28 @@ import { buildMetaTools } from './meta-tools';
|
|
|
16
17
|
import {
|
|
17
18
|
createByoHistorySanitizerMiddleware,
|
|
18
19
|
createCapabilityGateMiddleware,
|
|
20
|
+
createDanglingToolCallRepairMiddleware,
|
|
19
21
|
createPageContextMiddleware,
|
|
20
22
|
createSafetyGuardrailMiddleware,
|
|
21
23
|
createSummarizationMiddleware,
|
|
22
24
|
createToolRepetitionGuardMiddleware,
|
|
23
25
|
createToolValidationMiddleware,
|
|
24
26
|
} from './middlewares';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The model's arguments failed the tool's schema (LangChain's ToolNode
|
|
30
|
+
* throws `ToolInvocationError` before the tool runs). Matched by class AND by
|
|
31
|
+
* name / message shape: a bundle can carry the class twice (ESM and CJS
|
|
32
|
+
* entry points), and then `instanceof` is false for the very error it names.
|
|
33
|
+
*/
|
|
34
|
+
function isToolInvocationError(error: unknown): boolean {
|
|
35
|
+
if (error instanceof ToolInvocationError) return true;
|
|
36
|
+
if (!(error instanceof Error)) return false;
|
|
37
|
+
return (
|
|
38
|
+
error.name === 'ToolInvocationError' ||
|
|
39
|
+
/^Error invoking tool '.+' with kwargs /.test(error.message)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
25
42
|
import {
|
|
26
43
|
composePrompt,
|
|
27
44
|
formatTimeContext,
|
|
@@ -295,13 +312,21 @@ export async function createMainAgent(
|
|
|
295
312
|
const resolveModel = hooks?.resolveModel ?? ambient.llm.get.bind(ambient.llm);
|
|
296
313
|
|
|
297
314
|
const middleware = [
|
|
298
|
-
// Outermost
|
|
299
|
-
//
|
|
315
|
+
// Outermost: a turn that died between a tool call and its result (abort,
|
|
316
|
+
// object reset) must not poison every later model request on the thread.
|
|
317
|
+
createDanglingToolCallRepairMiddleware({ logger: ambient.logger }),
|
|
318
|
+
// Then, as on Node: rewrite cross-provider reasoning residue before the
|
|
319
|
+
// summarizer condenses (a well-formed) history.
|
|
300
320
|
createByoHistorySanitizerMiddleware({ logger: ambient.logger }),
|
|
301
321
|
// Condense long threads before everything tool-related.
|
|
302
322
|
// Without this, thread state — reloaded, re-serialized, and re-uploaded to
|
|
303
323
|
// the owner store on every turn — grows without bound.
|
|
304
|
-
|
|
324
|
+
// The summarizer's own model call is internal: it must never stream its
|
|
325
|
+
// tokens into the user's reply (belt: no streaming here; braces: the SSE
|
|
326
|
+
// stream drops `lc_source: 'summarization'` events).
|
|
327
|
+
createSummarizationMiddleware({
|
|
328
|
+
model: resolveModel('routing', { disableStreaming: true }),
|
|
329
|
+
}),
|
|
305
330
|
createCapabilityGateMiddleware({
|
|
306
331
|
pluginByToolName,
|
|
307
332
|
visibilityByToolName,
|
|
@@ -312,7 +337,34 @@ export async function createMainAgent(
|
|
|
312
337
|
logger: ambient.logger,
|
|
313
338
|
}),
|
|
314
339
|
createToolRepetitionGuardMiddleware({ logger: ambient.logger }),
|
|
315
|
-
|
|
340
|
+
// Retries are for transient failures. A ToolInvocationError is the
|
|
341
|
+
// model's arguments failing the tool's schema: the same call again gives
|
|
342
|
+
// the same rejection, so it is not retried — and it is logged, because
|
|
343
|
+
// the error ToolMessage it becomes is otherwise invisible in the logs
|
|
344
|
+
// (the tool-validation middleware above never sees it; ToolNode throws
|
|
345
|
+
// it before the tool runs).
|
|
346
|
+
toolRetryMiddleware({
|
|
347
|
+
retryOn: (error) => !isToolInvocationError(error),
|
|
348
|
+
onFailure: (error) => {
|
|
349
|
+
const rejected = isToolInvocationError(error);
|
|
350
|
+
ambient.logger.warn(
|
|
351
|
+
`[tool-retry] ${rejected ? 'tool call rejected by the tool schema' : 'tool failed after retries'}: ${error.message.split('\n')[0] ?? error.message}`,
|
|
352
|
+
);
|
|
353
|
+
if (!rejected) return error.message;
|
|
354
|
+
// The model (and the Portal's card) get the reason, not the kwargs
|
|
355
|
+
// dump — `Error invoking tool 'x' with kwargs {…} with error: Error:
|
|
356
|
+
// Received tool input did not match expected schema` stays in the tail.
|
|
357
|
+
const toolName = /^Error invoking tool '([^']+)'/.exec(
|
|
358
|
+
error.message,
|
|
359
|
+
)?.[1];
|
|
360
|
+
const reason =
|
|
361
|
+
error.message
|
|
362
|
+
.split(' with error: ')
|
|
363
|
+
.at(-1)
|
|
364
|
+
?.replace(/^Error: /, '') ?? error.message;
|
|
365
|
+
return `Invalid arguments for ${toolName ?? 'the tool'}: ${reason}. Check the tool's parameter schema and call it again with corrected arguments.`;
|
|
366
|
+
},
|
|
367
|
+
}),
|
|
316
368
|
// Same host-gated pair as the Node runtime: the page-context block needs a
|
|
317
369
|
// title lookup, the safety guardrail a classification model.
|
|
318
370
|
...(hooks?.getRoomTitle
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A turn that dies between the model's tool call and the tool's result — the
|
|
3
|
+
* user aborted it, or the runtime was reset while the tool ran — leaves the
|
|
4
|
+
* checkpoint with an assistant message whose `tool_calls` have no
|
|
5
|
+
* `ToolMessage`. OpenRouter chat completions tolerate that history; the
|
|
6
|
+
* ChatGPT-subscription lane (Responses API) rejects every later turn on the
|
|
7
|
+
* session with `400 No tool output found for function call …`. This
|
|
8
|
+
* middleware answers each unanswered call, for the model request only, with
|
|
9
|
+
* a tool result that says the call was interrupted. The checkpoint is left as
|
|
10
|
+
* it is: nothing is rewritten in place, and the model is free to call the
|
|
11
|
+
* tool again.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
type BaseMessage,
|
|
15
|
+
isAIMessage,
|
|
16
|
+
isToolMessage,
|
|
17
|
+
ToolMessage,
|
|
18
|
+
} from '@langchain/core/messages';
|
|
19
|
+
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
20
|
+
import type { Logger } from '../../plugin-api/types';
|
|
21
|
+
import { NOOP_LOGGER } from '../utils';
|
|
22
|
+
|
|
23
|
+
export interface DanglingToolCallRepairMiddlewareOptions {
|
|
24
|
+
/** Optional logger; defaults to a no-op. */
|
|
25
|
+
logger?: Logger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The result handed to the model for a tool call that never returned. */
|
|
29
|
+
export function interruptedToolResult(toolName: string): string {
|
|
30
|
+
return `The call to ${toolName} was interrupted before it returned a result (the turn was aborted or the runtime restarted while it ran), so nothing from it was recorded. Call it again if its result is still needed.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DanglingToolCallRepair {
|
|
34
|
+
/** The original array when nothing was missing, otherwise a repaired copy. */
|
|
35
|
+
messages: BaseMessage[];
|
|
36
|
+
/** How many tool calls were answered. */
|
|
37
|
+
repaired: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Insert a synthetic `ToolMessage` for every `tool_calls` entry of an
|
|
42
|
+
* assistant message that has no `ToolMessage` among the tool messages that
|
|
43
|
+
* directly follow it. The synthetic results go right after the real ones,
|
|
44
|
+
* before the next non-tool message, which is the position every provider's
|
|
45
|
+
* request converter requires.
|
|
46
|
+
*/
|
|
47
|
+
export function repairDanglingToolCalls(
|
|
48
|
+
messages: BaseMessage[],
|
|
49
|
+
): DanglingToolCallRepair {
|
|
50
|
+
const out: BaseMessage[] = [];
|
|
51
|
+
let repaired = 0;
|
|
52
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
53
|
+
const message = messages[i]!;
|
|
54
|
+
out.push(message);
|
|
55
|
+
if (!isAIMessage(message)) continue;
|
|
56
|
+
const toolCalls = message.tool_calls ?? [];
|
|
57
|
+
if (toolCalls.length === 0) continue;
|
|
58
|
+
|
|
59
|
+
const answered = new Set<string>();
|
|
60
|
+
let next = i + 1;
|
|
61
|
+
while (next < messages.length && isToolMessage(messages[next]!)) {
|
|
62
|
+
const toolMessage = messages[next] as ToolMessage;
|
|
63
|
+
answered.add(toolMessage.tool_call_id);
|
|
64
|
+
out.push(toolMessage);
|
|
65
|
+
next += 1;
|
|
66
|
+
}
|
|
67
|
+
for (const call of toolCalls) {
|
|
68
|
+
if (!call.id || answered.has(call.id)) continue;
|
|
69
|
+
out.push(
|
|
70
|
+
new ToolMessage({
|
|
71
|
+
tool_call_id: call.id,
|
|
72
|
+
name: call.name,
|
|
73
|
+
content: interruptedToolResult(call.name),
|
|
74
|
+
status: 'error',
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
repaired += 1;
|
|
78
|
+
}
|
|
79
|
+
i = next - 1;
|
|
80
|
+
}
|
|
81
|
+
return { messages: repaired === 0 ? messages : out, repaired };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const createDanglingToolCallRepairMiddleware = (
|
|
85
|
+
options?: DanglingToolCallRepairMiddlewareOptions,
|
|
86
|
+
): AgentMiddleware => {
|
|
87
|
+
const logger = options?.logger ?? NOOP_LOGGER;
|
|
88
|
+
return createMiddleware({
|
|
89
|
+
name: 'DanglingToolCallRepairMiddleware',
|
|
90
|
+
wrapModelCall: async (request, handler) => {
|
|
91
|
+
const { messages, repaired } = repairDanglingToolCalls(request.messages);
|
|
92
|
+
if (repaired === 0) return handler(request);
|
|
93
|
+
logger.warn(
|
|
94
|
+
`[DanglingToolCallRepair] ${repaired} tool call(s) had no result in the thread history; answered as interrupted for this model call`,
|
|
95
|
+
);
|
|
96
|
+
return handler({ ...request, messages });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
};
|
|
@@ -11,6 +11,11 @@ export {
|
|
|
11
11
|
} from './summarization';
|
|
12
12
|
export type { SummarizationMiddlewareOptions } from './summarization';
|
|
13
13
|
export { createByoHistorySanitizerMiddleware } from './byo-history-sanitizer';
|
|
14
|
+
export {
|
|
15
|
+
createDanglingToolCallRepairMiddleware,
|
|
16
|
+
repairDanglingToolCalls,
|
|
17
|
+
} from './dangling-tool-calls';
|
|
18
|
+
export type { DanglingToolCallRepairMiddlewareOptions } from './dangling-tool-calls';
|
|
14
19
|
export type { ByoHistorySanitizerMiddlewareOptions } from './byo-history-sanitizer';
|
|
15
20
|
export { createPageContextMiddleware } from './page-context';
|
|
16
21
|
export type { PageContextMiddlewareOptions } from './page-context';
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ChatOpenAIFields,
|
|
9
9
|
Logger,
|
|
10
10
|
MatrixEvent,
|
|
11
|
+
MatrixPostOpts,
|
|
11
12
|
MergedConfig,
|
|
12
13
|
MessageCacheInvalidationPayload,
|
|
13
14
|
ModelRole,
|
|
@@ -60,7 +61,11 @@ export interface BlobStoreAdapter {
|
|
|
60
61
|
|
|
61
62
|
/** Matrix adapter exposing only scoped operations a plugin should ever need. */
|
|
62
63
|
export interface MatrixAdapter {
|
|
63
|
-
postToRoom(
|
|
64
|
+
postToRoom(
|
|
65
|
+
roomId: string,
|
|
66
|
+
content: unknown,
|
|
67
|
+
opts?: MatrixPostOpts,
|
|
68
|
+
): Promise<string>;
|
|
64
69
|
/**
|
|
65
70
|
* Post a timeline event with a caller-chosen event type. `postToRoom` is
|
|
66
71
|
* the `m.room.message` shorthand; this is the general form. Returns the
|
|
@@ -70,6 +75,7 @@ export interface MatrixAdapter {
|
|
|
70
75
|
roomId: string,
|
|
71
76
|
eventType: string,
|
|
72
77
|
content: object,
|
|
78
|
+
opts?: MatrixPostOpts,
|
|
73
79
|
): Promise<string>;
|
|
74
80
|
getRoomState(roomId: string): Promise<RoomStateSnapshot>;
|
|
75
81
|
getEventById(roomId: string, eventId: string): Promise<MatrixEvent>;
|
|
@@ -165,6 +171,8 @@ export interface AmbientServices {
|
|
|
165
171
|
/** Host attachment access for the current session, when provided. */
|
|
166
172
|
attachments?: AttachmentViewSurface;
|
|
167
173
|
onTurnEnd?: (dispose: () => void | Promise<void>) => void;
|
|
174
|
+
/** Host keep-alive for un-awaited work (`ctx.waitUntil` on Workers). */
|
|
175
|
+
background?: (work: Promise<unknown>) => void;
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
// ── Scoped emitter ──────────────────────────────────────────────────────────
|
|
@@ -401,11 +409,11 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
|
|
|
401
409
|
ambient.blobStore.isValidBlobId(value),
|
|
402
410
|
},
|
|
403
411
|
matrix: {
|
|
404
|
-
postToRoom: (roomId, content) =>
|
|
405
|
-
ambient.matrix.postToRoom(roomId, content),
|
|
412
|
+
postToRoom: (roomId, content, opts) =>
|
|
413
|
+
ambient.matrix.postToRoom(roomId, content, opts),
|
|
406
414
|
botCredentials: () => ambient.matrix.botCredentials(),
|
|
407
|
-
postEvent: (roomId, eventType, content) =>
|
|
408
|
-
ambient.matrix.postEvent(roomId, eventType, content),
|
|
415
|
+
postEvent: (roomId, eventType, content, opts) =>
|
|
416
|
+
ambient.matrix.postEvent(roomId, eventType, content, opts),
|
|
409
417
|
getRoomState: (roomId) => ambient.matrix.getRoomState(roomId),
|
|
410
418
|
getEventById: (roomId, eventId) =>
|
|
411
419
|
ambient.matrix.getEventById(roomId, eventId),
|
|
@@ -444,6 +452,7 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
|
|
|
444
452
|
...(ambient.frontend ? { frontend: ambient.frontend } : {}),
|
|
445
453
|
...(ambient.attachments ? { attachments: ambient.attachments } : {}),
|
|
446
454
|
...(ambient.onTurnEnd ? { onTurnEnd: ambient.onTurnEnd } : {}),
|
|
455
|
+
...(ambient.background ? { background: ambient.background } : {}),
|
|
447
456
|
shared: EMPTY_SHARED,
|
|
448
457
|
...(runConfig.toolCall?.id ? { toolCallId: runConfig.toolCall.id } : {}),
|
|
449
458
|
};
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type AgentMiddleware,
|
|
13
13
|
type StructuredTool,
|
|
14
14
|
} from 'langchain';
|
|
15
|
+
import { createDanglingToolCallRepairMiddleware } from './middlewares/dangling-tool-calls';
|
|
15
16
|
import { z } from 'zod';
|
|
16
17
|
import type { Logger } from '../plugin-api/types';
|
|
17
18
|
import { NOOP_LOGGER } from './utils';
|
|
@@ -220,6 +221,11 @@ export function createSubagentAsTool(
|
|
|
220
221
|
},
|
|
221
222
|
...(parentContext ? { context: parentContext } : {}),
|
|
222
223
|
runName: spec.name,
|
|
224
|
+
// The sub-agent's model calls are the parent's tool work, not the
|
|
225
|
+
// reply: the SSE stream drops model events carrying this tag so its
|
|
226
|
+
// tokens never surface as user-visible text (its result reaches the
|
|
227
|
+
// model as the tool's output).
|
|
228
|
+
tags: ['internal', `subagent:${spec.name}`],
|
|
223
229
|
},
|
|
224
230
|
);
|
|
225
231
|
return result.messages as BaseMessage[];
|
|
@@ -269,7 +275,12 @@ export function createSubagentAsTool(
|
|
|
269
275
|
model: spec.model,
|
|
270
276
|
tools: innerTools,
|
|
271
277
|
systemPrompt: spec.systemPrompt,
|
|
272
|
-
|
|
278
|
+
// Sub-agent threads are checkpointed too, so an interrupted tool
|
|
279
|
+
// call in one would poison its later invocations the same way.
|
|
280
|
+
middleware: [
|
|
281
|
+
createDanglingToolCallRepairMiddleware(),
|
|
282
|
+
...(spec.middleware ?? []),
|
|
283
|
+
],
|
|
273
284
|
checkpointer,
|
|
274
285
|
});
|
|
275
286
|
|
package/src/do/ambient.ts
CHANGED
|
@@ -131,10 +131,15 @@ export function createMatrixAdapter(
|
|
|
131
131
|
gateway: DurableObjectStub<MatrixGatewayObject>,
|
|
132
132
|
): AmbientServices['matrix'] {
|
|
133
133
|
return {
|
|
134
|
-
postToRoom: (roomId, content) =>
|
|
135
|
-
gateway.sendEvent(
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
postToRoom: (roomId, content, opts) =>
|
|
135
|
+
gateway.sendEvent(
|
|
136
|
+
roomId,
|
|
137
|
+
'm.room.message',
|
|
138
|
+
JSON.stringify(content),
|
|
139
|
+
opts ?? {},
|
|
140
|
+
),
|
|
141
|
+
postEvent: (roomId, eventType, content, opts) =>
|
|
142
|
+
gateway.sendEvent(roomId, eventType, JSON.stringify(content), opts ?? {}),
|
|
138
143
|
botCredentials: () => gateway.botCredentials(),
|
|
139
144
|
getRoomState: async (roomId): Promise<RoomStateSnapshot> => {
|
|
140
145
|
const raw = await gateway.getRoomState(roomId);
|
|
@@ -221,6 +226,8 @@ export interface CreateAmbientInput {
|
|
|
221
226
|
frontend?: import('../plugin-api/types').FrontendCallSurface;
|
|
222
227
|
/** Per-turn cleanup registry (see `RuntimeContext.onTurnEnd`). */
|
|
223
228
|
onTurnEnd?: (dispose: () => void | Promise<void>) => void;
|
|
229
|
+
/** Keep-alive for un-awaited work (see `RuntimeContext.background`). */
|
|
230
|
+
background?: (work: Promise<unknown>) => void;
|
|
224
231
|
config: Record<string, unknown>;
|
|
225
232
|
identity: OracleIdentity;
|
|
226
233
|
availablePlugins: ReadonlySet<string>;
|
|
@@ -246,6 +253,8 @@ export function createAmbientServices(
|
|
|
246
253
|
...(input.tasks ? { tasks: input.tasks } : {}),
|
|
247
254
|
...(input.preferences ? { preferences: input.preferences } : {}),
|
|
248
255
|
...(input.frontend ? { frontend: input.frontend } : {}),
|
|
256
|
+
...(input.onTurnEnd ? { onTurnEnd: input.onTurnEnd } : {}),
|
|
257
|
+
...(input.background ? { background: input.background } : {}),
|
|
249
258
|
config: input.config,
|
|
250
259
|
identity: input.identity,
|
|
251
260
|
availablePlugins: input.availablePlugins,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a freshly opened working copy has writes the system of record has
|
|
3
|
+
* not seen. The dirty mark is normally set at the end of a turn; a turn that
|
|
4
|
+
* died in the middle (an object reset, a provider error, the user's stop)
|
|
5
|
+
* leaves LangGraph's per-step checkpoints committed in SQLite with no mark,
|
|
6
|
+
* and the upload waits for whatever dirties the object next — possibly
|
|
7
|
+
* never, and a reload from the system of record would drop those steps. The
|
|
8
|
+
* VFS records a write generation with the file and the object records the
|
|
9
|
+
* generation it last uploaded; a mismatch on boot is the missing mark.
|
|
10
|
+
*/
|
|
11
|
+
export interface BootDirtyInput {
|
|
12
|
+
/** The object's in-memory flag (a warm boot already knows). */
|
|
13
|
+
dirtyInMemory: boolean;
|
|
14
|
+
/** The persisted `meta:dirty` flag. */
|
|
15
|
+
dirtyFlag: unknown;
|
|
16
|
+
/** The persisted generation of the last successful upload (undefined = never uploaded). */
|
|
17
|
+
uploadedGen: unknown;
|
|
18
|
+
/** The working copy's current write generation. */
|
|
19
|
+
writeGeneration: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type BootDirtyDecision =
|
|
23
|
+
| 'already-dirty'
|
|
24
|
+
/** The persisted flag is set: adopt it (the flush alarm is armed already). */
|
|
25
|
+
| 'flagged'
|
|
26
|
+
/** Never uploaded: the first completed turn marks and flushes; nothing to reconcile. */
|
|
27
|
+
| 'never-uploaded'
|
|
28
|
+
/** Writes after the last upload with no mark: mark dirty now. */
|
|
29
|
+
| 'behind-upload'
|
|
30
|
+
| 'clean';
|
|
31
|
+
|
|
32
|
+
export function decideBootDirty(input: BootDirtyInput): BootDirtyDecision {
|
|
33
|
+
if (input.dirtyInMemory) return 'already-dirty';
|
|
34
|
+
if (input.dirtyFlag === true) return 'flagged';
|
|
35
|
+
if (typeof input.uploadedGen !== 'number') return 'never-uploaded';
|
|
36
|
+
if (input.uploadedGen !== input.writeGeneration) return 'behind-upload';
|
|
37
|
+
return 'clean';
|
|
38
|
+
}
|