@zackbart/connecta 0.10.5 → 0.10.6

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 (67) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/activity.d.ts +11 -1
  3. package/dist/activity.d.ts.map +1 -1
  4. package/dist/activity.js +44 -3
  5. package/dist/activity.js.map +1 -1
  6. package/dist/catalog-service.d.ts +39 -0
  7. package/dist/catalog-service.d.ts.map +1 -1
  8. package/dist/catalog-service.js +72 -11
  9. package/dist/catalog-service.js.map +1 -1
  10. package/dist/errors.d.ts +48 -1
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +67 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/execute.d.ts +72 -0
  15. package/dist/execute.d.ts.map +1 -1
  16. package/dist/execute.js +162 -9
  17. package/dist/execute.js.map +1 -1
  18. package/dist/index.d.ts +15 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/invocation.d.ts +9 -2
  23. package/dist/invocation.d.ts.map +1 -1
  24. package/dist/invocation.js +59 -29
  25. package/dist/invocation.js.map +1 -1
  26. package/dist/meta-tools.d.ts +5 -1
  27. package/dist/meta-tools.d.ts.map +1 -1
  28. package/dist/meta-tools.js +171 -26
  29. package/dist/meta-tools.js.map +1 -1
  30. package/dist/operator-ui/generated.d.ts +1 -1
  31. package/dist/operator-ui/generated.d.ts.map +1 -1
  32. package/dist/operator-ui/generated.js +1 -1
  33. package/dist/operator-ui/generated.js.map +1 -1
  34. package/dist/registry.d.ts +11 -0
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +5 -2
  37. package/dist/registry.js.map +1 -1
  38. package/dist/routes/mcp.d.ts.map +1 -1
  39. package/dist/routes/mcp.js +9 -0
  40. package/dist/routes/mcp.js.map +1 -1
  41. package/dist/routes/shared.d.ts +4 -0
  42. package/dist/routes/shared.d.ts.map +1 -1
  43. package/dist/routes/shared.js.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/documentation/code-mode.md +119 -31
  47. package/documentation/meta-tools.md +79 -3
  48. package/documentation/rich-output-design.md +212 -0
  49. package/ethos.md +17 -19
  50. package/examples/worker/README.md +11 -3
  51. package/examples/worker/src/d1-activity-row.ts +40 -0
  52. package/examples/worker/src/d1-activity.ts +3 -2
  53. package/package.json +1 -1
  54. package/src/activity.ts +64 -3
  55. package/src/catalog-service.ts +135 -22
  56. package/src/errors.ts +102 -1
  57. package/src/execute.ts +238 -9
  58. package/src/index.ts +22 -0
  59. package/src/invocation.ts +59 -17
  60. package/src/meta-tools.ts +211 -30
  61. package/src/operator-ui/browser.ts +10 -2
  62. package/src/operator-ui/generated.ts +1 -1
  63. package/src/registry.ts +5 -2
  64. package/src/routes/mcp.ts +9 -0
  65. package/src/routes/shared.ts +4 -0
  66. package/src/version.ts +1 -1
  67. package/templates/node/package.json +1 -1
