@ixo/oracle-runtime-workers 0.41.3 → 0.42.1

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.
@@ -1,11 +1,16 @@
1
+ import { ToolMessage, type BaseMessage } from '@langchain/core/messages';
2
+ import { createMiddleware, type AgentMiddleware } from 'langchain';
1
3
  import { z } from 'zod';
4
+ import { turnStart } from '../../core/middlewares/turn-boundary';
5
+ import { computeSubAgentToolName } from '../../core/subagent-as-tool';
6
+ import { isToolNotRun, toolNotRunMessage } from '../../core/tool-not-run';
2
7
  import { tool } from '../../plugin-api/tool-helper';
3
8
  import type {
4
9
  PluginSubAgent,
5
10
  PluginTool,
6
11
  RuntimeContext,
7
12
  } from '../../plugin-api/types';
8
- import type { BlueprintSection } from './blueprint-types';
13
+ import type { BlueprintSection, PodBlueprint } from './blueprint-types';
9
14
  import type { BlueprintStore, BlueprintStoreFor } from './blueprint-store';
10
15
  import type { CapsuleContentClient } from './capsule-content-client';
11
16
  import {
@@ -13,7 +18,7 @@ import {
13
18
  type DesignPodRole,
14
19
  type DesignPodStage,
15
20
  } from './design-pod-roles';
16
- import { deriveStage, sectionsView } from './stage';
21
+ import { deriveStage, isStageAfter, nextStep, sectionsView } from './stage';
17
22
 
18
23
  const isGateRole = (role: DesignPodRole): boolean =>
19
24
  role.stage === 'evaluate' || role.stage === 'gate';
@@ -109,6 +114,21 @@ function roleTools(
109
114
  const submitSection = tool(
110
115
  async (args, ctx) => {
111
116
  const input = submitSchema.parse(args);
117
+ const store = storeFor(ctx);
118
+ const current = await store.get(ctx.session.id);
119
+ // A specialist of a stage already passed has nothing left to record:
120
+ // overwriting its section would only re-open the design behind the
121
+ // pipeline. A failed gate verdict re-opens its own stage, so a re-run
122
+ // of that role is never refused here.
123
+ if (
124
+ current?.sections[role.id] !== undefined &&
125
+ isStageAfter(deriveStage(current), role.stage)
126
+ ) {
127
+ return {
128
+ submitted: false,
129
+ message: `The ${role.stage} stage is already complete; this specialist has nothing to do. Stop and tell the user to continue.`,
130
+ };
131
+ }
112
132
  const section: BlueprintSection = {
113
133
  role: role.id,
114
134
  stage: role.stage,
@@ -117,8 +137,15 @@ function roleTools(
117
137
  ...(input.verdict !== undefined ? { verdict: input.verdict } : {}),
118
138
  ...(input.blockers !== undefined ? { blockers: input.blockers } : {}),
119
139
  };
120
- await storeFor(ctx).putSection(ctx.session.id, section);
121
- return { submitted: role.id, stage: role.stage };
140
+ const bp = await store.putSection(ctx.session.id, section);
141
+ return {
142
+ submitted: role.id,
143
+ stage: role.stage,
144
+ message:
145
+ input.verdict === 'fail'
146
+ ? 'Section recorded with a fail verdict: this specialist is done. Tell the user its blockers and stop; it can run again after the user changes the design in their next message.'
147
+ : `Section recorded: this specialist is done, do not call it again for this stage. ${nextStep(bp)}`,
148
+ };
122
149
  },
123
150
  {
124
151
  name: 'submit_section',
@@ -192,3 +219,176 @@ export async function buildStageSubAgents(
192
219
  roles.map((role) => buildSubAgent(role, rt, storeFor, capsules)),
193
220
  );
194
221
  }
222
+
223
+ /**
224
+ * Build every specialist sub-agent for a thread with a design, and none for a
225
+ * thread without one (a single local read, like {@link buildStageSubAgents}).
226
+ *
227
+ * The plugin binds all of them and lets {@link createSpecialistStageMiddleware}
228
+ * show the model only the current stage's on each model call: a stage passed
229
+ * during a turn hands over to the next stage's specialists on the very next
230
+ * model call. Bound only per stage at turn start, they were missing for the
231
+ * rest of that turn; a model that called them got "no such tool" and went on
232
+ * telling the user they did not exist after they were offered.
233
+ */
234
+ export async function buildSpecialistSubAgents(
235
+ rt: RuntimeContext,
236
+ storeFor: BlueprintStoreFor,
237
+ capsules: CapsuleContentClient,
238
+ ): Promise<PluginSubAgent[]> {
239
+ const bp = await storeFor(rt).get(rt.session.id);
240
+ if (!bp) return [];
241
+ return Promise.all(
242
+ DESIGN_POD_ROLES.map((role) => buildSubAgent(role, rt, storeFor, capsules)),
243
+ );
244
+ }
245
+
246
+ /** The specialist role each `call_<role>_agent` tool runs. */
247
+ const ROLE_BY_TOOL_NAME: ReadonlyMap<string, DesignPodRole> = new Map(
248
+ DESIGN_POD_ROLES.map((role) => [computeSubAgentToolName(role.id), role]),
249
+ );
250
+
251
+ /**
252
+ * Shows the conductor only the current stage's specialists and refuses a
253
+ * call to another stage's specialist with the next step instead of running
254
+ * it. When the blueprint cannot be read, no specialist is shown or run (a
255
+ * specialist cannot record without it either).
256
+ *
257
+ * Two rules hold within a turn, because the next stage's specialists are
258
+ * reachable in the same turn:
259
+ * - A gate specialist (evaluate or gate stage) that recorded a fail in this
260
+ * turn is not shown or run again in it: nothing in the design changed, so
261
+ * a re-run only repeats the verdict. It runs again after the user's next
262
+ * message.
263
+ * - `start_pod_design` with `restart` is refused once a specialist ran in
264
+ * this turn: a restart discards that work and is for a user who asks for
265
+ * one, not a way round a failed check. Whether one ran is read from the
266
+ * turn's own messages, so it holds across a run recovered mid-turn.
267
+ * Other tools pass through untouched. Refusals are "not run" results, so the
268
+ * repetition guard lets the same call through once the step is right.
269
+ *
270
+ * One instance serves one attempt of one turn. The blueprint is read once and
271
+ * again only after a call that can change it (a specialist or
272
+ * `start_pod_design`); a recovered attempt starts with a fresh read and an
273
+ * empty fail list (a failed gate may then run once more).
274
+ */
275
+ export function createSpecialistStageMiddleware(
276
+ rt: RuntimeContext,
277
+ storeFor: BlueprintStoreFor,
278
+ ): AgentMiddleware {
279
+ /** The last read of the thread's design; `undefined` until read or after a change. */
280
+ let cached: PodBlueprint | null | undefined;
281
+ /** The thread's design, or null when it has none or it cannot be read. */
282
+ const readBlueprint = async (): Promise<PodBlueprint | null> => {
283
+ if (cached !== undefined) return cached;
284
+ try {
285
+ cached = (await storeFor(rt).get(rt.session.id)) ?? null;
286
+ } catch (error) {
287
+ rt.logger.warn(
288
+ `[pod-creator] blueprint unreadable; no specialist is offered this step: ${
289
+ error instanceof Error ? error.message : String(error)
290
+ }`,
291
+ );
292
+ // Not cached: the next step tries again.
293
+ return null;
294
+ }
295
+ return cached;
296
+ };
297
+ /** Gate roles that recorded a fail in this turn. */
298
+ const failedThisTurn = new Set<string>();
299
+
300
+ const refusal = (
301
+ request: { toolCall: { id?: string; name: string } },
302
+ content: string,
303
+ ): ToolMessage =>
304
+ toolNotRunMessage({
305
+ toolCallId: request.toolCall.id ?? '',
306
+ name: request.toolCall.name,
307
+ content,
308
+ });
309
+
310
+ /** Whether a specialist answered in this turn (a refusal does not count). */
311
+ const specialistRanThisTurn = (messages: readonly BaseMessage[]): boolean =>
312
+ messages
313
+ .slice(turnStart(messages))
314
+ .some(
315
+ (message) =>
316
+ ToolMessage.isInstance(message) &&
317
+ typeof message.name === 'string' &&
318
+ ROLE_BY_TOOL_NAME.has(message.name) &&
319
+ !isToolNotRun(message),
320
+ );
321
+
322
+ return createMiddleware({
323
+ name: 'PodCreatorSpecialistStageMiddleware',
324
+ wrapModelCall: async (request, handler) => {
325
+ const isSpecialist = (name: unknown): boolean =>
326
+ typeof name === 'string' && ROLE_BY_TOOL_NAME.has(name);
327
+ if (!request.tools.some((t) => isSpecialist(t.name)))
328
+ return handler(request);
329
+ const bp = await readBlueprint();
330
+ const stage = bp ? deriveStage(bp) : null;
331
+ const tools = request.tools.filter((t) => {
332
+ if (!isSpecialist(t.name)) return true;
333
+ const role = ROLE_BY_TOOL_NAME.get(String(t.name));
334
+ return role?.stage === stage && !failedThisTurn.has(role.id);
335
+ });
336
+ return handler({ ...request, tools });
337
+ },
338
+ wrapToolCall: async (request, handler) => {
339
+ const name = request.toolCall.name;
340
+ if (name === 'start_pod_design') {
341
+ const args: unknown = request.toolCall.args;
342
+ const restart =
343
+ typeof args === 'object' &&
344
+ args !== null &&
345
+ 'restart' in args &&
346
+ args.restart === true;
347
+ if (restart && specialistRanThisTurn(request.state.messages))
348
+ return refusal(
349
+ request,
350
+ 'Not restarted: specialists already ran in this turn, and a restart discards their work. Restart only when the user asks for it in their message. Tell the user where the design stands and what blocks it.',
351
+ );
352
+ cached = undefined;
353
+ try {
354
+ return await handler(request);
355
+ } finally {
356
+ cached = undefined;
357
+ }
358
+ }
359
+ const role = ROLE_BY_TOOL_NAME.get(name);
360
+ if (!role) return handler(request);
361
+ const bp = await readBlueprint();
362
+ if (!bp)
363
+ return refusal(
364
+ request,
365
+ `${name} was not run: this conversation has no readable POD design. Call start_pod_design first.`,
366
+ );
367
+ if (deriveStage(bp) !== role.stage)
368
+ return refusal(
369
+ request,
370
+ `${name} was not run: it belongs to the ${role.stage} stage, and the design is at the ${deriveStage(bp)} stage. ${nextStep(bp)}`,
371
+ );
372
+ if (failedThisTurn.has(role.id))
373
+ return refusal(
374
+ request,
375
+ `${name} was not run again: it failed its check in this turn and nothing in the design has changed since, so it would only repeat the verdict. Tell the user its blockers and stop; it runs again after the user's next message.`,
376
+ );
377
+ const recordedBefore = bp.sections[role.id]?.recordedAt;
378
+ try {
379
+ return await handler(request);
380
+ } finally {
381
+ cached = undefined;
382
+ if (role.stage === 'evaluate' || role.stage === 'gate') {
383
+ const section = (await readBlueprint())?.sections[role.id];
384
+ // Only a fail this call recorded: an older one says nothing new.
385
+ if (
386
+ section?.verdict === 'fail' &&
387
+ section.recordedAt !== recordedBefore
388
+ )
389
+ failedThisTurn.add(role.id);
390
+ }
391
+ }
392
+ },
393
+ });
394
+ }