@opengeni/worker-bundle 1.2.1-canary.37222965366001 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/activities/agent-turn/errors.d.ts +1 -13
  2. package/dist/activities/agent-turn/subscription-lease.d.ts +1 -0
  3. package/dist/activities/agent-turn/tool-policy.d.ts +10 -1
  4. package/dist/activities/agent-turn/web-search.d.ts +33 -0
  5. package/dist/activities/codemode-dispatcher.d.ts +6 -2
  6. package/dist/activities/open-suffix-resume.d.ts +2 -0
  7. package/dist/activities/programmatic-approvals.d.ts +27 -0
  8. package/dist/activities/run-input.d.ts +2 -0
  9. package/dist/{activities-control-QSDPXLLD.js → activities-control-D5KVEPHI.js} +3 -3
  10. package/dist/{activities-turn-BMY3CDKL.js → activities-turn-4Z3M7Z3F.js} +554 -113
  11. package/dist/activities-turn-4Z3M7Z3F.js.map +1 -0
  12. package/dist/{chunk-JKMG5ZHH.js → chunk-BMTWULF7.js} +115 -31
  13. package/dist/chunk-BMTWULF7.js.map +1 -0
  14. package/dist/{chunk-XONQEVAD.js → chunk-FF4HPY7L.js} +1 -1
  15. package/dist/chunk-FF4HPY7L.js.map +1 -0
  16. package/dist/index.js +2 -2
  17. package/dist/observability-metrics.d.ts +1 -1
  18. package/package.json +21 -21
  19. package/src/activities/agent-turn/agent-build.ts +6 -4
  20. package/src/activities/agent-turn/errors.ts +82 -3
  21. package/src/activities/agent-turn/governance-model.ts +8 -8
  22. package/src/activities/agent-turn/sandbox-runtime.ts +16 -0
  23. package/src/activities/agent-turn/stream-attempt.ts +100 -21
  24. package/src/activities/agent-turn/subscription-lease.ts +10 -1
  25. package/src/activities/agent-turn/tool-environment.ts +20 -0
  26. package/src/activities/agent-turn/tool-policy.ts +26 -5
  27. package/src/activities/agent-turn/web-search.ts +252 -0
  28. package/src/activities/codemode-dispatcher.ts +145 -7
  29. package/src/activities/connector-attachments.ts +3 -1
  30. package/src/activities/open-suffix-resume.ts +107 -86
  31. package/src/activities/programmatic-approvals.ts +52 -0
  32. package/src/activities/run-input.ts +4 -1
  33. package/src/activity-services.ts +8 -0
  34. package/src/observability-metrics.ts +1 -0
  35. package/src/sandbox-routing.ts +58 -24
  36. package/dist/activities-turn-BMY3CDKL.js.map +0 -1
  37. package/dist/chunk-JKMG5ZHH.js.map +0 -1
  38. package/dist/chunk-XONQEVAD.js.map +0 -1
  39. /package/dist/{activities-control-QSDPXLLD.js.map → activities-control-D5KVEPHI.js.map} +0 -0
