@zackbart/connecta 0.10.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +150 -0
  3. package/README.md +5 -4
  4. package/bin/connecta.mjs +0 -7
  5. package/dist/activity.d.ts +11 -1
  6. package/dist/activity.d.ts.map +1 -1
  7. package/dist/activity.js +44 -3
  8. package/dist/activity.js.map +1 -1
  9. package/dist/catalog-service.d.ts +24 -0
  10. package/dist/catalog-service.d.ts.map +1 -1
  11. package/dist/catalog-service.js +68 -9
  12. package/dist/catalog-service.js.map +1 -1
  13. package/dist/connectors/api.d.ts +2 -2
  14. package/dist/connectors/remote-mcp.d.ts +1 -1
  15. package/dist/errors.d.ts +49 -4
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +68 -1
  18. package/dist/errors.js.map +1 -1
  19. package/dist/execute.d.ts +73 -3
  20. package/dist/execute.d.ts.map +1 -1
  21. package/dist/execute.js +161 -29
  22. package/dist/execute.js.map +1 -1
  23. package/dist/index.d.ts +28 -30
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +29 -37
  26. package/dist/index.js.map +1 -1
  27. package/dist/invocation.d.ts +9 -2
  28. package/dist/invocation.d.ts.map +1 -1
  29. package/dist/invocation.js +61 -31
  30. package/dist/invocation.js.map +1 -1
  31. package/dist/meta-tools.d.ts +24 -59
  32. package/dist/meta-tools.d.ts.map +1 -1
  33. package/dist/meta-tools.js +107 -359
  34. package/dist/meta-tools.js.map +1 -1
  35. package/dist/operator-ui/generated.d.ts +1 -1
  36. package/dist/operator-ui/generated.d.ts.map +1 -1
  37. package/dist/operator-ui/generated.js +1 -1
  38. package/dist/operator-ui/generated.js.map +1 -1
  39. package/dist/registry.d.ts +12 -10
  40. package/dist/registry.d.ts.map +1 -1
  41. package/dist/registry.js +8 -17
  42. package/dist/registry.js.map +1 -1
  43. package/dist/routes/mcp.d.ts.map +1 -1
  44. package/dist/routes/mcp.js +19 -21
  45. package/dist/routes/mcp.js.map +1 -1
  46. package/dist/routes/shared.d.ts +9 -11
  47. package/dist/routes/shared.d.ts.map +1 -1
  48. package/dist/routes/shared.js.map +1 -1
  49. package/dist/server.js +5 -4
  50. package/dist/server.js.map +1 -1
  51. package/dist/skills.d.ts +8 -18
  52. package/dist/skills.d.ts.map +1 -1
  53. package/dist/skills.js +13 -60
  54. package/dist/skills.js.map +1 -1
  55. package/dist/types.d.ts +6 -20
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/documentation/code-first-exploration.md +16 -16
  60. package/documentation/code-mode.md +137 -63
  61. package/documentation/connectors.md +1 -1
  62. package/documentation/meta-tools.md +96 -33
  63. package/documentation/rich-output-design.md +212 -0
  64. package/ethos.md +17 -19
  65. package/examples/node/README.md +1 -2
  66. package/examples/node/src/index.ts +1 -3
  67. package/examples/worker/README.md +19 -16
  68. package/examples/worker/src/d1-activity-row.ts +40 -0
  69. package/examples/worker/src/d1-activity.ts +3 -2
  70. package/examples/worker/src/index.ts +6 -14
  71. package/examples/worker/wrangler.jsonc +3 -6
  72. package/package.json +1 -1
  73. package/src/activity.ts +69 -3
  74. package/src/catalog-service.ts +113 -20
  75. package/src/connectors/api.ts +2 -2
  76. package/src/connectors/remote-mcp.ts +1 -1
  77. package/src/errors.ts +104 -3
  78. package/src/execute.ts +237 -37
  79. package/src/index.ts +60 -67
  80. package/src/invocation.ts +61 -19
  81. package/src/meta-tools.ts +136 -482
  82. package/src/operator-ui/browser.ts +10 -2
  83. package/src/operator-ui/generated.ts +1 -1
  84. package/src/registry.ts +7 -35
  85. package/src/routes/mcp.ts +19 -21
  86. package/src/routes/shared.ts +8 -11
  87. package/src/server.ts +7 -7
  88. package/src/skills.ts +11 -74
  89. package/src/types.ts +6 -21
  90. package/src/version.ts +1 -1
  91. package/templates/node/README.md +2 -1
  92. package/templates/node/package.json +1 -1
  93. package/templates/node/src/index.ts +1 -1
