@ixo/oracle-runtime-workers 0.16.0 → 0.17.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 (40) hide show
  1. package/README.md +3 -0
  2. package/package.json +5 -3
  3. package/src/core/budgeted-llm.ts +115 -6
  4. package/src/core/context-budget.ts +24 -26
  5. package/src/core/context-window.ts +39 -12
  6. package/src/core/domain-context/document.ts +100 -0
  7. package/src/core/domain-context/findings.ts +25 -0
  8. package/src/core/domain-context/index.ts +28 -0
  9. package/src/core/domain-context/integrity.ts +60 -0
  10. package/src/core/domain-context/lru.ts +63 -0
  11. package/src/core/domain-context/resolver.ts +651 -0
  12. package/src/core/domain-context/transport.ts +340 -0
  13. package/src/core/domain-context/turn-context.ts +443 -0
  14. package/src/core/domain-context/types.ts +119 -0
  15. package/src/core/domain-context/validator.ts +41 -0
  16. package/src/core/env.ts +60 -6
  17. package/src/core/index.ts +2 -0
  18. package/src/core/llm.ts +225 -4
  19. package/src/core/main-agent-types.ts +28 -0
  20. package/src/core/main-agent.ts +61 -4
  21. package/src/core/middlewares/tool-execution.ts +19 -2
  22. package/src/core/registries.ts +14 -3
  23. package/src/core/state.ts +2 -1
  24. package/src/core/sub-agent-fallback.ts +14 -2
  25. package/src/core/turn-budget.ts +19 -22
  26. package/src/do/contracts.ts +23 -2
  27. package/src/do/domain-context-store.ts +123 -0
  28. package/src/do/run-request.ts +6 -0
  29. package/src/do/sse-stream.ts +1 -1
  30. package/src/do/transcript.ts +9 -0
  31. package/src/do/turn-body.ts +2 -1
  32. package/src/do/turn-metadata.ts +13 -6
  33. package/src/do/user-oracle-do.ts +109 -42
  34. package/src/index.ts +16 -0
  35. package/src/llm/byo-adapter.ts +6 -0
  36. package/src/llm/byo-catalog.ts +7 -4
  37. package/src/llm/byo-client.ts +42 -7
  38. package/src/llm/provider-error.ts +21 -3
  39. package/src/llm/stream-liveness.ts +498 -0
  40. package/src/plugins/firecrawl/firecrawl-tools.ts +6 -0
@@ -327,22 +327,33 @@ function withoutShadowingRequestTools(
327
327
  * tools), or one an earlier request-time entry of the same turn already
328
328
  * claimed, is dropped with one warning naming the plugin. Sub-agents are
329
329
  * compared by the tool name they are bound under. Boot-time entries pass
330
- * through untouched; their collisions are a boot error
331
- * (`assertNoCollisions`).
330
+ * through untouched (their collisions are a boot error, `assertNoCollisions`)
331
+ * unless they take one of `runtimeNames`: tools the runtime binds only on
332
+ * some turns (so no boot check can see them), which win over a plugin tool
333
+ * of either origin.
332
334
  */
