@ixo/oracle-runtime-workers 0.21.1 → 0.22.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 +1 -0
- package/package.json +1 -1
- package/src/core/env.ts +18 -0
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +24 -0
- package/src/core/middlewares/tool-execution.ts +87 -46
- package/src/core/middlewares/tool-marks.ts +54 -19
- package/src/core/runtime-context.ts +15 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/do/active-runs.ts +171 -0
- package/src/do/contracts.ts +47 -3
- package/src/do/run-coordinator.ts +30 -0
- package/src/do/run-request.ts +15 -2
- package/src/do/run-store-test-do.ts +10 -0
- package/src/do/run-store.ts +66 -13
- package/src/do/turn-body.ts +13 -1
- package/src/do/user-oracle-do.ts +167 -10
- package/src/plugin-api/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +63 -7
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
- package/src/plugins/portal/declared-tools.ts +12 -0
- package/src/plugins/portal/portal.plugin.ts +32 -8
- package/src/realtime/frontend-call-registry.ts +147 -27
- package/src/realtime/realtime-endpoint.ts +335 -41
- package/src/realtime/session-socket-hub.ts +90 -19
- package/src/shell/app.ts +24 -2
- package/src/sqlite/sessions-store.ts +22 -0
package/README.md
CHANGED
|
@@ -90,6 +90,7 @@ Every variable, binding and migration is explained in
|
|
|
90
90
|
| `GET /matrix/status`, `POST /matrix/start` | Gateway status and start |
|
|
91
91
|
| `POST/GET /sessions`, `POST/GET /messages/:id` | The chat API |
|
|
92
92
|
| `GET /runs/:runId`, `GET /sessions/:id/messages` | Re-join a durable run; one page of a transcript |
|
|
93
|
+
| `GET /sessions/:id/run`, `GET /runs/active` | A session's active run; every active run of the user |
|
|
93
94
|
| `/delegation`, `/models`, `/socket.io/`, `/byo-llm/*` | Delegation, models, realtime channel, BYO LLM |
|
|
94
95
|
| `POST /channels/turn` | IXO Channels ingress; refusals carry a `code` |
|
|
95
96
|
| `POST /messages/:sessionId/:messageId/feedback` | Anonymous feedback on a reply (when configured) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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": {
|
package/src/core/env.ts
CHANGED
|
@@ -63,6 +63,10 @@ export const LLM_STREAM_RETRIES_DEFAULT = 1;
|
|
|
63
63
|
export const LLM_STREAM_RETRIES_MAX = 5;
|
|
64
64
|
/** The turn's wall-clock deadline when `TURN_TIMEOUT_MS` is unset. */
|
|
65
65
|
export const TURN_TIMEOUT_MS_DEFAULT = 600_000;
|
|
66
|
+
/** How long a frontend call waits unsent for a browser when `FRONTEND_PARK_TIMEOUT_MS` is unset. */
|
|
67
|
+
export const FRONTEND_PARK_TIMEOUT_MS_DEFAULT = 300_000;
|
|
68
|
+
/** The longest delay `setTimeout` honours (2^31 − 1 ms); a larger one fires at once. */
|
|
69
|
+
export const MAX_TIMER_MS = 2_147_483_647;
|
|
66
70
|
|
|
67
71
|
/** An https URL, or http on localhost / 127.0.0.1 (local harness). */
|
|
68
72
|
const secureUrlSchema = z.string().refine((raw) => isSecureUrl(raw), {
|
|
@@ -220,6 +224,20 @@ export const baseEnvSchema = z.object({
|
|
|
220
224
|
.int()
|
|
221
225
|
.positive()
|
|
222
226
|
.default(2_000),
|
|
227
|
+
/**
|
|
228
|
+
* How long a browser tool / AG-UI call waits, unsent, for a socket of its
|
|
229
|
+
* session that can run it (ms) before it fails as never sent. The turn's
|
|
230
|
+
* own deadline (`TURN_TIMEOUT_MS`) still ends it first when sooner. At
|
|
231
|
+
* most `MAX_TIMER_MS` (a timer holds it). The user object reads it
|
|
232
|
+
* through this field (`frontendParkTimeoutMs`,
|
|
233
|
+
* src/realtime/realtime-endpoint.ts).
|
|
234
|
+
*/
|
|
235
|
+
FRONTEND_PARK_TIMEOUT_MS: z.coerce
|
|
236
|
+
.number()
|
|
237
|
+
.int()
|
|
238
|
+
.positive()
|
|
239
|
+
.max(MAX_TIMER_MS)
|
|
240
|
+
.default(FRONTEND_PARK_TIMEOUT_MS_DEFAULT),
|
|
223
241
|
|
|
224
242
|
// --- decisions ----------------------------------------------------------
|
|
225
243
|
// Bounded semantic Decision provider (`DECISION_PROVIDER`, `DECISION_MODEL`,
|
package/src/core/index.ts
CHANGED
|
@@ -802,5 +802,11 @@ export {
|
|
|
802
802
|
export type { TurnLimits, TurnUsage } from './turn-budget';
|
|
803
803
|
export { budgetedLlm } from './budgeted-llm';
|
|
804
804
|
export type { BudgetedLlm, BudgetedLlmOptions } from './budgeted-llm';
|
|
805
|
-
export { ToolScheduler } from './tool-scheduler';
|
|
806
|
-
export type {
|
|
805
|
+
export { sessionLaneKey, ToolScheduler } from './tool-scheduler';
|
|
806
|
+
export type {
|
|
807
|
+
LaneOccupancy,
|
|
808
|
+
SessionToolLane,
|
|
809
|
+
ToolLane,
|
|
810
|
+
ToolSchedulerOptions,
|
|
811
|
+
ToolSchedulerSnapshot,
|
|
812
|
+
} from './tool-scheduler';
|
|
@@ -218,6 +218,14 @@ export interface MainAgentBuildResult {
|
|
|
218
218
|
* turn, `write` never does). Declared by the plugin, else by name.
|
|
219
219
|
*/
|
|
220
220
|
toolEffects: Map<string, 'read' | 'write'>;
|
|
221
|
+
/**
|
|
222
|
+
* The plugin tools the turn can run that act in the user's browser
|
|
223
|
+
* (`PluginTool.surface`), a sub-agent's inner tools included: scheduled
|
|
224
|
+
* in their session's `frontend` lane. A tool absent from the map (meta,
|
|
225
|
+
* domain and dispatch tools, every server-side tool, and a name two tools
|
|
226
|
+
* disagree on) is scheduled by its effect.
|
|
227
|
+
*/
|
|
228
|
+
toolSurfaces: Map<string, 'frontend'>;
|
|
221
229
|
/** Names of the sub-agent dispatch tools (`call_*_agent`): scheduled in their own lane. */
|
|
222
230
|
subAgentToolNames: ReadonlySet<string>;
|
|
223
231
|
/**
|
package/src/core/main-agent.ts
CHANGED
|
@@ -235,6 +235,7 @@ export async function createMainAgent(
|
|
|
235
235
|
systemPrompt,
|
|
236
236
|
boundToolNames: [],
|
|
237
237
|
toolEffects: new Map(),
|
|
238
|
+
toolSurfaces: new Map(),
|
|
238
239
|
subAgentToolNames: new Set(),
|
|
239
240
|
context: { user: requestCtx.user, session: requestCtx.session },
|
|
240
241
|
};
|
|
@@ -556,6 +557,28 @@ export async function createMainAgent(
|
|
|
556
557
|
for (const { tool } of allTools)
|
|
557
558
|
toolEffects.set(tool.name, toolEffectOf(tool));
|
|
558
559
|
for (const t of subAgentTools) toolEffects.set(t.name, 'write');
|
|
560
|
+
// The plugin tools that act in the user's browser (`PluginTool.surface`),
|
|
561
|
+
// a sub-agent's inner tools included (the execution middleware schedules
|
|
562
|
+
// them too, by name): scheduled in their session's frontend lane. A name
|
|
563
|
+
// is listed only when every tool of that name declares it; meta, domain
|
|
564
|
+
// and dispatch tools never are.
|
|
565
|
+
const surfaceOf = new Map<string, PluginTool['surface']>();
|
|
566
|
+
const declareSurface = (tool: PluginTool): void => {
|
|
567
|
+
const known = surfaceOf.get(tool.name);
|
|
568
|
+
surfaceOf.set(
|
|
569
|
+
tool.name,
|
|
570
|
+
surfaceOf.has(tool.name) && known !== tool.surface
|
|
571
|
+
? undefined
|
|
572
|
+
: tool.surface,
|
|
573
|
+
);
|
|
574
|
+
};
|
|
575
|
+
for (const { tool } of [...turnTools, ...allTools]) declareSurface(tool);
|
|
576
|
+
for (const { subAgent } of subAgentEntries)
|
|
577
|
+
for (const tool of Array.isArray(subAgent.tools) ? subAgent.tools : [])
|
|
578
|
+
declareSurface(tool);
|
|
579
|
+
const toolSurfaces = new Map<string, 'frontend'>();
|
|
580
|
+
for (const [name, surface] of surfaceOf)
|
|
581
|
+
if (surface === 'frontend') toolSurfaces.set(name, surface);
|
|
559
582
|
// Tools whose identical call is a new action (a browser step), which the
|
|
560
583
|
// repetition guard caps like a read whatever their effect.
|
|
561
584
|
const repeatableToolNames = new Set(
|
|
@@ -909,6 +932,7 @@ export async function createMainAgent(
|
|
|
909
932
|
systemPrompt,
|
|
910
933
|
boundToolNames: tools.map((t) => t.name),
|
|
911
934
|
toolEffects,
|
|
935
|
+
toolSurfaces,
|
|
912
936
|
subAgentToolNames: new Set(subAgentTools.map((t) => t.name)),
|
|
913
937
|
context: runConfig.context,
|
|
914
938
|
...(domain
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* run: charged to the turn's budget, scheduled in its lane, and — for a
|
|
4
4
|
* write — claimed in the run ledger for as long as its outcome is unknown.
|
|
5
5
|
*
|
|
6
|
-
* The claim is a fingerprint of the tool name and its canonical arguments
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The claim is a fingerprint of the tool name and its canonical arguments
|
|
7
|
+
* (and, for a frontend write, its session), recorded before the write
|
|
8
|
+
* starts; a write is claimed by its effect, whichever lane it runs in. A
|
|
9
|
+
* returned result (success or a reported failure) releases it, and so does
|
|
10
|
+
* a frontend call that was never sent; a failure that says nothing about whether
|
|
9
11
|
* the write happened — abort, deadline, a dropped connection, a 5xx —
|
|
10
12
|
* leaves it in place, whether the tool threw it or caught it and returned
|
|
11
13
|
* it as an error result (many plugin tools and adapters never throw to the
|
|
@@ -30,6 +32,7 @@ import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
|
30
32
|
import type { Logger } from '../../plugin-api/types';
|
|
31
33
|
import type { ToolLane, ToolScheduler } from '../tool-scheduler';
|
|
32
34
|
import { findProviderStall } from '../../llm/stream-liveness';
|
|
35
|
+
import { isFrontendCallNotSent } from '../../realtime/frontend-call-registry';
|
|
33
36
|
import { isHarnessLimitError, type TurnBudget } from '../turn-budget';
|
|
34
37
|
import { NOOP_LOGGER } from '../utils';
|
|
35
38
|
|
|
@@ -51,8 +54,18 @@ export interface WriteClaimStore {
|
|
|
51
54
|
export interface ToolExecutionOptions {
|
|
52
55
|
budget: TurnBudget;
|
|
53
56
|
scheduler: ToolScheduler;
|
|
54
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Lane of a tool by name: `read` / `write` (user-wide, by effect),
|
|
59
|
+
* `frontend` for a tool acting in the user's browser
|
|
60
|
+
* (`PluginTool.surface`), `subagent` for a dispatch. The two per-session
|
|
61
|
+
* lanes are keyed by `sessionId` below.
|
|
62
|
+
*/
|
|
55
63
|
laneOf: (toolName: string) => ToolLane;
|
|
64
|
+
/**
|
|
65
|
+
* Effect of a tool by name. A `write` is claimed in the ledger whatever
|
|
66
|
+
* its lane (a frontend write too); a sub-agent dispatch never is.
|
|
67
|
+
*/
|
|
68
|
+
effectOf: (toolName: string) => 'read' | 'write';
|
|
56
69
|
runId: string;
|
|
57
70
|
sessionId: string;
|
|
58
71
|
/** Omitted → writes are scheduled and charged but not claimed (stateless builds, tests). */
|
|
@@ -77,12 +90,21 @@ export function canonicalArguments(value: unknown): string {
|
|
|
77
90
|
return JSON.stringify(value) ?? 'null';
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* SHA-256 of `name:args`, hex — the write's fingerprint in the ledger. With
|
|
95
|
+
* `sessionId` (a frontend write: it acts in that session's own browser, so
|
|
96
|
+
* the same call in another chat is a different action) the session is part
|
|
97
|
+
* of it: `name@<sessionId>:args`.
|
|
98
|
+
*/
|
|
81
99
|
export async function operationKey(
|
|
82
100
|
name: string,
|
|
83
101
|
args: unknown,
|
|
102
|
+
sessionId?: string,
|
|
84
103
|
): Promise<string> {
|
|
85
|
-
const
|
|
104
|
+
const scope = sessionId === undefined ? '' : `@${sessionId}`;
|
|
105
|
+
const bytes = new TextEncoder().encode(
|
|
106
|
+
`${name}${scope}:${canonicalArguments(args)}`,
|
|
107
|
+
);
|
|
86
108
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
87
109
|
return Array.from(new Uint8Array(digest), (b) =>
|
|
88
110
|
b.toString(16).padStart(2, '0'),
|
|
@@ -141,12 +163,16 @@ const isServerStatus = (status: unknown): boolean =>
|
|
|
141
163
|
/**
|
|
142
164
|
* `true` when a thrown error says nothing about whether the tool's side
|
|
143
165
|
* effect happened: the turn was aborted or hit its deadline, the transport
|
|
144
|
-
* failed, or the service answered with a server error.
|
|
166
|
+
* failed, or the service answered with a server error. A frontend call that
|
|
167
|
+
* was never sent (`FrontendCallNotSentError`: parked until the turn aborted
|
|
168
|
+
* or its park deadline passed) is a known outcome even on an aborted turn:
|
|
169
|
+
* no browser saw it.
|
|
145
170
|
*/
|
|
146
171
|
export function isUncertainOutcome(
|
|
147
172
|
error: unknown,
|
|
148
173
|
signal?: AbortSignal,
|
|
149
174
|
): boolean {
|
|
175
|
+
if (isFrontendCallNotSent(error)) return false;
|
|
150
176
|
if (signal?.aborted) return true;
|
|
151
177
|
if (isHarnessLimitError(error)) return true;
|
|
152
178
|
// A tool whose own model call stalled (stream-liveness.ts) stopped at an
|
|
@@ -267,6 +293,10 @@ export function createToolExecutionMiddleware(
|
|
|
267
293
|
const { toolCall } = request;
|
|
268
294
|
const toolName = toolCall.name;
|
|
269
295
|
const lane = options.laneOf(toolName);
|
|
296
|
+
// Claimed by effect, whatever the lane: a frontend write too. A
|
|
297
|
+
// dispatch is never fingerprinted (its inner writes are, by name).
|
|
298
|
+
const claimed =
|
|
299
|
+
lane !== 'subagent' && options.effectOf(toolName) === 'write';
|
|
270
300
|
const callId = toolCall.id ?? '';
|
|
271
301
|
const attempt = (attempts.get(callId) ?? 0) + 1;
|
|
272
302
|
attempts.set(callId, attempt);
|
|
@@ -290,51 +320,62 @@ export function createToolExecutionMiddleware(
|
|
|
290
320
|
);
|
|
291
321
|
return output;
|
|
292
322
|
};
|
|
293
|
-
return scheduler.run(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
`[tool-execution] ${toolName}: an identical write since ${claim.since} has no known outcome; not run again`,
|
|
323
|
+
return scheduler.run(
|
|
324
|
+
lane,
|
|
325
|
+
options.signal,
|
|
326
|
+
async () => {
|
|
327
|
+
budget.reserveTool(options.signal);
|
|
328
|
+
if (!claimed || !claims) return run();
|
|
329
|
+
// The ledger is user-wide; a frontend write's fingerprint names its
|
|
330
|
+
// session, so the same browser action in two chats never collides.
|
|
331
|
+
const fingerprint = await operationKey(
|
|
332
|
+
toolName,
|
|
333
|
+
toolCall.args,
|
|
334
|
+
lane === 'frontend' ? options.sessionId : undefined,
|
|
306
335
|
);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
336
|
+
const claim = await claims.claimWrite({
|
|
337
|
+
fingerprint,
|
|
338
|
+
toolName,
|
|
339
|
+
runId: options.runId,
|
|
340
|
+
sessionId: options.sessionId,
|
|
312
341
|
});
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
const output = await run();
|
|
316
|
-
const uncertain = uncertainResultReason(output);
|
|
317
|
-
if (uncertain !== null) {
|
|
342
|
+
if (claim.status === 'blocked') {
|
|
318
343
|
logger.warn(
|
|
319
|
-
`[tool-execution] ${toolName}:
|
|
344
|
+
`[tool-execution] ${toolName}: an identical write since ${claim.since} has no known outcome; not run again`,
|
|
320
345
|
);
|
|
321
|
-
|
|
322
|
-
|
|
346
|
+
return new ToolMessage({
|
|
347
|
+
tool_call_id: toolCall.id ?? '',
|
|
348
|
+
name: toolName,
|
|
349
|
+
content: uncertainWriteToolResult(toolName),
|
|
350
|
+
status: 'error',
|
|
351
|
+
});
|
|
323
352
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
.releaseWrite(fingerprint, options.runId)
|
|
333
|
-
|
|
353
|
+
try {
|
|
354
|
+
const output = await run();
|
|
355
|
+
const uncertain = uncertainResultReason(output);
|
|
356
|
+
if (uncertain !== null) {
|
|
357
|
+
logger.warn(
|
|
358
|
+
`[tool-execution] ${toolName}: returned a failure that leaves its outcome unknown (${uncertain}); its claim stays in the ledger`,
|
|
359
|
+
);
|
|
360
|
+
} else {
|
|
361
|
+
await claims.releaseWrite(fingerprint, options.runId);
|
|
362
|
+
}
|
|
363
|
+
return output;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (isUncertainOutcome(error, options.signal)) {
|
|
366
|
+
logger.warn(
|
|
367
|
+
`[tool-execution] ${toolName}: outcome unknown (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); its claim stays in the ledger`,
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
await claims
|
|
371
|
+
.releaseWrite(fingerprint, options.runId)
|
|
372
|
+
.catch(() => undefined);
|
|
373
|
+
}
|
|
374
|
+
throw error;
|
|
334
375
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
376
|
+
},
|
|
377
|
+
options.sessionId,
|
|
378
|
+
);
|
|
338
379
|
},
|
|
339
380
|
});
|
|
340
381
|
}
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* gets a synthetic result saying the outcome is unknown, so it can tell
|
|
13
13
|
* the user or verify with a read;
|
|
14
14
|
* - a started, unfinished **read** call runs again (it has no effect);
|
|
15
|
+
* - a started, unfinished **frontend** call (`PluginTool.surface`) that
|
|
16
|
+
* was never sent — it was parked waiting for a browser socket when the
|
|
17
|
+
* object restarted, so no browser saw it — runs again whatever its
|
|
18
|
+
* effect. Once sent (`markSent`, recorded by the frontend bridge when a
|
|
19
|
+
* socket receives it) it follows the read / write rules above;
|
|
15
20
|
* - a call that already finished is not executed again either (its result
|
|
16
21
|
* is in the checkpoint's pending writes; reaching here means LangGraph
|
|
17
22
|
* could not replay it) — writes get the unknown-outcome result, reads run.
|
|
@@ -41,6 +46,8 @@ export interface ToolMarkStore {
|
|
|
41
46
|
effect: ToolEffect;
|
|
42
47
|
}): Promise<ToolMark | undefined>;
|
|
43
48
|
bumpMark(runId: string, toolCallId: string): Promise<void>;
|
|
49
|
+
/** The call was handed to its executor outside the object (a browser socket). */
|
|
50
|
+
markSent(runId: string, toolCallId: string): Promise<void>;
|
|
44
51
|
finishMark(
|
|
45
52
|
runId: string,
|
|
46
53
|
toolCallId: string,
|
|
@@ -53,6 +60,12 @@ export interface ToolMarksOptions {
|
|
|
53
60
|
store: ToolMarkStore;
|
|
54
61
|
/** The effect of a tool by name; unknown tools are writes (see `toolEffectOf`). */
|
|
55
62
|
effectOf: (toolName: string) => ToolEffect;
|
|
63
|
+
/**
|
|
64
|
+
* `frontend` for a tool that runs in the user's browser
|
|
65
|
+
* (`PluginTool.surface`); such a call that was never sent before a restart
|
|
66
|
+
* runs again whatever its effect. Omitted → no tool is a frontend tool.
|
|
67
|
+
*/
|
|
68
|
+
surfaceOf?: (toolName: string) => 'frontend' | undefined;
|
|
56
69
|
/**
|
|
57
70
|
* The reply text the user already received before the run was cut off.
|
|
58
71
|
* Set only on a resumed attempt; consumed by the first model call.
|
|
@@ -84,18 +97,32 @@ export function continuationNote(partialText: string): string {
|
|
|
84
97
|
);
|
|
85
98
|
}
|
|
86
99
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
93
|
-
'
|
|
94
|
-
'
|
|
95
|
-
'
|
|
96
|
-
'
|
|
97
|
-
'
|
|
100
|
+
/**
|
|
101
|
+
* Verbs a read-only tool name starts with, in either convention: snake case
|
|
102
|
+
* (`get_entity`) or camel case (`getEntity`, as browser tools are named).
|
|
103
|
+
*/
|
|
104
|
+
const READ_VERBS = [
|
|
105
|
+
'list',
|
|
106
|
+
'get',
|
|
107
|
+
'find',
|
|
108
|
+
'search',
|
|
109
|
+
'read',
|
|
110
|
+
'preview',
|
|
111
|
+
'describe',
|
|
112
|
+
'check',
|
|
113
|
+
'explain',
|
|
114
|
+
'validate',
|
|
115
|
+
'view',
|
|
98
116
|
];
|
|
117
|
+
|
|
118
|
+
function startsWithReadVerb(name: string): boolean {
|
|
119
|
+
for (const verb of READ_VERBS) {
|
|
120
|
+
if (!name.startsWith(verb)) continue;
|
|
121
|
+
const next = name.charAt(verb.length);
|
|
122
|
+
if (next === '_' || (next >= 'A' && next <= 'Z')) return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
99
126
|
const READ_NAMES = new Set([
|
|
100
127
|
'requirements',
|
|
101
128
|
'flow_status',
|
|
@@ -125,9 +152,7 @@ export function toolEffectOf(tool: {
|
|
|
125
152
|
? tool.name.slice(tool.name.lastIndexOf('__') + 2)
|
|
126
153
|
: tool.name;
|
|
127
154
|
if (READ_NAMES.has(bare)) return 'read';
|
|
128
|
-
|
|
129
|
-
if (bare.startsWith(prefix)) return 'read';
|
|
130
|
-
return 'write';
|
|
155
|
+
return startsWithReadVerb(bare) ? 'read' : 'write';
|
|
131
156
|
}
|
|
132
157
|
|
|
133
158
|
/** Whether a tool call's output is an error result (`ToolMessage` status `error`). */
|
|
@@ -170,7 +195,16 @@ export function createToolMarksMiddleware(
|
|
|
170
195
|
effect,
|
|
171
196
|
});
|
|
172
197
|
if (existing) {
|
|
173
|
-
|
|
198
|
+
const neverSent =
|
|
199
|
+
!existing.doneAt &&
|
|
200
|
+
existing.sentAt === null &&
|
|
201
|
+
options.surfaceOf?.(toolName) === 'frontend';
|
|
202
|
+
if (neverSent) {
|
|
203
|
+
logger.log(
|
|
204
|
+
`[tool-marks] ${toolName} (${toolCallId}) was parked and never sent to a browser in an earlier attempt of run ${options.runId}; running it again`,
|
|
205
|
+
);
|
|
206
|
+
await options.store.bumpMark(options.runId, toolCallId);
|
|
207
|
+
} else if (effect === 'write') {
|
|
174
208
|
logger.warn(
|
|
175
209
|
`[tool-marks] ${toolName} (${toolCallId}) was ${existing.doneAt ? 'already executed' : 'interrupted mid-execution'} in an earlier attempt of run ${options.runId}; not run again`,
|
|
176
210
|
);
|
|
@@ -185,11 +219,12 @@ export function createToolMarksMiddleware(
|
|
|
185
219
|
content: unknownOutcomeToolResult(toolName),
|
|
186
220
|
status: 'error',
|
|
187
221
|
});
|
|
222
|
+
} else {
|
|
223
|
+
logger.log(
|
|
224
|
+
`[tool-marks] ${toolName} (${toolCallId}) is read-only; running it again after the interruption`,
|
|
225
|
+
);
|
|
226
|
+
await options.store.bumpMark(options.runId, toolCallId);
|
|
188
227
|
}
|
|
189
|
-
logger.log(
|
|
190
|
-
`[tool-marks] ${toolName} (${toolCallId}) is read-only; running it again after the interruption`,
|
|
191
|
-
);
|
|
192
|
-
await options.store.bumpMark(options.runId, toolCallId);
|
|
193
228
|
}
|
|
194
229
|
try {
|
|
195
230
|
const output = await handler(request);
|
|
@@ -202,6 +202,12 @@ export interface AmbientServices {
|
|
|
202
202
|
onTurnEnd?: (dispose: () => void | Promise<void>) => void;
|
|
203
203
|
/** Host keep-alive for un-awaited work (`ctx.waitUntil` on Workers). */
|
|
204
204
|
background?: (work: Promise<unknown>) => void;
|
|
205
|
+
/**
|
|
206
|
+
* Records that the tool call `toolCallId` of this turn left the object
|
|
207
|
+
* (`ctx.markDispatched`). Set per turn by a host that keeps write-ahead
|
|
208
|
+
* tool marks.
|
|
209
|
+
*/
|
|
210
|
+
markDispatched?: (toolCallId: string) => void;
|
|
205
211
|
}
|
|
206
212
|
|
|
207
213
|
// ── Scoped emitter ──────────────────────────────────────────────────────────
|
|
@@ -434,6 +440,8 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
|
|
|
434
440
|
};
|
|
435
441
|
|
|
436
442
|
const delegation = user.ucanDelegation;
|
|
443
|
+
const toolCallId = runConfig.toolCall?.id;
|
|
444
|
+
const { markDispatched } = ambient;
|
|
437
445
|
|
|
438
446
|
const ctx: RuntimeContext<TConfig> = {
|
|
439
447
|
user,
|
|
@@ -527,7 +535,10 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
|
|
|
527
535
|
...(ambient.onTurnEnd ? { onTurnEnd: ambient.onTurnEnd } : {}),
|
|
528
536
|
...(ambient.background ? { background: ambient.background } : {}),
|
|
529
537
|
shared: EMPTY_SHARED,
|
|
530
|
-
...(
|
|
538
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
539
|
+
...(toolCallId && markDispatched
|
|
540
|
+
? { markDispatched: () => markDispatched(toolCallId) }
|
|
541
|
+
: {}),
|
|
531
542
|
};
|
|
532
543
|
if (sharedFactory) {
|
|
533
544
|
ctx.shared = sharedFactory(ctx);
|
|
@@ -730,5 +741,8 @@ export function createNoopAmbient(
|
|
|
730
741
|
logger: overrides.logger ?? NOOP_LOGGER,
|
|
731
742
|
...(overrides.decisions ? { decisions: overrides.decisions } : {}),
|
|
732
743
|
...(overrides.kv ? { kv: overrides.kv } : {}),
|
|
744
|
+
...(overrides.markDispatched
|
|
745
|
+
? { markDispatched: overrides.markDispatched }
|
|
746
|
+
: {}),
|
|
733
747
|
};
|
|
734
748
|
}
|
package/src/core/state.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface BrowserToolCall {
|
|
|
21
21
|
name: string;
|
|
22
22
|
description: string;
|
|
23
23
|
schema: Record<string, unknown>;
|
|
24
|
+
/** The tool's effect as the client declares it (`'read'` / `'write'`). */
|
|
25
|
+
effect?: 'read' | 'write';
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/** AG-UI action descriptor injected per request from the client. */
|