package/src/activity.ts CHANGED
@@ -1,8 +1,33 @@
1
+ import { boundedEchoText } from "./errors.js";
1
2
  import type { Logger } from "./types.js";
2
3
 
4
+ /**
5
+ * How long an identity field may be before the store stops believing it.
6
+ *
7
+ * `connectorId` and `toolName` are ordinarily operator- and connector-authored,
8
+ * and 128 bytes is far past any real one. But an address that resolved to
9
+ * nothing is recorded *as written*, which puts a caller-authored string in both
10
+ * fields — and "payload-free by construction" has to mean the event type has
11
+ * nowhere to put a payload, not merely that connecta declines to. A 40 KB
12
+ * invented connector id is a payload wearing an id's clothing.
13
+ *
14
+ * Clamped rather than dropped: the invented id is precisely what an operator
15
+ * needs to see, and its first 128 bytes identify the mistake as well as all
16
+ * 40,000 would. The `…` marker keeps a clamped value from reading as a real one.
17
+ */
18
+ const MAX_ACTIVITY_NAME_BYTES = 128;
19
+
20
+ /** Two names and the dot between them. */
21
+ const MAX_ACTIVITY_ADDRESS_BYTES = MAX_ACTIVITY_NAME_BYTES * 2 + 1;
22
+
3
23
  export type ActivityCallSource =
4
24
  | "call_tool"
5
25
  | "call_destructive_tool"
26
+ // Read-only history. Nothing emits `batch_call` since issue #273 removed the
27
+ // tool, but activity storage is append-only: rows written by older
28
+ // deployments are still read back — the D1 example maps a stored row straight
29
+ // into this type — and an operator's timeline should not have to lie about
30
+ // where a call came from. Never widen this member back into a live source.
6
31
  | "batch_call"
7
32
  | "execute_code";
8
33
 
@@ -12,6 +37,35 @@ export type ActivityOutcome =
12
37
  | "timeout"
13
38
  | "cancelled";
14
39
 
40
+ export type AgentFriction =
41
+ | "tool_not_found"
42
+ | "schema_retry"
43
+ | "destructive_reroute"
44
+ | "auth_required"
45
+ | "result_too_large";
46
+
47
+ /** Coarse recovery class derived without inspecting payloads or error prose. */
48
+ export function agentFrictionForCode(
49
+ code: string | undefined,
50
+ ): AgentFriction | undefined {
51
+ switch (code) {
52
+ case "unknown_address":
53
+ case "unknown_tool":
54
+ case "ambiguous_tool_alias":
55
+ return "tool_not_found";
56
+ case "invalid_args":
57
+ return "schema_retry";
58
+ case "destructive_tool_requires_approval":
59
+ return "destructive_reroute";
60
+ case "auth_required":
61
+ return "auth_required";
62
+ case "result_too_large":
63
+ return "result_too_large";
64
+ default:
65
+ return undefined;
66
+ }
67
+ }
68
+
15
69
  /**
16
70
  * Authenticated identity attached to an activity event. `id` is intentionally
17
71
  * optional: open deployments and shared bearer tokens cannot honestly identify
@@ -48,7 +102,14 @@ export interface ToolCallActivityEvent {
48
102
  outcome: ActivityOutcome;
49
103
  durationMs: number;
50
104
  attempts: number;
105
+ /** Set only when the call actually failed; a truncated success has none. */
51
106
  errorCode?: string;
