@zackbart/connecta 0.10.4 → 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 (71) hide show
  1. package/CHANGELOG.md +135 -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 +40 -0
  7. package/dist/catalog-service.d.ts.map +1 -1
  8. package/dist/catalog-service.js +97 -15
  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 +163 -10
  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 +12 -3
  27. package/dist/meta-tools.d.ts.map +1 -1
  28. package/dist/meta-tools.js +185 -30
  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/skills.d.ts +1 -1
  45. package/dist/skills.d.ts.map +1 -1
  46. package/dist/skills.js +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/documentation/code-mode.md +125 -34
  50. package/documentation/meta-tools.md +91 -9
  51. package/documentation/rich-output-design.md +212 -0
  52. package/ethos.md +17 -19
  53. package/examples/worker/README.md +11 -3
  54. package/examples/worker/src/d1-activity-row.ts +40 -0
  55. package/examples/worker/src/d1-activity.ts +3 -2
  56. package/package.json +1 -1
  57. package/src/activity.ts +64 -3
  58. package/src/catalog-service.ts +166 -26
  59. package/src/errors.ts +102 -1
  60. package/src/execute.ts +240 -10
  61. package/src/index.ts +22 -0
  62. package/src/invocation.ts +59 -17
  63. package/src/meta-tools.ts +239 -37
  64. package/src/operator-ui/browser.ts +10 -2
  65. package/src/operator-ui/generated.ts +1 -1
  66. package/src/registry.ts +5 -2
  67. package/src/routes/mcp.ts +9 -0
  68. package/src/routes/shared.ts +4 -0
  69. package/src/skills.ts +1 -1
  70. package/src/version.ts +1 -1
  71. 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;
@@ -526,6 +672,7 @@ export async function buildSandboxProviders(
526
672
  try {
527
673
  const result = await typedDiscovery(async () => {
528
674
  const args = (raw ?? {}) as {
675
+ address?: unknown;
529
676
  addresses?: unknown;
530
677
  format?: "compact" | "json";
531
678
  fullDescriptions?: boolean;
@@ -565,7 +712,12 @@ export function createExecuteTool(
565
712
  executor: Executor,
566
713
  logger: Logger,
567
714
  activity?: ActivityRequestContext,
568
- config: { discoveryConcurrency?: number } = {},
715
+ config: {
716
+ discoveryConcurrency?: number;
717
+ probeTimeoutMs?: number;
718
+ maxEmittedBytes?: number;
719
+ maxEmittedBlocks?: number;
720
+ } = {},
569
721
  ) {
570
722
  return async (
571
723
  { code, diagnostics: diagnosticsRequested }: {
@@ -583,6 +735,11 @@ export function createExecuteTool(
583
735
  let lease;
584
736
  let outcome;
585
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
+ );
586
743
  const invocationFailures: InvocationFailure[] = [];
587
744
  try {
588
745
  // Admission comes before provider construction: queued calls retain no
@@ -615,10 +772,14 @@ export function createExecuteTool(
615
772
  onInvocationFailure: (failure) => {
616
773
  invocationFailures.push(failure);
617
774
  },
775
+ emitCollector: emitted,
618
776
  ...(diagnostics ? { diagnostics } : {}),
619
777
  ...(config.discoveryConcurrency !== undefined
620
778
  ? { discoveryConcurrency: config.discoveryConcurrency }
621
779
  : {}),
780
+ ...(config.probeTimeoutMs !== undefined
781
+ ? { probeTimeoutMs: config.probeTimeoutMs }
782
+ : {}),
622
783
  },
623
784
  );
624
785
  } finally {
@@ -669,12 +830,15 @@ export function createExecuteTool(
669
830
  message: `Executor failed: ${msg(err)}`,
670
831
  retryable: false,
671
832
  },
833
+ ...discardedEmits(emitted),
672
834
  diagnostics: diagnostics.finish(),
673
835
  });
674
836
  result.isError = true;
675
837
  return result;
676
838
  }
677
- return errorResult(`Executor failed: ${msg(err)}`);
839
+ return errorResult(
840
+ `Executor failed: ${msg(err)}${discardedEmitsText(emitted)}`,
841
+ );
678
842
  } finally {
679
843
  // A sandbox timeout or early return must also release any outstanding
680
844
  // host waits and signal cooperative connectors to stop their work.
@@ -717,9 +881,14 @@ export function createExecuteTool(
717
881
  if (invocationFailure) break;
718
882
  }
719
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.
720
888
  const result = jsonResult({
721
889
  error: invocationFailure.details,
722
890
  ...(logs ? { logs } : {}),
891
+ ...discardedEmits(emitted),
723
892
  ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
724
893
  });
725
894
  result.isError = true;
@@ -734,12 +903,15 @@ export function createExecuteTool(
734
903
  retryable: false,
735
904
  },
736
905
  ...(logs ? { logs } : {}),
906
+ ...discardedEmits(emitted),
737
907
  diagnostics: diagnostics.finish(),
738
908
  });
739
909
  result.isError = true;
740
910
  return result;
741
911
  }
742
- return errorResult(`${message}${logs ? `\n\nLogs:\n${logs}` : ""}`);
912
+ return errorResult(
913
+ `${message}${logs ? `\n\nLogs:\n${logs}` : ""}${discardedEmitsText(emitted)}`,
914
+ );
743
915
  }
744
916
  // A result crossing back as a host BigInt (or otherwise unserializable
745
917
  // value) makes JSON.stringify throw — keep that inside the structured
@@ -757,21 +929,50 @@ export function createExecuteTool(
757
929
  retryable: false,
758
930
  },
759
931
  ...(logs ? { logs } : {}),
932
+ ...discardedEmits(emitted),
760
933
  diagnostics: diagnostics.finish(),
761
934
  });