@@ -27,8 +27,12 @@ import {
27
27
  isModalTaskExecStartPreDispatchUnavailableError,
28
28
  isModalCommandStartOutcomeUnknownError,
29
29
  isProviderCommandObservationUnavailableError,
30
+ ProviderCommandInputOutcomeUnknownError,
31
+ ProviderCommandStartOutcomeUnknownError,
30
32
  isRoutingMutationOutcomeUnknownError,
33
+ isRoutingMutationOutputRejectedError,
31
34
  RoutingWorkspaceRootChangedError,
35
+ RoutingBackendRecoveryRequiredError,
32
36
  ResponsesStreamingTerminalError,
33
37
  SandboxMaterializationVerificationError,
34
38
  materializationVerificationDiagnostic,
@@ -380,7 +384,7 @@ export function preClaimAdmissionFailure(error: unknown): ApplicationFailure {
380
384
  * objects (including array containers), not queue positions/duplicate refs.
381
385
  * The separate link ceiling bounds huge duplicate arrays without truncating
382
386
  * them into permission. Cycles are harmless; overflow/unreadable edges hold. */
383
- function databaseRecoveryCauseGraph(error: unknown): Map<object, Set<object>> | null {
387
+ function structuredRecoveryCauseGraph(error: unknown): Map<object, Set<object>> | null {
384
388
  const graph = new Map<object, Set<object>>();
385
389
  const queue: object[] = [];
386
390
  let links = 0;
@@ -414,7 +418,7 @@ function retryableDatabaseFailureCode(
414
418
  requireDatabaseProvenance = false,
415
419
  ): PostClaimDatabaseRecoveryDetail["code"] | null {
416
420
  try {
417
- const graph = databaseRecoveryCauseGraph(error);
421
+ const graph = structuredRecoveryCauseGraph(error);
418
422
  if (!graph) return null;
419
423
  const transports = new Set<object>();
420
424
  const boundaries = new Set<object>();
@@ -429,7 +433,8 @@ function retryableDatabaseFailureCode(
429
433
  // Ask the canonical transport predicate about ONLY this node's facts. Its
430
434
  // recursive search must not pair a DB sibling with an unrelated provider.
431
435
  for (const node of graph.keys()) {
432
- if (isRoutingMutationOutcomeUnknownError(node)) return null;
436
+ if (isRoutingMutationOutcomeUnknownError(node) || isRoutingMutationOutputRejectedError(node))
437
+ return null;
433
438
  const record = node as Record<string, unknown>;
434
439
  if (
435
440
  requireDatabaseProvenance
@@ -742,13 +747,56 @@ export function modelPreparationFailureEventPayload(error: unknown, durationMs:
742
747
  * only those structural error links with a strict bound; never classify from
743
748
  * message text, which could originate in model or tool content.
744
749
  */
750
+ /**
751
+ * Only the home resolver's positive pre-dispatch signal permits a fresh attempt.
752
+ * The same error class can also escape AFTER a provider mutation, so neither its
753
+ * retryable property nor message alone establishes replay safety. Inspect the
754
+ * complete bounded SDK cause graph and let uncertain peer outcomes veto this
755
+ * narrow recovery; unreadable or overflowing graphs fail closed.
756
+ */
757
+ function isPreDispatchHomeBackendRecoveryRequired(error: unknown): boolean {
758
+ const graph = structuredRecoveryCauseGraph(error);
759
+ if (!graph) return false;
760
+ let found = false;
761
+ try {
762
+ for (const node of graph.keys()) {
763
+ if (
764
+ isRoutingMutationOutcomeUnknownError(node) ||
765
+ isRoutingMutationOutputRejectedError(node) ||
766
+ isRawProviderCommandOutcomeUnknown(node) ||
767
+ isModalCommandStartOutcomeUnknownError(node) ||
768
+ isProviderCommandObservationUnavailableError(node)
769
+ )
770
+ return false;
771
+ if (node instanceof RoutingBackendRecoveryRequiredError) {
772
+ if (
773
+ node.op !== "resolve_home_backend" ||
774
+ !Number.isSafeInteger(node.leaseEpoch) ||
775
+ node.leaseEpoch < 0 ||
776
+ !node.retryable ||
777
+ (node.recovery !== "pending" && node.recovery !== "superseded")
778
+ )
779
+ return false;
780
+ found = true;
781
+ }
782
+ }
783
+ } catch {
784
+ return false;
785
+ }
786
+ return found;
787
+ }
788
+
745
789
  export function sandboxRouteTransitionCode(
746
790
  error: unknown,
747
791
  ):
748
792
  | "home_unavailable_this_turn"
749
793
  | "workspace_root_changed_this_turn"
750
794
  | "native_capabilities_changed_this_attempt"
795
+ | "home_backend_recovery_pending"
751
796
  | null {
797
+ if (isPreDispatchHomeBackendRecoveryRequired(error)) {
798
+ return "home_backend_recovery_pending";
799
+ }
752
800
  const pending: unknown[] = [error];
753
801
  const seen = new WeakSet<object>();
754
802
  let inspected = 0;
@@ -1273,10 +1321,41 @@ function anthropicRequestDiagnostic(error: unknown): AnthropicRequestError | und
1273
1321
  : undefined;
1274
1322
  }
1275
1323
 
1324
+ function isRawProviderCommandOutcomeUnknown(error: unknown): boolean {
1325
+ try {
1326
+ return (
1327
+ error instanceof ProviderCommandInputOutcomeUnknownError ||
1328
+ error instanceof ProviderCommandStartOutcomeUnknownError
1329
+ );
1330
+ } catch {
1331
+ return false;
1332
+ }
1333
+ }
1334
+
1276
1335
  export function agentRunFailurePayload(
1277
1336
  error: unknown,
1278
1337
  options: { isCodexTurn?: boolean } = {},
1279
1338
  ): ReturnType<typeof baseAgentRunFailurePayload> {
1339
+ const graph = structuredRecoveryCauseGraph(error);
1340
+ const nodes = graph ? [...graph.keys()] : [];
1341
+ const outputRejected = nodes.find(isRoutingMutationOutputRejectedError);
1342
+ if (outputRejected) {
1343
+ // A known receipt applies only to its own mutation. Uncertain peers retain
1344
+ // their established terminal classification, regardless of SDK graph order.
1345
+ const unknown = nodes.find(
1346
+ (node) =>
1347
+ isRoutingMutationOutcomeUnknownError(node) ||
1348
+ isModalCommandStartOutcomeUnknownError(node) ||
1349
+ isProviderCommandObservationUnavailableError(node) ||
1350
+ isRawProviderCommandOutcomeUnknown(node),
1351
+ );
1352
+ if (unknown) return { ...baseAgentRunFailurePayload(unknown, options), retryable: false };
1353
+ return {
1354
+ error: outputRejected.message,
1355
+ code: outputRejected.code,
1356
+ retryable: false,
1357
+ };
1358
+ }
1280
1359
  const failure = baseAgentRunFailurePayload(error, options);
1281
1360
  const diagnostic = materializationVerificationDiagnostic(error);
1282
1361
  const anthropic = anthropicRequestDiagnostic(error);
@@ -59,6 +59,7 @@ import {
59
59
  lazyToolTransportForTurn,
60
60
  openAiHostedImageProviderBindingForTurn,
61
61
  modelAttachmentInputPolicyForTurn,
62
+ resolveAcceptedTurnModel,
62
63
  } from "./tool-policy";
63
64
 
64
65
  import type { ClaimTurnOk } from "./claim";
@@ -361,10 +362,11 @@ export async function prepareGovernanceAndModel(
361
362
  // a chat-only Fireworks model. Resolving against the default-model settings
362
363
  // keeps gating consistent with the router. Cost accounting covers registry
363
364
  // models via configuredModelPricing.
364
- const resolvedModel = runtime.resolveTurnModel(
365
- capabilitySettings,
366
- turnExecutionPolicy.productModelId,
367
- );
365
+ //
366
+ // The accepted policy is then projected back onto the resolved shape: a
367
+ // turn frozen before hosted web search was enabled keeps its frozen tool set
368
+ // on every attempt, so recovery never adds a tool mid-turn.
369
+ const resolvedModel = resolveAcceptedTurnModel(runtime, capabilitySettings, turnExecutionPolicy);
368
370
  const providerApi = resolvedModel?.provider.api ?? "responses";
369
371
  const nativeImageProviderBinding =
370
372
  providerApi === "responses"
@@ -372,10 +374,8 @@ export async function prepareGovernanceAndModel(
372
374
  : null;
373
375
  const lazyToolTransport = lazyToolTransportForTurn(resolvedModel);
374
376
  const modelInputPolicy = modelAttachmentInputPolicyForTurn(resolvedModel);
375
- // Use the proven wire capability, not the catalogue modality alone. Chat
376
- // providers may advertise vision, but OpenGeni intentionally has no typed
377
- // image transport for that wire yet; exposing view_image there would turn
378
- // pixels into a multi-megabyte text/base64 function result.
377
+ // The shared input policy combines model modality with the supported wire
378
+ // transport, including typed image projection for vision-capable Chat models.
379
379
  const supportsImageInput = modelInputPolicy.supportsImageInput;
380
380
  media.modelCanReceiveRetainedSessionImages = supportsImageInput;
381
381
  const attachmentProjector = createModelHistoryAttachmentProjector(
@@ -5,6 +5,7 @@ import {
5
5
  readLease,
6
6
  accrueWarmSeconds,
7
7
  SandboxWorkspaceMutationFencedError,
8
+ SandboxWorkspaceMutationOutputRejectedError,
8
9
  retainWorkspaceMutationProcess,
9
10
  retainedProviderCommandPersistence,
10
11
  SandboxRetainedProcessPromotionFencedError,
@@ -12,6 +13,7 @@ import {
12
13
  import { createProviderCommandRetainer } from "@opengeni/db/retained-provider-commands";
13
14
  import {
14
15
  RoutingMutationOutcomeUnknownError,
16
+ RoutingMutationOutputRejectedError,
15
17
  ProviderCommandStartOutcomeUnknownError,
16
18
  ProviderCommandObservationUnavailableError,
17
19
  isModalCommandStartOutcomeUnknownError,
@@ -622,6 +624,20 @@ export function createSandboxTurnRuntime(deps: SandboxTurnRuntimeDeps) {
622
624
  try {
623
625
  await settleMutation("resolved");
624
626
  } catch (settlementError) {
627
+ if (
628
+ settlementError instanceof SandboxWorkspaceMutationOutputRejectedError &&
629
+ settlementError.matchesPhysicalSettlement({
630
+ accountId: identity.accountId,
631
+ workspaceId: identity.workspaceId,
632
+ admission,
633
+ operation,
634
+ outcome: "resolved",
635
+ })
636
+ ) {
637
+ throw new RoutingMutationOutputRejectedError(operation, settlementError.code, {
638
+ cause: settlementError,
639
+ });
640
+ }
625
641
  throw new RoutingMutationOutcomeUnknownError(
626
642
  operation,
627
643
  `Platform workspace mutation "${operation}" returned from the provider but lost its durable settlement fence; its outcome is unknown and it was not replayed`,
@@ -7,6 +7,7 @@ import {
7
7
  getHumanInputResumeForEvent,
8
8
  getSessionHumanInputRequest,
9
9
  getWorkspace,
10
+ listTurnCodemodeApprovals,
10
11
  recordUsageEvent,
11
12
  registerPendingSessionToolCall,
12
13
  recordPendingSessionToolCallResult,
@@ -77,6 +78,11 @@ import {
77
78
  generatedImageFromSdkEvent,
78
79
  isCompletedGeneratedImageSdkEvent,
79
80
  } from "../generated-images";
81
+ import {
82
+ programmaticApproval,
83
+ programmaticContinuationNote,
84
+ recoverProgrammaticOperationsBeforeFirstRequest,
85
+ } from "../programmatic-approvals";
80
86
  import { ToolResultSpill } from "./tool-result-spill";
81
87
  import { ownedTurnSandboxForAgent } from "./turn-sandbox-access";
82
88
  import { createTurnCredentialLeases } from "./credential-leases";
@@ -449,6 +455,8 @@ export async function runTurnStreamAttempt(
449
455
  await parallelSessionTitle?.cancel();
450
456
  };
451
457
  let runInput: Awaited<ReturnType<typeof turnInput>>["input"] | null = null;
458
+ let codemodeContinuationNote: string | undefined;
459
+ let programmaticApprovalAcknowledged = false;
452
460
  const prepareRunAttemptInput = async () => {
453
461
  const historyPreparationStartedAt = performance.now();
454
462
  let historyPreparationOutcome: "completed" | "failed" = "completed";
@@ -461,6 +469,8 @@ export async function runTurnStreamAttempt(
461
469
  subjectId: fileAuthoritySubjectId,
462
470
  },
463
471
  recovering: turn.executionGeneration > 1,
472
+ programmaticApprovalAcknowledged,
473
+ ...(codemodeContinuationNote ? { codemodeContinuationNote } : {}),
464
474
  ...(unavailableSandboxFilesNote ? { unavailableSandboxFilesNote } : {}),
465
475
  ...(runCredentialsNote ? { runCredentialsNote } : {}),
466
476
  ...(mcpAvailabilityNote ? { mcpAvailabilityNote } : {}),
@@ -1656,10 +1666,21 @@ export async function runTurnStreamAttempt(
1656
1666
  });
1657
1667
  }
1658
1668
  }
1659
- if (eventing.stream.interruptions.length > 0) {
1669
+ const programmaticPending = (
1670
+ await listTurnCodemodeApprovals(db, {
1671
+ accountId: input.accountId,
1672
+ workspaceId: input.workspaceId,
1673
+ sessionId: input.sessionId,
1674
+ turnId: activeTurnId,
1675
+ })
1676
+ )
1677
+ .filter((operation) => operation.state === "waiting_for_approval")
1678
+ .map(programmaticApproval);
1679
+ if (eventing.stream.interruptions.length > 0 || programmaticPending.length > 0) {
1660
1680
  await eventing.preparedTools?.inputWaitYield?.sealForSettlement(runtimeCancellationSignal);
1661
1681
  await historySink.reconcileConversationTruth({ requireDurable: true });
1662
- const approvals = runtime.serializeApprovals(eventing.stream.interruptions);
1682
+ const sdkApprovals = runtime.serializeApprovals(eventing.stream.interruptions);
1683
+ const approvals = [...sdkApprovals, ...programmaticPending];
1663
1684
  const humanInputInterruptions =
1664
1685
  runtime.serializeHumanInputRequests?.(eventing.stream.interruptions) ?? [];
1665
1686
  const interactionInterventionInterruptions =
@@ -1739,28 +1760,34 @@ export async function runTurnStreamAttempt(
1739
1760
  const interruptionCallIds = interruptionCallIdsFromPause({
1740
1761
  humanInputRequests,
1741
1762
  interactionInterventionRequests,
1742
- pendingApprovals,
1763
+ pendingApprovals: [
1764
+ ...sdkApprovals,
1765
+ ...interactionInterventionInterruptions.map((interruption) => interruption.approval),
1766
+ ],
1743
1767
  });
1744
1768
  assertOpenSuffixResumable(suffixMembers, interruptionCallIds);
1745
1769
  const suffixByCallId = new Map(suffixMembers.map((member) => [member.callId, member]));
1746
- const attached = await attachOpenSuffixToPendingToolCalls(db, {
1747
- accountId: input.accountId,
1748
- workspaceId: input.workspaceId,
1749
- sessionId: input.sessionId,
1750
- turnId: activeTurnId,
1751
- executionGeneration: attempt.executionGeneration,
1752
- attemptId: input.attemptId,
1753
- members: interruptionCallIds.map((callId) => {
1754
- const member = suffixByCallId.get(callId)!;
1755
- return {
1756
- callId,
1757
- interruptionKind: interruptionKindForCallItem(
1758
- member.callItem as Record<string, unknown>,
1759
- ),
1760
- reasoningItems: member.reasoningItems as Array<Record<string, unknown>>,
1761
- };
1762
- }),
1763
- });
1770
+ const attached =
1771
+ interruptionCallIds.length === 0
1772
+ ? { accepted: true }
1773
+ : await attachOpenSuffixToPendingToolCalls(db, {
1774
+ accountId: input.accountId,
1775
+ workspaceId: input.workspaceId,
1776
+ sessionId: input.sessionId,
1777
+ turnId: activeTurnId,
1778
+ executionGeneration: attempt.executionGeneration,
1779
+ attemptId: input.attemptId,
1780
+ members: interruptionCallIds.map((callId) => {
1781
+ const member = suffixByCallId.get(callId)!;
1782
+ return {
1783
+ callId,
1784
+ interruptionKind: interruptionKindForCallItem(
1785
+ member.callItem as Record<string, unknown>,
1786
+ ),
1787
+ reasoningItems: member.reasoningItems as Array<Record<string, unknown>>,
1788
+ };
1789
+ }),
1790
+ });
1764
1791
  if (!attached.accepted) {
1765
1792
  return claimedResult({ status: "cancelled" });
1766
1793
  }
@@ -1935,7 +1962,59 @@ export async function runTurnStreamAttempt(
1935
1962
  return claimedResult({ status: "idle" });
1936
1963
  };
1937
1964
 
1965
+ // Recover committed waits/results before any model dispatch, including crash replacement.
1966
+ // A new attempt uses its own signed caller; origin provenance never changes.
1967
+ // Lazy MCP preparation is never awaited unless stored work must resume.
1968
+ // Measured as its own startup phase so a first-request barrier cannot hide.
1969
+ const programmaticRecoveryStartedAt = performance.now();
1970
+ let programmaticRecoveryOutcome: "completed" | "failed" = "failed";
1971
+ const codemodeOperations = await recoverProgrammaticOperationsBeforeFirstRequest({
1972
+ approvalDecisionId:
1973
+ trigger.type === "user.approvalDecision"
1974
+ ? (trigger.payload as { approvalId: string }).approvalId
1975
+ : undefined,
1976
+ hasUnfinishedOperations: async () =>
1977
+ (
1978
+ await listTurnCodemodeApprovals(db, {
1979
+ accountId: input.accountId,
1980
+ workspaceId: input.workspaceId,
1981
+ sessionId: input.sessionId,
1982
+ turnId: activeTurnId,
1983
+ })
1984
+ ).length > 0,
1985
+ toolPreparationReady: eventing.toolPreparationReady,
1986
+ resumeApproved: (decisionOperationId) =>
1987
+ eventing.codemodeDispatcher?.resumeApproved(
1988
+ `sandbox:${input.attemptId}`,
1989
+ decisionOperationId,
1990
+ ),
1991
+ })
1992
+ .then((operations) => {
1993
+ programmaticRecoveryOutcome = "completed";
1994
+ return operations;
1995
+ })
1996
+ .finally(() => {
1997
+ recordTurnStartupPhase(observability, {
1998
+ phase: "programmatic_operation_recovery",
1999
+ provider: turnExecutionPolicy.providerId,
2000
+ backend: activeSandboxBackend ?? groupBoxBackend,
2001
+ outcome: programmaticRecoveryOutcome,
2002
+ durationSeconds: (performance.now() - programmaticRecoveryStartedAt) / 1_000,
2003
+ });
2004
+ });
2005
+ codemodeContinuationNote = programmaticContinuationNote(codemodeOperations);
2006
+ programmaticApprovalAcknowledged =
2007
+ trigger.type === "user.approvalDecision" &&
2008
+ codemodeOperations.some(
2009
+ (operation) =>
2010
+ operation.operationId === (trigger.payload as { approvalId?: unknown }).approvalId,
2011
+ );
2012
+ const pendingProgrammaticApprovals = codemodeOperations
2013
+ .filter((operation) => operation.state === "waiting_for_approval")
2014
+ .map(programmaticApproval);
1938
2015
  const openSuffixResume = await settleOpenSuffixResumeIfNeeded({
2016
+ additionalApprovals: pendingProgrammaticApprovals,
2017
+ programmaticApprovalAcknowledged,
1939
2018
  db,
1940
2019
  agent,
1941
2020
  accountId: input.accountId,
@@ -1,6 +1,8 @@
1
1
  export type LeaseRenewReason = "timer" | "runtime_event" | "model_usage";
2
2
  export type LeaseLossReason = "deadline" | "not_found";
3
3
 
4
+ const HEARTBEAT_INTERVAL_MS = 60_000;
5
+
4
6
  type LeaseIdentity = { turnId: string; holderId: string; generation: number };
5
7
  export type SubscriptionLeaseDeps = {
6
8
  ttlMs: number;
@@ -24,11 +26,13 @@ export class SubscriptionTurnLease {
24
26
  private heartbeatInFlight = false;
25
27
  private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
26
28
  private readonly now: () => number;
29
+ private readonly renewalIntervalMs: number;
27
30
 
28
31
  constructor(private readonly leaseDeps: SubscriptionLeaseDeps) {
29
32
  if (!Number.isFinite(leaseDeps.ttlMs) || leaseDeps.ttlMs <= 0)
30
33
  throw new Error("Subscription lease TTL must be positive");
31
34
  this.now = leaseDeps.now ?? (() => performance.now());
35
+ this.renewalIntervalMs = Math.min(HEARTBEAT_INTERVAL_MS, leaseDeps.ttlMs / 5);
32
36
  }
33
37
 
34
38
  private expired = (deadline: number | null): boolean =>
@@ -62,6 +66,11 @@ export class SubscriptionTurnLease {
62
66
  return;
63
67
  }
64
68
  if (this.heartbeatInFlight) return;
69
+ // Acquisition and successful renewal confirm ownership from request start.
70
+ // Reuse that proof between heartbeats; a failed renewal leaves it unchanged
71
+ // so the next checkpoint can retry immediately. Expiry is checked first.
72
+ const renewAt = this.confirmedUntilMs! - this.leaseDeps.ttlMs + this.renewalIntervalMs;
73
+ if (this.now() < renewAt) return;
65
74
  this.heartbeatInFlight = true;
66
75
  const priorDeadline = this.confirmedUntilMs;
67
76
  const startedAt = this.now();
@@ -103,7 +112,7 @@ export class SubscriptionTurnLease {
103
112
 
104
113
  startHeartbeat = (): void => {
105
114
  if (!this.leaseDeps.getTurnId() || this.heartbeatTimer) return;
106
- this.heartbeatTimer = setInterval(() => void this.renew("timer"), 60_000);
115
+ this.heartbeatTimer = setInterval(() => void this.renew("timer"), HEARTBEAT_INTERVAL_MS);
107
116
  this.heartbeatTimer.unref?.();
108
117
  };
109
118
 
@@ -67,6 +67,7 @@ import {
67
67
  scheduledTurnMcpServerIds,
68
68
  hasPermission,
69
69
  requireExplicitPermissionDelegation,
70
+ createWebSearchBilling,
70
71
  } from "@opengeni/core";
71
72
  import { loadWorkspaceEnvironmentForRunWithCredentials } from "../environment";
72
73
  import { withFirstPartyTools } from "../goals";
@@ -116,6 +117,7 @@ import { resolveTurnSandboxAccess } from "./turn-sandbox-access";
116
117
  import { createListModelsAttemptToolDefinition } from "./list-models";
117
118
  import { createRefreshCredentialsAttemptToolDefinition } from "./refresh-credentials";
118
119
  import { codeSearchToolDefinitions, codeSearchWorkspaceFromChannel } from "./code-search";
120
+ import { turnWebSearchPlan, webSearchToolDefinitions } from "./web-search";
119
121
  import { createWorkspaceSkillTools } from "./skill-tools";
120
122
  import { loadConfiguredBundledSkills } from "./skill-selection";
121
123
  import { guardSkillFilesystem } from "./skill-transfer";
@@ -886,6 +888,22 @@ export async function prepareTurnToolRuntime(deps: PrepareTurnToolRuntimeDeps) {
886
888
  const attemptToolFamilies = resolveAgentToolFamilies(session.agent, {
887
889
  hasSkills: skillCatalog.length > 0,
888
890
  });
891
+ // Provider web search: only where the turn has no hosted search (or the
892
+ // operator chose `replace`), only when a provider is configured, and only
893
+ // when the session's agent configuration allows web search (filtered below).
894
+ const webSearchTools = webSearchToolDefinitions({
895
+ settings: runSettings,
896
+ tools: turnWebSearchPlan(resolvedModel, runSettings).providerTools,
897
+ scope: {
898
+ accountId: input.accountId,
899
+ workspaceId: input.workspaceId,
900
+ sessionId: input.sessionId,
901
+ turnId: turn.id,
902
+ attemptId: input.attemptId,
903
+ },
904
+ billing: createWebSearchBilling({ db, settings: runSettings }),
905
+ observability,
906
+ });
889
907
  const attemptToolDefinitions = [
890
908
  ...(operationReadStore
891
909
  ? [
@@ -976,6 +994,7 @@ export async function prepareTurnToolRuntime(deps: PrepareTurnToolRuntimeDeps) {
976
994
  ? [googleDrivePublicationTool]
977
995
  : []),
978
996
  ...codeSearchTools,
997
+ ...webSearchTools,
979
998
  ].filter((tool) => attemptToolFamilies.allowsFunctionTool(tool.modelName));
980
999
  recordTurnStartupPhase(observability, {
981
1000
  phase: "tool_context_preparation",
@@ -1234,6 +1253,7 @@ export async function prepareTurnToolRuntime(deps: PrepareTurnToolRuntimeDeps) {
1234
1253
  {},
1235
1254
  (name) => mcpToolDisplayMetadata(tools.mcpServers, name),
1236
1255
  (entry) => toolFamilyForCatalogIdentity(entry, runSettings.mcpServers),
1256
+ tools.inputWaitYield,
1237
1257
  );
1238
1258
  eventing.codemodeDispatcher.start();
1239
1259
  };
@@ -2,8 +2,11 @@ import {
2
2
  desktopCapableBackend,
3
3
  type ComputerToolMode,
4
4
  type LazyToolTransport,
5
+ type OpenGeniRuntime,
5
6
  } from "@opengeni/runtime";
7
+ import type { TurnExecutionPolicyV1 } from "@opengeni/contracts";
6
8
  import {
9
+ configuredModelForAcceptedTurnExecutionPolicy,
7
10
  isDirectOpenAiApiBaseUrl,
8
11
  type ModelProviderApi,
9
12
  type ResolvedModelProvider,
@@ -208,6 +211,28 @@ export function shouldDeferNonEagerToolPreparation(args: {
208
211
  );
209
212
  }
210
213
 
214
+ /**
215
+ * Resolve the provider routing/gating shape for an accepted turn. The current
216
+ * definition is projected back onto the verified frozen policy: a turn
217
+ * accepted before hosted web search was enabled keeps its frozen tool set on
218
+ * every recovery attempt (stable tool prefix, exact accepted definition); the
219
+ * next accepted logical turn resolves the newly enabled tool.
220
+ */
221
+ export function resolveAcceptedTurnModel(
222
+ runtime: Pick<OpenGeniRuntime, "resolveTurnModel">,
223
+ settings: Settings,
224
+ policy: TurnExecutionPolicyV1,
225
+ ): ReturnType<OpenGeniRuntime["resolveTurnModel"]> {
226
+ const current = runtime.resolveTurnModel(settings, policy.productModelId);
227
+ if (!current) return null;
228
+ const configured = configuredModelForAcceptedTurnExecutionPolicy(
229
+ current.configured,
230
+ current.provider,
231
+ policy,
232
+ );
233
+ return configured === current.configured ? current : { ...current, configured };
234
+ }
235
+
211
236
  /**
212
237
  * Native web search is a runtime capability, not part of the session's MCP
213
238
  * allow-list. Attach it whenever the resolved provider advertises runnable
@@ -294,12 +319,8 @@ export function modelAttachmentInputPolicyForTurn(
294
319
  };
295
320
  } | null,
296
321
  ): ModelAttachmentInputPolicy {
297
- const typedTransport =
298
- resolvedModel === null ||
299
- resolvedModel.provider.api === "responses" ||
300
- resolvedModel.provider.api === "anthropic-messages";
301
322
  return {
302
- supportsImageInput: typedTransport && modelSupportsImageInputForTurn(resolvedModel),
323
+ supportsImageInput: modelSupportsImageInputForTurn(resolvedModel),
303
324
  inputFileMediaTypes: [],
304
325
  };
305
326
  }