@opengeni/core 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,17 +50,17 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@opengeni/capabilities": "1.0.2",
54
- "@opengeni/codex": "1.0.2",
55
- "@opengeni/config": "1.0.2",
56
- "@opengeni/contracts": "1.0.2",
57
- "@opengeni/db": "1.0.2",
58
- "@opengeni/documents": "1.0.2",
59
- "@opengeni/events": "1.0.2",
60
- "@opengeni/network": "1.0.2",
61
- "@opengeni/observability": "1.0.2",
62
- "@opengeni/runtime": "1.0.2",
63
- "@opengeni/storage": "1.0.2",
53
+ "@opengeni/capabilities": "1.2.0",
54
+ "@opengeni/codex": "1.2.0",
55
+ "@opengeni/config": "1.2.0",
56
+ "@opengeni/contracts": "1.2.0",
57
+ "@opengeni/db": "1.2.0",
58
+ "@opengeni/documents": "1.2.0",
59
+ "@opengeni/events": "1.2.0",
60
+ "@opengeni/network": "1.2.0",
61
+ "@opengeni/observability": "1.2.0",
62
+ "@opengeni/runtime": "1.2.0",
63
+ "@opengeni/storage": "1.2.0",
64
64
  "hono": "^4.12.18",
65
65
  "zod": "^4.2.1"
66
66
  },
@@ -19,10 +19,8 @@ import {
19
19
  isRetryableDatabaseTransportFailure,
20
20
  nestedPostgresSqlState,
21
21
  replayAppliedSessionFork,
22
- sessionTenancyProductActivated,
23
22
  SessionTenancyAccessError,
24
23
  SessionTenancyInvalidRequestError,
25
- SessionTenancyNotActivatedError,
26
24
  transitionSessionVisibility,
27
25
  type Database,
28
26
  type ForkSessionContentResult,
@@ -101,9 +99,10 @@ export async function getManagedHumanSessionCreateCapabilities(
101
99
  }
102
100
  throw error;
103
101
  }
104
- // Mirror the database create fence exactly: a managed human's own Personal
105
- // workspace needs only the operator readiness receipt, while a shared
106
- // organization workspace additionally needs the owner/admin product setting.
102
+ // Mirror the database create fence exactly: every organization is
103
+ // session-tenancy activated (migration 0611), so a member's own Personal
104
+ // workspace is always private-capable, while a shared organization workspace
105
+ // additionally needs the owner/admin Only-me product setting.
107
106
  let activated = false;