762
935
  response.isError = true;
763
936
  return response;
764
937
  }
765
- return errorResult(`${message}${logs ? `\n\nLogs:\n${logs}` : ""}`);
938
+ return errorResult(
939
+ `${message}${logs ? `\n\nLogs:\n${logs}` : ""}${discardedEmitsText(emitted)}`,
940
+ );
766
941
  }
767
- return jsonResult({
942
+ const response = jsonResult({
768
943
  result,
944
+ ...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
769
945
  ...(logs ? { logs } : {}),
770
946
  ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
771
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;
772
957
  };
773
958
  }
774
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
+
775
976
  /**
776
977
  * How the tool opens, and where a program's argument schemas come from. Both
777
978
  * differ by surface: on the classic surface `execute_code` is the tool of last
@@ -794,12 +995,14 @@ const EXECUTE_SCHEMA_SOURCE = {
794
995
 
795
996
  const executeDescription = (
796
997
  surface: ConnectaSurface,
998
+ emitBudgets: { maxBytes: number; maxBlocks: number },
797
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.
798
1000
 
799
1001
  Write an async arrow function. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
800
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.
801
1003
  - connecta.call(address, args) and connecta.batch(calls) — call raw addresses.
802
- - connecta.search(args) and connecta.describe({ addresses: ["<connectorId>.<toolName>", ...] }) — load and inspect request-local catalogs on demand; describe takes an addresses array, never a single address string. 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.
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.
803
1006
  - console.log(...) — captured and returned alongside the result.
804
1007
 
805
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.
@@ -818,24 +1021,51 @@ export function registerExecuteTool(
818
1021
  activity?: ActivityRequestContext;
819
1022
  requestSignal?: AbortSignal;
820
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;
821
1035
  /** The advertised surface, which decides this tool's routing copy. */
822
1036
  surface?: ConnectaSurface;
823
1037
  },
824
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
+ };
825
1048
  const handler = createExecuteTool(
826
1049
  registry,
827
1050
  ctx.baseUrl,
828
1051
  ctx.executor,
829
1052
  ctx.logger,
830
1053
  ctx.activity,
831
- ctx.discoveryConcurrency !== undefined
832
- ? { discoveryConcurrency: ctx.discoveryConcurrency }
833
- : {},
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
+ },
834
1064
  );
835
1065
  server.registerTool(
836
1066
  "execute_code",
837
1067
  {
838
- description: executeDescription(ctx.surface ?? "classic"),
1068
+ description: executeDescription(ctx.surface ?? "classic", emitBudgets),
839
1069
  inputSchema: z.object({
840
1070
  code: z
841
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,