@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.
- package/dist/activities/agent-turn/errors.d.ts +1 -13
- package/dist/activities/agent-turn/subscription-lease.d.ts +1 -0
- package/dist/activities/agent-turn/tool-policy.d.ts +10 -1
- package/dist/activities/agent-turn/web-search.d.ts +33 -0
- package/dist/activities/programmatic-approvals.d.ts +17 -0
- package/dist/{activities-control-PEXBWKQJ.js → activities-control-D5KVEPHI.js} +3 -3
- package/dist/{activities-turn-W27A2SBO.js → activities-turn-4Z3M7Z3F.js} +288 -23
- package/dist/activities-turn-4Z3M7Z3F.js.map +1 -0
- package/dist/{chunk-QUX5LT2B.js → chunk-BMTWULF7.js} +113 -30
- package/dist/chunk-BMTWULF7.js.map +1 -0
- package/dist/{chunk-XONQEVAD.js → chunk-FF4HPY7L.js} +1 -1
- package/dist/chunk-FF4HPY7L.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/observability-metrics.d.ts +1 -1
- package/package.json +21 -21
- package/src/activities/agent-turn/agent-build.ts +4 -2
- package/src/activities/agent-turn/errors.ts +82 -3
- package/src/activities/agent-turn/governance-model.ts +8 -8
- package/src/activities/agent-turn/sandbox-runtime.ts +16 -0
- package/src/activities/agent-turn/stream-attempt.ts +40 -6
- package/src/activities/agent-turn/subscription-lease.ts +10 -1
- package/src/activities/agent-turn/tool-environment.ts +19 -0
- package/src/activities/agent-turn/tool-policy.ts +25 -0
- package/src/activities/agent-turn/web-search.ts +252 -0
- package/src/activities/connector-attachments.ts +3 -1
- package/src/activities/programmatic-approvals.ts +26 -0
- package/src/activity-services.ts +8 -0
- package/src/observability-metrics.ts +1 -0
- package/src/sandbox-routing.ts +58 -24
- package/dist/activities-turn-W27A2SBO.js.map +0 -1
- package/dist/chunk-QUX5LT2B.js.map +0 -1
- package/dist/chunk-XONQEVAD.js.map +0 -1
- /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
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
//
|
|
376
|
-
//
|
|
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 {
|
|
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
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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"),
|
|
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
|
}
|