@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
@@ -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,26 @@ const MAX_QUERY_ANALYSIS_TERM_LENGTH = 64;
44
48
 
45
49
  const encoder = new TextEncoder();
46
50
 
51
+ /**
52
+ * The tool a describe-path error should name when it tells a caller to retry.
53
+ * This is the route the *caller* took, not the deployment's advertised surface:
54
+ * a classic deployment with an executor serves `describe_tools` at top level
55
+ * while every in-program describe still arrives through `connecta.describe`, so
56
+ * one CatalogService cannot infer the answer from `surface` alone. Callers pass
57
+ * the route they own.
58
+ */
59
+ export type DescribeRoute = "describe_tools" | "connecta.describe";
60
+
61
+ /**
62
+ * The discovery route a routing failure should send a caller back through. Same
63
+ * rule as {@link DescribeRoute} — the route the *caller* took, not the
64
+ * deployment's advertised surface — with one difference worth keeping the two
65
+ * options separate for: `search_tools` exists on both advertised surfaces, so a
66
+ * top-level handler never has to derive this one, while an in-program caller
67
+ * still has to be told about `connecta.search` because it cannot call a tool.
68
+ */
69
+ export type SearchRoute = "search_tools" | "connecta.search";
70
+
47
71
  export class DiscoveryPolicyError extends Error {
48
72
  constructor(
49
73
  readonly code: "invalid_args" | "result_too_large",
@@ -72,7 +96,10 @@ function discoverySearchLimit(value: unknown): number {
72
96
  }
73
97
 
74
98
  /** Normalize the single-address convenience form, then validate the bounded list. */
75
- function discoveryAddresses(args: CatalogDescribeArgs): unknown[] {
99
+ function discoveryAddresses(
100
+ args: CatalogDescribeArgs,
101
+ describeRoute: DescribeRoute,
102
+ ): unknown[] {
76
103
  if (args.address !== undefined && args.addresses !== undefined) {
77
104
  throw new DiscoveryPolicyError(
78
105
  "invalid_args",
@@ -94,12 +121,26 @@ function discoveryAddresses(args: CatalogDescribeArgs): unknown[] {
94
121
  if (value.length > MAX_DESCRIBE_ADDRESSES) {
95
122
  throw new DiscoveryPolicyError(
96
123
  "invalid_args",
97
- `addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across describe_tools calls.`,
124
+ `addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across ${describeRoute} calls.`,
98
125
  );
99
126
  }
100
127
  return value;
101
128
  }
102
129
 
130
+ /**
131
+ * Search terms derived from an address the catalog could not resolve. Bounded
132
+ * because the address is entirely caller-authored: an invented one can be any
133
+ * length, and this string is copied into a recovery record that is itself
134
+ * copied into both halves of the result envelope.
135
+ */
136
+ function recoveryQuery(address: string): string {
137
+ const separator = address.indexOf(".");
138
+ const candidate = separator >= 0 ? address.slice(separator + 1) : address;
139
+ return boundedEchoText(
140
+ candidate.replaceAll(/[._-]+/g, " ").trim() || address,
141
+ );
142
+ }
143
+
103
144
  /** Serialize once and count the exact bytes the MCP adapter would emit. */
104
145
  export function boundedDiscoveryText(value: unknown, hint: string): string {
105
146
  const text = JSON.stringify(value);
@@ -286,6 +327,8 @@ export class CatalogService {
286
327
  readonly requestScope: object;
287
328
  private readonly probeTimeoutMs: number;
288
329
  private readonly concurrency: number;
330
+ private readonly describeRoute: DescribeRoute;
331
+ private readonly searchRoute: SearchRoute;
289
332
  private readonly loaded = new Map<string, ToolDef[]>();
290
333
  private readonly loading = new Map<string, Promise<ToolDef[]>>();
291
334
 
@@ -296,12 +339,43 @@ export class CatalogService {
296
339
  requestScope?: object;
297
340
  probeTimeoutMs?: number;
298
341
  concurrency?: number;
342
+ /** The tool describe-path errors name. Default `describe_tools`. */
343
+ describeRoute?: DescribeRoute;
344
+ /** The discovery route recovery records name. Default `search_tools`. */
345
+ searchRoute?: SearchRoute;
299
346
  } = {},
300
347
  ) {
301
348
  this.requestScope = options.requestScope ?? {};
302
349
  this.probeTimeoutMs =
303
350
  normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
304
351
  this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
352
+ this.describeRoute = options.describeRoute ?? "describe_tools";
353
+ this.searchRoute = options.searchRoute ?? "search_tools";
354
+ }
355
+
356
+ /**
357
+ * Send a caller back to discovery through the surface it can actually reach.
358
+ * Both variants carry the same scoping arguments because `connecta.search`
359
+ * takes the same ones `search_tools` does; only the key naming the callable
360
+ * differs, the way the ambiguous-alias record already names a function.
361
+ *
362
+ * Not private: `InvocationService` builds the same class of record when a
363
+ * call fails schema validation, and it is this catalog's route that decides
364
+ * which key that record carries. Duplicating the branch there would let the
365
+ * two drift.
366
+ */
367
+ searchRecovery(
368
+ args: { query: string; connector?: string },
369
+ purpose: string,
370
+ ): NonNullable<CallErrorDetails["nextAction"]> {
371
+ const searchArgs = {
372
+ query: args.query,
373
+ ...(args.connector !== undefined ? { connector: args.connector } : {}),
374
+ includeSchemas: "compact" as const,
375
+ };
376
+ return this.searchRoute === "connecta.search"
377
+ ? { function: "connecta.search", arguments: searchArgs, purpose }
378
+ : { tool: "search_tools", arguments: searchArgs, purpose };
305
379
  }
306
380
 
307
381
  async loadConnector(
@@ -345,10 +419,16 @@ export class CatalogService {
345
419
  if (!resolved) {
346
420
  return {
347
421
  ok: false,
348
- error: framingError(
349
- "unknown_address",
350
- `Unknown address "${address}"`,
351
- ),
422
+ error: {
423
+ ...framingError(
424
+ "unknown_address",
425
+ `Unknown address "${boundedEchoText(address)}"`,
426
+ ),
427
+ nextAction: this.searchRecovery(
428
+ { query: recoveryQuery(address) },
429
+ "Find the configured canonical address before retrying.",
430
+ ),
431
+ },
352
432
  catalogMs: 0,
353
433
  };
354
434
  }
@@ -370,10 +450,19 @@ export class CatalogService {
370
450
  if (!definition) {
371
451
  return {
372
452
  ok: false,
373
- error: framingError(
374
- "unknown_tool",
375
- `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
376
- ),
453
+ error: {
454
+ ...framingError(
455
+ "unknown_tool",
456
+ `Unknown tool "${boundedEchoText(resolved.toolName)}" on connector "${resolved.connector.id}"`,
457
+ ),
458
+ nextAction: this.searchRecovery(
459
+ {
460
+ query: recoveryQuery(resolved.toolName),
461
+ connector: resolved.connector.id,
462
+ },
463
+ "Find the connector's current canonical tool address.",
464
+ ),
465
+ },
377
466
  catalogMs: Date.now() - started,
378
467
  connector: resolved.connector,
379
468
  toolName: resolved.toolName,
@@ -405,10 +494,16 @@ export class CatalogService {
405
494
  if (!connector) {
406
495
  return {
407
496
  ok: false,
408
- error: framingError(
409
- "unknown_address",
410
- `Unknown address "${connectorId}.${alias}"`,
411
- ),
497
+ error: {
498
+ ...framingError(
499
+ "unknown_address",
500
+ `Unknown address "${boundedEchoText(`${connectorId}.${alias}`)}"`,
501
+ ),
502
+ nextAction: this.searchRecovery(
503
+ { query: recoveryQuery(alias) },
504
+ "Find the configured canonical address before retrying.",
505
+ ),
506
+ },
412
507
  catalogMs: 0,
413
508
  };
414
509
  }
@@ -432,10 +527,16 @@ export class CatalogService {
432
527
  if (!definition) {
433
528
  return {
434
529
  ok: false,
435
- error: framingError(
436
- "unknown_tool",
437
- `Unknown tool "${alias}" on connector "${connector.id}"`,
438
- ),
530
+ error: {
531
+ ...framingError(
532
+ "unknown_tool",
533
+ `Unknown tool "${boundedEchoText(alias)}" on connector "${connector.id}"`,
534
+ ),
535
+ nextAction: this.searchRecovery(
536
+ { query: recoveryQuery(alias), connector: connector.id },
537
+ "Find the connector's current canonical tool address.",
538
+ ),
539
+ },
439
540
  catalogMs: Date.now() - started,
440
541
  connector,
441
542
  toolName: alias,
@@ -449,8 +550,16 @@ export class CatalogService {
449
550
  ok: false,
450
551
  error: {
451
552
  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.`,
553
+ 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
554
  retryable: false,
555
+ nextAction: {
556
+ function: "connecta.call",
557
+ addresses: [definition, ...collisions].map(
558
+ (tool) => `${connector.id}.${tool.name}`,
559
+ ),
560
+ purpose:
561
+ "Choose the intended canonical address and call it with the original arguments.",
562
+ },
454
563
  },
455
564
  catalogMs: Date.now() - started,
456
565
  connector,
@@ -486,6 +595,10 @@ export class CatalogService {
486
595
  connectors,
487
596
  this.concurrency,
488
597
  (connector) =>
598
+ // Unlike the describe path, this label never reaches a caller: search
599
+ // only counts rejected catalogs (`unavailableCatalogs` below) and
600
+ // renders its own guidance, so the folded name here stays internal and
601
+ // needs no surface awareness.
489
602
  this.loadForDiscovery(
490
603
  connector.id,
491
604
  `search_tools probe of "${connector.id}"`,
@@ -704,7 +817,7 @@ export class CatalogService {
704
817
  }
705
818
 
706
819
  async describe(args: CatalogDescribeArgs): Promise<CatalogDescription[]> {
707
- const addresses = discoveryAddresses(args);
820
+ const addresses = discoveryAddresses(args, this.describeRoute);
708
821
  const format = args.format ?? "compact";
709
822
  const resolved = addresses.map((rawAddress) => {
710
823
  const address = String(rawAddress);
@@ -721,7 +834,7 @@ export class CatalogService {
721
834
  connectorIds,
722
835
  this.concurrency,
723
836
  (id) =>
724
- this.loadForDiscovery(id, `describe_tools probe of "${id}"`),
837
+ this.loadForDiscovery(id, `${this.describeRoute} probe of "${id}"`),
725
838
  );
726
839
  const catalogs = new Map<string, ToolDef[] | Error>();
727
840
  loaded.forEach((result, index) => {
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 {
@@ -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;