package/src/execute.ts CHANGED
@@ -34,6 +34,15 @@ import type {
34
34
  const EXECUTE_MAX_HOST_CALLS = 20;
35
35
  export const EXECUTE_MAX_BATCH_CALLS = 10;
36
36
  const EXECUTE_HOST_CALL_TIMEOUT_MS = 15_000;
37
+ /**
38
+ * Default budgets for `connecta.emit`. The byte budget is a transport bound,
39
+ * not a context bound — emitted image/audio blocks reach the model as media,
40
+ * not base64 text — so it sits far above the 24k return-value guard: room for
41
+ * two or three real screenshots after base64's 4/3 inflation, well short of a
42
+ * file-hosting ambition (design record M5).
43
+ */
44
+ export const EXECUTE_MAX_EMITTED_BYTES = 4_000_000;
45
+ export const EXECUTE_MAX_EMITTED_BLOCKS = 32;
37
46
  const diagnosticsEncoder = new TextEncoder();
38
47
 
39
48
  type ExecuteDiagnosticOperation = "search" | "describe" | "call" | "batch";
@@ -60,6 +69,13 @@ class ExecuteDiagnostics {
60
69
  admissionMs = 0;
61
70
  setupMs = 0;
62
71
  executorWallMs = 0;
72
+ private emitted?: { count: number; bytes: number };
73
+
74
+ /** Numbers only, per R8 — and only once something was emitted, so a
75
+ * non-emitting run's diagnostics stay byte-for-byte what they were. */
76
+ recordEmitted(count: number, bytes: number): void {
77
+ if (count > 0) this.emitted = { count, bytes };
78
+ }
63
79
 
64
80
  private stats(operation: ExecuteDiagnosticOperation) {
65
81
  let stats = this.operations.get(operation);
@@ -142,6 +158,7 @@ class ExecuteDiagnostics {
142
158
  connectorMs: number;
143
159
  };
144
160
  operations: ExecuteOperationDiagnostics[];
161
+ emitted?: { count: number; bytes: number };
145
162
  } {
146
163
  const operations = [...this.operations.values()];
147
164
  return {
@@ -157,10 +174,109 @@ class ExecuteDiagnostics {
157
174
  ),
158
175
  },
159
176
  operations,
177
+ ...(this.emitted ? { emitted: this.emitted } : {}),
160
178
  };
161
179
  }
162
180
  }
163
181
 
