@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
@@ -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
+ }
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { bindMcpTelemetry, measureMcpPhase, withMcpCallIdentity } from "@opengeni/observability";
2
+ import {
3
+ bindMcpTelemetry,
4
+ measureMcpPhase,
5
+ withMcpCallIdentity,
6
+ recordToolApproval,
7
+ } from "@opengeni/observability";
3
8
  import {
4
9
  AttemptToolApprovalRequiredError,
5
10
  AttemptToolCatalogStaleError,
@@ -15,13 +20,18 @@ import {
15
20
  CODEMODE_CLAIM_LEASE_MS,
16
21
  CODEMODE_CLAIM_HEARTBEAT_MS,
17
22
  CODEMODE_MAX_CONCURRENT_CALLS_PER_ATTEMPT,
18
- type CodemodeOperation,
23
+ CodemodeOperation,
19
24
  type SessionEvent,
20
25
  type ToolDisplayMetadata,
21
26
  type ToolFamily,
22
27
  } from "@opengeni/contracts";
23
28
  import {
24
29
  cancelQueuedCodemodeOperationsForAttempt,
30
+ waitForCodemodeApproval,
31
+ bindCodemodePreparation,
32
+ listTurnCodemodeApprovals,
33
+ adoptCodemodeApprovalWithOutput,
34
+ readTurnCodemodeOperation,
25
35
  claimCodemodeOperation,
26
36
  failCodemodeOperation,
27
37
  getSessionEventByClientEventId,
@@ -76,6 +86,7 @@ export class CodemodeAttemptDispatcher {
76
86
  private readonly toolDisplayMetadata?: (modelName: string) => ToolDisplayMetadata | undefined,
77
87
  /** Content-free analytics family for a catalog entry; event enrichment only. */
78
88
  private readonly toolFamily?: (entry: AttemptToolCatalogEntry) => ToolFamily | null,
89
+ private readonly approvalWait?: { beginWait(): (accepted: boolean) => void },
79
90
  ) {
80
91
  if (
81
92
  environment.catalog.accountId !== scope.accountId ||
@@ -179,6 +190,94 @@ export class CodemodeAttemptDispatcher {
179
190
  );
180
191
  }
181
192
 
193
+ async resumeApproved(
194
+ callerSubjectId: string,
195
+ decisionOperationId?: string,
196
+ ): Promise<CodemodeOperation[]> {
197
+ const operations = await listTurnCodemodeApprovals(this.db, this.scope);
198
+ if (
199
+ decisionOperationId &&
200
+ // Model-tool approval IDs are opaque strings. Only programmatic UUIDs
201
+ // can address the Codemode operation journal.
202
+ CodemodeOperation.shape.operationId.safeParse(decisionOperationId).success &&
203
+ !operations.some((operation) => operation.operationId === decisionOperationId)
204
+ ) {
205
+ const decided = await readTurnCodemodeOperation(this.db, {
206
+ ...this.scope,
207
+ callerSubjectId,
208
+ operationId: decisionOperationId,
209
+ });
210
+ if (decided) operations.push(decided);
211
+ }
212
+ for (const operation of operations) {
213
+ if (!["waiting_for_approval", "queued", "running"].includes(operation.state)) continue;
214
+ let effectDigest: string | null = null;
215
+ try {
216
+ effectDigest = this.environment.effectDigest(operation.identity);
217
+ } catch {
218
+ /* Removed tool: stale. */
219
+ }
220
+ const continuation = await adoptCodemodeApprovalWithOutput(this.db, {
221
+ ...this.scope,
222
+ operationId: operation.operationId,
223
+ callerSubjectId,
224
+ catalogDigest: this.environment.catalog.digest,
225
+ effectDigest,
226
+ });
227
+ await publishDurableSessionEvents(
228
+ this.bus,
229
+ this.scope.workspaceId,
230
+ this.scope.sessionId,
231
+ continuation.events,
232
+ );
233
+ const adopted = continuation.operation;
234
+ if (adopted && adopted.state !== operation.state) {
235
+ const outcome =
236
+ adopted.state === "queued"
237
+ ? "resumed"
238
+ : adopted.state === "outcome_unknown"
239
+ ? "unknown"
240
+ : adopted.errorCode === "approval_stale"
241
+ ? "stale"
242
+ : "rejected";
243
+ recordToolApproval(
244
+ outcome,
245
+ "continuation",
246
+ operation.state === "waiting_for_approval"
247
+ ? Math.max(0, Date.now() - Date.parse(operation.updatedAt))
248
+ : undefined,
249
+ );
250
+ }
251
+ if (adopted?.state !== "queued") continue;
252
+ const claim = await claimCodemodeOperation(this.db, {
253
+ ...this.scope,
254
+ operationId: adopted.operationId,
255
+ claimId: randomUUID(),
256
+ catalogDigest: this.environment.catalog.digest,
257
+ claimLeaseMs: this.claimLeaseMs,
258
+ });
259
+ if (claim.status === "claimed") {
260
+ const execution = this.execute(claim.operation, claim.claimId, true);
261
+ this.inFlight.set(adopted.operationId, execution);
262
+ try {
263
+ await execution;
264
+ } finally {
265
+ this.inFlight.delete(adopted.operationId);
266
+ }
267
+ }
268
+ }
269
+ const receipts: CodemodeOperation[] = [];
270
+ for (const operation of operations) {
271
+ const receipt = await readTurnCodemodeOperation(this.db, {
272
+ ...this.scope,
273
+ callerSubjectId,
274
+ operationId: operation.operationId,
275
+ });
276
+ if (receipt) receipts.push(receipt);
277
+ }
278
+ return receipts;
279
+ }
280
+
182
281
  async close(reason = "Execution attempt ended"): Promise<void> {
183
282
  if (this.closing) {
184
283
  await Promise.allSettled([...this.inFlight.values()]);
@@ -277,18 +376,56 @@ export class CodemodeAttemptDispatcher {
277
376
  }
278
377
 
279
378
  let preparedCall: Awaited<ReturnType<AttemptToolEnvironment["prepareCall"]>>;
379
+ let releaseWait: ((accepted: boolean) => void) | undefined;
280
380
  try {
381
+ if (operation.durableApproval) {
382
+ if (!this.approvalWait) throw new Error("Durable approval yield is unavailable");
383
+ releaseWait = this.approvalWait.beginWait();
384
+ if (
385
+ !(await bindCodemodePreparation(this.db, {
386
+ ...this.scope,
387
+ operationId: operation.operationId,
388
+ claimId,
389
+ effectDigest: this.environment.effectDigest(operation.identity),
390
+ }))
391
+ )
392
+ throw new Error("Operation preparation is stale");
393
+ }
281
394
  preparedCall = await this.environment.prepareCall(
282
395
  {
283
396
  operationId: operation.operationId,
284
- catalogDigest: operation.catalogDigest,
397
+ catalogDigest: this.environment.catalog.digest,
285
398
  identity: operation.identity,
286
399
  arguments: operation.arguments,
287
- caller: operation.caller,
400
+ caller: {
401
+ kind: "codemode",
402
+ subjectId:
403
+ operation.caller.subjectId === `sandbox:${operation.attemptId}`
404
+ ? `sandbox:${this.scope.attemptId}`
405
+ : operation.caller.subjectId,
406
+ },
407
+ },
408
+ {
409
+ signal,
410
+ ...(operation.durableApproval ? { transportMeta: { durableApproval: true } } : {}),
288
411
  },
289
- { signal },
290
412
  );
413
+ if (preparedCall.waitingForApproval) {
414
+ const waiting = await waitForCodemodeApproval(this.db, {
415
+ ...this.scope,
416
+ operationId: operation.operationId,
417
+ claimId,
418
+ ...preparedCall.waitingForApproval,
419
+ effectDigest: preparedCall.effectDigest!,
420
+ });
421
+ if (!waiting) throw new Error("Approval could not be bound to the operation");
422
+ recordToolApproval("waiting");
423
+ releaseWait?.(true);
424
+ return;
425
+ }
426
+ releaseWait?.(false);
291
427
  } catch (error) {
428
+ releaseWait?.(false);
292
429
  await this.settleWithOutput(operation, claimId, {
293
430
  state: "failed",
294
431
  errorCode: errorCode(error, signal, false),
@@ -448,6 +585,7 @@ export class CodemodeAttemptDispatcher {
448
585
  settlement,
449
586
  });
450
587
  if (!result.committed || result.events.length === 0) return;
588
+ recordToolApproval(settlement.state === "outcome_unknown" ? "unknown" : settlement.state);
451
589
  await publishDurableSessionEvents(
452
590
  this.bus,
453
591
  this.scope.workspaceId,
@@ -490,8 +628,8 @@ function assertMatchingCodemodeToolCallCreated(
490
628
  if (
491
629
  event.type !== "agent.toolCall.created" ||
492
630
  event.turnId !== scope.turnId ||
493
- event.turnGeneration !== scope.executionGeneration ||
494
- event.turnAttemptId !== scope.attemptId ||
631
+ event.turnGeneration !== operation.executionGeneration ||
632
+ event.turnAttemptId !== operation.attemptId ||
495
633
  event.turnAssociation !== "current" ||
496
634
  payload?.id !== operation.operationId ||
497
635
  payload?.name !== entry.modelName ||
@@ -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
  }