@nylorun/runtime 0.38.0-beta → 0.40.0-beta

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/CHANGELOG.md +76 -0
  2. package/dist/api/a2a/endpoint.js +1 -1
  3. package/dist/api/a2a/protocol.d.ts +6 -0
  4. package/dist/api/a2a/protocol.js +13 -0
  5. package/dist/api/a2a/routes.js +1 -0
  6. package/dist/api/components.d.ts +1726 -80
  7. package/dist/api/http/routes/agents.js +3 -3
  8. package/dist/core/turn-manifest.d.ts +23 -0
  9. package/dist/core/turn-manifest.js +36 -1
  10. package/dist/gates/policy.d.ts +13 -3
  11. package/dist/gates/policy.js +17 -4
  12. package/dist/harness/executors.d.ts +4 -2
  13. package/dist/harness/executors.js +26 -4
  14. package/dist/harness-api/record.js +4 -2
  15. package/dist/harness-api/server.js +37 -13
  16. package/dist/harness-api/start.js +43 -5
  17. package/dist/keys/keys.d.ts +6 -3
  18. package/dist/keys/keys.js +1 -1
  19. package/dist/management-openapi.json +6 -4
  20. package/dist/mcp/pool.d.ts +5 -0
  21. package/dist/mcp/pool.js +1 -1
  22. package/dist/mcp/snapshot.d.ts +3 -1
  23. package/dist/mcp/snapshot.js +5 -3
  24. package/dist/openapi.json +1637 -564
  25. package/dist/store/postgres/store.js +14 -0
  26. package/dist/store/types.d.ts +9 -0
  27. package/dist/tenant/agent-versions.d.ts +13 -5
  28. package/dist/tenant/agent-versions.js +30 -6
  29. package/dist/tenant/agents.d.ts +14 -2
  30. package/dist/tenant/agents.js +51 -13
  31. package/dist/tenant/commands.js +16 -8
  32. package/dist/tenant/context.d.ts +30 -1
  33. package/dist/tenant/session-model.d.ts +35 -6
  34. package/dist/tenant/session-model.js +77 -9
  35. package/dist/tenant/sessions.js +13 -3
  36. package/dist/vault/service.d.ts +44 -3
  37. package/dist/vault/service.js +68 -27
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +3 -3
@@ -1460,6 +1460,20 @@ class PostgresTx {
1460
1460
  .orderBy(desc(memoryVersions.id))
1461
1461
  .limit(options.limit);
1462
1462
  }
1463
+ async memoryWritesSince(agentId, after, sessionId) {
1464
+ this.check();
1465
+ const conditions = [eq(memoryVersions.agentId, agentId)];
1466
+ if (after !== undefined)
1467
+ conditions.push(gt(memoryVersions.id, after));
1468
+ const [row] = await this.db
1469
+ .select({
1470
+ newest: sql `max(${memoryVersions.id})`,
1471
+ others: sql `coalesce(bool_or(${memoryVersions.sessionId} is distinct from ${sessionId}), false)`,
1472
+ })
1473
+ .from(memoryVersions)
1474
+ .where(and(...conditions));
1475
+ return { ...(row?.newest ? { newest: row.newest } : {}), others: row?.others === true };
1476
+ }
1463
1477
  async memoryVersion(agentId, id) {
1464
1478
  this.check();
1465
1479
  const [row] = await this.db
@@ -803,6 +803,15 @@ export interface Tx {
803
803
  before?: string;
804
804
  limit: number;
805
805
  }): Promise<MemoryVersionRow[]>;
806
+ /**
807
+ * Of Agent `agentId`'s memory versions after version id `after` (every one without it): the
808
+ * newest's id, and whether any was written other than by session `sessionId` (another session,
809
+ * or the API).
810
+ */
811
+ memoryWritesSince(agentId: string, after: string | undefined, sessionId: string): Promise<{
812
+ newest?: string;
813
+ others: boolean;
814
+ }>;
806
815
  memoryVersion(agentId: string, id: string): Promise<MemoryVersionRow | undefined>;
807
816
  /** Copies Agent `from`'s memories to new Agent `to`, each at version 1, with no history (a fork). */
808
817
  copyMemories(from: string, to: string, now: string): Promise<number>;
@@ -21,8 +21,11 @@
21
21
  * - **A turn takes the Agent's version.** A turn's first segment re-pins its session to the
22
22
  * version the Agent runs when that changed since the session's last turn (`repinTurn`), before
23
23
  * anything of the turn has run: no effect is journaled for it yet. The turn keeps that version