182
+ /**
183
+ * One MCP content block a program may emit. The complete set, by design:
184
+ * `resource` and `resource_link` are refused in ethos.md — pointers get
185
+ * followed, and connecta serves no resources for them to point at.
186
+ */
187
+ export type EmittedBlock =
188
+ | { type: "text"; text: string }
189
+ | { type: "image"; data: string; mimeType: string }
190
+ | { type: "audio"; data: string; mimeType: string };
191
+
192
+ const EMIT_SHAPE_HINT =
193
+ '{ type: "text", text } or { type: "image" | "audio", data (base64), mimeType }';
194
+
195
+ /**
196
+ * Strict M1 validation: required fields present and string-valued, nothing
197
+ * else — no `annotations`, no `_meta`, no sugar forms. Rejected rather than
198
+ * stripped, because silently deleting fields would deliver something the
199
+ * program did not ask to emit.
200
+ */
201
+ function requireEmittedBlock(raw: unknown): EmittedBlock {
202
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
203
+ throw new Error(
204
+ `connecta.emit accepts exactly one content block: ${EMIT_SHAPE_HINT}`,
205
+ );
206
+ }
207
+ const block = raw as Record<string, unknown>;
208
+ const fields =
209
+ block.type === "text"
210
+ ? ["type", "text"]
211
+ : block.type === "image" || block.type === "audio"
212
+ ? ["type", "data", "mimeType"]
213
+ : undefined;
214
+ if (!fields) {
215
+ throw new Error(
216
+ `connecta.emit supports content types "text", "image", and "audio"; got ${JSON.stringify(block.type)}`,
217
+ );
218
+ }
219
+ for (const field of fields) {
220
+ if (typeof block[field] !== "string") {
221
+ throw new Error(
222
+ `connecta.emit block field "${field}" must be a string: ${EMIT_SHAPE_HINT}`,
223
+ );
224
+ }
225
+ }
226
+ const extra = Object.keys(block).filter((key) => !fields.includes(key));
227
+ if (extra.length > 0) {
228
+ throw new Error(
229
+ `connecta.emit block carries unsupported field(s) ${extra.map((key) => JSON.stringify(key)).join(", ")}; a "${String(block.type)}" block is exactly { ${fields.join(", ")} }`,
230
+ );
231
+ }
232
+ return raw as EmittedBlock;
233
+ }
234
+
235
+ /**
236
+ * Request-local collection for `connecta.emit`. Budgets fail loudly at the
237
+ * crossing call — the block is not partially accepted and prior blocks are
238
+ * unaffected — so a program learns it is over budget while it can still
239
+ * choose differently (M5). Accepted blocks never ride `ExecuteResult`; the
240
+ * handler that owns this collector appends them to the final tool result.
241
+ */
242
+ export class EmitCollector {
243
+ readonly blocks: EmittedBlock[] = [];
244
+ bytes = 0;
245
+ constructor(
246
+ private readonly maxBytes: number,
247
+ private readonly maxBlocks: number,
248
+ private readonly diagnostics?: ExecuteDiagnostics,
249
+ ) {}
250
+
251
+ accept(raw: unknown): void {
252
+ const block = requireEmittedBlock(raw);
253
+ if (this.blocks.length >= this.maxBlocks) {
254
+ throw new Error(
255
+ `connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 remaining`,
256
+ );
257
+ }
258
+ const size = diagnosticsEncoder.encode(JSON.stringify(block)).byteLength;
259
+ if (this.bytes + size > this.maxBytes) {
260
+ throw new Error(
261
+ `connecta.emit byte budget exceeded: block is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`,
262
+ );
263
+ }
264
+ this.blocks.push(block);
265
+ this.bytes += size;
266
+ this.diagnostics?.recordEmitted(this.blocks.length, this.bytes);
267
+ }
268
+ }
269
+
270
+ /** A configured emit budget must be a finite number >= 1; anything else falls back. */
271
+ function resolveEmitBudget(
272
+ value: number | undefined,
273
+ fallback: number,
274
+ ): number {
275
+ return typeof value === "number" && Number.isFinite(value) && value >= 1
276
+ ? Math.trunc(value)
277
+ : fallback;
278
+ }
279
+
164
280
  function serializedDiagnosticBytes(value: unknown): number {
165
281
  try {
166
282
  const text = JSON.stringify(value);
@@ -292,8 +408,16 @@ export async function buildSandboxProviders(
292
408
  maxHostCalls?: number;
293
409
  hostCallTimeoutMs?: number;
294
410
  discoveryConcurrency?: number;
411
+ /** Per-connector deadline for in-program catalog probes. Default 30_000. */
412
+ probeTimeoutMs?: number;
295
413
  onInvocationFailure?: (failure: InvocationFailure) => void;
296
414
  diagnostics?: ExecuteDiagnostics;
415
+ /**
416
+ * Where `connecta.emit` collects. The handler that will deliver the
417
+ * blocks owns it; without one, emit fails loudly rather than accept
418
+ * blocks nobody will ever return.
419
+ */
420
+ emitCollector?: EmitCollector;
297
421
  } = {},
298
422
  ): Promise<ExecutorProvider[]> {
299
423
  // All host calls made by one execute_code invocation share a downstream
@@ -301,9 +425,19 @@ export async function buildSandboxProviders(
301
425
  const requestScope = {};
302
426
  const catalog = new CatalogService(registry, baseUrl, {
303
427
  requestScope,
428
+ // Every describe reaching this catalog came from inside a program, on a
429
+ // code-first and a classic-with-executor deployment alike, so the retry
430
+ // advice names connecta.describe regardless of what this server advertises.
431
+ describeRoute: "connecta.describe",
432
+ // Same reasoning for the discovery route a routing failure hands back: the
433
+ // program that just missed an address cannot call search_tools.
434
+ searchRoute: "connecta.search",
304
435
  ...(limits.discoveryConcurrency !== undefined
305
436
  ? { concurrency: limits.discoveryConcurrency }
306
437
  : {}),
438
+ ...(limits.probeTimeoutMs !== undefined
439
+ ? { probeTimeoutMs: limits.probeTimeoutMs }
440
+ : {}),
307
441
  });
308
442
  const invocation = new InvocationService(registry, catalog, activity);
309
443
  const maxHostCalls = Math.max(
@@ -418,6 +552,18 @@ export async function buildSandboxProviders(
418
552
  __callNamespace: callNamespace,
419
553
  call: (address: unknown, args: unknown) =>
420
554
  callAddress(address, args),
555
+ // Emission is a provider function, never an ExecuteResult field —
556
+ // that is what keeps the Executor contract untouched and parity
557
+ // structural (M8). It spends no host-call budget (M7); its own
558
+ // budgets live in the collector.
559
+ emit: async (block: unknown) => {
560
+ if (!limits.emitCollector) {
561
+ throw new Error(
562
+ "connecta.emit is unavailable: no emission collector was configured for this execution",
563
+ );
564
+ }
565
+ limits.emitCollector.accept(block);
566
+ },
421
567
  batch: async (calls: unknown) => {
422
568
  const started = Date.now();
423
569
  const callCount = Array.isArray(calls) ? calls.length : 0;
@@ -566,7 +712,12 @@ export function createExecuteTool(
566
712
  executor: Executor,
567
713
  logger: Logger,
568
714
  activity?: ActivityRequestContext,
569
- config: { discoveryConcurrency?: number } = {},
715
+ config: {
716
+ discoveryConcurrency?: number;
717
+ probeTimeoutMs?: number;
718
+ maxEmittedBytes?: number;
719
+ maxEmittedBlocks?: number;
720
+ } = {},
570
721
  ) {
571
722
  return async (
572
723
  { code, diagnostics: diagnosticsRequested }: {
@@ -584,6 +735,11 @@ export function createExecuteTool(
584
735
  let lease;
585
736
  let outcome;
586
737
  const diagnostics = diagnosticsRequested ? new ExecuteDiagnostics() : undefined;
738
+ const emitted = new EmitCollector(
739
+ resolveEmitBudget(config.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES),
740
+ resolveEmitBudget(config.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS),
741
+ diagnostics,
742
+ );
587
743
  const invocationFailures: InvocationFailure[] = [];
588
744
  try {
589
745
  // Admission comes before provider construction: queued calls retain no
@@ -616,10 +772,14 @@ export function createExecuteTool(
616
772
  onInvocationFailure: (failure) => {
617
773
  invocationFailures.push(failure);
618
774
  },
775
+ emitCollector: emitted,
619
776
  ...(diagnostics ? { diagnostics } : {}),
620
777
  ...(config.discoveryConcurrency !== undefined
621
778
  ? { discoveryConcurrency: config.discoveryConcurrency }
622
779
  : {}),
780
+ ...(config.probeTimeoutMs !== undefined
781
+ ? { probeTimeoutMs: config.probeTimeoutMs }
782
+ : {}),
623
783
  },
624
784
  );
625
785
  } finally {
@@ -670,12 +830,15 @@ export function createExecuteTool(
670
830
  message: `Executor failed: ${msg(err)}`,
671
831
  retryable: false,
672
832
  },
833
+ ...discardedEmits(emitted),
673
834
  diagnostics: diagnostics.finish(),
674
835
  });
675
836
  result.isError = true;
676
837
  return result;
677
838
  }
678
- return errorResult(`Executor failed: ${msg(err)}`);
839
+ return errorResult(
840
+ `Executor failed: ${msg(err)}${discardedEmitsText(emitted)}`,
841
+ );
679
842
  } finally {
680
843
  // A sandbox timeout or early return must also release any outstanding
681
844
  // host waits and signal cooperative connectors to stop their work.
@@ -718,9 +881,14 @@ export function createExecuteTool(
718
881
  if (invocationFailure) break;
719
882
  }
720
883
  if (invocationFailure) {
884
+ // Handed back whole, with no size guard of its own — the failure was
885
+ // framed with bounded caller text (`boundedEchoText`) precisely so
886
+ // this path never needs one. Adding a cap here instead would leave the
887
+ // top-level surfaces, which have the same amplification, uncovered.
721
888
  const result = jsonResult({
722
889
  error: invocationFailure.details,
723
890
  ...(logs ? { logs } : {}),
891
+ ...discardedEmits(emitted),
724
892
  ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
725
893
  });
726
894
  result.isError = true;
@@ -735,12 +903,15 @@ export function createExecuteTool(
735
903
  retryable: false,
736
904
  },
737
905
  ...(logs ? { logs } : {}),
906
+ ...discardedEmits(emitted),
738
907
  diagnostics: diagnostics.finish(),
739
908
  });
740
909
  result.isError = true;
741
910
  return result;
742
911
  }
743
- return errorResult(`${message}${logs ? `\n\nLogs:\n${logs}` : ""}`);
912
+ return errorResult(
913
+ `${message}${logs ? `\n\nLogs:\n${logs}` : ""}${discardedEmitsText(emitted)}`,
914
+ );
744
915
  }
745
916
  // A result crossing back as a host BigInt (or otherwise unserializable
746
917
  // value) makes JSON.stringify throw — keep that inside the structured
@@ -758,21 +929,50 @@ export function createExecuteTool(
758
929
  retryable: false,
759
930
  },
760
931
  ...(logs ? { logs } : {}),
932
+ ...discardedEmits(emitted),
761
933
  diagnostics: diagnostics.finish(),
762
934
  });
763
935
  response.isError = true;
764
936
  return response;
765
937
  }
766
- return errorResult(`${message}${logs ? `\n\nLogs:\n${logs}` : ""}`);
938
+ return errorResult(
939
+ `${message}${logs ? `\n\nLogs:\n${logs}` : ""}${discardedEmitsText(emitted)}`,
940
+ );
767
941
  }