108
107
  try {
109
108
  const policy = await getPrivateSessionCreatePolicy(deps.db, {
@@ -153,9 +152,6 @@ async function requireSessionTenancyMutationGate(
153
152
  // missing, shared, or another owner's private session.
154
153
  requireVerifiedOwningUser(authorization, workspaceId);
155
154
  for (const permission of permissions) requirePermission(authorization.grant, permission);
156
- if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
157
- throw new SessionTenancyNotActivatedError();
158
- }
159
155
  }
160
156
 
161
157
  async function publishExactCommittedEvent(
@@ -8,8 +8,6 @@ import {
8
8
  issueSelfUserResourceGrant,
9
9
  listSelfUserResourceAuthorities,
10
10
  revokeSelfUserResourceGrant,
11
- sessionTenancyProductActivated,
12
- SessionTenancyNotActivatedError,
13
11
  } from "@opengeni/db";
14
12
  import { requirePermission, type AccessGrantAuthorization } from "../access";
15
13
  import type { AppDependencies } from "../dependencies";
@@ -46,9 +44,6 @@ async function requireOwnerProductGate(
46
44
  // Target-free gates must run before an optional target-session lookup or host
47
45
  // callback so rejected principals cannot use grant management as an oracle.
48
46
  requireOwnerAuthority(authorization, workspaceId, permissions);
49
- if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
50
- throw new SessionTenancyNotActivatedError();
51
- }
52
47
  }
53
48
 
54
49
  function requireOwnerAuthority(
@@ -75,14 +70,6 @@ export async function listManagedHumanUserResourceAuthorities(
75
70
  input: { resourceKind: UserResourceKind; cursor?: string | undefined; limit: number },
76
71
  ) {
77
72
  requireOwnerAuthority(authorization, workspaceId, LIST_PERMISSIONS[input.resourceKind]);
78
- // Before activation there cannot be usable personal-resource authority in
79
- // this organization. Discovery therefore has an exact empty answer; only
80
- // issue/revoke and runtime use remain behind the forward-only product gate.
81
- // This keeps ordinary workspace-owned Rig/Variable Set selection independent
82
- // from an optional personal-resource product that is not enabled.
83
- if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
84
- return { authorities: [], nextCursor: null };
85
- }
86
73
  return await listSelfUserResourceAuthorities(deps.db, {
87
74
  accountId: authorization.grant.accountId,
88
75
  workspaceId,
@@ -0,0 +1,107 @@
1
+ import {
2
+ canonicalizeConfiguredModelId,
3
+ configuredStaticUsageLimits,
4
+ type Settings,
5
+ } from "@opengeni/config";
6
+ import type { EntitlementsPort } from "@opengeni/contracts";
7
+ import { modelFundingForAdmission } from "./limits";
8
+ import {
9
+ checkWorkspaceAllowance,
10
+ getSpendableCreditBalance,
11
+ isCodexBilledTurn,
12
+ sumUsageQuantity,
13
+ type Database,
14
+ } from "@opengeni/db";
15
+
16
+ export type AgentRunAdmissionDenial =
17
+ | "insufficient_credits"
18
+ | "allowance_exhausted"
19
+ | "monthly_model_cost_limit"
20
+ | "monthly_agent_run_limit";
21
+
22
+ /** Read-only admission shared by service-authored runs and goal Resume. */
23
+ export async function agentRunAdmissionDenial(
24
+ services: { db: Database; settings: Settings; entitlements?: EntitlementsPort | null },
25
+ input: {
26
+ accountId: string;
27
+ workspaceId: string;
28
+ model: string;
29
+ requestedAgentRuns: number;
30
+ /** The accepted work's causal human, not the scheduler/service caller. */
31
+ initiatingHumanSubjectId?: string | null;
32
+ },
33
+ ): Promise<AgentRunAdmissionDenial | null> {
34
+ const codexBilled = await isCodexBilledTurn({
35
+ db: services.db,
36
+ settings: services.settings,
37
+ workspaceId: input.workspaceId,
38
+ model: input.model,
39
+ });
40
+ const externallyBilled = modelFundingForAdmission(
41
+ services.settings,
42
+ input.model,
43
+ codexBilled,
44
+ ).fundedWithoutCredits;
45
+ if (
46
+ !externallyBilled &&
47
+ (services.settings.billingMode === "stripe" || services.settings.usageLimitsMode === "managed")
48
+ ) {
49
+ if (services.entitlements) {
50
+ const decision = await services.entitlements.admitRun({
51
+ accountId: input.accountId,
52
+ workspaceId: input.workspaceId,
53
+ action: "agent_run:create",
54
+ quantity: input.requestedAgentRuns,
55
+ });
56
+ if (!decision.allowed) return "insufficient_credits";
57
+ } else {
58
+ const balance = await getSpendableCreditBalance(
59
+ services.db,
60
+ input.accountId,
61
+ canonicalizeConfiguredModelId(services.settings, input.model),
62
+ );
63
+ if (balance.balanceMicros <= 0) return "insufficient_credits";
64
+ }
65
+ }
66
+ if (!externallyBilled) {
67
+ const refusal = await checkWorkspaceAllowance(services.db, {
68
+ accountId: input.accountId,
69
+ workspaceId: input.workspaceId,
70
+ subjectId: input.initiatingHumanSubjectId ?? null,
71
+ });
72
+ if (refusal) return refusal.code;
73
+ }
74
+ if (
75
+ services.settings.usageLimitsMode !== "static" &&
76
+ services.settings.usageLimitsMode !== "managed"
77
+ ) {
78
+ return null;
79
+ }
80
+ const limits = configuredStaticUsageLimits(services.settings);
81
+ if (!externallyBilled && limits.maxMonthlyCostMicrosPerAccount) {
82
+ const used = await sumUsageQuantity(services.db, {
83
+ accountId: input.accountId,
84
+ eventType: "model.cost",
85
+ since: startOfUtcMonth(),
86
+ });
87
+ if (used >= limits.maxMonthlyCostMicrosPerAccount) {
88
+ return "monthly_model_cost_limit";
89
+ }
90
+ }
91
+ if (limits.maxMonthlyAgentRunsPerWorkspace) {
92
+ const used = await sumUsageQuantity(services.db, {
93
+ workspaceId: input.workspaceId,
94
+ eventType: "agent_run.created",
95
+ since: startOfUtcMonth(),
96
+ });
97
+ if (used + input.requestedAgentRuns > limits.maxMonthlyAgentRunsPerWorkspace) {
98
+ return "monthly_agent_run_limit";
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function startOfUtcMonth(): Date {
105
+ const now = new Date();
106
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
107
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  configuredStaticUsageLimits,
3
+ canonicalizeConfiguredModelId,
3
4
  resolveModelProviderForTurn,
4
5
  type Settings,
5
6
  } from "@opengeni/config";
@@ -16,7 +17,7 @@ import {
16
17
  countActiveOrganizationApiKeysForAccount,
17
18
  countScheduledTasksForWorkspace,
18
19
  countWorkspacesForAccount,
19
- getBillingBalance,
20
+ getSpendableCreditBalance,
20
21
  isCodexBilledTurn,
21
22
  recordUsageEvent,
22
23
  sumUsageQuantity,
@@ -148,11 +149,21 @@ async function checkCreditBalance(
148
149
  if (!usesCreditLimits(deps) || !isCostlyAction(input.action)) {
149
150
  return { allowed: true };
150
151
  }
151
- const balance = await getBillingBalance(deps.db, input.accountId);
152
+ const balance = await getSpendableCreditBalance(
153
+ deps.db,
154
+ input.accountId,
155
+ input.model ? canonicalizeConfiguredModelId(deps.settings, input.model) : undefined,
156
+ );
152
157
  if (balance.balanceMicros > 0) {
153
158
  return { allowed: true };
154
159
  }
155
- return { allowed: false, code: "insufficient_credits", message: "insufficient Opengeni credits" };
160
+ return {
161
+ allowed: false,
162
+ code: "insufficient_credits",
163
+ message: input.model
164
+ ? "No credits available for this model. Choose a model covered by your promotional credits or add credits."
165
+ : "No general credits available. Promotional credits cover eligible models only.",
166
+ };
156
167
  }
157
168
 
158
169
  async function checkStaticCaps(
@@ -13,6 +13,7 @@ import {
13
13
  type WorkspaceSessionDefaults,
14
14
  type XaiProviderAccountAuthoritySnapshotV1,
15
15
  type ClaudeProviderAccountAuthoritySnapshotV1,
16
+ type BillingBalance,
16
17
  } from "@opengeni/contracts";
17
18
  import {
18
19
  getScheduledTaskXaiProviderAccountAuthoritySnapshot,
@@ -30,7 +31,8 @@ import {
30
31
  workspaceCodexSubscriptionActive,
31
32
  workspaceOpenRouterConnectionActive,
32
33
  workspaceVercelAiGatewayConnectionActive,
33
- organizationHoldsCredits,
34
+ getBillingBalance,
35
+ spendableCreditMicros,
34
36
  workspaceXaiSubscriptionActive,
35
37
  workspaceXaiSubscriptionActiveForAuthority,
36
38
  XaiAuthorityPoolInactiveError,
@@ -112,16 +114,22 @@ export type DefaultSessionModelInput = {
112
114
  * `organizationHoldsCredits`).
113
115
  */
114
116
  creditsAvailable: boolean;
117
+ creditBalance?: BillingBalance;
115
118
  };
116
119
 
117
120
  function creditsCandidate(
118
- input: Pick<DefaultSessionModelInput, "settings" | "selections">,
121
+ input: Pick<DefaultSessionModelInput, "settings" | "selections" | "creditBalance">,
119
122
  ): DefaultModelSelection | null {
123
+ const selections = input.creditBalance
124
+ ? input.selections.filter(
125
+ (selection) => spendableCreditMicros(input.creditBalance!, selection.model.id) > 0,
126
+ )
127
+ : input.selections;
120
128
  const fallbackEffort = input.settings.openaiReasoningEffort;
121
- const deployment = findSelection(input.selections, input.settings.openaiModel);
122
- const configured = findSelection(input.selections, input.settings.creditsDefaultModel);
129
+ const deployment = findSelection(selections, input.settings.openaiModel);
130
+ const configured = findSelection(selections, input.settings.creditsDefaultModel);
123
131
  if (deployment?.availability.selectable && deployment.model.cost === "credits") {
124
- // An operator's paid deployment default is never replaced. When it is the
132
+ // A funded paid deployment default is preserved. When it is the
125
133
  // credits default model itself, credit holders get the credits default
126
134
  // effort rather than the deployment-wide fallback effort.
127
135
  if (configured?.model.id !== deployment.model.id) return null;
@@ -146,7 +154,7 @@ function creditsCandidate(
146
154
  source: "credits",
147
155
  };
148
156
  }
149
- const first = input.selections.find(
157
+ const first = selections.find(
150
158
  (selection) => selection.availability.selectable && selection.model.cost === "credits",
151
159
  );
152
160
  return first
@@ -175,8 +183,8 @@ function creditsCandidate(
175
183
  * configured credits default (`OPENGENI_CREDITS_DEFAULT_MODEL`, effort
176
184
  * clamped to what the model supports), or the first selectable
177
185
  * credits-billed model when that one is not selectable. When the
178
- * deployment default is already a selectable credits-billed model it is
179
- * never replaced: it keeps the deployment effort, except that it takes the
186
+ * deployment default is already a funded, selectable credits-billed model it
187
+ * keeps the deployment effort, except that it takes the
180
188
  * credits default effort when it is the credits default model itself.
181
189
  * 4. `deployment`: the deployment default with the deployment reasoning effort
182
190
  * when stably admissible; otherwise the first stably admissible catalog
@@ -247,6 +255,7 @@ export function creditsDefaultSessionModel(input: {
247
255
  settings: Settings;
248
256
  selections: readonly WorkspaceModelSelection[];
249
257
  workspaceSettings: unknown;
258
+ creditBalance?: BillingBalance;
250
259
  }): DefaultModelSelection | null {
251
260
  if (input.settings.billingMode !== "stripe") return null;
252
261
  return selectDefaultSessionModel({
@@ -254,6 +263,7 @@ export function creditsDefaultSessionModel(input: {
254
263
  selections: input.selections,
255
264
  workspaceDefaults: resolveWorkspaceSessionDefaults(input.workspaceSettings),
256
265
  creditsAvailable: true,
266
+ ...(input.creditBalance ? { creditBalance: input.creditBalance } : {}),
257
267
  });
258
268
  }
259
269
 
@@ -279,13 +289,14 @@ export async function resolveDefaultSessionModelForSelections(
279
289
  if (
280
290
  withoutCredits.source !== "deployment" ||
281
291
  input.settings.billingMode !== "stripe" ||
282
- creditsCandidate(decision) === null
292
+ !input.selections.some(
293
+ (selection) => selection.availability.selectable && selection.model.cost === "credits",
294
+ )
283
295
  ) {
284
296
  return withoutCredits;
285
297
  }
286
- return (await organizationHoldsCredits(db, input.accountId))
287
- ? selectDefaultSessionModel({ ...decision, creditsAvailable: true })
288
- : withoutCredits;
298
+ const creditBalance = await getBillingBalance(db, input.accountId);
299
+ return selectDefaultSessionModel({ ...decision, creditsAvailable: true, creditBalance });
289
300
  }
290
301
 
291
302
  export type WorkspaceModelSelectionContext = {
@@ -3,6 +3,7 @@ import type {
3
3
  ConnectionCredentialsPort,
4
4
  Document,
5
5
  DocumentAuthorityKind,
6
+ EntitlementsPort,
6
7
  GitHubAppApiPort,
7
8
  ScheduledTask,
8
9
  ScheduledTaskTriggerType,
@@ -155,6 +156,8 @@ export type AppDependencies = {
155
156
  */
156
157
  catalogSourceSettings?: Settings;
157
158
  db: Database;
159
+ /** Read-only host funding admission; unset uses the standalone billing ledger. */
160
+ entitlements?: EntitlementsPort | null;
158
161
  /**
159
162
  * Host-composed editable artifact engine. Standalone startup binds the same
160
163
  * native kernel/DB/object-store implementation; embedded hosts may inject an
@@ -40,7 +40,7 @@ const usage = mock(
40
40
  );
41
41
  mock.module("@opengeni/db", () => ({
42
42
  listKnowledgeEntries: list,
43
- getBillingBalance: async () => ({ balanceMicros: balance }),
43
+ getSpendableCreditBalance: async () => ({ balanceMicros: balance }),
44
44
  applyCreditDebitAfterUse: debits,
45
45
  checkWorkspaceAllowance: allowanceChecks,
46
46
  creditDebitAttributionForTurn: async (_db: unknown, input: { turnId: string }) => ({
@@ -4,7 +4,7 @@ import {
4
4
  applyCreditDebitAfterUse,
5
5
  checkWorkspaceAllowance,
6
6
  creditDebitAttributionForTurn,
7
- getBillingBalance,
7
+ getSpendableCreditBalance,
8
8
  listKnowledgeEntries,
9
9
  recordUsageEvent,
10
10
  sumUsageQuantity,
@@ -190,7 +190,7 @@ export async function searchKnowledgeEntries(
190
190
  context.accountId,
191
191
  context.workspaceId,
192
192
  async (lockedDb) => {
193
- const balance = await getBillingBalance(lockedDb, context.accountId);
193
+ const balance = await getSpendableCreditBalance(lockedDb, context.accountId);
194
194
  if (balance.balanceMicros <= 0)
195
195
  return keywordFallback(lockedDb, new KnowledgeVectorFundingError(), "awaiting_funding");
196
196
  const attribution =
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  AGENT_SKILL_MANAGE_TOOL_NAMES,
10
10
  AUTOMATIC_SESSION_TITLE_FALLBACK,
11
+ bundledSkillSelectionForAgentConfig,
11
12
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
12
13
  FIRST_PARTY_IN_PROCESS_TOOL_NAMES,
13
14
  type FirstPartyMcpToolName,
@@ -395,14 +396,19 @@ export async function workspaceSessionEffectiveToolsContext(
395
396
 
396
397
  function sessionHasSkills(session: Session, context: SessionEffectiveToolsContext): boolean {
397
398
  if (context.hasWorkspaceSkills || session.skills.length > 0) return true;
398
- // Undefined is the worker's bundled default. Explicit [] means no bundles.
399
- if (session.bundledSkillIds === undefined) return true;
399
+ // Undefined is the worker's bundled default. Explicit [] means no bundles,
400
+ // and a "none" agent without an explicit list has none (as in the worker).
401
+ const bundledSkillIds = bundledSkillSelectionForAgentConfig(
402
+ session.bundledSkillIds,
403
+ session.agent,
404
+ );
405
+ if (bundledSkillIds === undefined) return true;
400
406
  const tools = new Set(
401
407
  resolveAgentToolFamilies(session.agent).firstPartyTools(
402
408
  allowedFirstPartyMcpToolsForSession(context.settings, session.firstPartyMcpTools),
403
409
  ),
404
410
  );
405
- return session.bundledSkillIds.some((id) => {
411
+ return bundledSkillIds.some((id) => {
406
412
  if (
407
413
  [
408
414
  "builtin:opengeni-documents",
@@ -479,7 +485,7 @@ export function sessionEffectiveToolProjectionInput(
479
485
  (!session.title?.trim() || session.title.trim() === AUTOMATIC_SESSION_TITLE_FALLBACK);
480
486
  const firstPartyMcpTools =
481
487
  context && toolRefs.some((ref) => ref.id === "opengeni")
482
- ? resolveAgentToolFamilies(session.agent)
488
+ ? resolveAgentToolFamilies(session.agent, { sandboxAttached: sandboxAvailable })
483
489
  .firstPartyTools(
484
490
  allowedFirstPartyMcpToolsForSession(context.settings, session.firstPartyMcpTools),
485
491
  )
@@ -512,7 +518,9 @@ export function sessionEffectiveToolProjectionInput(
512
518
  firstPartyModelNames.set(name, `interaction__${name}`);
513
519
  if (
514
520
  !firstPartyMcpTools.includes(name) &&
515
- resolveAgentToolFamilies(session.agent).allowsFirstPartyTool(name) &&
521
+ resolveAgentToolFamilies(session.agent, {
522
+ sandboxAttached: sandboxAvailable,
523
+ }).allowsFirstPartyTool(name) &&
516
524
  session.firstPartyMcpTools.includes(name)
517
525
  )
518
526
  firstPartyMcpTools.push(name);
@@ -561,6 +569,7 @@ export function sessionEffectiveToolProjectionInput(
561
569
  routerInHistory:
562
570
  context?.routerInHistory === true ||
563
571
  context?.routerHistorySessionIds?.has(session.id) === true,
572
+ ...(context ? { sandboxAttached: sandboxAvailable } : {}),
564
573
  };
565
574
  const families = resolveAgentToolFamilies(session.agent, environment);
566
575
  environment.hasDeferredTools =
@@ -72,6 +72,7 @@ import {
72
72
  OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
73
73
  SessionSkills,
74
74
  resolveBundledSkillSelection,
75
+ bundledSkillSelectionForAgentConfig,
75
76
  SessionSpawnDenial,
76
77
  ServiceTurnInitiator,
77
78
  ServiceTurnInitiatorContext,
@@ -182,7 +183,6 @@ import {
182
183
  SessionToolPolicyVersionConflictError,
183
184
  SessionCreateIdempotencyConflictError,
184
185
  PersonalResourceAttachmentAcceptanceError,
185
- sessionTenancyProductActivated,
186
186
  workspaceControlRequestLockTimeoutMs,
187
187
  WorkspaceControlBusyError,
188
188
  type SessionCommandActor,
@@ -327,11 +327,6 @@ async function requireAtomicPersonalResourceAttachment(
327
327
  cause: error,
328
328
  });
329
329
  }
330
- if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
331
- throw new HTTPException(409, {
332
- message: "Session tenancy is not activated for this organization.",
333
- });
334
- }
335
330
  if (existingSession && intent.expectedAuthorityEpoch === undefined) {
336
331
  throw new HTTPException(422, {
337
332
  message: "Personal-resource attachment requires expectedAuthorityEpoch.",
@@ -1051,6 +1046,13 @@ export async function createAndStartSessionWithOutcome(input: {
1051
1046
  allowNestedAgentDepthIncrease?: boolean;
1052
1047
  subjectId?: string | null;
1053
1048
  }): Promise<CreateSessionOutcome> {
1049
+ const frozenBundledSkillIds = bundledSkillSelectionForAgentConfig(
1050
+ input.bundledSkillIds,
1051
+ input.agentConfig,
1052
+ );
1053
+ if (frozenBundledSkillIds !== input.bundledSkillIds) {
1054
+ input = { ...input, bundledSkillIds: frozenBundledSkillIds };
1055
+ }
1054
1056
  const sessionMetadata = metadataWithTurnExecutionPolicyV1(
1055
1057
  {
1056
1058
  ...input.metadata,
@@ -2545,6 +2547,9 @@ async function createSessionForRequestInFileScope(
2545
2547
  if (agentResolution.instructions !== undefined) {
2546
2548
  payload.instructions = agentResolution.instructions;
2547
2549
  }
2550
+ // Freeze what the agent configuration implies before keyed replay compares
2551
+ // it: a `"none"` agent omits the bundled guides unless the request lists them.
2552
+ bundledSkillIds = bundledSkillSelectionForAgentConfig(bundledSkillIds, agentConfig);
2548
2553
  const parentCallingTurn =
2549
2554
  parentSession && creationInitiator.actor
2550
2555
  ? await getSessionTurnForAttempt(