24
- * through every later segment, approval and rollover. A turn whose message named a variant,
25
- * and a flow's sessions, keep the version they pinned.
24
+ * through every later segment, approval and rollover. What its message asked of the turn's
25
+ * manifest (`Session.turnRequest`) applies again to the new version: its `removeTools`, and its
26
+ * variant, validated again against the new version. A variant that is no variant of it keeps
27
+ * the turn on the version it pinned (`turn.version_kept`), so a variant never widens what the
28
+ * turn may do. A flow's sessions keep the version they pinned.
26
29
  *
27
30
  * Re-pinning at the first segment, not when the message is accepted, keeps a turn that waits for
28
31
  * its compute to start on a version the Agent has at that start.
@@ -64,9 +67,14 @@ export declare function applyPin(session: Session, pin: SessionPin): void;
64
67
  * active turn is about to run its first segment and nothing of the turn has run yet: the turn
65
68
  * then runs that version. Returns whether it changed the session (the caller saves it).
66
69
  *
67
- * Leaves the session as it is for a flow and a flow's linked session, a turn whose message named
68
- * a variant, a later segment, a turn with any journaled effect, and a version whose harness is
69
- * not the session's (an Agent's harness is fixed for its life, D73).
70
+ * The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
71
+ * its variant, validated against the new version, without the tools its `removeTools` names.
72
+ *
73
+ * Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
74
+ * with any journaled effect, a version whose harness is not the session's (an Agent's harness is
75
+ * fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
76
+ * Runtime), and a variant that is no variant of the new version: then the turn runs on the
77
+ * version it pinned, and `turn.version_kept` says why.
70
78
  */
71
79
  export declare function repinTurn(t: Tx, session: Session): Promise<boolean>;
72
80
  /** What a move of `agent` to `target` would do, and the target's runtime resolved. */
@@ -1,6 +1,8 @@
1
1
  import { hashManifest } from "@nylorun/core/compatibility";
2
2
  import { loopManifestOf, runtimeOf } from "@nylorun/core/define";
3
3
  import { isWorkflowManifest } from "../core/flow-host.js";
4
+ import { turnManifestFor } from "../core/turn-manifest.js";
5
+ import { variantStore } from "./session.js";
4
6
  import { canonical } from "../store/canonical.js";
5
7
  import { resolveRecipe, sandboxSpecOf } from "../sandbox/resolve.js";
6
8
  import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
@@ -60,9 +62,14 @@ export function applyPin(session, pin) {
60
62
  * active turn is about to run its first segment and nothing of the turn has run yet: the turn
61
63
  * then runs that version. Returns whether it changed the session (the caller saves it).
62
64
  *
63
- * Leaves the session as it is for a flow and a flow's linked session, a turn whose message named
64
- * a variant, a later segment, a turn with any journaled effect, and a version whose harness is
65
- * not the session's (an Agent's harness is fixed for its life, D73).
65
+ * The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
66
+ * its variant, validated against the new version, without the tools its `removeTools` names.
67
+ *
68
+ * Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
69
+ * with any journaled effect, a version whose harness is not the session's (an Agent's harness is
70
+ * fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
71
+ * Runtime), and a variant that is no variant of the new version: then the turn runs on the
72
+ * version it pinned, and `turn.version_kept` says why.
66
73
  */