768
- return jsonResult({
942
+ const response = jsonResult({
769
943
  result,
944
+ ...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
770
945
  ...(logs ? { logs } : {}),
771
946
  ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
772
947
  });
948
+ if (emitted.blocks.length > 0) {
949
+ // Emitted image/audio blocks are valid MCP content that ToolResult's
950
+ // text-only typing does not model — the same acknowledged gap
951
+ // guardContent lives with for downstream block passthrough.
952
+ response.content.push(
953
+ ...(emitted.blocks as unknown as typeof response.content),
954
+ );
955
+ }
956
+ return response;
773
957
  };
774
958
  }
775
959
 
960
+ /** M4: a failed program delivers no blocks, but the discard is visible. */
961
+ function discardedEmits(emitted: EmitCollector): {
962
+ emittedDiscarded?: number;
963
+ } {
964
+ return emitted.blocks.length > 0
965
+ ? { emittedDiscarded: emitted.blocks.length }
966
+ : {};
967
+ }
968
+
969
+ /** The same visibility for the plain-text error paths. */
970
+ function discardedEmitsText(emitted: EmitCollector): string {
971
+ return emitted.blocks.length > 0
972
+ ? `\n\nemittedDiscarded: ${emitted.blocks.length}`
973
+ : "";
974
+ }
975
+
776
976
  /**
777
977
  * How the tool opens, and where a program's argument schemas come from. Both
778
978
  * differ by surface: on the classic surface `execute_code` is the tool of last
@@ -795,12 +995,14 @@ const EXECUTE_SCHEMA_SOURCE = {
795
995
 
796
996
  const executeDescription = (
797
997
  surface: ConnectaSurface,
998
+ emitBudgets: { maxBytes: number; maxBlocks: number },
798
999
  ) => `${EXECUTE_ROUTING[surface]} Only tools explicitly annotated readOnlyHint: true are available. Each run is limited to ${EXECUTE_MAX_HOST_CALLS} host calls; connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS}; each host call has a ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second deadline.
799
1000
 
800
1001
  Write an async arrow function. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
801
1002
  - One global per connector: every address <connectorId>.<toolName> from search_tools is callable as <connectorId>.<toolName>(args) with a single args object matching the schema from ${EXECUTE_SCHEMA_SOURCE[surface]}. Names are sanitized to JS identifiers: characters outside [A-Za-z0-9_$] become "_" (e.g. my-service.get.thing → my_service.get_thing), leading digits get "_" prefixed, reserved words get "_" appended.
802
1003
  - connecta.call(address, args) and connecta.batch(calls) — call raw addresses.
803
1004
  - connecta.search(args), connecta.describe({ address: "<connectorId>.<toolName>" }), and connecta.describe({ addresses: [...] }) — load and inspect request-local catalogs on demand. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; the filter changes results, not authority. Matches carrying schemas also list inputKeys, requiredInputKeys, and outputKeys — the same names the schema shows, ready to check against before building args. They are absent when a schema is not a plain object shape, so read the schema itself rather than assuming a missing list means no fields.
1005
+ - connecta.emit(block) — deliver rich MCP content alongside the JSON return: exactly { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }, no other fields. Blocks are appended to the result on success only, spend no host calls, and are budgeted per run (${emitBudgets.maxBlocks} blocks, ${emitBudgets.maxBytes} serialized bytes); an over-budget or invalid emit throws catchably and accepts nothing.
804
1006
  - console.log(...) — captured and returned alongside the result.
805
1007
 
806
1008
  Tool calls return plain values (MCP text content is JSON-parsed when possible) and throw on downstream errors — use try/catch to handle them. A thrown error carries only a message; connecta.batch reports each call as { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }, so use it when the program must tell a policy refusal from a transient failure. Never retry a failure whose retryable is false, and never retry a rate_limited one immediately — the sandbox has no timers. Return a JSON-serializable value; large results are truncated, so reduce data in code instead of returning raw payloads.
@@ -819,24 +1021,51 @@ export function registerExecuteTool(
819
1021
  activity?: ActivityRequestContext;
820
1022
  requestSignal?: AbortSignal;
821
1023
  discoveryConcurrency?: number;
1024
+ /**
1025
+ * The deployment's configured per-connector probe deadline. Programs probe
1026
+ * the same downstream catalogs the top-level tools do, so an operator who
1027
+ * tightened `discovery.probeTimeoutMs` gets it honored inside the sandbox
1028
+ * too rather than silently falling back to the 30s default.
1029
+ */
1030
+ probeTimeoutMs?: number;
1031
+ /** Aggregate serialized-byte budget for connecta.emit. Default 4_000_000. */
1032
+ maxEmittedBytes?: number;
1033
+ /** Block-count budget for connecta.emit. Default 32. */
1034
+ maxEmittedBlocks?: number;
822
1035
  /** The advertised surface, which decides this tool's routing copy. */
