@ixo/oracle-runtime-workers 0.41.3 → 0.42.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.
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Public documents on the oracle's homeserver for the POD creator's linked
3
+ * resources (the profile, the domain card): reserved when a batch is
4
+ * prepared, uploaded only once the user approved the batch that names them.
5
+ *
6
+ * Reserving is Matrix's asynchronous upload (client-server API v1.7):
7
+ * `POST /_matrix/media/v1/create` answers a `content_uri` before any content
8
+ * exists, so a message can name the document's download URL and proof while
9
+ * nothing is public yet, and `PUT /_matrix/media/v3/upload/{serverName}/{mediaId}`
10
+ * uploads it later. The homeserver refuses a second upload to the same id
11
+ * (409 `M_CANNOT_OVERWRITE_MEDIA`): for a document of ours that is the same
12
+ * bytes, already public. An id never uploaded to expires at the answer's
13
+ * `unused_expires_at` (24 h on Synapse); then the upload is a 404.
14
+ *
15
+ * Every request is made as the oracle's plugin device
16
+ * (`ctx.matrix.botCredentials`), bounded in time and by the turn's abort; a
17
+ * 401 asks the host once for fresh credentials and tries again. Expected
18
+ * failures come back as a {@link GatewayRefusal}; only the turn's abort is
19
+ * thrown.
20
+ */
21
+ import { sha256 } from '@noble/hashes/sha2';
22
+ import { bytesToHex } from '@noble/hashes/utils';
23
+ import { z } from 'zod';
24
+ import { MXC_PATTERN } from '../../attachments/download';
25
+ import {
26
+ boundedBytes,
27
+ fetchWithoutRedirects,
28
+ } from '../../core/domain-context/transport';
29
+ import type { RuntimeContext } from '../../plugin-api/types';
30
+ import {
31
+ MIN_ATTEMPT_MS,
32
+ errorBodyOf,
33
+ throwIfAborted,
34
+ untilAborted,
35
+ } from '../ixo-chain/http';
36
+ import type {
37
+ GatewayRefusal,
38
+ GatewayResult,
39
+ PendingDocument,
40
+ } from './chain-gateway';
41
+
42
+ /** The most of a homeserver answer read (they are small JSON objects). */
43
+ const ANSWER_MAX_BYTES = 16 * 1024;
44
+
45
+ /**
46
+ * `ctx.kv` namespace remembering, per homeserver and document proof, the
47
+ * media a document was reserved as (and whether it was uploaded), so
48
+ * preparing the same document again reuses it instead of reserving another.
49
+ */
50
+ export const DOCUMENT_CACHE_NAMESPACE = 'pod-creator/public-documents';
51
+ /** How long a remembered document lives without being used. */
52
+ export const DOCUMENT_CACHE_TTL_MS = 60 * 60 * 1000;
53
+ const DOCUMENT_CACHE_MAX_ENTRIES = 100;
54
+ /** A reservation this close to expiring is not reused: its upload could come too late. */
55
+ const RESERVATION_MARGIN_MS = 60 * 60 * 1000;
56
+
57
+ const REDIRECTED = 'homeserver-redirected';
58
+
59
+ /** What bounds the homeserver exchanges; every member is required. */
60
+ export interface MatrixMediaDeps {
61
+ fetch: typeof fetch;
62
+ /** Upper bound on one homeserver request, its answer included. */
63
+ requestTimeoutMs: number;
64
+ /** Upper bound on getting the plugin device's credentials. */
65
+ credentialsTimeoutMs: number;
66
+ }
67
+
68
+ /** A document to make public, as the exact text whose SHA-256 is its proof. */
69
+ export interface PublicDocument {
70
+ text: string;
71
+ filename: string;
72
+ contentType: string;
73
+ }
74
+
75
+ /** Where a reserved document will be readable, and what is left to upload (null: it is public). */
76
+ export interface ReservedDocument {
77
+ url: string;
78
+ pending: PendingDocument | null;
79
+ }
80
+
81
+ /** The SHA-256 of `text`'s UTF-8 bytes, lower-case hex: a document's proof. */
82
+ export function documentProof(text: string): string {
83
+ return bytesToHex(sha256(new TextEncoder().encode(text)));
84
+ }
85
+
86
+ const refuse = (message: string, retryable: boolean): GatewayRefusal => ({
87
+ ok: false,
88
+ message,
89
+ retryable,
90
+ });
91
+
92
+ const OUT_OF_TIME = refuse(
93
+ 'The homeserver step ran out of time; try again in a moment.',
94
+ true,
95
+ );
96
+
97
+ function errorText(error: unknown): string {
98
+ return error instanceof Error ? error.message : String(error);
99
+ }
100
+
101
+ interface Device {
102
+ baseUrl: string;
103
+ accessToken: string;
104
+ }
105
+
106
+ /**
107
+ * The oracle's plugin device, within its time bound and the turn. `rejected`
108
+ * is the token the homeserver just refused: the host re-validates or
109
+ * re-logs the device and answers with fresh credentials.
110
+ */
111
+ async function pluginDevice(
112
+ ctx: RuntimeContext,
113
+ deps: MatrixMediaDeps,
114
+ deadline: number,
115
+ rejected?: string,
116
+ ): Promise<Device | GatewayRefusal> {
117
+ const left = deadline - Date.now();
118
+ if (left < MIN_ATTEMPT_MS) return OUT_OF_TIME;
119
+ const ms = Math.min(deps.credentialsTimeoutMs, left);
120
+ let timer: ReturnType<typeof setTimeout> | undefined;
121
+ const timeout = new Promise<'timeout'>((resolve) => {
122
+ timer = setTimeout(() => resolve('timeout'), ms);
123
+ });
124
+ try {
125
+ const answer = await untilAborted(
126
+ Promise.race([
127
+ rejected === undefined
128
+ ? ctx.matrix.botCredentials()
129
+ : ctx.matrix.botCredentials({ rejected }),
130
+ timeout,
131
+ ]),
132
+ ctx.abortSignal,
133
+ );
134
+ if (answer === 'timeout')
135
+ return refuse(
136
+ "The oracle's Matrix device did not answer in time; try again in a moment.",
137
+ true,
138
+ );
139
+ return {
140
+ baseUrl: answer.baseUrl.replace(/\/+$/, ''),
141
+ accessToken: answer.accessToken,
142
+ };
143
+ } catch (error) {
144
+ throwIfAborted(ctx.abortSignal);
145
+ return refuse(
146
+ `The oracle's Matrix device is unavailable (${errorText(error)}); try again in a moment.`,
147
+ true,
148
+ );
149
+ } finally {
150
+ clearTimeout(timer);
151
+ }
152
+ }
153
+
154
+ /** A homeserver answer: its status, its JSON body (undefined when it is not JSON) and its `errcode`. */
155
+ interface Answer {
156
+ status: number;
157
+ body: unknown;
158
+ errcode: string | undefined;
159
+ }
160
+
161
+ const errcodeSchema = z.object({ errcode: z.string() });
162
+
163
+ function parseJson(text: string | undefined): unknown {
164
+ if (text === undefined) return undefined;
165
+ try {
166
+ return JSON.parse(text);
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ }
171
+
172
+ /** One bounded request to the device's homeserver; a refusal when it got no answer. */
173
+ async function request(
174
+ ctx: RuntimeContext,
175
+ deps: MatrixMediaDeps,
176
+ deadline: number,
177
+ device: Device,
178
+ path: string,
179
+ init: { method: string; contentType?: string; body?: Uint8Array },
180
+ ): Promise<Answer | GatewayRefusal> {
181
+ const left = deadline - Date.now();
182
+ if (left < MIN_ATTEMPT_MS) return OUT_OF_TIME;
183
+ const ms = Math.min(deps.requestTimeoutMs, left);
184
+ const controller = new AbortController();
185
+ const timer = setTimeout(() => controller.abort(), ms);
186
+ const onAbort = () => controller.abort();
187
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
188
+ try {
189
+ const response = await fetchWithoutRedirects(
190
+ deps.fetch,
191
+ `${device.baseUrl}${path}`,
192
+ {
193
+ method: init.method,
194
+ headers: {
195
+ authorization: `Bearer ${device.accessToken}`,
196
+ ...(init.contentType ? { 'content-type': init.contentType } : {}),
197
+ },
198
+ ...(init.body ? { body: init.body } : {}),
199
+ signal: controller.signal,
200
+ },
201
+ REDIRECTED,
202
+ );
203
+ const text = response.ok
204
+ ? new TextDecoder().decode(await boundedBytes(response, ANSWER_MAX_BYTES))
205
+ : await errorBodyOf(response);
206
+ const body = parseJson(text);
207
+ const errcode = errcodeSchema.safeParse(body);
208
+ return {
209
+ status: response.status,
210
+ body,
211
+ errcode: errcode.success ? errcode.data.errcode : undefined,
212
+ };
213
+ } catch (error) {
214
+ throwIfAborted(ctx.abortSignal);
215
+ if (controller.signal.aborted)
216
+ return refuse(
217
+ 'The homeserver did not answer in time; try again in a moment.',
218
+ true,
219
+ );
220
+ if (error instanceof Error && error.message === REDIRECTED)
221
+ return refuse(
222
+ 'The homeserver redirected the request, which the oracle does not follow.',
223
+ false,
224
+ );
225
+ if (error instanceof Error && error.message === 'document-too-large')
226
+ return refuse(
227
+ 'The homeserver answered with far more than expected.',
228
+ false,
229
+ );
230
+ return refuse(
231
+ `The homeserver could not be reached (${error instanceof Error ? error.name : 'unknown error'}); try again in a moment.`,
232
+ true,
233
+ );
234
+ } finally {
235
+ clearTimeout(timer);
236
+ ctx.abortSignal.removeEventListener('abort', onAbort);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * {@link request} as the plugin device, asking once for fresh credentials
242
+ * when the homeserver answers 401 (a token it no longer accepts).
243
+ */
244
+ async function asPluginDevice(
245
+ ctx: RuntimeContext,
246
+ deps: MatrixMediaDeps,
247
+ deadline: number,
248
+ path: (device: Device) => string,
249
+ init: { method: string; contentType?: string; body?: Uint8Array },
250
+ ): Promise<{ device: Device; answer: Answer } | GatewayRefusal> {
251
+ let device = await pluginDevice(ctx, deps, deadline);
252
+ if ('ok' in device) return device;
253
+ let answer = await request(ctx, deps, deadline, device, path(device), init);
254
+ if ('ok' in answer) return answer;
255
+ if (answer.status === 401) {
256
+ ctx.logger.warn(
257
+ '[pod-creator] the homeserver rejected the plugin device token; asking for fresh credentials',
258
+ );
259
+ device = await pluginDevice(ctx, deps, deadline, device.accessToken);
260
+ if ('ok' in device) return device;
261
+ answer = await request(ctx, deps, deadline, device, path(device), init);
262
+ if ('ok' in answer) return answer;
263
+ }
264
+ return { device, answer };
265
+ }
266
+
267
+ /** A failed answer as a refusal: retryable for a 401 that persisted, 429 or 5xx. */
268
+ function refusalOf(what: string, answer: Answer): GatewayRefusal {
269
+ const code = answer.errcode ? ` ${answer.errcode}` : '';
270
+ const retryable =
271
+ answer.status === 401 || answer.status === 429 || answer.status >= 500;
272
+ return refuse(
273
+ `The homeserver refused ${what} (HTTP ${answer.status}${code})${retryable ? '; try again in a moment.' : '.'}`,
274
+ retryable,
275
+ );
276
+ }
277
+
278
+ /** The public download URL of `mxc` on `baseUrl`, the one the Portal's `mxcUrlToHttp` forms. */
279
+ function downloadUrl(baseUrl: string, mxc: string): string | null {
280
+ const match = MXC_PATTERN.exec(mxc);
281
+ return match
282
+ ? `${baseUrl}/_matrix/media/v3/download/${match[1]}/${match[2]}`
283
+ : null;
284
+ }
285
+
286
+ const contentUriSchema = z.object({
287
+ content_uri: z.string(),
288
+ unused_expires_at: z.number().optional(),
289
+ });
290
+
291
+ const cachedSchema = z.object({
292
+ mxc: z.string(),
293
+ /** Epoch ms the unused reservation expires; null once uploaded. */
294
+ reservedUntil: z.number().nullable(),
295
+ });
296
+
297
+ type Cached = z.infer<typeof cachedSchema>;
298
+
299
+ const cacheKey = (baseUrl: string, proof: string): string =>
300
+ JSON.stringify([baseUrl, proof]);
301
+
302
+ async function cached(
303
+ ctx: RuntimeContext,
304
+ baseUrl: string,
305
+ proof: string,
306
+ ): Promise<Cached | undefined> {
307
+ if (!ctx.kv) return undefined;
308
+ const parsed = cachedSchema.safeParse(
309
+ await ctx.kv.get(DOCUMENT_CACHE_NAMESPACE, cacheKey(baseUrl, proof)),
310
+ );
311
+ if (!parsed.success) return undefined;
312
+ const { reservedUntil } = parsed.data;
313
+ return reservedUntil === null ||
314
+ reservedUntil - RESERVATION_MARGIN_MS > Date.now()
315
+ ? parsed.data
316
+ : undefined;
317
+ }
318
+
319
+ async function remember(
320
+ ctx: RuntimeContext,
321
+ baseUrl: string,
322
+ proof: string,
323
+ entry: Cached,
324
+ ): Promise<void> {
325
+ await ctx.kv?.set(DOCUMENT_CACHE_NAMESPACE, cacheKey(baseUrl, proof), entry, {
326
+ idleTtlMs: DOCUMENT_CACHE_TTL_MS,
327
+ maxEntries: DOCUMENT_CACHE_MAX_ENTRIES,
328
+ });
329
+ }
330
+
331
+ /** Upload `document` now (`POST /_matrix/media/v3/upload`), for a homeserver without async uploads. */
332
+ async function uploadDirectly(
333
+ ctx: RuntimeContext,
334
+ deps: MatrixMediaDeps,
335
+ deadline: number,
336
+ document: PublicDocument,
337
+ ): Promise<GatewayResult<{ mxc: string; baseUrl: string }>> {
338
+ const sent = await asPluginDevice(
339
+ ctx,
340
+ deps,
341
+ deadline,
342
+ () =>
343
+ `/_matrix/media/v3/upload?filename=${encodeURIComponent(document.filename)}`,
344
+ {
345
+ method: 'POST',
346
+ contentType: document.contentType,
347
+ body: new TextEncoder().encode(document.text),
348
+ },
349
+ );
350
+ if ('ok' in sent) return sent;
351
+ if (sent.answer.status !== 200)
352
+ return refusalOf('the document upload', sent.answer);
353
+ const parsed = contentUriSchema.safeParse(sent.answer.body);
354
+ if (!parsed.success || !MXC_PATTERN.test(parsed.data.content_uri))
355
+ return refuse(
356
+ 'The homeserver answered the document upload without a valid content URI.',
357
+ false,
358
+ );
359
+ return {
360
+ ok: true,
361
+ mxc: parsed.data.content_uri,
362
+ baseUrl: sent.device.baseUrl,
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Reserve `document` on the oracle's homeserver and return its public
368
+ * download URL, with the upload still to do after approval. A document of
369
+ * the same bytes already reserved (or uploaded) for this user is reused.
370
+ */
371
+ export async function reservePublicDocument(
372
+ ctx: RuntimeContext,
373
+ document: PublicDocument,
374
+ deps: MatrixMediaDeps,
375
+ deadline: number,
376
+ ): Promise<GatewayResult<ReservedDocument>> {
377
+ const proof = documentProof(document.text);
378
+ const device = await pluginDevice(ctx, deps, deadline);
379
+ if ('ok' in device) return device;
380
+ const pendingOf = (mxc: string): PendingDocument => ({
381
+ mxc,
382
+ contentType: document.contentType,
383
+ filename: document.filename,
384
+ text: document.text,
385
+ });
386
+ const known = await cached(ctx, device.baseUrl, proof);
387
+ const knownUrl = known && downloadUrl(device.baseUrl, known.mxc);
388
+ if (known && knownUrl)
389
+ return {
390
+ ok: true,
391
+ url: knownUrl,
392
+ pending: known.reservedUntil === null ? null : pendingOf(known.mxc),
393
+ };
394
+ const created = await asPluginDevice(
395
+ ctx,
396
+ deps,
397
+ deadline,
398
+ () => '/_matrix/media/v1/create',
399
+ { method: 'POST' },
400
+ );
401
+ if ('ok' in created) return created;
402
+ const { answer } = created;
403
+ if (
404
+ answer.status === 404 ||
405
+ answer.status === 405 ||
406
+ answer.errcode === 'M_UNRECOGNIZED'
407
+ ) {
408
+ // A homeserver before client-server API v1.7 has no asynchronous
409
+ // upload, so nothing can be reserved: the document is uploaded now and
410
+ // is public before the user approves the batch that names it.
411
+ ctx.logger.warn(
412
+ '[pod-creator] the homeserver has no asynchronous upload; uploading the document before approval',
413
+ );
414
+ const uploaded = await uploadDirectly(ctx, deps, deadline, document);
415
+ if (!uploaded.ok) return uploaded;
416
+ const url = downloadUrl(uploaded.baseUrl, uploaded.mxc);
417
+ if (!url)
418
+ return refuse('The homeserver answered an invalid content URI.', false);
419
+ await remember(ctx, uploaded.baseUrl, proof, {
420
+ mxc: uploaded.mxc,
421
+ reservedUntil: null,
422
+ });
423
+ return { ok: true, url, pending: null };
424
+ }
425
+ if (answer.status !== 200) return refusalOf('reserving the document', answer);
426
+ const parsed = contentUriSchema.safeParse(answer.body);
427
+ const url = parsed.success
428
+ ? downloadUrl(created.device.baseUrl, parsed.data.content_uri)
429
+ : null;
430
+ if (!parsed.success || !url)
431
+ return refuse(
432
+ 'The homeserver answered the reservation without a valid content URI.',
433
+ false,
434
+ );
435
+ await remember(ctx, created.device.baseUrl, proof, {
436
+ mxc: parsed.data.content_uri,
437
+ reservedUntil: parsed.data.unused_expires_at ?? Date.now(),
438
+ });
439
+ return { ok: true, url, pending: pendingOf(parsed.data.content_uri) };
440
+ }
441
+
442
+ /**
443
+ * Upload each reserved document (`PUT /_matrix/media/v3/upload/{server}/{id}`).
444
+ * A 409 for the id is a document uploaded before: a success.
445
+ */
446
+ export async function publishPendingDocuments(
447
+ ctx: RuntimeContext,
448
+ documents: readonly PendingDocument[],
449
+ deps: MatrixMediaDeps,
450
+ deadline: number,
451
+ ): Promise<GatewayResult<object>> {
452
+ for (const document of documents) {
453
+ const match = MXC_PATTERN.exec(document.mxc);
454
+ if (!match)
455
+ return refuse(
456
+ `The prepared document names an invalid content URI; prepare it again.`,
457
+ false,
458
+ );
459
+ const sent = await asPluginDevice(
460
+ ctx,
461
+ deps,
462
+ deadline,
463
+ () =>
464
+ `/_matrix/media/v3/upload/${match[1]}/${match[2]}?filename=${encodeURIComponent(document.filename)}`,
465
+ {
466
+ method: 'PUT',
467
+ contentType: document.contentType,
468
+ body: new TextEncoder().encode(document.text),
469
+ },
470
+ );
471
+ if ('ok' in sent) return sent;
472
+ const { status } = sent.answer;
473
+ if (status === 404)
474
+ return refuse(
475
+ 'The reserved upload slot for the document expired before it was used; prepare it again.',
476
+ false,
477
+ );
478
+ if (status !== 200 && status !== 409)
479
+ return refusalOf('the document upload', sent.answer);
480
+ await remember(ctx, sent.device.baseUrl, documentProof(document.text), {
481
+ mxc: document.mxc,
482
+ reservedUntil: null,
483
+ });
484
+ }
485
+ return { ok: true };
486
+ }
@@ -8,6 +8,7 @@ import {
8
8
  SPECIALISTS_FOR_STAGE,
9
9
  assembleServicePodBlueprint,
10
10
  computeReadiness,
11
+ nextStep,
11
12
  sectionsView,
12
13
  } from './stage';
13
14
 
@@ -74,12 +75,13 @@ export function createOrchestrationTools(
74
75
  nextSpecialists: SPECIALISTS_FOR_STAGE[readiness.stage].map((id) =>
75
76
  computeSubAgentToolName(id),
76
77
  ),
78
+ nextStep: nextStep(bp),
77
79
  };
78
80
  },
79
81
  {
80
82
  name: 'start_pod_design',
81
83
  description:
82
- 'Open a POD design session and initialise the blueprint. Call this first when the user wants to create a POD; pass restart=true to discard the existing design and begin again.',
84
+ 'Open a POD design session and initialise the blueprint. Call this first when the user wants to create a POD. Pass restart=true only when the user asks to start over: it discards the existing design.',
83
85
  schema: startSchema,
84
86
  },
85
87
  );
@@ -99,6 +101,7 @@ export function createOrchestrationTools(
99
101
  started: true,
100
102
  ...(bp.brief !== undefined ? { brief: bp.brief } : {}),
101
103
  readiness: computeReadiness(bp),
104
+ nextStep: nextStep(bp),
102
105
  ...sectionsView(bp, roles),
103
106
  };
104
107
  },
@@ -120,7 +123,7 @@ export function createOrchestrationTools(
120
123
  message: 'No POD design session started yet.',
121
124
  };
122
125
  }
123
- return computeReadiness(bp);
126
+ return { ...computeReadiness(bp), nextStep: nextStep(bp) };
124
127
  },