107
+ /**
108
+ * Payload-free recovery class. Usually derived from `errorCode`, but it can
109
+ * also stand alone: a result too large to return inline is friction for the
110
+ * agent while remaining an `outcome: "success"` call with no error code.
111
+ */
112
+ friction?: AgentFriction;
52
113
  serverName: string;
53
114
  serverVersion: string;
54
115
  deploymentId?: string;
@@ -121,6 +182,7 @@ export type ActivityEventInput = Pick<
121
182
  | "durationMs"
122
183
  | "attempts"
123
184
  | "errorCode"
185
+ | "friction"
124
186
  >;
125
187
 
126
188
  /**
@@ -133,20 +195,24 @@ export function recordToolActivity(
133
195
  input: ActivityEventInput,
134
196
  ): void {
135
197
  if (!context) return;
198
+ // A caller-supplied class wins because it knows something the code table
199
+ // cannot: friction that belongs to a call which did not fail.
200
+ const friction = input.friction ?? agentFrictionForCode(input.errorCode);
136
201
  const event: ToolCallActivityEvent = {
137
202
  schemaVersion: 1,
138
203
  id: crypto.randomUUID(),
139
204
  occurredAt: new Date().toISOString(),
140
205
  requestId: context.requestId,
141
206
  actor: context.actor,
142
- connectorId: input.connectorId,
143
- toolName: input.toolName,
144
- address: input.address,
207
+ connectorId: boundedEchoText(input.connectorId, MAX_ACTIVITY_NAME_BYTES),
208
+ toolName: boundedEchoText(input.toolName, MAX_ACTIVITY_NAME_BYTES),
209
+ address: boundedEchoText(input.address, MAX_ACTIVITY_ADDRESS_BYTES),
145
210
  source: input.source,
146
211
  outcome: input.outcome,
147
212
  durationMs: Math.max(0, Math.trunc(input.durationMs)),
148
213
  attempts: Math.max(1, Math.trunc(input.attempts)),
149
214
  ...(input.errorCode ? { errorCode: input.errorCode } : {}),
215
+ ...(friction ? { friction } : {}),
150
216
  serverName: context.serverInfo.name,
151
217
  serverVersion: context.serverInfo.version,
152
218
  ...(context.deploymentId
@@ -13,7 +13,11 @@ import {
13
13
  mapSettledWithConcurrency,
14
14
  resolveDiscoveryConcurrency,
15
15
  } from "./concurrency.js";
16
- import { classifyCallError, framingError } from "./errors.js";
16
+ import {
17
+ boundedEchoText,
18
+ classifyCallError,
19
+ framingError,
20
+ } from "./errors.js";
17
21
  import type { CallErrorDetails } from "./errors.js";
18
22
  import type {
19
23
  ConnectorOperationOptions,
@@ -44,6 +48,13 @@ const MAX_QUERY_ANALYSIS_TERM_LENGTH = 64;
44
48
 
45
49
  const encoder = new TextEncoder();
46
50
 
51
+ /**
52
+ * The discovery route a routing failure should send a caller back through. Same
53
+ * catalog logic serves both the top-level `search_tools` path and the
54
+ * in-program `connecta.search` path, so callers pass the route they own.
55
+ */
56
+ export type SearchRoute = "search_tools" | "connecta.search";
57
+
47
58
  export class DiscoveryPolicyError extends Error {
48
59
  constructor(
49
60
  readonly code: "invalid_args" | "result_too_large",
@@ -94,12 +105,26 @@ function discoveryAddresses(args: CatalogDescribeArgs): unknown[] {
94
105
  if (value.length > MAX_DESCRIBE_ADDRESSES) {
95
106
  throw new DiscoveryPolicyError(
96
107
  "invalid_args",
97
- `addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across describe_tools calls.`,
108
+ `addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across connecta.describe calls.`,
98
109
  );
99
110
  }
100
111
  return value;
101
112
  }
102
113
 
114
+ /**
115
+ * Search terms derived from an address the catalog could not resolve. Bounded
116
+ * because the address is entirely caller-authored: an invented one can be any
117
+ * length, and this string is copied into a recovery record that is itself
118
+ * copied into both halves of the result envelope.
119
+ */
120
+ function recoveryQuery(address: string): string {
121
+ const separator = address.indexOf(".");
122
+ const candidate = separator >= 0 ? address.slice(separator + 1) : address;
123
+ return boundedEchoText(
124
+ candidate.replaceAll(/[._-]+/g, " ").trim() || address,
125
+ );
126
+ }
127
+
103
128
  /** Serialize once and count the exact bytes the MCP adapter would emit. */
104
129
  export function boundedDiscoveryText(value: unknown, hint: string): string {
105
130
  const text = JSON.stringify(value);
@@ -286,6 +311,7 @@ export class CatalogService {
286
311
  readonly requestScope: object;
287
312
  private readonly probeTimeoutMs: number;
288
313
  private readonly concurrency: number;
314
+ private readonly searchRoute: SearchRoute;
289
315
  private readonly loaded = new Map<string, ToolDef[]>();
290
316
  private readonly loading = new Map<string, Promise<ToolDef[]>>();
291
317
 
@@ -296,12 +322,40 @@ export class CatalogService {
296
322
  requestScope?: object;
297
323
  probeTimeoutMs?: number;
298
324
  concurrency?: number;
325
+ /** The discovery route recovery records name. Default `search_tools`. */
326
+ searchRoute?: SearchRoute;
299
327
  } = {},
300
328
  ) {
301
329
  this.requestScope = options.requestScope ?? {};
302
330
  this.probeTimeoutMs =
303
331
  normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
304
332
  this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
333
+ this.searchRoute = options.searchRoute ?? "search_tools";
334
+ }
335
+
336
+ /**
337
+ * Send a caller back to discovery through the surface it can actually reach.
338
+ * Both variants carry the same scoping arguments because `connecta.search`
339
+ * takes the same ones `search_tools` does; only the key naming the callable
340
+ * differs, the way the ambiguous-alias record already names a function.
341
+ *
342
+ * Not private: `InvocationService` builds the same class of record when a
343
+ * call fails schema validation, and it is this catalog's route that decides
344
+ * which key that record carries. Duplicating the branch there would let the
345
+ * two drift.
346
+ */
347
+ searchRecovery(
348
+ args: { query: string; connector?: string },
349
+ purpose: string,
350
+ ): NonNullable<CallErrorDetails["nextAction"]> {
351
+ const searchArgs = {
352
+ query: args.query,
353
+ ...(args.connector !== undefined ? { connector: args.connector } : {}),
354
+ includeSchemas: "compact" as const,
355
+ };
356
+ return this.searchRoute === "connecta.search"
357
+ ? { function: "connecta.search", arguments: searchArgs, purpose }
358
+ : { tool: "search_tools", arguments: searchArgs, purpose };
305
359
  }
306
360
 
307
361
  async loadConnector(
@@ -345,10 +399,16 @@ export class CatalogService {
345
399
  if (!resolved) {
346
400
  return {
347
401
  ok: false,
348
- error: framingError(
349
- "unknown_address",
350
- `Unknown address "${address}"`,
351
- ),
402
+ error: {
403
+ ...framingError(
404
+ "unknown_address",
405
+ `Unknown address "${boundedEchoText(address)}"`,
406
+ ),
407
+ nextAction: this.searchRecovery(
408
+ { query: recoveryQuery(address) },
409
+ "Find the configured canonical address before retrying.",
410
+ ),
411
+ },
352
412
  catalogMs: 0,
353
413
  };
354
414
  }
@@ -370,10 +430,19 @@ export class CatalogService {
370
430
  if (!definition) {
371
431
  return {
372
432
  ok: false,
373
- error: framingError(
374
- "unknown_tool",
375
- `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
376
- ),
433
+ error: {
434
+ ...framingError(
435
+ "unknown_tool",
436
+ `Unknown tool "${boundedEchoText(resolved.toolName)}" on connector "${resolved.connector.id}"`,
437
+ ),
438
+ nextAction: this.searchRecovery(
439
+ {
440
+ query: recoveryQuery(resolved.toolName),
441
+ connector: resolved.connector.id,
442
+ },
443
+ "Find the connector's current canonical tool address.",
444
+ ),
445
+ },
377
446
  catalogMs: Date.now() - started,
378
447
  connector: resolved.connector,
379
448
  toolName: resolved.toolName,
@@ -405,10 +474,16 @@ export class CatalogService {
405
474
  if (!connector) {
406
475
  return {
407
476
  ok: false,
408
- error: framingError(
409
- "unknown_address",
410
- `Unknown address "${connectorId}.${alias}"`,
411
- ),
477
+ error: {
478
+ ...framingError(
479
+ "unknown_address",
480
+ `Unknown address "${boundedEchoText(`${connectorId}.${alias}`)}"`,
481
+ ),
482
+ nextAction: this.searchRecovery(
483
+ { query: recoveryQuery(alias) },
484
+ "Find the configured canonical address before retrying.",
485
+ ),
486
+ },
412
487
  catalogMs: 0,
413
488
  };
414
489
  }
@@ -432,10 +507,16 @@ export class CatalogService {
432
507
  if (!definition) {
433
508
  return {
434
509
  ok: false,
435
- error: framingError(
436
- "unknown_tool",
437
- `Unknown tool "${alias}" on connector "${connector.id}"`,
438
- ),
510
+ error: {
511
+ ...framingError(
512
+ "unknown_tool",
513
+ `Unknown tool "${boundedEchoText(alias)}" on connector "${connector.id}"`,
514
+ ),
515
+ nextAction: this.searchRecovery(
516
+ { query: recoveryQuery(alias), connector: connector.id },
517
+ "Find the connector's current canonical tool address.",
518
+ ),
519
+ },
439
520
  catalogMs: Date.now() - started,
440
521
  connector,
441
522
  toolName: alias,
@@ -449,8 +530,16 @@ export class CatalogService {
449
530
  ok: false,
450
531
  error: {
451
532
  code: "ambiguous_tool_alias",
452
- message: `Tool alias "${alias}" is ambiguous on connector "${connector.id}" because ${names} sanitize to the same name. Use connecta.call with an exact address.`,
533
+ message: `Tool alias "${boundedEchoText(alias)}" is ambiguous on connector "${connector.id}" because ${names} sanitize to the same name. Use connecta.call with an exact address.`,
453
534
  retryable: false,
535
+ nextAction: {
536
+ function: "connecta.call",
537
+ addresses: [definition, ...collisions].map(
538
+ (tool) => `${connector.id}.${tool.name}`,
539
+ ),
540
+ purpose:
541
+ "Choose the intended canonical address and call it with the original arguments.",
542
+ },
454
543
  },
455
544
  catalogMs: Date.now() - started,
456
545
  connector,
@@ -486,6 +575,10 @@ export class CatalogService {
486
575
  connectors,
487
576
  this.concurrency,
488
577
  (connector) =>
578
+ // Unlike the describe path, this label never reaches a caller: search
579
+ // only counts rejected catalogs (`unavailableCatalogs` below) and
580
+ // renders its own guidance, so the folded name here stays internal and
581
+ // needs no surface awareness.
489
582
  this.loadForDiscovery(
490
583
  connector.id,
491
584
  `search_tools probe of "${connector.id}"`,
@@ -721,7 +814,7 @@ export class CatalogService {
721
814
  connectorIds,
722
815
  this.concurrency,
723
816
  (id) =>
724
- this.loadForDiscovery(id, `describe_tools probe of "${id}"`),
817
+ this.loadForDiscovery(id, `connecta.describe probe of "${id}"`),
725
818
  );
726
819
  const catalogs = new Map<string, ToolDef[] | Error>();
727
820
  loaded.forEach((result, index) => {
@@ -20,7 +20,7 @@ export interface ApiTool {
20
20
  outputSchema?: JsonSchema;
21
21
  /**
22
22
  * Standard MCP-style behavior hints. Only an explicit readOnlyHint: true
23
- * admits the tool to call_tool, batch_call, and execute_code.
23
+ * admits the tool to call_tool and execute_code.
24
24
  */
25
25
  annotations?: ToolAnnotations;
26
26
  handler: (args: any, ctx: ConnectorContext) => Promise<unknown> | unknown;
@@ -32,7 +32,7 @@ export interface ApiOptions {
32
32
  description?: string;
33
33
  /**
34
34
  * Max inline result size (bytes) for this connector's tools before
35
- * call_tool/batch_call truncate and stash the full text for get_result
35
+ * call_tool truncates and stashes the full text for get_result
36
36
  * paging. Overrides the deployment's `calls.maxResultBytes`; omit to inherit
37
37
  * it. Must be a whole number of bytes >= 1; anything else warns at startup
38
38
  * and is ignored.
@@ -38,7 +38,7 @@ export interface RemoteMcpOptions {
38
38
  description?: string;
39
39
  /**
40
40
  * Max inline result size (bytes) for this connector's tools before
41
- * call_tool/batch_call truncate and stash the full text for get_result
41
+ * call_tool truncates and stashes the full text for get_result
42
42
  * paging. Overrides the deployment's `calls.maxResultBytes`; omit to inherit
43
43
  * it. Must be a whole number of bytes >= 1; anything else warns at startup
44
44
  * and is ignored.
package/src/errors.ts CHANGED
@@ -43,6 +43,76 @@ function boundedIssueText(
43
43
  };
44
44
  }
45
45
 
46
+ /**
47
+ * How many bytes of the caller's own arguments an error envelope will echo
48
+ * back to it. Small on purpose: an error result is not size-guarded the way a
49
+ * *result* is, so an unbounded echo turns a 50 KB argument object into a 100 KB
50
+ * refusal against a deployment that capped results at 1 KB — twice over, since
51
+ * the payload lands in both the text content and `structuredContent`. The agent
52
+ * already holds what it sent; the echo is a convenience, never the record.
53
+ */
54
+ const MAX_ECHOED_ARGS_BYTES = 512;
55
+
56
+ /**
57
+ * The same budget spent on caller-authored *text* — the address it mistyped,
58
+ * the discovery query derived from it — rather than on its arguments.
59
+ *
60
+ * Text is clamped where {@link echoedCallArgs} drops: an address is the thing
61
+ * the refusal exists to correct, so a refusal that named nothing would be
62
+ * useless, while a clipped one still carries the prefix that identifies the
63
+ * mistake. The marker says it was clipped, so nothing reads a clamped address
64
+ * as the address that was sent. Arguments get the opposite rule because they
65
+ * end at a human approving one specific call.
66
+ *
67
+ * The number is not cosmetic. An error result is not size-guarded the way a
68
+ * result is, and every echoed byte lands twice (text content *and*
69
+ * `structuredContent`), so an unbounded address turned a 50 KB typo into a
70
+ * 200 KB refusal against a deployment that capped results at 1 KB.
71
+ */
72
+ const MAX_ECHOED_TEXT_BYTES = 512;
73
+
74
+ const echoEncoder = new TextEncoder();
75
+ const echoDecoder = new TextDecoder();
76
+
77
+ /**
78
+ * Clamp a string to a UTF-8 byte budget, appending `…` when it clipped.
79
+ * Short strings — the common case, and the one that has to stay exact — are
80
+ * returned unchanged and untagged.
81
+ */
82
+ export function boundedEchoText(
83
+ value: string,
84
+ maxBytes: number = MAX_ECHOED_TEXT_BYTES,
85
+ ): string {
86
+ const bytes = echoEncoder.encode(value);
87
+ if (bytes.length <= maxBytes) return value;
88
+ // Never split a codepoint: walk back off UTF-8 continuation bytes (10xxxxxx)
89
+ // so the clamp cannot manufacture a replacement character.
90
+ let end = Math.max(0, maxBytes);
91
+ while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end--;
92
+ return `${echoDecoder.decode(bytes.slice(0, end))}…`;
93
+ }
94
+
95
+ /**
96
+ * `{ args }` when the caller's arguments fit {@link MAX_ECHOED_ARGS_BYTES},
97
+ * `{}` when they do not. All or nothing: a clipped echo would be a *different*
98
+ * call than the one that was refused, and the routes this feeds end at a human
99
+ * approving one. Unserializable arguments are treated the same way as oversized
100
+ * ones — there is nothing honest to put in the field.
101
+ */
102
+ export function echoedCallArgs(args: unknown): { args?: unknown } {
103
+ if (args === undefined) return {};
104
+ let text: string | undefined;
105
+ try {
106
+ text = JSON.stringify(args);
107
+ } catch {
108
+ return {};
109
+ }
110
+ if (text === undefined) return {};
111
+ return echoEncoder.encode(text).length <= MAX_ECHOED_ARGS_BYTES
112
+ ? { args }
113
+ : {};
114
+ }
115
+
46
116
  function boundedValidation(
47
117
  details: ArgumentValidationDetails | undefined,
48
118
  ): ArgumentValidationDetails | undefined {
@@ -151,7 +221,7 @@ export class ConnectorCallError extends Error {
151
221
  }
152
222
  }
153
223
 
154
- /** The `error` object surfaced in call_tool/batch_call value-mode results. */
224
+ /** The `error` object surfaced in value-mode call results and connecta.batch entries. */
155
225
  export interface CallErrorDetails {
156
226
  code: string;
157
227
  message: string;
@@ -179,10 +249,41 @@ export interface CallErrorDetails {
179
249
  tool: "search_tools";
180
250
  arguments: {
181
251
  query: string;
182
- connector: string;
252
+ connector?: string;
183
253
  includeSchemas: "compact";
184
254
  };
185
255
  purpose: string;
256
+ } | {
257
+ tool: "call_destructive_tool";
258
+ arguments: {
259
+ address: string;
260
+ /**
261
+ * The caller's own arguments, echoed only when they fit
262
+ * {@link MAX_ECHOED_ARGS_BYTES} — and then whole, never clipped. Absent
263
+ * means "re-send exactly what you sent": a half-copied argument object
264
+ * routed into a human approval prompt would describe a call nobody made.
265
+ */
266
+ args?: unknown;
267
+ };
268
+ purpose: string;
269
+ } | {
270
+ /**
271
+ * The same scoped discovery as the `search_tools` route above, addressed to
272
+ * a caller inside `execute_code`, which cannot call a tool. Which of the two
273
+ * a routing failure emits follows the route the caller took, not the
274
+ * deployment's advertised surface.
275
+ */
276
+ function: "connecta.search";
277
+ arguments: {
278
+ query: string;
279
+ connector?: string;
280
+ includeSchemas: "compact";
281
+ };
282
+ purpose: string;
283
+ } | {
284
+ function: "connecta.call";
285
+ addresses: string[];
286
+ purpose: string;
186
287
  };
187
288
  /** Explicit retry guidance; recovery never retries or mutates by itself. */
188
289
  retry?: string;
@@ -221,7 +322,7 @@ const RETRYABLE_MESSAGE_RE =
221
322
  const TIMEOUT_MESSAGE_RE = /timed out|timeout/i;
222
323
 
223
324
  /** Message-text fallback used when an error carries no typed classification. */
224
- export function messageLooksRetryable(message: string): boolean {
325
+ function messageLooksRetryable(message: string): boolean {
225
326
  return RETRYABLE_MESSAGE_RE.test(message);
226
327
  }
227
328