@ixo/oracle-runtime-workers 0.2.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 +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- package/src/tasks/store.ts +351 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Durable Object stub that never goes stale.
|
|
3
|
+
*
|
|
4
|
+
* A stub is a connection to one incarnation of the target object: once that
|
|
5
|
+
* object restarts (deploy, eviction, memory reset, `restart()`), calls on the
|
|
6
|
+
* old stub fail with "Network connection lost" until the CALLER is itself
|
|
7
|
+
* recreated. The user object keeps one gateway handle for its whole life and
|
|
8
|
+
* hands it to long-lived helpers (owner store, preferences, status card,
|
|
9
|
+
* ambient matrix adapter), so a single gateway restart silently degraded
|
|
10
|
+
* every best-effort gateway call for hours.
|
|
11
|
+
*
|
|
12
|
+
* `freshStub` returns a proxy with the stub's interface whose every property
|
|
13
|
+
* access resolves against a NEW stub from `get()` — `namespace.get(id)` is a
|
|
14
|
+
* local, allocation-only operation, so this costs nothing per call and each
|
|
15
|
+
* RPC lands on whatever incarnation is current.
|
|
16
|
+
*/
|
|
17
|
+
export function freshStub<T extends object>(get: () => T): T {
|
|
18
|
+
const handler: ProxyHandler<object> = {
|
|
19
|
+
get(_target, prop) {
|
|
20
|
+
// Hand the property back untouched: an RPC property carries its own
|
|
21
|
+
// target, and touching it in any other way (e.g. `.bind`) is itself
|
|
22
|
+
// treated as an RPC call by the runtime ("The RPC receiver does not
|
|
23
|
+
// implement the method \"bind\"").
|
|
24
|
+
const stub = get();
|
|
25
|
+
return Reflect.get(stub, prop, stub) as unknown;
|
|
26
|
+
},
|
|
27
|
+
has(_target, prop) {
|
|
28
|
+
return Reflect.has(get(), prop);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
return new Proxy({}, handler) as T;
|
|
32
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry a gateway RPC across a gateway restart.
|
|
3
|
+
*
|
|
4
|
+
* The Matrix gateway object is replaced on every deploy and can be evicted;
|
|
5
|
+
* an RPC issued in that window fails with a transport error ("Network
|
|
6
|
+
* connection lost", "Durable Object reset", …) rather than a Matrix error.
|
|
7
|
+
* Waited sends that must not be replayed blindly (the session marker: its
|
|
8
|
+
* event id becomes the session id) instead retry the SAME transaction id a
|
|
9
|
+
* few times over ~10 s, so the homeserver deduplicates a send whose response
|
|
10
|
+
* was lost and a restart never produces a second marker.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const GATEWAY_RETRY_DELAYS_MS: readonly number[] = [
|
|
14
|
+
// Must outlast a gateway restart or idle recycle: the SDK's stop, alarm
|
|
15
|
+
// re-arm and boot take a few seconds normally, longer on a cold start.
|
|
16
|
+
500, 1000, 2000, 4000, 8000, 8000, 8000,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const TRANSIENT_PATTERNS = [
|
|
20
|
+
/network connection lost/i,
|
|
21
|
+
/durable object (reset|storage operation exceeded|has been)/i,
|
|
22
|
+
/object (was|has been) (reset|evicted)/i,
|
|
23
|
+
/internal error in durable object/i,
|
|
24
|
+
/not (yet )?started/i,
|
|
25
|
+
/client is not running/i,
|
|
26
|
+
/matrix client.*(stopped|not ready|not started)/i,
|
|
27
|
+
/timed out waiting for the matrix client/i,
|
|
28
|
+
/failed to fetch/i,
|
|
29
|
+
/econnreset|econnrefused|socket hang up/i,
|
|
30
|
+
// `ctx.abort(reason)` surfaces the reason to in-flight RPC callers.
|
|
31
|
+
/gateway reset/i,
|
|
32
|
+
/planned recycle/i,
|
|
33
|
+
/\babort(ed)?\b/i,
|
|
34
|
+
// matrix-js-sdk's wording while the bot stops or recycles.
|
|
35
|
+
/shutting down/i,
|
|
36
|
+
/OutgoingRequestsManager was stopped/i,
|
|
37
|
+
/null pointer passed to rust/i,
|
|
38
|
+
// A retry that raced the stopping client's pending list (see startedClient).
|
|
39
|
+
/known txnId/i,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function isTransientGatewayError(err: unknown): boolean {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
return TRANSIENT_PATTERNS.some((p) => p.test(message));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RetryGatewayOptions {
|
|
48
|
+
delaysMs?: readonly number[];
|
|
49
|
+
isTransient?: (err: unknown) => boolean;
|
|
50
|
+
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
|
|
51
|
+
sleep?: (ms: number) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run `fn`, retrying transient gateway failures with the given delays. */
|
|
55
|
+
export async function retryGateway<T>(
|
|
56
|
+
fn: () => Promise<T>,
|
|
57
|
+
opts: RetryGatewayOptions = {},
|
|
58
|
+
): Promise<T> {
|
|
59
|
+
const delays = opts.delaysMs ?? GATEWAY_RETRY_DELAYS_MS;
|
|
60
|
+
const isTransient = opts.isTransient ?? isTransientGatewayError;
|
|
61
|
+
const sleep =
|
|
62
|
+
opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
63
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
64
|
+
try {
|
|
65
|
+
return await fn();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const delay = delays[attempt];
|
|
68
|
+
if (delay === undefined || !isTransient(err)) throw err;
|
|
69
|
+
opts.onRetry?.(err, attempt + 1, delay);
|
|
70
|
+
await sleep(delay);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapts `WorkersSecretsService` (per-room JWE secrets over the Matrix
|
|
3
|
+
* gateway) to the core's `SecretsAdapter` shape — the same mapping the Node
|
|
4
|
+
* runtime's `ambient-factory` performs over its `SecretsService`:
|
|
5
|
+
* `getIndex` lists names (plugins never see event ids), `getValues` decrypts
|
|
6
|
+
* only the requested names. Degrades exactly like Node when the oracle has no
|
|
7
|
+
* P-256 key seated: the index still lists, values come back empty.
|
|
8
|
+
*/
|
|
9
|
+
import type { SecretsAdapter } from '../core/runtime-context';
|
|
10
|
+
import type { SecretIndex } from '../plugin-api/types';
|
|
11
|
+
import type { WorkersSecretsService } from '../secrets/secrets-service';
|
|
12
|
+
|
|
13
|
+
export function createSecretsAdapter(
|
|
14
|
+
service: WorkersSecretsService,
|
|
15
|
+
): SecretsAdapter {
|
|
16
|
+
return {
|
|
17
|
+
async getIndex(roomId: string): Promise<SecretIndex> {
|
|
18
|
+
const entries = await service.getIndex(roomId);
|
|
19
|
+
const index: Record<string, { key: string }> = {};
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
index[entry.name] = { key: entry.name };
|
|
22
|
+
}
|
|
23
|
+
return index satisfies SecretIndex;
|
|
24
|
+
},
|
|
25
|
+
async getValues(
|
|
26
|
+
roomId: string,
|
|
27
|
+
keys: string[],
|
|
28
|
+
): Promise<Record<string, string>> {
|
|
29
|
+
return service.getValues(roomId, keys);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage watermark alerts: a Slack webhook message the first time a user's
|
|
3
|
+
* working-copy SQLite file crosses each whole-GB threshold from
|
|
4
|
+
* `START_ALERT_GB` up (1 GB, then 2 GB, ... 9 GB is the last crossing below
|
|
5
|
+
* the cap). One alert per threshold — the caller persists the highest GB
|
|
6
|
+
* already alerted and passes it back in.
|
|
7
|
+
*
|
|
8
|
+
* Exists for two reasons: the DO storage cap is 10 GB per user object, and
|
|
9
|
+
* resident storage is the runtime's dominant cost ($0.20/GB-month) — see the
|
|
10
|
+
* cost-planning notes in the package README. Early crossings are the signal
|
|
11
|
+
* to schedule the R2 page-tier work with runway, not under fire.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const START_ALERT_GB = 1;
|
|
15
|
+
const GB = 1024 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The threshold this file size newly crosses, or null when no alert is due.
|
|
19
|
+
* `lastAlertedGb` is the highest threshold already alerted (0 = none).
|
|
20
|
+
* A file that jumped several GB since the last check (e.g. a big legacy
|
|
21
|
+
* import) alerts once at its CURRENT floor, not once per skipped step.
|
|
22
|
+
*/
|
|
23
|
+
export function crossedGbThreshold(
|
|
24
|
+
fileBytes: number,
|
|
25
|
+
lastAlertedGb: number,
|
|
26
|
+
startGb = START_ALERT_GB,
|
|
27
|
+
): number | null {
|
|
28
|
+
const floorGb = Math.floor(fileBytes / GB);
|
|
29
|
+
if (floorGb < startGb || floorGb <= lastAlertedGb) return null;
|
|
30
|
+
return floorGb;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function storageAlertText(args: {
|
|
34
|
+
oracleName: string;
|
|
35
|
+
oracleDid: string;
|
|
36
|
+
userDid: string;
|
|
37
|
+
fileBytes: number;
|
|
38
|
+
gb: number;
|
|
39
|
+
}): string {
|
|
40
|
+
const sizeGb = (args.fileBytes / GB).toFixed(2);
|
|
41
|
+
return (
|
|
42
|
+
`:warning: *Oracle user storage crossed ${args.gb} GB*\n` +
|
|
43
|
+
`• Oracle: ${args.oracleName} (\`${args.oracleDid}\`)\n` +
|
|
44
|
+
`• User: \`${args.userDid}\`\n` +
|
|
45
|
+
`• Working-copy SQLite file: ${sizeGb} GB of the 10 GB Durable Object cap\n` +
|
|
46
|
+
`At 10 GB this user's turns and exports start failing — ship the storage tier before then.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fire the webhook. Failures are logged and swallowed — an alert must never
|
|
52
|
+
* take down the flush path it rides on.
|
|
53
|
+
*/
|
|
54
|
+
export async function postSlackAlert(
|
|
55
|
+
webhookUrl: string,
|
|
56
|
+
text: string,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(webhookUrl, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'content-type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ text }),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
console.warn(
|
|
66
|
+
`[size-alerts] Slack webhook responded ${res.status}: ${await res
|
|
67
|
+
.text()
|
|
68
|
+
.catch(() => '')}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.warn(
|
|
73
|
+
`[size-alerts] Slack webhook failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangGraph `streamEvents` → Server-Sent Events, as a Web `ReadableStream`.
|
|
3
|
+
*
|
|
4
|
+
* Wire-compatible port of the Node runtime's `SseStreamRunner`: the same
|
|
5
|
+
* event names and payload shapes the Portal / client SDK parse
|
|
6
|
+
* (`message`, `reasoning`, `tool_call`, `action_call`, `error`, `done`),
|
|
7
|
+
* the same "instant Thinking…" ack, the same 15 s heartbeat comment.
|
|
8
|
+
*
|
|
9
|
+
* On Workers the response body is a stream we write into; the client
|
|
10
|
+
* disconnecting cancels the stream, which aborts the run.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
classifyLlmError,
|
|
14
|
+
isOperatorFault,
|
|
15
|
+
redactOperatorFault,
|
|
16
|
+
} from '../llm/provider-error';
|
|
17
|
+
import type { AIMessageChunk, ToolMessage } from '@langchain/core/messages';
|
|
18
|
+
|
|
19
|
+
export interface SseTurnRunnerInput {
|
|
20
|
+
/** `agent.streamEvents(stateInput, config)` async iterable. */
|
|
21
|
+
events: AsyncIterable<unknown>;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
requestId: string;
|
|
24
|
+
abortController: AbortController;
|
|
25
|
+
/** Names declared as client-side AG-UI actions (rendered as `action_call`). */
|
|
26
|
+
agActionNames?: ReadonlySet<string>;
|
|
27
|
+
onComplete?: (fullText: string) => void | Promise<void>;
|
|
28
|
+
onError?: (error: unknown) => void;
|
|
29
|
+
log?: (msg: string) => void;
|
|
30
|
+
/** BYO provider of this turn, when it ran on the user's own credential. */
|
|
31
|
+
byoProvider?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Receives every `tool_call` / `action_call` / `router_update` frame the
|
|
34
|
+
* stream writes, for the session's realtime sockets (the SSE consumer
|
|
35
|
+
* already has them). Payloads carry `sessionId`.
|
|
36
|
+
*/
|
|
37
|
+
mirror?: (eventName: string, payload: Record<string, unknown>) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const THINKING_PHRASES = [
|
|
41
|
+
'Thinking...',
|
|
42
|
+
'Working...',
|
|
43
|
+
'Analyzing...',
|
|
44
|
+
'Processing...',
|
|
45
|
+
'Reasoning...',
|
|
46
|
+
'Reading...',
|
|
47
|
+
'Synthesizing...',
|
|
48
|
+
'Considering...',
|
|
49
|
+
'Investigating...',
|
|
50
|
+
'Solving...',
|
|
51
|
+
'Reviewing...',
|
|
52
|
+
'Reflecting...',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function pickThinkingPhrase(): string {
|
|
56
|
+
return (
|
|
57
|
+
THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)] ??
|
|
58
|
+
'Thinking...'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatSSE(eventType: string, data: unknown): string {
|
|
63
|
+
return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const SSE_HEADERS: Record<string, string> = {
|
|
67
|
+
'Content-Type': 'text/event-stream',
|
|
68
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
69
|
+
Connection: 'keep-alive',
|
|
70
|
+
'X-Accel-Buffering': 'no',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
interface ToolCallPayload {
|
|
74
|
+
sessionId: string;
|
|
75
|
+
requestId: string;
|
|
76
|
+
toolName: string;
|
|
77
|
+
args: Record<string, unknown>;
|
|
78
|
+
status: 'isRunning' | 'done';
|
|
79
|
+
output?: string;
|
|
80
|
+
eventId?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ActionCallPayload {
|
|
84
|
+
sessionId: string;
|
|
85
|
+
requestId: string;
|
|
86
|
+
toolName: string;
|
|
87
|
+
toolCallId: string;
|
|
88
|
+
args?: Record<string, unknown>;
|
|
89
|
+
status: 'isRunning' | 'done' | 'error';
|
|
90
|
+
output?: string;
|
|
91
|
+
error?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function extractToolArgs(input: unknown): Record<string, unknown> {
|
|
95
|
+
if (input && typeof input === 'object') {
|
|
96
|
+
const maybe = input as { input?: unknown };
|
|
97
|
+
if (maybe.input && typeof maybe.input === 'object')
|
|
98
|
+
return maybe.input as Record<string, unknown>;
|
|
99
|
+
return input as Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function collectBlockStrings(
|
|
105
|
+
content: unknown,
|
|
106
|
+
type: string,
|
|
107
|
+
field: string,
|
|
108
|
+
): string {
|
|
109
|
+
if (!Array.isArray(content)) return '';
|
|
110
|
+
return content
|
|
111
|
+
.map((block) => {
|
|
112
|
+
if (
|
|
113
|
+
block &&
|
|
114
|
+
typeof block === 'object' &&
|
|
115
|
+
(block as { type?: unknown }).type === type
|
|
116
|
+
) {
|
|
117
|
+
const v = (block as Record<string, unknown>)[field];
|
|
118
|
+
return typeof v === 'string' ? v : '';
|
|
119
|
+
}
|
|
120
|
+
return '';
|
|
121
|
+
})
|
|
122
|
+
.join('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function safeParseToolContent(
|
|
126
|
+
content: unknown,
|
|
127
|
+
): { success?: boolean; error?: unknown } | null {
|
|
128
|
+
if (typeof content !== 'string') return null;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(content);
|
|
131
|
+
return parsed && typeof parsed === 'object'
|
|
132
|
+
? (parsed as { success?: boolean; error?: unknown })
|
|
133
|
+
: null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toText(content: unknown): string {
|
|
140
|
+
if (typeof content === 'string') return content;
|
|
141
|
+
if (Array.isArray(content))
|
|
142
|
+
return collectBlockStrings(content, 'text', 'text');
|
|
143
|
+
return content == null ? '' : JSON.stringify(content);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the SSE body for one turn. The returned stream starts emitting the
|
|
148
|
+
* Thinking… ack immediately; `events` is consumed lazily once the consumer
|
|
149
|
+
* reads. Errors inside the run become an `error` frame followed by `done`.
|
|
150
|
+
*/
|
|
151
|
+
/** SSE frame name → socket event name (Node's `serverEvents` spelling). */
|
|
152
|
+
const MIRRORED_EVENTS: Record<string, string | undefined> = {
|
|
153
|
+
tool_call: 'tool_call',
|
|
154
|
+
action_call: 'action_call',
|
|
155
|
+
'router.update': 'router_update',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export function createSseTurnStream(
|
|
159
|
+
input: SseTurnRunnerInput,
|
|
160
|
+
): ReadableStream<Uint8Array> {
|
|
161
|
+
const encoder = new TextEncoder();
|
|
162
|
+
const { sessionId, requestId, abortController } = input;
|
|
163
|
+
const toolCallMap = new Map<string, ToolCallPayload>();
|
|
164
|
+
const actionCallMap = new Map<string, ActionCallPayload>();
|
|
165
|
+
let closed = false;
|
|
166
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
167
|
+
|
|
168
|
+
return new ReadableStream<Uint8Array>({
|
|
169
|
+
start: (controller) => {
|
|
170
|
+
const write = (
|
|
171
|
+
eventName: string,
|
|
172
|
+
payload: unknown,
|
|
173
|
+
opts: { force?: boolean } = {},
|
|
174
|
+
) => {
|
|
175
|
+
if (closed || (abortController.signal.aborted && !opts.force)) return;
|
|
176
|
+
try {
|
|
177
|
+
controller.enqueue(encoder.encode(formatSSE(eventName, payload)));
|
|
178
|
+
} catch {
|
|
179
|
+
closed = true;
|
|
180
|
+
}
|
|
181
|
+
// Node's WebSocket gateway relays these to the session's sockets as
|
|
182
|
+
// well; message chunks and `done` stay SSE-only, as there.
|
|
183
|
+
const mirrored = MIRRORED_EVENTS[eventName];
|
|
184
|
+
if (mirrored && input.mirror && payload && typeof payload === 'object')
|
|
185
|
+
input.mirror(mirrored, payload as Record<string, unknown>);
|
|
186
|
+
};
|
|
187
|
+
const finish = () => {
|
|
188
|
+
if (closed) return;
|
|
189
|
+
closed = true;
|
|
190
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
191
|
+
try {
|
|
192
|
+
controller.close();
|
|
193
|
+
} catch {
|
|
194
|
+
/* already closed */
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
write('router.update', {
|
|
199
|
+
step: pickThinkingPhrase(),
|
|
200
|
+
sessionId,
|
|
201
|
+
requestId,
|
|
202
|
+
});
|
|
203
|
+
heartbeat = setInterval(() => {
|
|
204
|
+
if (closed) return;
|
|
205
|
+
try {
|
|
206
|
+
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
|
207
|
+
} catch {
|
|
208
|
+
closed = true;
|
|
209
|
+
}
|
|
210
|
+
}, 15_000);
|
|
211
|
+
|
|
212
|
+
const flushOrphans = () => {
|
|
213
|
+
for (const [runId, evt] of actionCallMap) {
|
|
214
|
+
write('action_call', {
|
|
215
|
+
...evt,
|
|
216
|
+
status: 'error',
|
|
217
|
+
error: 'Action did not complete',
|
|
218
|
+
toolCallId: runId,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
actionCallMap.clear();
|
|
222
|
+
for (const [runId, evt] of toolCallMap) {
|
|
223
|
+
write('tool_call', {
|
|
224
|
+
...evt,
|
|
225
|
+
status: 'done',
|
|
226
|
+
output: '⏱️ Tool did not complete',
|
|
227
|
+
eventId: runId,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
toolCallMap.clear();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const run = async () => {
|
|
234
|
+
let fullContent = '';
|
|
235
|
+
try {
|
|
236
|
+
for await (const raw of input.events) {
|
|
237
|
+
if (abortController.signal.aborted) break;
|
|
238
|
+
const evt = raw as {
|
|
239
|
+
event: string;
|
|
240
|
+
run_id: string;
|
|
241
|
+
name?: string;
|
|
242
|
+
data: unknown;
|
|
243
|
+
};
|
|
244
|
+
if (evt.event === 'on_tool_start') {
|
|
245
|
+
const toolName = evt.name ?? 'tool';
|
|
246
|
+
const args = extractToolArgs(
|
|
247
|
+
(evt.data as { input?: unknown })?.input,
|
|
248
|
+
);
|
|
249
|
+
if (input.agActionNames?.has(toolName)) {
|
|
250
|
+
const payload: ActionCallPayload = {
|
|
251
|
+
requestId,
|
|
252
|
+
sessionId,
|
|
253
|
+
toolCallId: evt.run_id,
|
|
254
|
+
toolName,
|
|
255
|
+
args,
|
|
256
|
+
status: 'isRunning',
|
|
257
|
+
};
|
|
258
|
+
actionCallMap.set(evt.run_id, payload);
|
|
259
|
+
write('action_call', payload);
|
|
260
|
+
} else {
|
|
261
|
+
const payload: ToolCallPayload = {
|
|
262
|
+
requestId,
|
|
263
|
+
sessionId,
|
|
264
|
+
toolName,
|
|
265
|
+
args: { ...args, toolName },
|
|
266
|
+
status: 'isRunning',
|
|
267
|
+
eventId: evt.run_id,
|
|
268
|
+
};
|
|
269
|
+
toolCallMap.set(evt.run_id, payload);
|
|
270
|
+
write('tool_call', payload);
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (evt.event === 'on_tool_end') {
|
|
275
|
+
const output = (evt.data as { output?: ToolMessage })?.output;
|
|
276
|
+
const text = toText(output?.content);
|
|
277
|
+
const action = actionCallMap.get(evt.run_id);
|
|
278
|
+
if (action) {
|
|
279
|
+
const parsed = safeParseToolContent(output?.content);
|
|
280
|
+
const failed =
|
|
281
|
+
parsed?.success === false || Boolean(parsed?.error);
|
|
282
|
+
write('action_call', {
|
|
283
|
+
...action,
|
|
284
|
+
output: text,
|
|
285
|
+
status: failed ? 'error' : 'done',
|
|
286
|
+
...(failed && {
|
|
287
|
+
error:
|
|
288
|
+
typeof parsed?.error === 'string'
|
|
289
|
+
? parsed.error
|
|
290
|
+
: 'Action failed',
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
actionCallMap.delete(evt.run_id);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const tool = toolCallMap.get(evt.run_id);
|
|
297
|
+
if (tool) {
|
|
298
|
+
write('tool_call', {
|
|
299
|
+
...tool,
|
|
300
|
+
status: 'done',
|
|
301
|
+
output: text,
|
|
302
|
+
eventId: evt.run_id,
|
|
303
|
+
args: {
|
|
304
|
+
...tool.args,
|
|
305
|
+
toolName: output?.name ?? tool.toolName,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
toolCallMap.delete(evt.run_id);
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (evt.event === 'on_tool_error') {
|
|
313
|
+
// A tool that threw (retries exhausted, upstream down, MCP
|
|
314
|
+
// timeout) never emits `on_tool_end`; without this branch the
|
|
315
|
+
// client only learns "did not complete" at stream end.
|
|
316
|
+
const raw = (evt.data as { error?: unknown })?.error;
|
|
317
|
+
const message =
|
|
318
|
+
raw instanceof Error
|
|
319
|
+
? raw.message
|
|
320
|
+
: String(raw ?? 'Tool failed');
|
|
321
|
+
const action = actionCallMap.get(evt.run_id);
|
|
322
|
+
if (action) {
|
|
323
|
+
write('action_call', {
|
|
324
|
+
...action,
|
|
325
|
+
output: message,
|
|
326
|
+
status: 'error',
|
|
327
|
+
error: message,
|
|
328
|
+
});
|
|
329
|
+
actionCallMap.delete(evt.run_id);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const tool = toolCallMap.get(evt.run_id);
|
|
333
|
+
if (tool) {
|
|
334
|
+
write('tool_call', {
|
|
335
|
+
...tool,
|
|
336
|
+
status: 'done',
|
|
337
|
+
output: `⚠️ ${message}`,
|
|
338
|
+
eventId: evt.run_id,
|
|
339
|
+
});
|
|
340
|
+
toolCallMap.delete(evt.run_id);
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (evt.event === 'on_chat_model_stream') {
|
|
345
|
+
const chunk = (evt.data as { chunk?: AIMessageChunk })?.chunk;
|
|
346
|
+
if (!chunk) continue;
|
|
347
|
+
const raw = chunk.additional_kwargs?.__raw_response as
|
|
348
|
+
| {
|
|
349
|
+
choices?: Array<{
|
|
350
|
+
delta?: {
|
|
351
|
+
reasoning?: string;
|
|
352
|
+
reasoning_content?: string;
|
|
353
|
+
reasoning_details?: unknown[];
|
|
354
|
+
};
|
|
355
|
+
}>;
|
|
356
|
+
}
|
|
357
|
+
| undefined;
|
|
358
|
+
const delta = raw?.choices?.[0]?.delta;
|
|
359
|
+
const deltaReasoning =
|
|
360
|
+
delta?.reasoning ?? delta?.reasoning_content;
|
|
361
|
+
const reasoning = deltaReasoning?.trim()
|
|
362
|
+
? deltaReasoning
|
|
363
|
+
: collectBlockStrings(chunk.content, 'reasoning', 'reasoning');
|
|
364
|
+
if (reasoning && reasoning.trim()) {
|
|
365
|
+
const details = Array.isArray(delta?.reasoning_details)
|
|
366
|
+
? delta.reasoning_details
|
|
367
|
+
.filter(
|
|
368
|
+
(d): d is { type: string; text: string } =>
|
|
369
|
+
!!d &&
|
|
370
|
+
typeof d === 'object' &&
|
|
371
|
+
typeof (d as { type?: unknown }).type === 'string' &&
|
|
372
|
+
typeof (d as { text?: unknown }).text === 'string' &&
|
|
373
|
+
(d as { text: string }).text.trim().length > 0,
|
|
374
|
+
)
|
|
375
|
+
.map((d) => ({
|
|
376
|
+
type: d.type,
|
|
377
|
+
text: d.text,
|
|
378
|
+
format: 'unknown',
|
|
379
|
+
index: 0,
|
|
380
|
+
}))
|
|
381
|
+
: undefined;
|
|
382
|
+
write('reasoning', {
|
|
383
|
+
sessionId,
|
|
384
|
+
requestId,
|
|
385
|
+
reasoning,
|
|
386
|
+
...(details && { reasoningDetails: details }),
|
|
387
|
+
isComplete: false,
|
|
388
|
+
timestamp: new Date().toISOString(),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const text =
|
|
392
|
+
typeof chunk.content === 'string'
|
|
393
|
+
? chunk.content
|
|
394
|
+
: collectBlockStrings(chunk.content, 'text', 'text');
|
|
395
|
+
if (text) {
|
|
396
|
+
fullContent += text;
|
|
397
|
+
write('message', {
|
|
398
|
+
content: text,
|
|
399
|
+
timestamp: new Date().toISOString(),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!abortController.signal.aborted) {
|
|
405
|
+
flushOrphans();
|
|
406
|
+
write('reasoning', {
|
|
407
|
+
sessionId,
|
|
408
|
+
requestId,
|
|
409
|
+
reasoning: '',
|
|
410
|
+
isComplete: true,
|
|
411
|
+
timestamp: new Date().toISOString(),
|
|
412
|
+
});
|
|
413
|
+
write('done', {});
|
|
414
|
+
await input.onComplete?.(fullContent);
|
|
415
|
+
} else {
|
|
416
|
+
// Aborted by the client (POST /messages/abort): close the stream
|
|
417
|
+
// cleanly so the UI leaves its "thinking" state.
|
|
418
|
+
write('done', {}, { force: true });
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const aborted =
|
|
422
|
+
error instanceof Error &&
|
|
423
|
+
(error.name === 'AbortError' || /abort/i.test(error.message));
|
|
424
|
+
if (!aborted) {
|
|
425
|
+
input.onError?.(error);
|
|
426
|
+
flushOrphans();
|
|
427
|
+
write('reasoning', {
|
|
428
|
+
sessionId,
|
|
429
|
+
requestId,
|
|
430
|
+
reasoning: '',
|
|
431
|
+
isComplete: true,
|
|
432
|
+
timestamp: new Date().toISOString(),
|
|
433
|
+
});
|
|
434
|
+
// Same wire shape as the Node runtime's `sendSSEError`: the
|
|
435
|
+
// classification is redacted here — the one place an LLM failure
|
|
436
|
+
// becomes bytes on a client's wire.
|
|
437
|
+
const classified = classifyLlmError(error, {
|
|
438
|
+
byoProvider: input.byoProvider,
|
|
439
|
+
});
|
|
440
|
+
if (isOperatorFault(classified))
|
|
441
|
+
input.log?.(
|
|
442
|
+
`OPERATOR FAULT (platform ${classified.kind}) on turn ${requestId}: ${classified.detail}`,
|
|
443
|
+
);
|
|
444
|
+
const safe = redactOperatorFault(classified);
|
|
445
|
+
write('error', {
|
|
446
|
+
error: safe.message,
|
|
447
|
+
kind: safe.kind,
|
|
448
|
+
source: safe.source,
|
|
449
|
+
...(safe.provider && { provider: safe.provider }),
|
|
450
|
+
...(safe.providerLabel && { providerLabel: safe.providerLabel }),
|
|
451
|
+
...(safe.status !== undefined && { status: safe.status }),
|
|
452
|
+
retryable: safe.retryable,
|
|
453
|
+
detail: safe.detail,
|
|
454
|
+
sessionId,
|
|
455
|
+
requestId,
|
|
456
|
+
timestamp: new Date().toISOString(),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
write('done', {}, { force: true });
|
|
460
|
+
} finally {
|
|
461
|
+
finish();
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
void run();
|
|
465
|
+
},
|
|
466
|
+
cancel: () => {
|
|
467
|
+
closed = true;
|
|
468
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
469
|
+
abortController.abort();
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
}
|