125
128
  {
126
129
  name: 'compute_readiness',
@@ -1,3 +1,4 @@
1
+ import type { AgentMiddleware } from 'langchain';
1
2
  import { z } from 'zod';
2
3
  import { OraclePlugin } from '../../plugin-api/oracle-plugin';
3
4
  import type {
@@ -13,15 +14,19 @@ import {
13
14
  type CapsuleContentClientOptions,
14
15
  type CapsuleContentFetcher,
15
16
  } from './capsule-content-client';
16
- import { notConfiguredChainGateway, type ChainGateway } from './chain-gateway';
17
+ import type { ChainGateway } from './chain-gateway';
17
18
  import { podCreatorConfigSchema } from './config';
18
19
  import {
19
20
  KvCreateSessionStore,
20
21
  type CreateSessionStore,
21
22
  } from './create-session-store';
22
23
  import { createCreateTools } from './create-tools';
24
+ import { createIxoChainGateway } from './ixo-chain-gateway';
23
25
  import { createOrchestrationTools } from './orchestration-tools';
24
- import { buildStageSubAgents } from './sub-agents';
26
+ import {
27
+ buildSpecialistSubAgents,
28
+ createSpecialistStageMiddleware,
29
+ } from './sub-agents';
25
30
 
26
31
  /**
27
32
  * Sibling env read at request time to configure the capsule client. The
@@ -71,9 +76,10 @@ export interface PodCreatorPluginOptions {
71
76
  */
72
77
  capsuleContentFetcher?: CapsuleContentFetcher;
73
78
  /**
74
- * Chain gateway for the create path. Injected by tests; the real IXO MCP
75
- * server binding wires in here. When omitted, the create tools report
76
- * on-chain creation as unavailable.
79
+ * Chain gateway for the create path. When omitted, the bundled
80
+ * `createIxoChainGateway()`: one `MsgCreateEntity` owned by the user and
81
+ * signed by their Portal wallet. Tests inject a fake; a fork may pass its
82
+ * own, or `notConfiguredChainGateway` to keep creation off.
77
83
  */
78
84
  chainGateway?: ChainGateway;
79
85
  }
@@ -104,8 +110,9 @@ function requireUserKv(ctx: RuntimeContext): UserKvSurface {
104
110
  * Exposes the conductor's orchestration tools (the blueprint lifecycle), the
105
111
  * stage-gated specialist sub-agents, and the on-chain create path — a
106
112
  * propose → approve → commit handoff with single-use approvals, ending in the
107
- * blocking `sign_transaction` AG-UI round-trip. The chain encoding sits behind
108
- * an injectable gateway.
113
+ * blocking `sign_transaction` AG-UI round-trip. Composing the batch and
114
+ * reading the broadcast transaction back sit behind an injectable gateway
115
+ * (the bundled one by default).
109
116
  *
110
117
  * Per-thread blueprints and create sessions are rows in the user's own
111
118
  * database (`ctx.kv`), so a design survives the user object being evicted and
@@ -137,7 +144,7 @@ export class PodCreatorPlugin extends OraclePlugin {
137
144
  constructor(options: PodCreatorPluginOptions = {}) {
138
145
  super();
139
146
  this.capsuleContentFetcher = options.capsuleContentFetcher;
140
- this.chainGateway = options.chainGateway ?? notConfiguredChainGateway;
147
+ this.chainGateway = options.chainGateway ?? createIxoChainGateway();
141
148
  }
142
149
 
143
150
  override getTools(): PluginTool[] {
@@ -158,7 +165,21 @@ export class PodCreatorPlugin extends OraclePlugin {
158
165
  // on a host without the store there is no design to offer specialists
159
166
  // for, and the tools themselves report the missing store when called.
160
167
  if (!rt.kv) return [];
161
- return buildStageSubAgents(rt, blueprintStoreFor, this.capsuleClient(rt));
168
+ return buildSpecialistSubAgents(
169
+ rt,
170
+ blueprintStoreFor,
171
+ this.capsuleClient(rt),
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Every specialist is bound once a design exists; this shows the model only
177
+ * the current stage's, re-read before each model call, so a stage passed
178
+ * during a turn hands over within that turn.
179
+ */
180
+ override getRequestMiddlewares(rt: RuntimeContext): AgentMiddleware[] {
181
+ if (!rt.kv) return [];
182
+ return [createSpecialistStageMiddleware(rt, blueprintStoreFor)];
162
183
  }
163
184
 
164
185
  private capsuleClient(rt: RuntimeContext): CapsuleContentClient {
@@ -1,3 +1,4 @@
1
+ import { computeSubAgentToolName } from '../../core/subagent-as-tool';
1
2
  import type {
2
3
  BlueprintSection,
3
4
  PodBlueprint,
@@ -117,6 +118,35 @@ export function computeReadiness(bp: PodBlueprint): Readiness {
117
118
  };
118
119
  }
119
120
 
121
+ /**
122
+ * What the conductor does next, in plain words. A stage's specialists are
123
+ * offered per turn (the plugin's request-time sub-agent hook), so a stage
124
+ * reached during a turn has its specialists only from the user's next
125
+ * message: the model is told to stop and ask the user to continue rather
126
+ * than reach for another stage's specialist.
127
+ */
128
+ export function nextStep(bp: PodBlueprint): string {
129
+ if (computeReadiness(bp).complete)
130
+ return 'The design passed the launch-readiness gate. Agree the POD name and a one- or two-sentence description with the user, then call prepare_pod_transaction.';
131
+ const stage = deriveStage(bp);
132
+ const specialists = SPECIALISTS_FOR_STAGE[stage]
133
+ .filter((roleId) => !sectionSatisfies(bp.sections[roleId]))
134
+ .map((roleId) => computeSubAgentToolName(roleId));
135
+ return (
136
+ `Next: the ${stage} stage, run by ${specialists.join(', ')}. ` +
137
+ 'They are in your tool list from your next step, except in the turn that opened the design: then stop and ask the user to send a message (for example "continue") to run them. ' +
138
+ "Never use another stage's specialist for this stage."
139
+ );
140
+ }
141
+
142
+ /** Whether a stage comes after another in {@link STAGE_ORDER}. */
143
+ export function isStageAfter(
144
+ stage: DesignPodStage,
145
+ other: DesignPodStage,
146
+ ): boolean {
147
+ return STAGE_ORDER.indexOf(stage) > STAGE_ORDER.indexOf(other);
148
+ }
149
+
120
150
  /**
121
151
  * Compact per-section view for blueprint reads. Sections carry arbitrary-size
122
152
  * role-defined payloads, so blueprint reads return this summary by default and