@opengeni/worker-bundle 1.3.0 → 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 (33) 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/programmatic-approvals.d.ts +17 -0
  6. package/dist/{activities-control-PEXBWKQJ.js → activities-control-D5KVEPHI.js} +3 -3
  7. package/dist/{activities-turn-W27A2SBO.js → activities-turn-4Z3M7Z3F.js} +288 -23
  8. package/dist/activities-turn-4Z3M7Z3F.js.map +1 -0
  9. package/dist/{chunk-QUX5LT2B.js → chunk-BMTWULF7.js} +113 -30
  10. package/dist/chunk-BMTWULF7.js.map +1 -0
  11. package/dist/{chunk-XONQEVAD.js → chunk-FF4HPY7L.js} +1 -1
  12. package/dist/chunk-FF4HPY7L.js.map +1 -0
  13. package/dist/index.js +2 -2
  14. package/dist/observability-metrics.d.ts +1 -1
  15. package/package.json +21 -21
  16. package/src/activities/agent-turn/agent-build.ts +4 -2
  17. package/src/activities/agent-turn/errors.ts +82 -3
  18. package/src/activities/agent-turn/governance-model.ts +8 -8
  19. package/src/activities/agent-turn/sandbox-runtime.ts +16 -0
  20. package/src/activities/agent-turn/stream-attempt.ts +40 -6
  21. package/src/activities/agent-turn/subscription-lease.ts +10 -1
  22. package/src/activities/agent-turn/tool-environment.ts +19 -0
  23. package/src/activities/agent-turn/tool-policy.ts +25 -0
  24. package/src/activities/agent-turn/web-search.ts +252 -0
  25. package/src/activities/connector-attachments.ts +3 -1
  26. package/src/activities/programmatic-approvals.ts +26 -0
  27. package/src/activity-services.ts +8 -0
  28. package/src/observability-metrics.ts +1 -0
  29. package/src/sandbox-routing.ts +58 -24
  30. package/dist/activities-turn-W27A2SBO.js.map +0 -1
  31. package/dist/chunk-QUX5LT2B.js.map +0 -1
  32. package/dist/chunk-XONQEVAD.js.map +0 -1
  33. /package/dist/{activities-control-PEXBWKQJ.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`,
@@ -78,7 +78,11 @@ import {
78
78
  generatedImageFromSdkEvent,
79
79
  isCompletedGeneratedImageSdkEvent,
80
80
  } from "../generated-images";
81
- import { programmaticApproval, programmaticContinuationNote } from "../programmatic-approvals";
81
+ import {
82
+ programmaticApproval,
83
+ programmaticContinuationNote,
84
+ recoverProgrammaticOperationsBeforeFirstRequest,
85
+ } from "../programmatic-approvals";
82
86
  import { ToolResultSpill } from "./tool-result-spill";
83
87
  import { ownedTurnSandboxForAgent } from "./turn-sandbox-access";
84
88
  import { createTurnCredentialLeases } from "./credential-leases";
@@ -1960,14 +1964,44 @@ export async function runTurnStreamAttempt(
1960
1964
 
1961
1965
  // Recover committed waits/results before any model dispatch, including crash replacement.
1962
1966
  // A new attempt uses its own signed caller; origin provenance never changes.
1963
- await eventing.toolPreparationReady;
1964
- const codemodeOperations =
1965
- (await eventing.codemodeDispatcher?.resumeApproved(
1966
- `sandbox:${input.attemptId}`,
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:
1967
1973
  trigger.type === "user.approvalDecision"
1968
1974
  ? (trigger.payload as { approvalId: string }).approvalId
1969
1975
  : undefined,
1970
- )) ?? [];
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
+ });
1971
2005
  codemodeContinuationNote = programmaticContinuationNote(codemodeOperations);
1972
2006
  programmaticApprovalAcknowledged =
1973
2007
  trigger.type === "user.approvalDecision" &&
@@ -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",
@@ -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
@@ -0,0 +1,252 @@
1
+ import type { AttemptToolDefinition } from "@opengeni/codemode";
2
+ import {
3
+ WEB_FETCH_TOOL_NAME,
4
+ WEB_SEARCH_TOOL_NAME,
5
+ webSearchCallPricing,
6
+ webSearchCreditMicros,
7
+ webSearchProviderConfig,
8
+ webSearchToolPlan,
9
+ type WebSearchProviderConfig,
10
+ type WebSearchProviderToolName,
11
+ type WebSearchSettings,
12
+ } from "@opengeni/config";
13
+ import {
14
+ WebSearchBillingRefusedError,
15
+ type WebSearchBilling,
16
+ type WebSearchCallCost,
17
+ type WebSearchCallScope,
18
+ } from "@opengeni/core";
19
+ import type { Observability } from "@opengeni/observability";
20
+ import {
21
+ WEB_FETCH_TOOL_DESCRIPTION,
22
+ WEB_FETCH_RETAINED_MAX_CHARS,
23
+ WEB_SEARCH_TOOL_DESCRIPTION,
24
+ WebSearchProviderError,
25
+ WebToolArgumentError,
26
+ createWebFetchProvider,
27
+ createWebSearchProvider,
28
+ parseWebFetchArguments,
29
+ parseWebSearchArguments,
30
+ renderWebPageWindow,
31
+ renderWebSearchResults,
32
+ webFetchInputSchema,
33
+ webSearchInputSchema,
34
+ type WebPage,
35
+ } from "@opengeni/runtime/web-search";
36
+ import { hostedWebSearchForTurn } from "./tool-policy";
37
+
38
+ /**
39
+ * Which web search one turn gets: the provider's hosted tool, Opengeni's
40
+ * provider tools, or neither. Tool preparation and agent construction both
41
+ * call this with the same accepted model and settings, so they agree.
42
+ */
43
+ export function turnWebSearchPlan(
44
+ resolvedModel: { configured: { hostedWebSearch: boolean }; provider: { kind?: string } } | null,
45
+ settings: WebSearchSettings,
46
+ ) {
47
+ return webSearchToolPlan(settings, {
48
+ hostedWebSearch: hostedWebSearchForTurn(resolvedModel, settings.webSearchEnabled),
49
+ // SuperGrok's native search is added by its transport, not as an agent
50
+ // tool, so provider tools would only duplicate it.
51
+ transportHostedSearch: resolvedModel?.provider.kind === "xai-subscription",
52
+ });
53
+ }
54
+
55
+ /** Pages kept per attempt so paging through one page is not billed again. */
56
+ const PAGE_CACHE_ENTRIES = 16;
57
+
58
+ function textResult(text: string, isError: boolean) {
59
+ return { isError, content: [{ type: "text" as const, text }] };
60
+ }
61
+
62
+ type Pricing = ReturnType<typeof webSearchCallPricing>;
63
+
64
+ function settledCost(
65
+ operationId: string,
66
+ operation: "search" | "fetch",
67
+ provider: string,
68
+ pricing: Pricing,
69
+ reportedCostMicros: number | undefined,
70
+ ): WebSearchCallCost {
71
+ // An explicit operator price wins; then the provider's own reported cost;
72
+ // then the built-in list price.
73
+ const basis: WebSearchCallCost["basis"] = pricing.explicit
74
+ ? "configured_price"
75
+ : reportedCostMicros !== undefined
76
+ ? "provider_reported"
77
+ : "list_price";
78
+ const providerMicros =
79
+ basis === "provider_reported" ? reportedCostMicros! : pricing.providerMicros;
80
+ return {
81
+ operationId,
82
+ operation,
83
+ provider,
84
+ providerMicros,
85
+ creditMicros: webSearchCreditMicros(providerMicros, pricing.marginBps),
86
+ marginBps: pricing.marginBps,
87
+ basis,
88
+ };
89
+ }
90
+
91
+ function providerFailure(error: WebSearchProviderError): string {
92
+ return `${error.message}${error.retryable ? ". You may retry shortly." : "."}`;
93
+ }
94
+
95
+ /**
96
+ * The `web_search` and `web_fetch` attempt tools for one turn. The caller
97
+ * decides which names the turn gets ({@link webSearchToolPlan}); this only
98
+ * builds them. Provider keys stay in the worker and never reach a sandbox.
99
+ */
100
+ export function webSearchToolDefinitions(input: {
101
+ settings: WebSearchSettings;
102
+ tools: readonly WebSearchProviderToolName[];
103
+ scope: WebSearchCallScope;
104
+ billing: WebSearchBilling;
105
+ observability: Pick<Observability, "warn">;
106
+ fetch?: typeof fetch;
107
+ }): AttemptToolDefinition[] {
108
+ const config: WebSearchProviderConfig | null = webSearchProviderConfig(input.settings);
109
+ if (!config || input.tools.length === 0) return [];
110
+ const adapterInput = {
111
+ timeoutMs: config.timeoutMs,
112
+ ...(input.fetch ? { fetch: input.fetch } : {}),
113
+ };
114
+ const settle = async (cost: WebSearchCallCost) => {
115
+ try {
116
+ await input.billing.settle(input.scope, cost);
117
+ } catch (error) {
118
+ // The provider already answered. Keep the model's result; the missing
119
+ // receipt is an operator-visible accounting fault, never a tool failure.
120
+ input.observability.warn("web search usage settlement failed", {
121
+ operation: cost.operation,
122
+ provider: cost.provider,
123
+ error: error instanceof Error ? error.message : String(error),
124
+ });
125
+ }
126
+ };
127
+ const definitions: AttemptToolDefinition[] = [];
128
+
129
+ if (input.tools.includes(WEB_SEARCH_TOOL_NAME)) {
130
+ const provider = createWebSearchProvider({ ...adapterInput, endpoint: config.search });
131
+ const pricing = webSearchCallPricing(config, "search");
132
+ definitions.push({
133
+ identity: { serverId: "opengeni", toolName: WEB_SEARCH_TOOL_NAME },
134
+ modelName: WEB_SEARCH_TOOL_NAME,
135
+ codemodePath: ["opengeni", WEB_SEARCH_TOOL_NAME],
136
+ title: "Search the web",
137
+ description: WEB_SEARCH_TOOL_DESCRIPTION,
138
+ inputSchema: webSearchInputSchema as unknown as AttemptToolDefinition["inputSchema"],
139
+ annotations: {
140
+ title: "Search the web",
141
+ readOnlyHint: true,
142
+ destructiveHint: false,
143
+ idempotentHint: true,
144
+ openWorldHint: true,
145
+ },
146
+ source: "opengeni",
147
+ approval: "none",
148
+ execute: async (args, context) => {
149
+ let request: ReturnType<typeof parseWebSearchArguments>;
150
+ try {
151
+ request = parseWebSearchArguments(args);
152
+ await input.billing.admit(input.scope, pricing.providerMicros);
153
+ } catch (error) {
154
+ if (
155
+ error instanceof WebToolArgumentError ||
156
+ error instanceof WebSearchBillingRefusedError
157
+ )
158
+ return textResult(error.message, true);
159
+ throw error;
160
+ }
161
+ try {
162
+ const response = await provider.search(request, { signal: context.signal });
163
+ await settle(
164
+ settledCost(
165
+ context.operationId,
166
+ "search",
167
+ config.search.provider,
168
+ pricing,
169
+ response.reportedCostMicros,
170
+ ),
171
+ );
172
+ return textResult(renderWebSearchResults(request.query, response.results), false);
173
+ } catch (error) {
174
+ if (context.signal?.aborted) throw error;
175
+ if (error instanceof WebSearchProviderError) {
176
+ return textResult(`Web search failed: ${providerFailure(error)}`, true);
177
+ }
178
+ throw error;
179
+ }
180
+ },
181
+ });
182
+ }
183
+
184
+ if (input.tools.includes(WEB_FETCH_TOOL_NAME) && config.fetch) {
185
+ const fetchEndpoint = config.fetch;
186
+ const provider = createWebFetchProvider({ ...adapterInput, endpoint: fetchEndpoint });
187
+ const pricing = webSearchCallPricing(config, "fetch");
188
+ const pages = new Map<string, WebPage>();
189
+ definitions.push({
190
+ identity: { serverId: "opengeni", toolName: WEB_FETCH_TOOL_NAME },
191
+ modelName: WEB_FETCH_TOOL_NAME,
192
+ codemodePath: ["opengeni", WEB_FETCH_TOOL_NAME],
193
+ title: "Read a web page",
194
+ description: WEB_FETCH_TOOL_DESCRIPTION,
195
+ inputSchema: webFetchInputSchema as unknown as AttemptToolDefinition["inputSchema"],
196
+ annotations: {
197
+ title: "Read a web page",
198
+ readOnlyHint: true,
199
+ destructiveHint: false,
200
+ idempotentHint: true,
201
+ openWorldHint: true,
202
+ },
203
+ source: "opengeni",
204
+ approval: "none",
205
+ execute: async (args, context) => {
206
+ let request: ReturnType<typeof parseWebFetchArguments>;
207
+ try {
208
+ request = parseWebFetchArguments(args);
209
+ } catch (error) {
210
+ if (error instanceof WebToolArgumentError) return textResult(error.message, true);
211
+ throw error;
212
+ }
213
+ const cached = pages.get(request.url);
214
+ if (cached) return textResult(renderWebPageWindow(cached, request), false);
215
+ try {
216
+ await input.billing.admit(input.scope, pricing.providerMicros);
217
+ } catch (error) {
218
+ if (error instanceof WebSearchBillingRefusedError) return textResult(error.message, true);
219
+ throw error;
220
+ }
221
+ try {
222
+ const page = await provider.fetch({ url: request.url }, { signal: context.signal });
223
+ await settle(
224
+ settledCost(
225
+ context.operationId,
226
+ "fetch",
227
+ fetchEndpoint.provider,
228
+ pricing,
229
+ page.reportedCostMicros,
230
+ ),
231
+ );
232
+ if (pages.size >= PAGE_CACHE_ENTRIES) {
233
+ pages.delete(pages.keys().next().value!);
234
+ }
235
+ // Bound per-attempt memory; one extra character keeps the cut visible.
236
+ pages.set(request.url, {
237
+ ...page,
238
+ content: page.content.slice(0, WEB_FETCH_RETAINED_MAX_CHARS + 1),
239
+ });
240
+ return textResult(renderWebPageWindow(page, request), false);
241
+ } catch (error) {
242
+ if (context.signal?.aborted) throw error;
243
+ if (error instanceof WebSearchProviderError) {
244
+ return textResult(`Web fetch failed: ${providerFailure(error)}`, true);
245
+ }
246
+ throw error;
247
+ }
248
+ },
249
+ });
250
+ }
251
+ return definitions;
252
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  ChannelAPartialMutationError,
14
14
  RoutingMutationOutcomeUnknownError,
15
+ isRoutingMutationOutputRejectedError,
15
16
  SandboxChannelAService,
16
17
  } from "@opengeni/runtime/sandbox";
17
18
 
@@ -159,7 +160,8 @@ export async function materializeConnectorAttachmentsInChannel(
159
160
  } catch (error) {
160
161
  if (
161
162
  error instanceof ChannelAPartialMutationError ||
162
- error instanceof RoutingMutationOutcomeUnknownError
163
+ error instanceof RoutingMutationOutcomeUnknownError ||
164
+ isRoutingMutationOutputRejectedError(error)
163
165
  ) {
164
166
  throw error;
165
167
  }