@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
@@ -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",
@@ -71,23 +95,52 @@ function discoverySearchLimit(value: unknown): number {
71
95
  return value;
72
96
  }
73
97
 
74
- /** Validate the raw list so duplicate addresses consume the same bound. */
75
- function discoveryAddresses(value: unknown): unknown[] {
98
+ /** Normalize the single-address convenience form, then validate the bounded list. */
99
+ function discoveryAddresses(
100
+ args: CatalogDescribeArgs,
101
+ describeRoute: DescribeRoute,
102
+ ): unknown[] {
103
+ if (args.address !== undefined && args.addresses !== undefined) {
104
+ throw new DiscoveryPolicyError(
105
+ "invalid_args",
106
+ "describe takes either address or addresses, not both.",
107
+ );
108
+ }
109
+ const value =
110
+ args.address !== undefined
111
+ ? typeof args.address === "string"
112
+ ? [args.address]
113
+ : undefined
114
+ : args.addresses;
76
115
  if (!Array.isArray(value)) {
77
116
  throw new DiscoveryPolicyError(
78
117
  "invalid_args",
79
- 'describe takes { addresses: ["<connectorId>.<toolName>", ...] }; addresses must be an array.',
118
+ 'describe takes { address: "<connectorId>.<toolName>" } or { addresses: ["<connectorId>.<toolName>", ...] }.',
80
119
  );
81
120
  }
82
121
  if (value.length > MAX_DESCRIBE_ADDRESSES) {
83
122
  throw new DiscoveryPolicyError(
84
123
  "invalid_args",
85
- `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.`,
86
125
  );
87
126
  }
88
127
  return value;
89
128
  }
90
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
+
91
144
  /** Serialize once and count the exact bytes the MCP adapter would emit. */
92
145
  export function boundedDiscoveryText(value: unknown, hint: string): string {
93
146
  const text = JSON.stringify(value);
@@ -150,6 +203,7 @@ function toolsForSafety(
150
203
  }
151
204
 
152
205
  export interface CatalogDescribeArgs {
206
+ address?: unknown;
153
207
  addresses?: unknown;
154
208
  format?: "compact" | "json";
155
209
  fullDescriptions?: boolean;
@@ -273,6 +327,8 @@ export class CatalogService {
273
327
  readonly requestScope: object;
274
328
  private readonly probeTimeoutMs: number;
275
329
  private readonly concurrency: number;
330
+ private readonly describeRoute: DescribeRoute;
331
+ private readonly searchRoute: SearchRoute;
276
332
  private readonly loaded = new Map<string, ToolDef[]>();
277
333
  private readonly loading = new Map<string, Promise<ToolDef[]>>();
278
334
 
@@ -283,12 +339,43 @@ export class CatalogService {
283
339
  requestScope?: object;
284
340
  probeTimeoutMs?: number;
285
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;
286
346
  } = {},
287
347
  ) {
288
348
  this.requestScope = options.requestScope ?? {};
289
349
  this.probeTimeoutMs =
290
350
  normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
291
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 };
292
379
  }
293
380
 
294
381
  async loadConnector(
@@ -332,10 +419,16 @@ export class CatalogService {
332
419
  if (!resolved) {
333
420
  return {
334
421
  ok: false,
335
- error: framingError(
336
- "unknown_address",
337
- `Unknown address "${address}"`,
338
- ),
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
+ },
339
432
  catalogMs: 0,
340
433
  };
341
434
  }
@@ -357,10 +450,19 @@ export class CatalogService {
357
450
  if (!definition) {
358
451
  return {
359
452
  ok: false,
360
- error: framingError(
361
- "unknown_tool",
362
- `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
363
- ),
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
+ },
364
466
  catalogMs: Date.now() - started,
365
467
  connector: resolved.connector,
366
468
  toolName: resolved.toolName,
@@ -392,10 +494,16 @@ export class CatalogService {
392
494
  if (!connector) {
393
495
  return {
394
496
  ok: false,
395
- error: framingError(
396
- "unknown_address",
397
- `Unknown address "${connectorId}.${alias}"`,
398
- ),
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
+ },
399
507
  catalogMs: 0,
400
508
  };
401
509
  }
@@ -419,10 +527,16 @@ export class CatalogService {
419
527
  if (!definition) {
420
528
  return {
421
529
  ok: false,
422
- error: framingError(
423
- "unknown_tool",
424
- `Unknown tool "${alias}" on connector "${connector.id}"`,
425
- ),
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
+ },
426
540
  catalogMs: Date.now() - started,
427
541
  connector,
428
542
  toolName: alias,
@@ -436,8 +550,16 @@ export class CatalogService {
436
550
  ok: false,
437
551
  error: {
438
552
  code: "ambiguous_tool_alias",
439
- 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.`,
440
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
+ },
441
563
  },
442
564
  catalogMs: Date.now() - started,
443
565
  connector,
@@ -473,6 +595,10 @@ export class CatalogService {
473
595
  connectors,
474
596
  this.concurrency,
475
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.
476
602
  this.loadForDiscovery(
477
603
  connector.id,
478
604
  `search_tools probe of "${connector.id}"`,
@@ -542,6 +668,10 @@ export class CatalogService {
542
668
  args.includeSchemas && match.tool.outputSchema
543
669
  ? renderSearchSchema(match.tool.outputSchema, args.includeSchemas)
544
670
  : undefined;
671
+ const schemaKeys =
672
+ args.includeSchemas && args.includeSchemaKeys
673
+ ? schemaKeyMetadata(input, match.tool.outputSchema)
674
+ : undefined;
545
675
  const description = summarizeDiscoveryDescription(
546
676
  match.tool.description,
547
677
  args.fullDescriptions === true,
@@ -571,8 +701,18 @@ export class CatalogService {
571
701
  ...(renderedOutput?.truncated
572
702
  ? { outputSchemaTruncated: true as const }
573
703
  : {}),
574
- ...(args.includeSchemas && args.includeSchemaKeys
575
- ? schemaKeyMetadata(input, match.tool.outputSchema)
704
+ ...(schemaKeys && !renderedInput?.truncated
705
+ ? {
706
+ ...(schemaKeys.inputKeys
707
+ ? { inputKeys: schemaKeys.inputKeys }
708
+ : {}),
709
+ ...(schemaKeys.requiredInputKeys
710
+ ? { requiredInputKeys: schemaKeys.requiredInputKeys }
711
+ : {}),
712
+ }
713
+ : {}),
714
+ ...(schemaKeys?.outputKeys && !renderedOutput?.truncated
715
+ ? { outputKeys: schemaKeys.outputKeys }
576
716
  : {}),
577
717
  ...(match.tool.annotations
578
718
  ? { annotations: match.tool.annotations }
@@ -677,7 +817,7 @@ export class CatalogService {
677
817
  }
678
818
 
679
819
  async describe(args: CatalogDescribeArgs): Promise<CatalogDescription[]> {
680
- const addresses = discoveryAddresses(args.addresses);
820
+ const addresses = discoveryAddresses(args, this.describeRoute);
681
821
  const format = args.format ?? "compact";
682
822
  const resolved = addresses.map((rawAddress) => {
683
823
  const address = String(rawAddress);
@@ -694,7 +834,7 @@ export class CatalogService {
694
834
  connectorIds,
695
835
  this.concurrency,
696
836
  (id) =>
697
- this.loadForDiscovery(id, `describe_tools probe of "${id}"`),
837
+ this.loadForDiscovery(id, `${this.describeRoute} probe of "${id}"`),
698
838
  );
699
839
  const catalogs = new Map<string, ToolDef[] | Error>();
700
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;