67
74
  export async function repinTurn(t, session) {
68
75
  const id = session.environmentId;
@@ -73,8 +80,9 @@ export async function repinTurn(t, session) {
73
80
  return false;
74
81
  if (!checkpoint || checkpoint.segment !== 0 || checkpoint.turnId !== session.activeTurnId)
75
82
  return false;
76
- // A variant the message named (a turn-manifest variant) is validated against the old pin.
77
- if (checkpoint.manifestHash !== session.manifestHash)
83
+ const request = session.turnRequest?.turnId === session.activeTurnId ? session.turnRequest : undefined;
84
+ // A variant whose request is not recorded cannot be validated against another version.
85
+ if (checkpoint.manifestHash !== session.manifestHash && request === undefined)
78
86
  return false;
79
87
  const agent = await t.environment(id);
80
88
  if (!agent)
@@ -86,10 +94,26 @@ export async function repinTurn(t, session) {
86
94
  const { pin } = await pinOf(t, agent);
87
95
  if (pin.harness !== harnessOf(session))
88
96
  return false;
97
+ const turn = request
98
+ ? turnManifestFor({ pinned: pin.manifest, pinnedHash: pin.manifestHash, request })
99
+ : { ok: true, manifest: pin.manifest, hash: pin.manifestHash };
100
+ if (!turn.ok) {
101
+ const from = { ...session.definition };
102
+ const to = { name: agent.manifestName, version: agent.manifestVersion };
103
+ await t.event(session.id, session.activeTurnId, "turn.version_kept", {
104
+ reason: "variant_invalid",
105
+ from,
106
+ to,
107
+ message: `The turn manifest the message carried is no variant of ${to.name}@${to.version}, the version Agent ${agent.id} runs now, so this turn runs on ${from.name}@${from.version}. Send a variant of the new version, or name the tools the turn may not use with removeTools.`,
108
+ });
109
+ return false;
110
+ }
89
111
  const changed = pin.manifestHash !== session.manifestHash;
90
112
  applyPin(session, pin);
91
113
  session.environmentVersion = agent.version;
92
- session.checkpoint = { ...session.checkpoint, manifestHash: pin.manifestHash };
114
+ if (turn.hash !== pin.manifestHash)
115
+ variantStore(session).put(turn.hash, turn.manifest);
116
+ session.checkpoint = { ...session.checkpoint, manifestHash: turn.hash };
93
117
  // MCP discovery is per loop manifest: the next one is the new version's.
94
118
  if (changed) {
95
119
  delete session.mcpSnapshot;
@@ -56,13 +56,25 @@ export declare function planAgentPut(ctx: TenantContext, id: string, body: PutAg
56
56
  * they are set (track T3). Best effort: the write stands either way.
57
57
  */
58
58
  export declare function secretWritten(ctx: TenantContext, id: string): Promise<void>;
59
+ /**
60
+ * A secret write as the vault takes it (`Keys.putEnvironmentSecret`): the typed request, and
61
+ * whether its `via` is the slot's (`viaFromSlot`), so a session follows the slot of the version
62
+ * its Agent runs, rather than this installation's own `via` (Host feature `secret-routing`).
63
+ */
64
+ export interface SecretWrite {
65
+ readonly request: PutAgentSecretRequest;
66
+ readonly viaFromSlot: boolean;
67
+ }
59
68
  /**
60
69
  * The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
61
70
  * slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
62
71
  * `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
63
- * accepted only when its binding is the slot's. Without slots, today's typed body.
72
+ * accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
73
+ * `bearer` or `headers` slot's write may add this installation's routing (Host feature
74
+ * `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
75
+ * gives way to the slot's (a different one is accepted, with a warning in the log).
64
76
  */
65
- export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<PutAgentSecretRequest>;
77
+ export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<SecretWrite>;
66
78
  /** What a create does for a fork (`POST .../fork`); `copied` is filled in by the create. */
67
79
  interface Fork {
68
80
  source: string;
@@ -560,15 +560,18 @@ export async function secretWritten(ctx, id) {
560
560
  * The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
561
561
  * slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
562
562
  * `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
563
- * accepted only when its binding is the slot's. Without slots, today's typed body.
563
+ * accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
564
+ * `bearer` or `headers` slot's write may add this installation's routing (Host feature
565
+ * `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
566
+ * gives way to the slot's (a different one is accepted, with a warning in the log).
564
567
  */
565
568
  export async function secretRequestFor(ctx, id, name, raw) {
566
- const request = await parseSecretRequest(ctx, id, name, raw);
569
+ const write = await parseSecretRequest(ctx, id, name, raw);
567
570
  // Within the Tenant's Runtime limits `secretValueChars` and `secretHosts`, read for the write.
568
- const issues = secretRequestIssues(request, await ctx.store.tx((t) => readRuntimeLimits(t)));
571
+ const issues = secretRequestIssues(write.request, await ctx.store.tx((t) => readRuntimeLimits(t)));
569
572
  if (issues.length > 0)
570
573
  fail(400, issues.join(" "));
571
- return request;
574
+ return write;
572
575
  }
573
576
  async function parseSecretRequest(ctx, id, name, raw) {
574
577
  const { environment, slots } = await ctx.store.tx(async (t) => {
@@ -576,38 +579,73 @@ async function parseSecretRequest(ctx, id, name, raw) {
576
579
  return { environment, slots: environment && (await slotsAhead(t, environment)) };
577
580
  });
578
581
  if (!environment || slots === undefined)
579
- return PutAgentSecretRequestSchema.parse(raw);
582
+ return { request: PutAgentSecretRequestSchema.parse(raw), viaFromSlot: false };
580
583
  const named = `${environment.manifestName}@${environment.targetVersion ?? environment.manifestVersion}`;
581
584
  const slot = slots[name] ??
582
585
  fail(409, `Agent ${id}'s manifest (${named}) declares no secret ${name}: add the slot to the manifest's runtime section, then move the Agent to the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
583
586
  const full = slotRequest(slot);
587
+ const slotVia = typeof full.via === "string" ? full.via : undefined;
588
+ const routed = slot.type === "bearer" || slot.type === "headers";
589
+ /** The write's prefix gives way to the slot's: said in the log when they differ. */
590
+ const prefixed = (toolPrefix) => {
591
+ if (routed && toolPrefix !== undefined && slot.toolPrefix !== undefined && toolPrefix !== slot.toolPrefix)
592
+ ctx.config.logger.warn("secret tool prefix gives way to the manifest's", {
593
+ environmentId: id,
594
+ secret: name,
595
+ manifest: named,
596
+ });
597
+ };
584
598
  if (raw !== null && typeof raw === "object" && "type" in raw) {
585
599
  const body = PutAgentSecretRequestSchema.parse(raw);
586
- // The gateway's tool prefix is this installation's to add (`gateway-credentials`).
587
- const { requestId: _, value: _v, headers: _h, toolPrefix: _p, ...binding } = body;
588
- if (canonical(binding) !== canonical(full))
600
+ // This installation's routing (`gateway-credentials`, `secret-routing`): its via and the
601
+ // gateway's tool prefix are its own to add.
602
+ const { requestId: _, value: _v, headers: _h, toolPrefix, via, ...binding } = body;
603
+ const { via: _s, ...slotBinding } = full;
604
+ if (canonical(binding) !== canonical(slotBinding))
589
605
  fail(409, `Secret ${name}'s type and binding come from Agent ${id}'s manifest: send only its value ({ "value": "…" }).`, {
590
606
  code: "secret_not_in_manifest",
591
607
  });
592
- return body;
608
+ prefixed(toolPrefix);
609
+ if (!routed || (via !== undefined && via !== slotVia))
610
+ return { request: body, viaFromSlot: false };
611
+ // No via of its own: the slot's, which a session reads from the version its Agent runs.
612
+ return {
613
+ request: PutAgentSecretRequestSchema.parse({ ...body, ...(slotVia === undefined ? {} : { via: slotVia }) }),
614
+ viaFromSlot: slotVia !== undefined,
615
+ };
593
616
  }
594
- const { requestId, value, headers, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
617
+ const { requestId, value, headers, via, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
618
+ if (!routed && (via !== undefined || toolPrefix !== undefined))
619
+ fail(400, `Secret ${name} is an ${slot.type}: via and toolPrefix are for a bearer or headers secret.`);
620
+ if (toolPrefix !== undefined && via === undefined && slotVia === undefined)
621
+ fail(400, `toolPrefix needs via: Secret ${name}'s slot names none, so send one with the value.`);
622
+ prefixed(toolPrefix);
595
623
  const withId = {
596
624
  ...(requestId === undefined ? {} : { requestId }),
597
625
  ...(toolPrefix === undefined ? {} : { toolPrefix }),
598
626
  };
627
+ // The write's via wins over the slot's (`secret-routing`); the URL still picks the credential.
628
+ const routing = via === undefined ? {} : { via };
629
+ const viaFromSlot = via === undefined && slotVia !== undefined;
599
630
  if (slot.type === "headers") {
600
631
  if (headers === undefined)
601
632
  fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
602
- return PutAgentSecretRequestSchema.parse({ ...withId, ...full, headers });
633
+ return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, headers }), viaFromSlot };
603
634
  }
604
635
  if (value === undefined)
605
636
  fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
606
- return PutAgentSecretRequestSchema.parse({ ...withId, ...full, value });
637
+ return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, value }), viaFromSlot };
607
638
  }
608
- /** A slot's type and binding, as a secret write's fields (without the value). */
639
+ /**
640
+ * A slot's type and binding, as a secret write's fields (without the value). A slot's
641
+ * `toolPrefix` stays the slot's: a session reads it from the version its Agent runs.
642
+ */
609
643
  function slotRequest(slot) {
610
644
  const { required: _r, description: _d, ...binding } = slot;
645
+ if ("toolPrefix" in binding) {
646
+ const { toolPrefix: _p, ...rest } = binding;
647
+ return rest;
648
+ }
611
649
  return binding;
612
650
  }
613
651
  /**
@@ -24,7 +24,7 @@
24
24
  import { randomUUID } from "node:crypto";
25
25
  import { createDurableCheckpoint, createFlowCheckpoint, resumeFlowCheckpoint, } from "@nylorun/harness/run";
26
26
  import { commandKey, cancelQueuedEffects, flowInteractionOf, foreignInteractionConflict, isWorkflowManifest, planCancelCascade, wakeLinkedWorkflow, } from "../core/flow-host.js";
27
- import { resolveMessageManifest } from "../core/turn-manifest.js";
27
+ import { resolveMessageManifest, turnManifestFor, turnManifestRequestOf } from "../core/turn-manifest.js";
28
28
  import { canonical } from "../store/canonical.js";
29
29
  import { lockedSession, } from "./context.js";
30
30
  import { fail, HttpError } from "./http.js";
@@ -388,14 +388,21 @@ async function beginMessageTurn(t, s, id, messages) {
388
388
  input: first.input,
389
389
  });
390
390
  else {
391
- const resolved = resolveMessageManifest({
392
- pinned: s.manifest,
393
- pinnedHash: s.manifestHash,
394
- messageManifest: first.command.manifest,
395
- store: variantStore(s),
396
- });
391
+ const request = turnManifestRequestOf(first.command);
392
+ const resolved = turnManifestFor({ pinned: s.manifest, pinnedHash: s.manifestHash, request: request ?? {} });
397
393
  if (!resolved.ok)
398
394
  return fail(400, resolved.message);
395
+ if (resolved.hash !== s.manifestHash)
396
+ variantStore(s).put(resolved.hash, resolved.manifest);
397
+ // A turn's first segment applies the request again to the version it re-pins (`repinTurn`).
398
+ if (request)
399
+ s.turnRequest = {
400
+ turnId,
401
+ ...(resolved.variant === undefined ? {} : { manifest: resolved.variant }),
402
+ ...(request.removeTools === undefined ? {} : { removeTools: [...request.removeTools] }),
403
+ };
404
+ else
405
+ delete s.turnRequest;
399
406
  checkpoint = createDurableCheckpoint({
400
407
  manifest: resolved.manifest,
401
408
  sessionId: id,
@@ -456,7 +463,8 @@ export async function startQueuedTurn(ctx, t, s, id) {
456
463
  const queued = await t.queuedMessages(id);
457
464
  if (queued.length === 0)
458
465
  return undefined;
459
- const manifestOf = (row) => canonical(row.command.manifest ?? null);
466
+ // Messages that ask the same of their turn's manifest (a variant, `removeTools`) share a turn.
467
+ const manifestOf = (row) => canonical(turnManifestRequestOf(row.command) ?? null);
460
468
  const batch = isWorkflowManifest(s.manifest) ? [queued[0]] : [];
461
469
  if (batch.length === 0)
462
470
  for (const row of queued) {
@@ -15,7 +15,7 @@
15
15
  * under ownership (§10.6); `abortLocal` aborts an advance running on this process.
16
16
  */
17
17
  import type { SessionHistory } from "./history.js";
18
- import type { IssuerScope, SessionModelChoice, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
18
+ import type { IssuerScope, SessionModelChoice, SessionModelRecord, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
19
19
  import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
20
20
  import type { AgentManifest, JsonValue, SandboxManifest } from "@nylorun/core/define";
21
21
  import type { OutboxRequest, SessionStore, Tx } from "../store/types.js";
@@ -63,6 +63,17 @@ export interface Session {
63
63
  loop?: Record<string, unknown>;
64
64
  /** Validated turn-manifest variants, keyed by hash (loops.md §4.2). */
65
65
  variants?: Record<string, AgentManifest>;
66
+ /**
67
+ * What the active turn's first message asked of its manifest: a variant (`manifest`) and the
68
+ * tools it may not use (`removeTools`, Host feature `remove-tools`). A turn's first segment that
69
+ * re-pins the session to its Agent's version applies it again to that version (`repinTurn`).
70
+ * Absent when the message asked for neither.
71
+ */
72
+ turnRequest?: {
73
+ turnId: string;
74
+ manifest?: AgentManifest;
75
+ removeTools?: string[];
76
+ };
66
77
  info?: any;
67
78
  status: string;
68
79
  activeTurnId: string | null;
@@ -147,6 +158,24 @@ export interface Session {
147
158
  * place of the Tenant's while its provider is connected. Absent: the Tenant's model.
148
159
  */
149
160
  model?: SessionModelChoice;
161
+ /**
162
+ * The Agent's memory as the session's last turn started (track T5): the newest memory version
163
+ * then (`seen`), and whether that turn's first segment asked the harness to pin the index again
164
+ * (`refresh`: someone else wrote the memory since the turn before). Absent before its first turn.
165
+ */
166
+ memoryIndex?: {
167
+ turnId: string;
168
+ seen?: string;
169
+ refresh: boolean;
170
+ };
171
+ /** Where `model` came from: `manifest` when its Agent's version named it (`harness.model`). Absent: chosen. */
172
+ modelSource?: "manifest";
173
+ /**
174
+ * The model the session's turns call and why (`SessionModelRecord`, `tenant/session-model.ts`):
175
+ * as the session opened or chose it, and at each segment's start since. Absent while the
176
+ * Tenant has no model, and on a session opened before it was kept.
177
+ */
178
+ modelRecord?: SessionModelRecord;
150
179
  }
151
180
  export type AuthScope = {
152
181
  kind: "application";
@@ -4,10 +4,15 @@
4
4
  * (`Session.model`): the Model Gate reads it at each call (`HostModelVault.readHostModel`) and a
5
5
  * turn's start describes it (`turn.start.model`). The Tenant's model, and every other session's,
6
6
  * stay as they are. While its provider is disconnected the session uses the Tenant's model again.
7
+ *
8
+ * What its turns call, and why, is kept too (`Session.modelRecord`, `SessionModelRecord`): set when
9
+ * the session opens (the manifest's model, or the Tenant's and why), when it chooses or clears its
10
+ * model, and again at each segment's start, when a provider was connected or disconnected or the
11
+ * Tenant's model changed. Each change writes `session.model`.
7
12
  */
8
- import { type SessionModelChoice, type SessionModelView } from "@nylorun/core/contracts";
13
+ import { type SessionModelChoice, type SessionModelRecord, type SessionModelView } from "@nylorun/core/contracts";
9
14
  import type { Tx } from "../store/types.js";
10
- import { type SessionAccess, type TenantContext } from "./context.js";
15
+ import { type Session, type SessionAccess, type TenantContext } from "./context.js";
11
16
  /** The model the session's next turn uses, and where it comes from. */
12
17
  export declare function readSessionModel(ctx: TenantContext, id: string, access: SessionAccess | undefined): Promise<SessionModelView>;
13
18
  /**
@@ -15,12 +20,36 @@ export declare function readSessionModel(ctx: TenantContext, id: string, access:
15
20
  * Tenant's. A turn calls one model: the choice changes only while no turn runs or waits.
16
21
  */
17
22
  export declare function chooseSessionModel(ctx: TenantContext, id: string, choice: SessionModelChoice | undefined, access: SessionAccess | undefined): Promise<SessionModelView>;
23
+ /** Why a session's turns call the Tenant's model in place of the one asked for, and that one. */
24
+ export interface ModelFallback {
25
+ readonly reason: NonNullable<SessionModelRecord["reason"]>;
26
+ readonly requested: NonNullable<SessionModelRecord["requested"]>;
27
+ }
18
28
  /**
19
29
  * The model a new session starts with (Host feature `agent-model`): the one its version's
20
- * `harness.model` names, as `chooseSessionModel` would take it, or undefined when it names none or
21
- * one the Tenant cannot use now (a provider not connected, a model its catalog does not list), so
22
- * the Tenant's model applies. Never fails the session.
30
+ * `harness.model` names, as `chooseSessionModel` would take it (`choice`), or, when it names one
31
+ * the Tenant cannot use now (a provider not connected, a model its catalog does not list), why
32
+ * (`fallback`), so the Tenant's model applies. Undefined when it names none. Never fails the
33
+ * session.
23
34
  */
24
35
  export declare function agentModelOf(t: Tx, harness: {
25
36
  readonly model?: unknown;
26
- }): Promise<SessionModelChoice | undefined>;
37
+ }): Promise<{
38
+ choice: SessionModelChoice;
39
+ } | {
40
+ fallback: ModelFallback;
41
+ } | undefined>;
42
+ /**
43
+ * The model session `s`'s turns call now, and why (`SessionModelRecord`, as the Model Gate picks
44
+ * it: `hostCredentialRowOf`): its own model while its provider is connected (`source` `manifest`
45
+ * when its Agent's version named it, else `session`), else the Tenant's, with the reason when it
46
+ * falls back. `fallback` is why the session opened without the model its manifest names; kept
47
+ * from the session's record otherwise, until it chooses or clears a model. Undefined while the
48
+ * Tenant has no model to fall back to.
49
+ */
50
+ export declare function sessionModelRecordOf(t: Tx, s: Pick<Session, "model" | "modelSource" | "modelRecord">, fallback?: ModelFallback): Promise<SessionModelRecord | undefined>;
51
+ /**
52
+ * Keeps `record` as session `s`'s model record (the caller saves `s`) and, when it differs from
53
+ * the one it had, writes `session.model` with the one before. Returns whether it changed.
54
+ */
55
+ export declare function noteSessionModel(t: Tx, s: Session, record: SessionModelRecord | undefined): Promise<boolean>;
@@ -4,10 +4,16 @@
4
4
  * (`Session.model`): the Model Gate reads it at each call (`HostModelVault.readHostModel`) and a
5
5
  * turn's start describes it (`turn.start.model`). The Tenant's model, and every other session's,
6
6
  * stay as they are. While its provider is disconnected the session uses the Tenant's model again.
7
+ *
8
+ * What its turns call, and why, is kept too (`Session.modelRecord`, `SessionModelRecord`): set when
9
+ * the session opens (the manifest's model, or the Tenant's and why), when it chooses or clears its
10
+ * model, and again at each segment's start, when a provider was connected or disconnected or the
11
+ * Tenant's model changed. Each change writes `session.model`.
7
12
  */
8
- import { agentModelChoice } from "@nylorun/core/contracts";
13
+ import { agentModelChoice, } from "@nylorun/core/contracts";
14
+ import { canonical } from "../store/canonical.js";
9
15
  import { catalogModel, hostModelCatalog } from "../model/catalog.js";
10
- import { hostCredentialRowFor, modelBinding } from "../vault/service.js";
16
+ import { activeHostCredentialRow, hostCredentialRowFor, modelBinding } from "../vault/service.js";
11
17
  import { takesMessages } from "./commands.js";
12
18
  import { lockedSession, sessionOf } from "./context.js";
13
19
  import { fail } from "./http.js";
@@ -29,8 +35,9 @@ export async function chooseSessionModel(ctx, id, choice, access) {
29
35
  });
30
36
  if (choice)
31
37
  await checkChoice(t, choice);
32
- const { model: _model, ...rest } = s;
38
+ const { model: _model, modelSource: _source, ...rest } = s;
33
39
  const next = choice ? { ...rest, model: { provider: choice.provider, model: choice.model } } : rest;
40
+ await noteSessionModel(t, next, await sessionModelRecordOf(t, next));
34
41
  await t.put("sessions", id, next);
35
42
  return next;
36
43
  });
@@ -38,15 +45,70 @@ export async function chooseSessionModel(ctx, id, choice, access) {
38
45
  }
39
46
  /**
40
47
  * The model a new session starts with (Host feature `agent-model`): the one its version's
41
- * `harness.model` names, as `chooseSessionModel` would take it, or undefined when it names none or
42
- * one the Tenant cannot use now (a provider not connected, a model its catalog does not list), so
43
- * the Tenant's model applies. Never fails the session.
48
+ * `harness.model` names, as `chooseSessionModel` would take it (`choice`), or, when it names one
49
+ * the Tenant cannot use now (a provider not connected, a model its catalog does not list), why
50
+ * (`fallback`), so the Tenant's model applies. Undefined when it names none. Never fails the
51
+ * session.
44
52
  */
45
53
  export async function agentModelOf(t, harness) {
46
54
  if (typeof harness.model !== "string")
47
55
  return undefined;
48
56
  const choice = agentModelChoice(harness.model);
49
- return choice && (await choiceIssue(t, choice)) === undefined ? choice : undefined;
57
+ if (!choice)
58
+ return undefined;
59
+ const issue = await choiceIssue(t, choice);
60
+ return issue === undefined
61
+ ? { choice }
62
+ : { fallback: { reason: issue.reason, requested: { ...choice, by: "manifest" } } };
63
+ }
64
+ /**
65
+ * The model session `s`'s turns call now, and why (`SessionModelRecord`, as the Model Gate picks
66
+ * it: `hostCredentialRowOf`): its own model while its provider is connected (`source` `manifest`
67
+ * when its Agent's version named it, else `session`), else the Tenant's, with the reason when it
68
+ * falls back. `fallback` is why the session opened without the model its manifest names; kept
69
+ * from the session's record otherwise, until it chooses or clears a model. Undefined while the
70
+ * Tenant has no model to fall back to.
71
+ */
72
+ export async function sessionModelRecordOf(t, s, fallback) {
73
+ if (s.model) {
74
+ const by = s.modelSource ?? "session";
75
+ if (await hostCredentialRowFor(t, s.model.provider))
76
+ return { provider: s.model.provider, model: s.model.model, source: by };
77
+ return tenantModelRecord(t, { reason: "provider_not_connected", requested: { ...s.model, by } });
78
+ }
79
+ const kept = fallback ??
80
+ (s.modelRecord?.reason !== undefined && s.modelRecord.requested?.by === "manifest"
81
+ ? { reason: s.modelRecord.reason, requested: s.modelRecord.requested }
82
+ : undefined);
83
+ return tenantModelRecord(t, kept);
84
+ }
85
+ async function tenantModelRecord(t, fallback) {
86
+ const row = await activeHostCredentialRow(t);
87
+ if (!row)
88
+ return undefined;
89
+ const binding = modelBinding(row);
90
+ return {
91
+ provider: binding.provider,
92
+ model: binding.model,
93
+ source: "tenant",
94
+ ...(fallback === undefined ? {} : { reason: fallback.reason, requested: { ...fallback.requested } }),
95
+ };
96
+ }
97
+ /**
98
+ * Keeps `record` as session `s`'s model record (the caller saves `s`) and, when it differs from
99
+ * the one it had, writes `session.model` with the one before. Returns whether it changed.
100
+ */
101
+ export async function noteSessionModel(t, s, record) {
102
+ if (canonical(record ?? null) === canonical(s.modelRecord ?? null))
103
+ return false;
104
+ const previous = s.modelRecord;
105
+ if (record === undefined) {
106
+ delete s.modelRecord;
107
+ return true;
108
+ }
109
+ s.modelRecord = record;
110
+ await t.event(s.id, s.activeTurnId, "session.model", { ...record, ...(previous ? { previous } : {}) });
111
+ return true;
50
112
  }
51
113
  /** A choice names a connected provider and, when its catalog lists models, one of them. */
52
114
  async function checkChoice(t, choice) {
@@ -57,19 +119,25 @@ async function checkChoice(t, choice) {
57
119
  /** Why `choice` is not one the Tenant can use now; undefined when it is. */
58
120
  async function choiceIssue(t, choice) {
59
121
  if (!/^[a-zA-Z0-9._-]+$/.test(choice.provider))
60
- return { status: 400, message: "Invalid provider id", code: "invalid_request" };
122
+ return { status: 400, message: "Invalid provider id", code: "invalid_request", reason: "invalid_model" };
61
123
  const row = await hostCredentialRowFor(t, choice.provider);
62
124
  if (!row)
63
125
  return {
64
126
  status: 422,
65
127
  message: `Model provider ${choice.provider} is not connected: connect it in Model Settings first`,
66
128
  code: "request_rejected",
129
+ reason: "provider_not_connected",
67
130
  };
68
131
  // A custom endpoint serves the models it names; the provider's own model is always one.
69
132
  if (choice.provider === "custom" || modelBinding(row).model === choice.model)
70
133
  return undefined;
71
134
  const listed = hostModelCatalog().providers.find((provider) => provider.id === choice.provider);
72
135
  if (listed && listed.models.length > 0 && !catalogModel(choice.provider, choice.model))
73
- return { status: 422, message: `Unknown model ${choice.model} for provider ${choice.provider}`, code: "request_rejected" };
136
+ return {
137
+ status: 422,
138
+ message: `Unknown model ${choice.model} for provider ${choice.provider}`,
139
+ code: "request_rejected",
140
+ reason: "not_in_catalog",
141
+ };
74
142
  return undefined;
75
143
  }
@@ -15,7 +15,7 @@ import { openAgent, recordAttachment } from "./agents.js";
15
15
  import { harnessOf, reachesAgent, } from "./context.js";
16
16
  import { fail } from "./http.js";
17
17
  import { definitionFileIssues } from "./definition-files.js";
18
- import { agentModelOf } from "./session-model.js";
18
+ import { agentModelOf, noteSessionModel, sessionModelRecordOf } from "./session-model.js";
19
19
  import { limitsInForce, readTenantLimits } from "./limits.js";
20
20
  /** A session's creation identity: everything but the request id. */
21
21
  const sessionIdentity = (value) => {
@@ -98,7 +98,8 @@ export async function openSessionIn(ctx, t, id, body, access, options = {}) {
98
98
  await requirePods(ctx.pods, `Agent ${agent.id}`);
99
99
  // The model the version names (`harness.model`, Host feature `agent-model`) is the new
100
100
  // session's own, as `chooseSessionModel` would make it, when the Tenant can use it now.
101
- const model = await agentModelOf(t, version.body.harness);
101
+ const asked = await agentModelOf(t, version.body.harness);
102
+ const model = asked && "choice" in asked ? asked.choice : undefined;
102
103
  const created = {
103
104
  id,
104
105
  agentId: agent.id,
@@ -115,10 +116,16 @@ export async function openSessionIn(ctx, t, id, body, access, options = {}) {
115
116
  environmentId: agent.id,
116
117
  environmentVersion: agent.version,
117
118
  ...(pin.sandbox === undefined ? {} : { sandbox: pin.sandbox }),
118
- ...(model === undefined ? {} : { model }),
119
+ ...(model === undefined ? {} : { model, modelSource: "manifest" }),
119
120
  };
120
121
  await t.put("sessions", id, created);
121
122
  await recordAttachment(t, id, agent.id);
123
+ // The model its turns call, and why: a flow's agents call theirs in their own sessions.
124
+ if (!isWorkflowManifest(pin.manifest)) {
125
+ const fallback = asked && "fallback" in asked ? asked.fallback : undefined;
126
+ if (await noteSessionModel(t, created, await sessionModelRecordOf(t, created, fallback)))
127
+ await t.put("sessions", id, created);
128
+ }
122
129
  return created;
123
130
  }
124
131
  /**
@@ -165,6 +172,9 @@ export async function sessionView(t, s) {
165
172
  ...(s.closedReason === undefined ? {} : { closedReason: s.closedReason }),
166
173
  mcpSnapshot: s.mcpSnapshot ?? null,
167
174
  mcpDiagnostics: s.mcpDiagnostics ?? [],
175
+ // As its last turn started; a session that kept none yet (older, or opened before the
176
+ // Tenant had a model) answers what its next turn would call.
177
+ model: isWorkflowManifest(s.manifest) ? null : (s.modelRecord ?? (await sessionModelRecordOf(t, s)) ?? null),
168
178
  waits,
169
179
  error: s.error,
170
180
  uncertainEffects: uncertain.map((e) => ({