333
335
  export function dropShadowingRequestEntries(opts: {
334
336
  tools: readonly RegisteredTool[];
335
337
  requestSubAgents: readonly RegisteredSubAgent[];
336
338
  reservedNames: Iterable<string>;
339
+ /** Names the runtime binds this turn; reserved against boot-time tools too. */
340
+ runtimeNames?: Iterable<string>;
337
341
  logger: { warn(message: string): void };
338
342
  }): { tools: RegisteredTool[]; requestSubAgents: RegisteredSubAgent[] } {
339
- const taken = new Set(opts.reservedNames);
343
+ const runtime = new Set(opts.runtimeNames);
344
+ const taken = new Set([...opts.reservedNames, ...runtime]);
340
345
  const drop = (pluginName: string, name: string, kind: string): void =>
341
346
  opts.logger.warn(
342
347
  `${LOG_PREFIX} plugin "${pluginName}" request-time ${kind} "${name}" dropped: the name is already taken this turn`,
343
348
  );
344
349
  const tools: RegisteredTool[] = [];
345
350
  for (const entry of opts.tools) {
351
+ if (entry.origin === 'boot' && runtime.has(entry.tool.name)) {
352
+ opts.logger.warn(
353
+ `${LOG_PREFIX} plugin "${entry.pluginName}" tool "${entry.tool.name}" dropped for this turn: the runtime binds a tool of that name`,
354
+ );
355
+ continue;
356
+ }
346
357
  if (entry.origin === 'request') {
347
358
  if (taken.has(entry.tool.name)) {
348
359
  drop(entry.pluginName, entry.tool.name, 'tool');
package/src/core/state.ts CHANGED
@@ -69,7 +69,8 @@ export const MainAgentGraphState = Annotation.Root({
69
69
  default: () => undefined,
70
70
  reducer: (_, curr) => curr,
71
71
  }),
72
- currentEntityDid: Annotation<string | undefined>({
72
+ /** Subject entity (request `metadata.currentEntityDid`); `null` once a request cleared it. */
73
+ currentEntityDid: Annotation<string | null | undefined>({
73
74
  default: () => undefined,
74
75
  reducer: (_, curr) => curr,
75
76
  }),
@@ -55,6 +55,11 @@ export interface CollectSubAgentsInput {
55
55
  * in name collisions.
56
56
  */
57
57
  passthroughTools?: StructuredTool[];
58
+ /**
59
+ * Runtime context appended to every sub-agent's system prompt (the turn's
60
+ * domain-context block), so a sub-agent works from what the main agent saw.
61
+ */
62
+ contextPrompt?: string;
58
63
  /** Middlewares every sub-agent's inner graph gets in addition to its own. */
59
64
  extraMiddleware?: AgentMiddleware[];
60
65
  /**
@@ -170,6 +175,7 @@ export async function collectSubAgentsWithFallback(
170
175
  fallbackContext,
171
176
  toAgentSpec,
172
177
  passthroughTools,
178
+ contextPrompt,
173
179
  subAgents,
174
180
  extraMiddleware,
175
181
  resultCap,
@@ -197,9 +203,15 @@ export async function collectSubAgentsWithFallback(
197
203
  resultCap,
198
204
  dispatchMiddleware,
199
205
  );
200
- const withPassthrough: AgentSpec = passthroughTools?.length
201
- ? { ...spec, passthroughTools }
206
+ const withContext: AgentSpec = contextPrompt
207
+ ? {
208
+ ...spec,
209
+ systemPrompt: `${spec.systemPrompt}\n\n${contextPrompt}`,
210
+ }
202
211
  : spec;
212
+ const withPassthrough: AgentSpec = passthroughTools?.length
213
+ ? { ...withContext, passthroughTools }
214
+ : withContext;
203
215
  return createSubagentAsTool(
204
216
  withPassthrough,
205
217
  withPassthrough.forwardTools
@@ -1,13 +1,8 @@
1
1
  /**
2
2
  * One turn's resource budget, shared by the main agent, every sub-agent it
3
3
  * dispatches and the helper models (summarizer, attachment extraction) the
4
- * turn's LLM adapter hands out. Three limits:
4
+ * turn's LLM adapter hands out. Two limits:
5
5
  *
6
- * - `tokens` — cumulative model tokens. A call reserves an estimate
7
- * (chars/4 of what it sends plus the output reserve) before
8
- * it starts and settles to the provider's reported usage
9
- * when the call ends, so the counter is an estimate only
10
- * while a call is in flight.
11
6
  * - `tools` — tool attempts, counting a sub-agent dispatch and each
12
7
  * retry of a read.
13
8
  * - `durationMs` — wall clock since the budget was created (the turn's
@@ -16,6 +11,17 @@
16
11
  * Exhaustion is terminal for the turn: `HarnessLimitError` is not retryable
17
12
  * and the work already done (checkpoints, tool results) is kept.
18
13
  *
14
+ * Model tokens are accounted, never limited: a call reserves an estimate
15
+ * (chars/4 of what it sends plus the output reserve) before it starts and
16
+ * settles to the provider's reported usage when the call ends, so the
17
+ * counter is an estimate only while a call is in flight. The count is
18
+ * reported for observability (the host logs it at the end of the turn and
19
+ * keeps it with the run) and never fails a turn, however large it gets:
20
+ * every call re-sends the whole history, so a turn on a long thread
21
+ * legitimately sums to many times the context window. A turn is bounded
22
+ * by its tool attempts and its deadline, and each request by the context
23
+ * budget (`context-budget.ts`).
24
+ *
19
25
  * Token counts are estimates reconciled against provider usage, never a
20
26
  * billing figure; `TURN_RECURSION_LIMIT` stays the separate guard against a
21
27
  * runaway graph.
@@ -25,7 +31,7 @@ export class HarnessLimitError extends Error {
25
31
 
26
32
  constructor(
27
33
  readonly kind: 'budget_exhausted',
28
- readonly limit: 'tokens' | 'tools' | 'time',
34
+ readonly limit: 'tools' | 'time',
29
35
  message: string,
30
36
  ) {
31
37
  super(message);
@@ -33,13 +39,9 @@ export class HarnessLimitError extends Error {
33
39
  }
34
40
  }
35
41
 
36
- const LIMITS: ReadonlySet<string> = new Set(['tokens', 'tools', 'time']);
37
-
38
42
  function limitOf(error: Error): HarnessLimitError['limit'] | undefined {
39
43
  const value: unknown = Reflect.get(error, 'limit');
40
- return value === 'tokens' || value === 'tools' || value === 'time'
41
- ? value
42
- : undefined;
44
+ return value === 'tools' || value === 'time' ? value : undefined;
43
45
  }
44
46
 
45
47
  /**
@@ -54,7 +56,7 @@ export function harnessLimitOf(error: unknown): HarnessLimitError | undefined {
54
56
  for (let depth = 0; depth < 32 && current instanceof Error; depth += 1) {
55
57
  if (current instanceof HarnessLimitError) return current;
56
58
  const limit = limitOf(current);
57
- if (current.name === 'HarnessLimitError' && limit && LIMITS.has(limit))
59
+ if (current.name === 'HarnessLimitError' && limit)
58
60
  return new HarnessLimitError('budget_exhausted', limit, current.message);
59
61
  current = current.cause;
60
62
  }
@@ -67,8 +69,6 @@ export function isHarnessLimitError(error: unknown): boolean {
67
69
  }
68
70
 
69
71
  export interface TurnLimits {
70
- /** Cumulative model tokens (estimated input + reserved output, settled to usage). */
71
- tokens: number;
72
72
  /** Tool attempts, sub-agent dispatches included. */
73
73
  tools: number;
74
74
  /** Wall-clock deadline for the turn. */
@@ -76,14 +76,12 @@ export interface TurnLimits {
76
76
  }
77
77
 
78
78
  export const DEFAULT_TURN_LIMITS: TurnLimits = {
79
- tokens: 500_000,
80
79
  tools: 120,
81
80
  durationMs: 600_000,
82
81
  };
83
82
 
84
83
  /** Parse the `TURN_*` env knobs; anything unusable keeps its default. */
85
84
  export function turnLimitsFromEnv(env: {
86
- TURN_MAX_TOKENS?: number | string;
87
85
  TURN_MAX_TOOL_CALLS?: number | string;
88
86
  TURN_TIMEOUT_MS?: number | string;
89
87
  }): TurnLimits {
@@ -93,7 +91,6 @@ export function turnLimitsFromEnv(env: {
93
91
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
94
92
  };
95
93
  return {
96
- tokens: positive(env.TURN_MAX_TOKENS, DEFAULT_TURN_LIMITS.tokens),
97
94
  tools: positive(env.TURN_MAX_TOOL_CALLS, DEFAULT_TURN_LIMITS.tools),
98
95
  durationMs: positive(env.TURN_TIMEOUT_MS, DEFAULT_TURN_LIMITS.durationMs),
99
96
  };
@@ -134,6 +131,8 @@ export class TurnBudget {
134
131
  /**
135
132
  * Reserve a model call before it starts. Returns the reservation so the
136
133
  * caller can settle it against the provider's usage once the call ends.
134
+ * Only an aborted turn or a passed deadline refuses the call: the token
135
+ * count itself is never a reason to.
137
136
  */
138
137
  reserveModel(
139
138
  estimatedInputTokens: number,
@@ -141,14 +140,12 @@ export class TurnBudget {
141
140
  signal?: AbortSignal,
142
141
  ): number {
143
142
  this.check(signal);
144
- // A NaN would poison the counter for the rest of the turn (every later
145
- // comparison with the limit is false): an unusable number counts as 0.
143
+ // A NaN would poison the reported count for the rest of the turn: an
144
+ // unusable number counts as 0.
146
145
  const usable = (n: number): number =>
147
146
  Number.isFinite(n) ? Math.max(0, n) : 0;
148
147
  const reservation =
149
148
  usable(estimatedInputTokens) + usable(outputReserveTokens);
150
- if (this.tokens + reservation > this.limits.tokens)
151
- this.fail('tokens', 'token');
152
149
  this.tokens += reservation;
153
150
  this.modelCalls += 1;
154
151
  return reservation;
@@ -21,6 +21,7 @@ import type {
21
21
  FeedbackSettlement,
22
22
  FeedbackTarget,
23
23
  } from '../feedback/contract';
24
+ import type { DomainContextProvenance } from '../core/domain-context';
24
25
  /**
25
26
  * Cross-object contracts for the Workers runtime.
26
27
  *
@@ -227,10 +228,18 @@ export interface OracleWorkerEnv {
227
228
  /** Display markup for `GET /models` prices (default 1.6). */
228
229
  MODEL_PRICE_MARKUP?: string;
229
230
  MAIN_REASONING_EFFORT?: string;
231
+ /**
232
+ * Model-call liveness (docs/configuration.md): ms to response headers
233
+ * (default 120000), ms of silence between body bytes on the platform lane
234
+ * (90000) and on BYO lanes (300000), retries before the first byte (1).
235
+ */
236
+ LLM_HEADERS_TIMEOUT_MS?: string;
237
+ LLM_STREAM_IDLE_TIMEOUT_MS?: string;
238
+ LLM_BYO_STREAM_IDLE_TIMEOUT_MS?: string;
239
+ LLM_STREAM_RETRIES?: string;
230
240
  /** LangGraph steps one turn may take before `GraphRecursionError` (default 600; Node hard-codes 200). */
231
241
  TURN_RECURSION_LIMIT?: string;
232
- /** Turn budget: cumulative model tokens (default 500000), tool attempts (120), deadline in ms (600000). */
233
- TURN_MAX_TOKENS?: string;
242
+ /** Turn budget: tool attempts (default 120) and deadline in ms (600000). */
234
243
  TURN_MAX_TOOL_CALLS?: string;
235
244
  TURN_TIMEOUT_MS?: string;
236
245
  /** Repetition guard: identical successful writes (default 1) and reads (5) per turn. */
@@ -441,6 +450,18 @@ export interface ContextStatus {
441
450
  session?: SessionContextStatus | null;
442
451
  }
443
452
 
453
+ /**
454
+ * The `router_update` frame (`router.update` on the SSE stream) that reports
455
+ * a turn's domain context: one entry per domain the turn looked up (the
456
+ * oracle's, then the subject's), with CIDs, status and findings. Other
457
+ * `router_update` frames (a queued run, plugin steps) carry other fields.
458
+ */
459
+ export interface DomainContextRouterUpdate {
460
+ sessionId: string;
461
+ requestId: string;
462
+ domainContext: DomainContextProvenance[];
463
+ }
464
+
444
465
  export interface RunSummary {
445
466
  runId: string;
446
467
  sessionId: string;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Per-turn domain-context provenance, kept in the user's own SQLite file
3
+ * (`domain_context_runs`): which domain documents a turn was given, by CID,
4
+ * with their status and findings. Bodies, credentials and reasoning are
5
+ * never stored. A deleted session takes its rows with it
6
+ * (`deleteForSession`). Diagnostics only: a failed write is logged with
7
+ * `[domain-context]` and never fails the turn. The table is created by the
8
+ * first recorded turn, so an oracle without domain context never has it.
9
+ */
10
+ import type { DoSqliteDatabase } from '../sqlite/database';
11
+ import type { Logger } from '../plugin-api/types';
12
+ import type { DomainContextProvenance } from '../core/domain-context';
13
+
14
+ export interface DomainContextRunRow {
15
+ requestId: string;
16
+ sessionId: string;
17
+ provenance: DomainContextProvenance[];
18
+ createdAt: number;
19
+ }
20
+
21
+ export class DomainContextStore {
22
+ private setupPromise: Promise<void> | undefined;
23
+
24
+ constructor(
25
+ private readonly db: Pick<DoSqliteDatabase, 'run' | 'exec'>,
26
+ private readonly log: Pick<Logger, 'warn'>,
27
+ private readonly now: () => number = Date.now,
28
+ ) {}
29
+
30
+ private setup(): Promise<void> {
31
+ this.setupPromise ??= this.createTables().catch((error: unknown) => {
32
+ this.setupPromise = undefined;
33
+ throw error;
34
+ });
35
+ return this.setupPromise;
36
+ }
37
+
38
+ private async createTables(): Promise<void> {
39
+ await this.db.run(
40
+ `CREATE TABLE IF NOT EXISTS domain_context_runs (
41
+ request_id TEXT NOT NULL,
42
+ session_id TEXT NOT NULL,
43
+ provenance TEXT NOT NULL,
44
+ created_at INTEGER NOT NULL,
45
+ PRIMARY KEY (session_id, request_id)
46
+ )`,
47
+ );
48
+ }
49
+
50
+ /** Record a turn's provenance; a resumed attempt of the same request replaces it. */
51
+ async record(
52
+ sessionId: string,
53
+ requestId: string,
54
+ provenance: DomainContextProvenance[],
55
+ ): Promise<void> {
56
+ try {
57
+ await this.setup();
58
+ await this.db.run(
59
+ `INSERT OR REPLACE INTO domain_context_runs
60
+ (request_id, session_id, provenance, created_at) VALUES (?, ?, ?, ?)`,
61
+ [requestId, sessionId, JSON.stringify(provenance), this.now()],
62
+ );
63
+ } catch (error) {
64
+ this.log.warn(
65
+ `[domain-context] could not record the provenance of ${requestId}: ${error instanceof Error ? error.message : String(error)}`,
66
+ );
67
+ }
68
+ }
69
+
70
+ /** A session's rows, oldest first (diagnostics and tests). */
71
+ async listForSession(sessionId: string): Promise<DomainContextRunRow[]> {
72
+ await this.setup();
73
+ const rows = await this.db.exec<{
74
+ request_id: string;
75
+ session_id: string;
76
+ provenance: string;
77
+ created_at: number;
78
+ }>(
79
+ `SELECT request_id, session_id, provenance, created_at
80
+ FROM domain_context_runs WHERE session_id = ?
81
+ ORDER BY created_at, rowid`,
82
+ [sessionId],
83
+ );
84
+ return rows.map((row) => ({
85
+ requestId: row.request_id,
86
+ sessionId: row.session_id,
87
+ provenance: parseProvenance(row.provenance),
88
+ createdAt: row.created_at,
89
+ }));
90
+ }
91
+
92
+ /**
93
+ * Delete a session's rows. Never creates the table: a database that has
94
+ * never recorded a domain-context turn has nothing to delete.
95
+ */
96
+ async deleteForSession(sessionId: string): Promise<void> {
97
+ if (!(await this.tableExists())) return;
98
+ await this.db.run(`DELETE FROM domain_context_runs WHERE session_id = ?`, [
99
+ sessionId,
100
+ ]);
101
+ }
102
+
103
+ private async tableExists(): Promise<boolean> {
104
+ const rows = await this.db.exec<{ name: string }>(
105
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'domain_context_runs'`,
106
+ );
107
+ return rows.length > 0;
108
+ }
109
+ }
110
+
111
+ function parseProvenance(json: string): DomainContextProvenance[] {
112
+ const parsed: unknown = JSON.parse(json);
113
+ return Array.isArray(parsed) ? parsed.filter(isProvenance) : [];
114
+ }
115
+
116
+ function isProvenance(value: unknown): value is DomainContextProvenance {
117
+ return (
118
+ typeof value === 'object' &&
119
+ value !== null &&
120
+ typeof Reflect.get(value, 'did') === 'string' &&
121
+ typeof Reflect.get(value, 'status') === 'string'
122
+ );
123
+ }
@@ -10,6 +10,7 @@ import type { RunSummary, TurnRequest } from './contracts';
10
10
  import type { RunRecord } from './run-store';
11
11
  import type { TurnBody } from './turn-body';
12
12
  import type { RequestDisposition } from '../plugin-api/request-admission';
13
+ import type { DomainContextPins } from '../core/domain-context';
13
14
 
14
15
  /** The client-declared surface of a turn, as the body carries it. */
15
16
  export type ClientSurfaceBody = Pick<TurnBody, 'tools' | 'agActions'>;
@@ -21,6 +22,11 @@ export interface StoredRunRequest extends ClientSurfaceBody {
21
22
  };
22
23
  timezone?: string;
23
24
  stream?: boolean;
25
+ /**
26
+ * Domain-context anchors the run's first attempt read, by DID: a resumed
27
+ * attempt reads the same revisions instead of whatever is anchored now.
28
+ */
29
+ domainPins?: DomainContextPins;
24
30
  }
25
31
 
26
32
  export function storedRunRequest(
@@ -306,7 +306,7 @@ export async function runTurnFrames(
306
306
  ...(input.runId ? { runId: input.runId } : {}),
307
307
  });
308
308
 
309
- // The turn hit its budget (tokens, tool attempts, deadline): the abort
309
+ // The turn hit its budget (tool attempts, deadline): the abort
310
310
  // was the harness's, not the user's, so the client gets a terminal
311
311
  // `error` naming the limit before `done` — not a silent "aborted".
312
312
  const limitFailure = (limit: HarnessLimitError) => {
@@ -50,6 +50,13 @@ export interface MessageDto {
50
50
  reasoning?: string;
51
51
  isComplete?: boolean;
52
52
  isReasoning?: boolean;
53
+ /**
54
+ * When the message was first saved, ISO 8601: the saver's
55
+ * `additional_kwargs.timestamp`, which is also the listing's order key. The
56
+ * turn's admission time for the user's message; for a reply, the time its
57
+ * step was first checkpointed. Later saves never move it.
58
+ */
59
+ createdAt?: string;
53
60
  attachment?: AttachmentMeta;
54
61
  attachments?: AttachmentMeta[];
55
62
  metadata?: { 'org.ixo.qi.origin': ChannelOrigin };
@@ -203,6 +210,8 @@ export async function transformTranscript(
203
210
  }
204
211
  }
205
212
  if (reasoning) dto.reasoning = reasoning;
213
+ if (typeof kw.timestamp === 'string' && kw.timestamp.length > 0)
214
+ dto.createdAt = kw.timestamp;
206
215
  if (attachment) dto.attachment = attachment;
207
216
  if (attachments?.length) dto.attachments = attachments;
208
217
  if (message.type === 'human') {
@@ -38,7 +38,8 @@ export interface TurnBody {
38
38
  editorRoomId?: string;
39
39
  spaceId?: string;
40
40
  sessionRunId?: string;
41
- currentEntityDid?: string;
41
+ /** `null` clears the selected subject; omitted keeps it. */
42
+ currentEntityDid?: string | null;
42
43
  };
43
44
  tools?: Array<{
44
45
  name: string;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * - `editorRoomId` / `spaceId` / `currentEntityDid` update the thread's
5
5
  * checkpointed value when the request carries them, otherwise the prior
6
- * value stands;
6
+ * value stands; an explicit `currentEntityDid: null` clears the selected
7
+ * subject (omitting it keeps the prior one);
7
8
  * - `sessionRunId` follows the room: a request that names the editor room
8
9
  * also defines its session run, and "no run" must not fall back to one
9
10
  * remembered from an earlier flow — so it is written whenever
@@ -13,7 +14,8 @@
13
14
  * pass the capability gate without an explicit `load_capability` step.
14
15
  *
15
16
  * Metadata crosses the Durable Object boundary as a JSON string; only string
16
- * values are honoured, anything else is ignored.
17
+ * values are honoured (and `null` for `currentEntityDid`), anything else is
18
+ * ignored.
17
19
  */
18
20
 
19
21
  export const EDITOR_PLUGIN_NAME = 'editor';
@@ -22,7 +24,8 @@ export interface TurnMetadata {
22
24
  editorRoomId?: string;
23
25
  spaceId?: string;
24
26
  sessionRunId?: string;
25
- currentEntityDid?: string;
27
+ /** `null`: the request cleared the selected subject. */
28
+ currentEntityDid?: string | null;
26
29
  }
27
30
 
28
31
  const KEYS = [
@@ -48,6 +51,7 @@ export function parseTurnMetadata(json: string | undefined): TurnMetadata {
48
51
  const value = record[key];
49
52
  if (typeof value === 'string' && value.length > 0) out[key] = value;
50
53
  }
54
+ if (record.currentEntityDid === null) out.currentEntityDid = null;
51
55
  return out;
52
56
  }
53
57
 
@@ -101,7 +105,7 @@ export function metadataBuildState(
101
105
  editorRoomId?: string;
102
106
  sessionRunId?: string;
103
107
  spaceId?: string;
104
- currentEntityDid?: string;
108
+ currentEntityDid?: string | null;
105
109
  loadedPlugins: string[];
106
110
  } {
107
111
  const loaded = prior.loadedPlugins ?? [];
@@ -110,7 +114,10 @@ export function metadataBuildState(
110
114
  sessionRunId:
111
115
  meta.editorRoomId !== undefined ? meta.sessionRunId : prior.sessionRunId,
112
116
  spaceId: meta.spaceId ?? prior.spaceId,
113
- currentEntityDid: meta.currentEntityDid ?? prior.currentEntityDid,
117
+ currentEntityDid:
118
+ meta.currentEntityDid !== undefined
119
+ ? meta.currentEntityDid
120
+ : prior.currentEntityDid,
114
121
  loadedPlugins: editorContextActive(meta, prior)
115
122
  ? Array.from(new Set([...loaded, EDITOR_PLUGIN_NAME]))
116
123
  : loaded,
@@ -125,7 +132,7 @@ export function metadataGraphInput(
125
132
  editorRoomId?: string;
126
133
  sessionRunId?: string;
127
134
  spaceId?: string;
128
- currentEntityDid?: string;
135
+ currentEntityDid?: string | null;
129
136
  loadedPlugins?: string[];
130
137
  } {
131
138
  return {