@opengeni/codemode 0.4.25-canary.0 → 0.4.27-canary.1

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/src/index.ts CHANGED
@@ -1,23 +1,16 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import Ajv, { type ValidateFunction } from "ajv";
3
- import Ajv2019 from "ajv/dist/2019.js";
4
- import Ajv2020 from "ajv/dist/2020.js";
1
+ import { randomUUID } from "node:crypto";
5
2
  import {
6
3
  ATTEMPT_TOOL_CATALOG_VERSION,
7
4
  ATTEMPT_TOOL_CATALOG_MAX_BYTES,
8
5
  AttemptToolCall,
9
6
  AttemptToolCatalog,
10
- AttemptToolCatalogEntry,
11
7
  AttemptToolResult,
12
8
  CodemodeCallSubmission,
13
9
  CodemodeOperation,
14
10
  CodemodeDispatchAck,
15
11
  CodemodeDispatchRequest,
16
- OPENGENI_API_CONTRACT_HEADER,
17
- OPENGENI_API_CONTRACT_REVISION,
18
- isToolResultSpilledReceipt,
19
12
  type AttemptToolCall as AttemptToolCallValue,
20
- type AttemptToolCaller,
13
+ AttemptToolCaller,
21
14
  type AttemptToolCatalog as AttemptToolCatalogValue,
22
15
  type AttemptToolCatalogEntry as AttemptToolCatalogEntryValue,
23
16
  type AttemptToolIdentity,
@@ -26,6 +19,22 @@ import {
26
19
  type CodemodeDispatchRequest as CodemodeDispatchRequestValue,
27
20
  type CodemodeOperation as CodemodeOperationValue,
28
21
  } from "@opengeni/contracts";
22
+ import {
23
+ ToolGateway,
24
+ ToolGatewayApprovalRequiredError as AttemptToolApprovalRequiredError,
25
+ ToolGatewayCatalogIntegrityError as AttemptToolCatalogIntegrityError,
26
+ ToolGatewayCatalogStaleError as AttemptToolCatalogStaleError,
27
+ ToolGatewayCatalogTooLargeError as AttemptToolCatalogTooLargeError,
28
+ ToolGatewayInputValidationError as AttemptToolInputValidationError,
29
+ ToolGatewayOutputValidationError as AttemptToolOutputValidationError,
30
+ ToolGatewayPathCollisionError as AttemptToolPathCollisionError,
31
+ ToolGatewayToolNotFoundError as AttemptToolNotFoundError,
32
+ digestCanonicalJson,
33
+ prepareToolGatewayDefinitions,
34
+ type PreparedToolGatewayCall,
35
+ type ToolGatewayCallLifecycle,
36
+ type ToolGatewayDefinition,
37
+ } from "@opengeni/tool-gateway";
29
38
 
30
39
  export type { AttemptToolCatalog, AttemptToolCatalogEntry } from "@opengeni/contracts";
31
40
 
@@ -45,6 +54,8 @@ export type AttemptToolExecutionContext = {
45
54
  export type AttemptToolDefinition = Omit<AttemptToolCatalogEntryValue, "codemodePath"> & {
46
55
  /** Optional human-readable path. Unsafe/colliding segments are normalized. */
47
56
  codemodePath?: readonly string[];
57
+ /** In-process execution lifecycle shared by model MCP and Codemode. */
58
+ lifecycle?: ToolGatewayCallLifecycle;
48
59
  execute: (
49
60
  args: Record<string, unknown>,
50
61
  context: AttemptToolExecutionContext,
@@ -62,6 +73,8 @@ export type CreateAttemptToolEnvironmentInput = {
62
73
  definitions: readonly AttemptToolDefinition[];
63
74
  createdAt?: Date;
64
75
  authorize?: AttemptToolAuthorization;
76
+ /** Host-only approval projection for one exact model invocation. */
77
+ confirmModelApproval?: (input: { modelName: string; subjectId: string }) => boolean;
65
78
  };
66
79
 
67
80
  export type ModelAttemptToolCall = {
@@ -73,78 +86,40 @@ export type ModelAttemptToolCall = {
73
86
  signal?: AbortSignal;
74
87
  };
75
88
 
76
- export class AttemptToolCatalogStaleError extends Error {
77
- readonly code = "catalog_stale";
78
-
79
- constructor() {
80
- super("Codemode catalog is stale for the active execution attempt");
81
- this.name = "AttemptToolCatalogStaleError";
82
- }
83
- }
84
-
85
- export class AttemptToolNotFoundError extends Error {
86
- readonly code = "tool_not_found";
87
-
88
- constructor() {
89
- super("Tool is not present in the active execution attempt catalog");
90
- this.name = "AttemptToolNotFoundError";
91
- }
92
- }
93
-
94
- export class AttemptToolApprovalRequiredError extends Error {
95
- readonly code = "approval_required";
96
-
97
- constructor() {
98
- super("Tool requires human approval and must be invoked through the agent");
99
- this.name = "AttemptToolApprovalRequiredError";
100
- }
101
- }
102
-
103
- export class AttemptToolCatalogIntegrityError extends Error {
104
- readonly code = "catalog_integrity_failed";
105
-
106
- constructor() {
107
- super("Attempt tool catalog digest does not match its authoritative content");
108
- this.name = "AttemptToolCatalogIntegrityError";
109
- }
110
- }
111
-
112
- export class AttemptToolCatalogTooLargeError extends Error {
113
- readonly code = "catalog_too_large";
114
-
115
- constructor() {
116
- super("Attempt tool catalog exceeds the maximum serialized size");
117
- this.name = "AttemptToolCatalogTooLargeError";
118
- }
119
- }
120
-
121
- export class AttemptToolInputValidationError extends Error {
122
- readonly code = "invalid_tool_arguments";
123
-
124
- constructor() {
125
- super("Tool arguments do not match the attempt catalog input schema");
126
- this.name = "AttemptToolInputValidationError";
127
- }
128
- }
129
-
130
- export class AttemptToolOutputValidationError extends Error {
131
- readonly code = "invalid_tool_result";
132
-
133
- constructor() {
134
- super("Tool result does not match the attempt catalog output schema");
135
- this.name = "AttemptToolOutputValidationError";
136
- }
137
- }
89
+ export {
90
+ AttemptToolCatalogStaleError,
91
+ AttemptToolNotFoundError,
92
+ AttemptToolApprovalRequiredError,
93
+ AttemptToolCatalogIntegrityError,
94
+ AttemptToolCatalogTooLargeError,
95
+ AttemptToolInputValidationError,
96
+ AttemptToolOutputValidationError,
97
+ AttemptToolPathCollisionError,
98
+ };
138
99
 
139
100
  export class CodemodeTransportError extends Error {
140
101
  readonly code = "codemode_transport_error";
102
+ readonly remoteCode: string | null;
103
+ readonly retryable: boolean | null;
104
+ readonly outcomeUnknown: boolean | null;
105
+ readonly details: Readonly<Record<string, unknown>> | null;
141
106
 
142
107
  constructor(
143
108
  message: string,
144
109
  readonly status: number | null = null,
110
+ options: {
111
+ code?: string;
112
+ retryable?: boolean;
113
+ outcomeUnknown?: boolean;
114
+ details?: Readonly<Record<string, unknown>>;
115
+ } = {},
145
116
  ) {
146
117
  super(message);
147
118
  this.name = "CodemodeTransportError";
119
+ this.remoteCode = options.code ?? null;
120
+ this.retryable = options.retryable ?? null;
121
+ this.outcomeUnknown = options.outcomeUnknown ?? null;
122
+ this.details = options.details ?? null;
148
123
  }
149
124
  }
150
125
 
@@ -171,6 +146,7 @@ export type CodemodeClientOptions = {
171
146
 
172
147
  export type CodemodeCallOptions = {
173
148
  operationId?: string;
149
+ /** Stops client observation only; it never cancels an already-created server operation. */
174
150
  signal?: AbortSignal;
175
151
  timeoutMs?: number;
176
152
  };
@@ -248,20 +224,33 @@ export class CodemodeClient {
248
224
  argumentsValue: Record<string, unknown> = {},
249
225
  options: CodemodeCallOptions = {},
250
226
  ): Promise<AttemptToolResultValue> {
251
- const catalog = await this.catalog(options.signal ? { signal: options.signal } : {});
252
- if (
253
- !catalog.entries.some(
254
- (entry) =>
255
- entry.identity.serverId === identity.serverId &&
256
- entry.identity.toolName === identity.toolName,
227
+ return (
228
+ await this.callResolved(
229
+ (catalog) =>
230
+ catalog.entries.find(
231
+ (entry) =>
232
+ entry.identity.serverId === identity.serverId &&
233
+ entry.identity.toolName === identity.toolName,
234
+ ) ?? null,
235
+ argumentsValue,
236
+ options,
257
237
  )
258
- ) {
259
- throw new AttemptToolNotFoundError();
260
- }
238
+ ).result;
239
+ }
240
+
241
+ private async callResolved(
242
+ resolveEntry: (catalog: AttemptToolCatalogValue) => AttemptToolCatalogEntryValue | null,
243
+ argumentsValue: Record<string, unknown>,
244
+ options: CodemodeCallOptions,
245
+ ): Promise<{ result: AttemptToolResultValue; entry: AttemptToolCatalogEntryValue }> {
246
+ let catalog = await this.catalog(options.signal ? { signal: options.signal } : {});
247
+ let entry = resolveEntry(catalog);
248
+ if (!entry) throw new AttemptToolNotFoundError();
261
249
  const operationId = options.operationId ?? randomUUID();
262
250
  const deadline =
263
251
  Date.now() + boundedPositiveInteger(options.timeoutMs ?? this.timeoutMs, 1_000, 60 * 60_000);
264
252
  let submitted = false;
253
+ let staleRefreshAttempted = false;
265
254
  let operation: CodemodeOperationValue | null = null;
266
255
  let nextNotifyAt = 0;
267
256
  while (true) {
@@ -272,7 +261,9 @@ export class CodemodeClient {
272
261
  );
273
262
  }
274
263
  const shouldNotify =
275
- !submitted || (operation?.state === "queued" && Date.now() >= nextNotifyAt);
264
+ !submitted ||
265
+ ((operation?.state === "queued" || operation?.state === "running") &&
266
+ Date.now() >= nextNotifyAt);
276
267
  if (shouldNotify) {
277
268
  submitted = true;
278
269
  nextNotifyAt = Date.now() + 2_000;
@@ -280,25 +271,65 @@ export class CodemodeClient {
280
271
  operation = await this.submit(
281
272
  operationId,
282
273
  catalog.digest,
283
- identity,
274
+ entry.identity,
284
275
  argumentsValue,
285
276
  options.signal,
286
277
  );
287
278
  } catch (error) {
279
+ if (
280
+ operation === null &&
281
+ !staleRefreshAttempted &&
282
+ error instanceof CodemodeTransportError &&
283
+ error.remoteCode === "codemode_catalog_stale"
284
+ ) {
285
+ staleRefreshAttempted = true;
286
+ catalog = await this.catalog({
287
+ refresh: true,
288
+ ...(options.signal ? { signal: options.signal } : {}),
289
+ });
290
+ entry = resolveEntry(catalog);
291
+ if (!entry) throw new AttemptToolNotFoundError();
292
+ submitted = false;
293
+ nextNotifyAt = 0;
294
+ continue;
295
+ }
296
+ if (options.signal?.aborted) throw error;
297
+ if (operation === null && !canReconcileCodemodeSubmission(error)) throw error;
288
298
  // The POST may have committed before its response was lost, or an
289
- // attempt may have closed between submission and a wake retry. The
290
- // caller-owned id is the recovery handle: read before deciding that
291
- // another side effect is necessary.
299
+ // already-bound operation may have settled while a later wake
300
+ // notification failed deterministically. The caller-owned id is the
301
+ // recovery handle: read and re-prove the exact binding before
302
+ // deciding that another side effect is necessary.
303
+ let recovered: CodemodeOperationValue;
292
304
  try {
293
- operation = await this.read(operationId, options.signal);
294
- } catch {
295
- throw error;
305
+ recovered = await this.read(operationId, options.signal);
306
+ } catch (recoveryError) {
307
+ if (options.signal?.aborted) throw recoveryError;
308
+ throw new CodemodeTransportError(
309
+ `Codemode operation ${operationId} could not be reconciled after its submission response failed`,
310
+ null,
311
+ {
312
+ code: "codemode_operation_recovery_unavailable",
313
+ retryable: true,
314
+ outcomeUnknown: true,
315
+ details: { operationId },
316
+ },
317
+ );
296
318
  }
319
+ assertRecoveredCodemodeOperation(recovered, {
320
+ operationId,
321
+ catalog,
322
+ identity: entry.identity,
323
+ arguments: argumentsValue,
324
+ });
325
+ operation = recovered;
297
326
  }
298
327
  } else {
299
328
  operation = await this.read(operationId, options.signal);
300
329
  }
301
- if (operation.state === "completed") return AttemptToolResult.parse(operation.result);
330
+ if (operation.state === "completed") {
331
+ return { result: AttemptToolResult.parse(operation.result), entry };
332
+ }
302
333
  if (["failed", "outcome_unknown", "cancelled"].includes(operation.state)) {
303
334
  throw new CodemodeOperationError(
304
335
  operation,
@@ -318,14 +349,13 @@ export class CodemodeClient {
318
349
  if (path.length < 2 || path.some((segment) => segment.length === 0)) {
319
350
  throw new AttemptToolNotFoundError();
320
351
  }
321
- const catalog = await this.catalog(options.signal ? { signal: options.signal } : {});
322
- const matches = catalog.entries.filter(
323
- (entry) =>
324
- entry.codemodePath.length === path.length &&
325
- entry.codemodePath.every((segment, index) => segment === path[index]),
326
- );
327
- if (matches.length !== 1) throw new AttemptToolNotFoundError();
328
- return await this.call(matches[0]!.identity, argumentsValue, options);
352
+ return (
353
+ await this.callResolved(
354
+ (catalog) => catalogEntryForPath(catalog, path),
355
+ argumentsValue,
356
+ options,
357
+ )
358
+ ).result;
329
359
  }
330
360
 
331
361
  /** Return structured content when the catalog declares it; otherwise retain the full MCP result. */
@@ -337,15 +367,11 @@ export class CodemodeClient {
337
367
  if (path.length < 2 || path.some((segment) => segment.length === 0)) {
338
368
  throw new AttemptToolNotFoundError();
339
369
  }
340
- const catalog = await this.catalog(options.signal ? { signal: options.signal } : {});
341
- const matches = catalog.entries.filter(
342
- (entry) =>
343
- entry.codemodePath.length === path.length &&
344
- entry.codemodePath.every((segment, index) => segment === path[index]),
370
+ const { result, entry } = await this.callResolved(
371
+ (catalog) => catalogEntryForPath(catalog, path),
372
+ argumentsValue,
373
+ options,
345
374
  );
346
- if (matches.length !== 1) throw new AttemptToolNotFoundError();
347
- const entry = matches[0]!;
348
- const result = await this.call(entry.identity, argumentsValue, options);
349
375
  if (!entry.outputSchema) return result;
350
376
  if (result.isError) throw new CodemodeToolCallError(result);
351
377
  if (!result.structuredContent) {
@@ -385,33 +411,89 @@ export class CodemodeClient {
385
411
  return CodemodeOperation.parse(await response.json());
386
412
  }
387
413
 
388
- private async request(path: string, init: RequestInit): Promise<Response> {
414
+ /** Server-side Site preview forwarding. The attempt bearer never enters the page. */
415
+ async sessionRequest(path: string, init: RequestInit): Promise<Response> {
416
+ if (!path.startsWith("/v1/")) {
417
+ throw new Error("Unsupported Site session API path");
418
+ }
419
+ return this.request(`/sdk${path}`, init, false);
420
+ }
421
+
422
+ private async request(path: string, init: RequestInit, throwOnError = true): Promise<Response> {
389
423
  const token =
390
424
  typeof this.options.token === "function" ? await this.options.token() : this.options.token;
391
425
  const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
392
426
  ...init,
393
427
  headers: {
394
428
  ...Object.fromEntries(new Headers(init.headers).entries()),
395
- [OPENGENI_API_CONTRACT_HEADER]: OPENGENI_API_CONTRACT_REVISION,
396
429
  authorization: `Bearer ${token}`,
397
430
  },
398
431
  });
399
- if (!response.ok) {
432
+ if (!response.ok && throwOnError) {
400
433
  let message = `Codemode request failed with HTTP ${response.status}`;
434
+ let errorOptions: {
435
+ code?: string;
436
+ retryable?: boolean;
437
+ outcomeUnknown?: boolean;
438
+ details?: Readonly<Record<string, unknown>>;
439
+ } = {};
401
440
  try {
402
- const payload = (await response.json()) as {
403
- error?: { message?: unknown };
404
- };
405
- if (typeof payload.error?.message === "string") message = payload.error.message;
441
+ const error = parseCodemodeApiError(await response.json());
442
+ if (error?.message) message = error.message;
443
+ if (error) {
444
+ errorOptions = {
445
+ ...(error.code ? { code: error.code } : {}),
446
+ ...(error.retryable === undefined ? {} : { retryable: error.retryable }),
447
+ ...(error.outcomeUnknown === undefined ? {} : { outcomeUnknown: error.outcomeUnknown }),
448
+ ...(error.details ? { details: error.details } : {}),
449
+ };
450
+ }
406
451
  } catch {
407
452
  // The status is sufficient; never echo an unbounded provider body.
408
453
  }
409
- throw new CodemodeTransportError(message, response.status);
454
+ throw new CodemodeTransportError(message, response.status, errorOptions);
410
455
  }
411
456
  return response;
412
457
  }
413
458
  }
414
459
 
460
+ function canReconcileCodemodeSubmission(error: unknown): boolean {
461
+ return !(error instanceof CodemodeTransportError) || error.outcomeUnknown === true;
462
+ }
463
+
464
+ function assertRecoveredCodemodeOperation(
465
+ operation: CodemodeOperationValue,
466
+ expected: {
467
+ operationId: string;
468
+ catalog: AttemptToolCatalogValue;
469
+ identity: AttemptToolIdentity;
470
+ arguments: Record<string, unknown>;
471
+ },
472
+ ): void {
473
+ const matches =
474
+ operation.operationId === expected.operationId &&
475
+ operation.accountId === expected.catalog.accountId &&
476
+ operation.workspaceId === expected.catalog.workspaceId &&
477
+ operation.sessionId === expected.catalog.sessionId &&
478
+ operation.turnId === expected.catalog.turnId &&
479
+ operation.attemptId === expected.catalog.attemptId &&
480
+ operation.executionGeneration === expected.catalog.executionGeneration &&
481
+ operation.catalogDigest === expected.catalog.digest &&
482
+ operation.identity.serverId === expected.identity.serverId &&
483
+ operation.identity.toolName === expected.identity.toolName &&
484
+ digestCanonicalJson(operation.arguments) === digestCanonicalJson(expected.arguments);
485
+ if (matches) return;
486
+ throw new CodemodeTransportError(
487
+ "Codemode operation id is already bound to a different request",
488
+ 409,
489
+ {
490
+ code: "codemode_operation_conflict",
491
+ retryable: false,
492
+ outcomeUnknown: false,
493
+ },
494
+ );
495
+ }
496
+
415
497
  export function compileCodemodeTools(
416
498
  catalog: AttemptToolCatalogValue,
417
499
  client: CodemodeClient,
@@ -438,29 +520,11 @@ export function compileCodemodeTools(
438
520
  return root;
439
521
  }
440
522
 
441
- type CompiledDefinition = {
442
- entry: AttemptToolCatalogEntryValue;
443
- execute: AttemptToolDefinition["execute"];
444
- validateInput: ValidateFunction<unknown>;
445
- validateOutput: ValidateFunction<unknown> | null;
446
- };
447
-
448
523
  export class AttemptToolEnvironment {
449
- readonly catalog: AttemptToolCatalogValue;
450
- private readonly byIdentity = new Map<string, CompiledDefinition>();
451
- private readonly byModelName = new Map<string, CompiledDefinition>();
452
-
453
524
  constructor(
454
- catalog: AttemptToolCatalogValue,
455
- definitions: readonly CompiledDefinition[],
456
- private readonly authorize: AttemptToolAuthorization | undefined,
457
- ) {
458
- this.catalog = catalog;
459
- for (const definition of definitions) {
460
- this.byIdentity.set(identityKey(definition.entry.identity), definition);
461
- this.byModelName.set(definition.entry.modelName, definition);
462
- }
463
- }
525
+ readonly catalog: AttemptToolCatalogValue,
526
+ private readonly gateway: ToolGateway,
527
+ ) {}
464
528
 
465
529
  async call(
466
530
  input: AttemptToolCallValue,
@@ -469,58 +533,22 @@ export class AttemptToolEnvironment {
469
533
  signal?: AbortSignal;
470
534
  } = {},
471
535
  ): Promise<AttemptToolResultValue> {
536
+ return await (await this.prepareCall(input, context)).execute();
537
+ }
538
+
539
+ async prepareCall(
540
+ input: AttemptToolCallValue,
541
+ context: {
542
+ transportMeta?: Record<string, unknown> | null;
543
+ signal?: AbortSignal;
544
+ } = {},
545
+ ): Promise<PreparedToolGatewayCall> {
472
546
  const call = AttemptToolCall.parse(input);
473
- if (call.catalogDigest !== this.catalog.digest) {
474
- throw new AttemptToolCatalogStaleError();
475
- }
476
- const definition = this.byIdentity.get(identityKey(call.identity));
477
- if (!definition) {
478
- throw new AttemptToolNotFoundError();
479
- }
480
- if (call.caller.kind === "codemode" && definition.entry.approval === "human") {
481
- throw new AttemptToolApprovalRequiredError();
482
- }
483
- if (!definition.validateInput(call.arguments)) {
484
- throw new AttemptToolInputValidationError();
485
- }
486
- await this.authorize?.({ call, entry: definition.entry });
487
- const result = AttemptToolResult.parse(
488
- await definition.execute(call.arguments, {
489
- operationId: call.operationId,
490
- caller: call.caller,
491
- ...(context.transportMeta === undefined ? {} : { transportMeta: context.transportMeta }),
492
- ...(context.signal === undefined ? {} : { signal: context.signal }),
493
- }),
494
- );
495
- if (!result.isError && definition.validateOutput) {
496
- const outputMatchesSchema =
497
- result.structuredContent !== undefined &&
498
- definition.validateOutput(result.structuredContent);
499
- // Model overflow replaces the exact tool payload with a compact File
500
- // receipt after execute. That handle is not the catalog output schema.
501
- if (!outputMatchesSchema && !isToolResultSpilledReceipt(result.structuredContent)) {
502
- throw new AttemptToolOutputValidationError();
503
- }
504
- }
505
- return result;
547
+ return await this.gateway.prepareCall(call, context);
506
548
  }
507
549
 
508
550
  async callModel(input: ModelAttemptToolCall): Promise<AttemptToolResultValue> {
509
- const definition = this.byModelName.get(input.modelName);
510
- if (!definition) {
511
- throw new AttemptToolNotFoundError();
512
- }
513
- const call = AttemptToolCall.parse({
514
- operationId: input.operationId ?? randomUUID(),
515
- catalogDigest: this.catalog.digest,
516
- identity: definition.entry.identity,
517
- arguments: input.arguments,
518
- caller: { kind: "model", subjectId: input.subjectId },
519
- });
520
- return await this.call(call, {
521
- ...(input.transportMeta === undefined ? {} : { transportMeta: input.transportMeta }),
522
- ...(input.signal === undefined ? {} : { signal: input.signal }),
523
- });
551
+ return await this.gateway.callModel(input);
524
552
  }
525
553
  }
526
554
 
@@ -528,36 +556,55 @@ export function createAttemptToolEnvironment(
528
556
  input: CreateAttemptToolEnvironmentInput,
529
557
  ): AttemptToolEnvironment {
530
558
  const createdAt = (input.createdAt ?? new Date()).toISOString();
531
- const paths = allocateCodemodePaths(input.definitions);
532
- const schemaValidators = createSchemaValidators();
533
- const compiled = input.definitions.map((definition, index): CompiledDefinition => {
534
- const { execute, codemodePath: _path, ...entryInput } = definition;
535
- const entry = AttemptToolCatalogEntry.parse({
536
- ...entryInput,
537
- codemodePath: paths[index],
538
- });
539
- return {
540
- entry,
541
- execute,
542
- validateInput: compileCatalogSchema(schemaValidators, entry.inputSchema),
543
- validateOutput: entry.outputSchema
544
- ? compileCatalogSchema(schemaValidators, entry.outputSchema)
545
- : null,
546
- };
547
- });
559
+ const prepared = prepareToolGatewayDefinitions(
560
+ input.definitions.map(
561
+ (definition): ToolGatewayDefinition => ({
562
+ ...definition,
563
+ execute: async (argumentsValue, context) =>
564
+ await definition.execute(argumentsValue, {
565
+ ...context,
566
+ caller: AttemptToolCaller.parse(context.caller),
567
+ }),
568
+ }),
569
+ ),
570
+ );
548
571
  const unsigned = {
549
572
  version: ATTEMPT_TOOL_CATALOG_VERSION,
550
573
  ...input.scope,
551
574
  generation: input.generation,
552
575
  createdAt,
553
- entries: compiled.map(({ entry }) => entry),
576
+ entries: [...prepared.entries],
554
577
  };
555
578
  const catalog = AttemptToolCatalog.parse({
556
579
  ...unsigned,
557
580
  digest: digestAttemptToolCatalog(unsigned),
558
581
  });
559
582
  assertCatalogSize(catalog);
560
- return new AttemptToolEnvironment(catalog, compiled, input.authorize);
583
+ return new AttemptToolEnvironment(
584
+ catalog,
585
+ prepared.create({
586
+ catalogDigest: catalog.digest,
587
+ requireApproval: (entry, caller) => caller.kind === "codemode" && entry.approval === "human",
588
+ ...(input.confirmModelApproval
589
+ ? {
590
+ confirmModelApproval: ({ entry, subjectId }) =>
591
+ input.confirmModelApproval!({
592
+ modelName: entry.modelName,
593
+ subjectId,
594
+ }),
595
+ }
596
+ : {}),
597
+ ...(input.authorize
598
+ ? {
599
+ authorize: async ({ call, entry }) =>
600
+ await input.authorize!({
601
+ call: AttemptToolCall.parse(call),
602
+ entry,
603
+ }),
604
+ }
605
+ : {}),
606
+ }),
607
+ );
561
608
  }
562
609
 
563
610
  export function digestAttemptToolCatalog(catalog: Omit<AttemptToolCatalogValue, "digest">): string {
@@ -612,124 +659,6 @@ function assertCatalogSize(catalog: AttemptToolCatalogValue): void {
612
659
  }
613
660
  }
614
661
 
615
- type SchemaCompiler = { compile(schema: object): ValidateFunction<unknown> };
616
-
617
- // Validator compilation is structural and independent of attempt identity,
618
- // executable closures, credentials, and authorization. Reuse only exact
619
- // content-addressed validators; the attempt environment and catalog remain
620
- // freshly bound and digested on every execution. The hard cap prevents an
621
- // untrusted MCP schema stream from turning this process cache into a memory
622
- // sink.
623
- const COMPILED_CATALOG_SCHEMA_CACHE_MAX_ENTRIES = 512;
624
- const compiledCatalogSchemaCache = new Map<string, ValidateFunction<unknown>>();
625
-
626
- function createSchemaValidators(): {
627
- draft7: SchemaCompiler;
628
- draft2019: SchemaCompiler;
629
- draft2020: SchemaCompiler;
630
- } {
631
- const options = {
632
- allErrors: false,
633
- coerceTypes: false,
634
- strict: false,
635
- useDefaults: false,
636
- validateFormats: false,
637
- } as const;
638
- return {
639
- draft7: new Ajv(options),
640
- draft2019: new Ajv2019(options),
641
- draft2020: new Ajv2020(options),
642
- };
643
- }
644
-
645
- function compileCatalogSchema(
646
- validators: ReturnType<typeof createSchemaValidators>,
647
- schema: AttemptToolCatalogEntryValue["inputSchema"],
648
- ): ValidateFunction<unknown> {
649
- const dialect = typeof schema.$schema === "string" ? schema.$schema : "";
650
- const family = dialect.includes("2020-12")
651
- ? "2020-12"
652
- : dialect.includes("2019-09")
653
- ? "2019-09"
654
- : "draft7";
655
- const cacheKey = `${family}:${digestCanonicalJson(schema)}`;
656
- const cached = compiledCatalogSchemaCache.get(cacheKey);
657
- if (cached) {
658
- compiledCatalogSchemaCache.delete(cacheKey);
659
- compiledCatalogSchemaCache.set(cacheKey, cached);
660
- return cached;
661
- }
662
- const compiled =
663
- family === "2020-12"
664
- ? validators.draft2020.compile(schema)
665
- : family === "2019-09"
666
- ? validators.draft2019.compile(schema)
667
- : validators.draft7.compile(schema);
668
- while (compiledCatalogSchemaCache.size >= COMPILED_CATALOG_SCHEMA_CACHE_MAX_ENTRIES) {
669
- const oldest = compiledCatalogSchemaCache.keys().next().value;
670
- if (oldest === undefined) break;
671
- compiledCatalogSchemaCache.delete(oldest);
672
- }
673
- compiledCatalogSchemaCache.set(cacheKey, compiled);
674
- return compiled;
675
- }
676
-
677
- function allocateCodemodePaths(definitions: readonly AttemptToolDefinition[]): string[][] {
678
- const bases = definitions.map((definition) =>
679
- (definition.codemodePath?.length
680
- ? definition.codemodePath
681
- : [definition.identity.serverId, definition.identity.toolName]
682
- ).map(safeNamespaceSegment),
683
- );
684
- const counts = new Map<string, number>();
685
- for (const path of bases) {
686
- const key = path.join("\u0000");
687
- counts.set(key, (counts.get(key) ?? 0) + 1);
688
- }
689
- return bases.map((base, index) => {
690
- const key = base.join("\u0000");
691
- if (counts.get(key) === 1) return base;
692
- const suffix = `_${shortIdentityDigest(definitions[index]!.identity)}`;
693
- const last = base.at(-1)!;
694
- return [...base.slice(0, -1), `${last.slice(0, 128 - suffix.length)}${suffix}`];
695
- });
696
- }
697
-
698
- function safeNamespaceSegment(value: string): string {
699
- let normalized = value.replace(/[^A-Za-z0-9_$]/gu, "_");
700
- if (!/^[A-Za-z_$]/u.test(normalized)) normalized = `_${normalized}`;
701
- if (["__proto__", "prototype", "constructor"].includes(normalized)) {
702
- normalized = `_${normalized}`;
703
- }
704
- return normalized.slice(0, 128) || "_";
705
- }
706
-
707
- function shortIdentityDigest(identity: AttemptToolIdentity): string {
708
- return createHash("sha256").update(identityKey(identity), "utf8").digest("hex").slice(0, 10);
709
- }
710
-
711
- function identityKey(identity: AttemptToolIdentity): string {
712
- return `${identity.serverId}\u0000${identity.toolName}`;
713
- }
714
-
715
- function digestCanonicalJson(value: unknown): string {
716
- return createHash("sha256")
717
- .update(JSON.stringify(canonicalJsonValue(value)), "utf8")
718
- .digest("hex");
719
- }
720
-
721
- function canonicalJsonValue(value: unknown): unknown {
722
- if (Array.isArray(value)) return value.map(canonicalJsonValue);
723
- if (value !== null && typeof value === "object") {
724
- return Object.fromEntries(
725
- Object.entries(value as Record<string, unknown>)
726
- .sort(([left], [right]) => left.localeCompare(right))
727
- .map(([key, entry]) => [key, canonicalJsonValue(entry)]),
728
- );
729
- }
730
- return value;
731
- }
732
-
733
662
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
734
663
 
735
664
  function boundedPositiveInteger(value: number, minimum: number, maximum: number): number {
@@ -757,6 +686,55 @@ function structuredToolError(result: AttemptToolResultValue): {
757
686
  };
758
687
  }
759
688
 
689
+ function catalogEntryForPath(
690
+ catalog: AttemptToolCatalogValue,
691
+ path: readonly string[],
692
+ ): AttemptToolCatalogEntryValue | null {
693
+ const matches = catalog.entries.filter(
694
+ (entry) =>
695
+ entry.codemodePath.length === path.length &&
696
+ entry.codemodePath.every((segment, index) => segment === path[index]),
697
+ );
698
+ return matches.length === 1 ? matches[0]! : null;
699
+ }
700
+
701
+ function parseCodemodeApiError(input: unknown): {
702
+ message?: string;
703
+ code?: string;
704
+ retryable?: boolean;
705
+ outcomeUnknown?: boolean;
706
+ details?: Readonly<Record<string, unknown>>;
707
+ } | null {
708
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
709
+ const root = input as Record<string, unknown>;
710
+ const nested =
711
+ root.error && typeof root.error === "object" && !Array.isArray(root.error)
712
+ ? (root.error as Record<string, unknown>)
713
+ : root;
714
+ const details =
715
+ nested.details && typeof nested.details === "object" && !Array.isArray(nested.details)
716
+ ? (nested.details as Readonly<Record<string, unknown>>)
717
+ : undefined;
718
+ const detailCode = details?.code;
719
+ const code =
720
+ typeof detailCode === "string" && /^[a-z0-9_]{1,128}$/u.test(detailCode)
721
+ ? detailCode
722
+ : undefined;
723
+ const message = typeof nested.message === "string" ? nested.message : undefined;
724
+ const retryable = typeof nested.retryable === "boolean" ? nested.retryable : undefined;
725
+ const outcomeUnknown =
726
+ typeof nested.outcomeUnknown === "boolean" ? nested.outcomeUnknown : undefined;
727
+ return message || code || retryable !== undefined || outcomeUnknown !== undefined || details
728
+ ? {
729
+ ...(message ? { message } : {}),
730
+ ...(code ? { code } : {}),
731
+ ...(retryable === undefined ? {} : { retryable }),
732
+ ...(outcomeUnknown === undefined ? {} : { outcomeUnknown }),
733
+ ...(details ? { details } : {}),
734
+ }
735
+ : null;
736
+ }
737
+
760
738
  async function abortableDelay(delayMs: number, signal?: AbortSignal): Promise<void> {
761
739
  if (!signal) {
762
740
  await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
@@ -781,3 +759,4 @@ export * from "./interaction";
781
759
  export * from "./artifacts";
782
760
  export * from "./structured";
783
761
  export * from "./declarations";
762
+ export * from "./site";