823
1036
  surface?: ConnectaSurface;
824
1037
  },
825
1038
  ): void {
1039
+ // Resolved once so the description and the collector cannot disagree about
1040
+ // the budgets this deployment actually enforces.
1041
+ const emitBudgets = {
1042
+ maxBytes: resolveEmitBudget(ctx.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES),
1043
+ maxBlocks: resolveEmitBudget(
1044
+ ctx.maxEmittedBlocks,
1045
+ EXECUTE_MAX_EMITTED_BLOCKS,
1046
+ ),
1047
+ };
826
1048
  const handler = createExecuteTool(
827
1049
  registry,
828
1050
  ctx.baseUrl,
829
1051
  ctx.executor,
830
1052
  ctx.logger,
831
1053
  ctx.activity,
832
- ctx.discoveryConcurrency !== undefined
833
- ? { discoveryConcurrency: ctx.discoveryConcurrency }
834
- : {},
1054
+ {
1055
+ ...(ctx.discoveryConcurrency !== undefined
1056
+ ? { discoveryConcurrency: ctx.discoveryConcurrency }
1057
+ : {}),
1058
+ ...(ctx.probeTimeoutMs !== undefined
1059
+ ? { probeTimeoutMs: ctx.probeTimeoutMs }
1060
+ : {}),
1061
+ maxEmittedBytes: emitBudgets.maxBytes,
1062
+ maxEmittedBlocks: emitBudgets.maxBlocks,
1063
+ },
835
1064
  );
836
1065
  server.registerTool(
837
1066
  "execute_code",
838
1067
  {
839
- description: executeDescription(ctx.surface ?? "classic"),
1068
+ description: executeDescription(ctx.surface ?? "classic", emitBudgets),
840
1069
  inputSchema: z.object({
841
1070
  code: z
842
1071
  .string()
package/src/index.ts CHANGED
@@ -111,6 +111,19 @@ export interface ConnectaCallsConfig {
111
111
  maxBatchResultBytes?: number;
112
112
  }
113
113
 
114
+ /** Budgets for rich output emitted by execute_code programs (`connecta.emit`). */
115
+ export interface ConnectaExecuteConfig {
116
+ /**
117
+ * Aggregate serialized bytes `connecta.emit` accepts per run. Default
118
+ * 4_000_000 — a transport bound, not a context bound: emitted image/audio
119
+ * blocks reach the model as media, not base64 text. Invalid values fall
120
+ * back to the default.
121
+ */
122
+ maxEmittedBytes?: number;
123
+ /** Content blocks `connecta.emit` accepts per run. Default 32. */
124
+ maxEmittedBlocks?: number;
125
+ }
126
+
114
127
  export interface AdmissionPoolConfig {
115
128
  /** Simultaneous work admitted to this pool. */
116
129
  concurrency?: number;
@@ -164,6 +177,8 @@ export interface ConnectaConfig {
164
177
  discovery?: ConnectaDiscoveryConfig;
165
178
  /** Deployment-wide call deadlines and result paging threshold. */
166
179
  calls?: ConnectaCallsConfig;
180
+ /** Budgets for the `connecta.emit` rich-output channel in execute_code. */
181
+ execute?: ConnectaExecuteConfig;
167
182
  /** Bounded MCP and fallback code-mode admission. */
168
183
  admission?: ConnectaAdmissionConfig;
169
184
  /** Optional browser UI and OAuth result-page labels. */
@@ -568,6 +583,12 @@ export function createConnecta(config: ConnectaConfig): Connecta {
568
583
  ...(config.discovery?.concurrency !== undefined
569
584
  ? { discoveryConcurrency: config.discovery.concurrency }
570
585
  : {}),
586
+ ...(config.execute?.maxEmittedBytes !== undefined
587
+ ? { maxEmittedBytes: config.execute.maxEmittedBytes }
588
+ : {}),
589
+ ...(config.execute?.maxEmittedBlocks !== undefined
590
+ ? { maxEmittedBlocks: config.execute.maxEmittedBlocks }
591
+ : {}),
571
592
  ...(credentialVault !== undefined ? { credentialVault } : {}),
572
593
  ...(accessTokens !== undefined ? { accessTokens } : {}),
573
594
  ...(config.deploymentInfo !== undefined
@@ -667,6 +688,7 @@ export type {
667
688
  ActivityReadPage,
668
689
  ActivitySink,
669
690
  ActivityStore,
691
+ AgentFriction,
670
692
  ToolCallActivityEvent,
671
693
  } from "./activity.js";
672
694
  export { InvalidActivityCursorError } from "./activity.js";
package/src/invocation.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  recordToolActivity,
3
3
  type ActivityCallSource,
4
4
  type ActivityRequestContext,
5
+ type AgentFriction,
5
6
  } from "./activity.js";
6
7
  import { isCallAdmissionError } from "./call-admission.js";
7
8
  import {
@@ -12,12 +13,13 @@ import {
12
13
  import {
13
14
  classifyCallError,
14
15
  ConnectorCallError,
16
+ echoedCallArgs,
15
17
  framingError,
16
18
  type AuthRecoveryMode,
17
19
  type CallErrorDetails,
18
20
  } from "./errors.js";
19
21
  import { unwrapMcpResult } from "./mcp-result.js";
20
- import type { RegistryView } from "./registry.js";
22
+ import { splitAddress, type RegistryView } from "./registry.js";
21
23
  import { isExplicitlyReadOnly } from "./tool-safety.js";
22
24
  import type { ToolDef } from "./types.js";
23
25
  import { validateToolInput } from "./validate.js";
@@ -150,6 +152,13 @@ export interface InvocationContext<T> {
150
152
  value: unknown,
151
153
  resolved: ResolvedCatalogTool,
152
154
  ) => T | Promise<T>;
155
+ /**
156
+ * Optional payload-free friction class derived from a *successful* result —
157
+ * today only an oversized one that had to be paged. It is deliberately not an
158
+ * `errorCode`: the call succeeded, and a consumer that keys its dashboards on
159
+ * "has an error code" must not count a truncation as a failure.
160
+ */
161
+ activityFriction?: (value: T) => AgentFriction | undefined;
153
162
  /**
154
163
  * Called after address/catalog/safety admission and before the first provider
155
164
  * attempt. Code mode uses it for its host-call budget.
@@ -252,6 +261,10 @@ export class InvocationService {
252
261
  let activityTarget:
253
262
  | Pick<ResolvedCatalogTool, "connector" | "toolName">
254
263
  | undefined;
264
+ // The address as written, used for activity when resolution never reached
265
+ // a connector. Only its two halves are recorded — the same fields activity
266
+ // has always carried — so no new class of payload enters the log.
267
+ const attempted = splitAddress(address);
255
268
  const timing = (): InvocationTiming => ({
256
269
  catalogMs,
257
270
  admissionMs,
@@ -262,25 +275,56 @@ export class InvocationService {
262
275
  });
263
276
  const record = (
264
277
  outcome: "success" | "error" | "timeout" | "cancelled",
265
- errorCode?: string,
278
+ classification: { errorCode?: string; friction?: AgentFriction } = {},
266
279
  ) => {
267
- if (!activityTarget) return;
280
+ const identity = activityTarget
281
+ ? {
282
+ connectorId: activityTarget.connector.id,
283
+ toolName: activityTarget.toolName,
284
+ }
285
+ : attempted;
286
+ if (!identity) return;
268
287
  recordToolActivity(this.activity, {
269
- connectorId: activityTarget.connector.id,
270
- toolName: activityTarget.toolName,
271
- address: `${activityTarget.connector.id}.${activityTarget.toolName}`,
288
+ connectorId: identity.connectorId,
289
+ toolName: identity.toolName,
290
+ address: `${identity.connectorId}.${identity.toolName}`,
272
291
  source: context.source,
273
292
  outcome,
274
293
  durationMs: Date.now() - started,
275
294
  attempts,
276
- ...(errorCode ? { errorCode } : {}),
295
+ ...(classification.errorCode
296
+ ? { errorCode: classification.errorCode }
297
+ : {}),
298
+ ...(classification.friction
299
+ ? { friction: classification.friction }
300
+ : {}),
277
301
  });
278
302
  };
279
303
  const failed = (error: CallErrorDetails): InvocationOutcome<T> => {
280
304
  const diagnostics = timing();
281
305
  const target = resolved ?? activityTarget;
306
+ const echoed =
307
+ error.code === "destructive_tool_requires_approval"
308
+ ? echoedCallArgs(args)
309
+ : {};
282
310
  const details =
283
- error.code === "auth_required" && target
311
+ error.code === "destructive_tool_requires_approval" && target
312
+ ? {
313
+ ...error,
314
+ nextAction: {
315
+ tool: "call_destructive_tool" as const,
316
+ arguments: {
317
+ address: `${target.connector.id}.${target.toolName}`,
318
+ ...echoed,
319
+ },
320
+ purpose:
321
+ "Ask the MCP host to approve this consequential call. " +
322
+ ("args" in echoed
323
+ ? "Re-send these arguments and add a short reason for the human reviewer."
324
+ : "Re-send the arguments you just sent — they are too large to echo back — and add a short reason for the human reviewer."),
325
+ },
326
+ }
327
+ : error.code === "auth_required" && target
284
328
  ? {
285
329
  ...error,
286
330
  connector: target.connector.id,
@@ -305,16 +349,13 @@ export class InvocationService {
305
349
  ...error,
306
350
  connector: target.connector.id,
307
351
  operation: `${target.connector.id}.${target.toolName}`,
308
- nextAction: {
309
- tool: "search_tools" as const,
310
- arguments: {
352
+ nextAction: this.catalog.searchRecovery(
353
+ {
311
354
  query: target.toolName,
312
355
  connector: target.connector.id,
313
- includeSchemas: "compact" as const,
314
356
  },
315
- purpose:
316
- "Inspect the current input shape if the validation findings are not sufficient.",
317
- },
357
+ "Inspect the current input shape if the validation findings are not sufficient.",
358
+ ),
318
359
  retry:
319
360
  `Correct the listed arguments and retry ` +
320
361
  `${target.connector.id}.${target.toolName}.`,
@@ -326,7 +367,7 @@ export class InvocationService {
326
367
  : details.code === "cancelled"
327
368
  ? "cancelled"
328
369
  : "error",
329
- details.code,
370
+ { errorCode: details.code },
330
371
  );
331
372
  return {
332
373
  ok: false,
@@ -591,7 +632,8 @@ export class InvocationService {
591
632
  : (result as T);
592
633
  resultProcessingMs += Date.now() - processingStarted;
593
634
  const diagnostics = timing();
594
- record("success");
635
+ const friction = context.activityFriction?.(value);
636
+ record("success", friction ? { friction } : {});
595
637
  return {
596
638
  ok: true,
597
639
  value,