@agent-native/core 0.224.0 → 0.225.0-nightly-20261105110131

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.
package/dist/action.d.ts CHANGED
@@ -488,6 +488,13 @@ interface DefineActionWithSchema<TSchema extends StandardSchemaV1, TReturn = any
488
488
  mcpTool?: boolean;
489
489
  mcpAnnotations?: ActionMcpToolAnnotations;
490
490
  deferLoading?: boolean;
491
+ /** Words and short phrases a user says for this action that its name and
492
+ * description do not: "auto-archive rule", "canned reply", "reschedule".
493
+ * `tool-search` ranks them just below the name. They are never shown to a
494
+ * model, so they add no context cost; keep them factual, not a list of
495
+ * every request the action might serve. At most 12, each up to 60
496
+ * characters. */
497
+ searchHints?: readonly string[];
491
498
  eagerInputStreaming?: boolean;
492
499
  /** An empty list on this action sets an option and erases nothing saved, so
493
500
  * a call that pads unused fields with `null` and `[]` can run as sent. Leave
@@ -600,6 +607,8 @@ interface DefineActionWithParams<TParams extends Record<string, ParameterSchema>
600
607
  mcpTool?: boolean;
601
608
  mcpAnnotations?: ActionMcpToolAnnotations;
602
609
  deferLoading?: boolean;
610
+ /** See `searchHints` on the schema form above. */
611
+ searchHints?: readonly string[];
603
612
  eagerInputStreaming?: boolean;
604
613
  emptyListsEraseNothing?: boolean;
605
614
  readOnly?: boolean;
@@ -643,6 +652,7 @@ export interface ActionDefinition<TInput, TReturn> {
643
652
  readonly mcpTool?: boolean;
644
653
  readonly mcpAnnotations?: ActionMcpToolAnnotations;
645
654
  readonly deferLoading?: boolean;
655
+ readonly searchHints?: readonly string[];
646
656
  readonly eagerInputStreaming?: boolean;
647
657
  readonly emptyListsEraseNothing?: boolean;
648
658
  readonly readOnly?: boolean;
@@ -679,6 +689,24 @@ export interface ActionDefinition<TInput, TReturn> {
679
689
  readonly audit?: ActionAuditConfig;
680
690
  readonly access?: ActionAccessConfig;
681
691
  }
692
+ /**
693
+ * An action's own definition is invalid. Loaders never skip such an action or
694
+ * fall back to wrapping it as a CLI script: the app fails to load, naming the
695
+ * action, until the definition is fixed.
696
+ */
697
+ export declare class ActionDefinitionError extends TypeError {
698
+ readonly actionDefinitionError = true;
699
+ readonly actionName: string | undefined;
700
+ constructor(message: string, actionName?: string);
701
+ }
702
+ export declare function isActionDefinitionError(error: unknown): error is ActionDefinitionError;
703
+ /**
704
+ * The same error naming `actionName`, for a loader that knows the name the
705
+ * definition itself could not report.
706
+ */
707
+ export declare function namedActionDefinitionError(error: ActionDefinitionError, actionName: string): ActionDefinitionError;
708
+ /** Throws unless `value` is absent or a valid hint list. */
709
+ export declare function validateSearchHints(value: unknown, actionName?: string): readonly string[] | undefined;
682
710
  export declare function defineAction<TSchema extends StandardSchemaV1, TReturn>(options: DefineActionWithSchema<TSchema, TReturn, undefined>): ActionDefinition<StandardSchemaV1.InferInput<TSchema>, TReturn>;
683
711
  export declare function defineAction<TParams extends Record<string, ParameterSchema> | undefined, TReturn>(options: DefineActionWithParams<TParams, TReturn, undefined>): ActionDefinition<InferParams<TParams>, TReturn>;
684
712
  export declare function defineAction<TParams extends Record<string, ParameterSchema> | undefined, TOutputSchema extends StandardSchemaV1, TStrategy extends ActionOutputErrorStrategy = "strict">(options: DefineActionWithParams<TParams, StandardSchemaV1.InferInput<TOutputSchema>, TOutputSchema, TStrategy> & {
package/dist/action.js CHANGED
@@ -472,6 +472,51 @@ function withCommittedEffect(error) {
472
472
  export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui";
473
473
  export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
474
474
  export const MCP_APP_RESOURCE_URI_META_KEY = "ui/resourceUri";
475
+ const MAX_SEARCH_HINTS = 12;
476
+ const MAX_SEARCH_HINT_CHARS = 60;
477
+ /**
478
+ * An action's own definition is invalid. Loaders never skip such an action or
479
+ * fall back to wrapping it as a CLI script: the app fails to load, naming the
480
+ * action, until the definition is fixed.
481
+ */
482
+ export class ActionDefinitionError extends TypeError {
483
+ actionDefinitionError = true;
484
+ actionName;
485
+ constructor(message, actionName) {
486
+ super(actionName ? `Action "${actionName}": ${message}` : message);
487
+ this.name = "ActionDefinitionError";
488
+ this.actionName = actionName;
489
+ }
490
+ }
491
+ export function isActionDefinitionError(error) {
492
+ return (error instanceof ActionDefinitionError ||
493
+ (!!error &&
494
+ typeof error === "object" &&
495
+ error.actionDefinitionError ===
496
+ true));
497
+ }
498
+ /**
499
+ * The same error naming `actionName`, for a loader that knows the name the
500
+ * definition itself could not report.
501
+ */
502
+ export function namedActionDefinitionError(error, actionName) {
503
+ return error.actionName
504
+ ? error
505
+ : new ActionDefinitionError(error.message, actionName);
506
+ }
507
+ /** Throws unless `value` is absent or a valid hint list. */
508
+ export function validateSearchHints(value, actionName) {
509
+ if (value === undefined)
510
+ return undefined;
511
+ if (!Array.isArray(value) ||
512
+ value.length > MAX_SEARCH_HINTS ||
513
+ value.some((hint) => typeof hint !== "string" ||
514
+ hint.trim().length === 0 ||
515
+ hint.length > MAX_SEARCH_HINT_CHARS)) {
516
+ throw new ActionDefinitionError(`searchHints must be at most ${MAX_SEARCH_HINTS} non-empty strings of up to ${MAX_SEARCH_HINT_CHARS} characters.`, actionName);
517
+ }
518
+ return Object.freeze([...value]);
519
+ }
475
520
  export function defineAction(options) {
476
521
  const hasSchema = options.schema && "~standard" in options.schema;
477
522
  const tool = {
@@ -716,6 +761,7 @@ export function defineAction(options) {
716
761
  const deferLoading = typeof options.deferLoading === "boolean"
717
762
  ? options.deferLoading
718
763
  : undefined;
764
+ const searchHints = validateSearchHints(options.searchHints);
719
765
  const parallelSafe = typeof options.parallelSafe === "boolean"
720
766
  ? options.parallelSafe
721
767
  : undefined;
@@ -783,6 +829,7 @@ export function defineAction(options) {
783
829
  ...(typeof mcpTool === "boolean" ? { mcpTool } : {}),
784
830
  ...(mcpAnnotations ? { mcpAnnotations } : {}),
785
831
  ...(typeof deferLoading === "boolean" ? { deferLoading } : {}),
832
+ ...(searchHints ? { searchHints } : {}),
786
833
  ...(typeof options.eagerInputStreaming === "boolean"
787
834
  ? { eagerInputStreaming: options.eagerInputStreaming }
788
835
  : {}),
@@ -193,6 +193,8 @@ export interface ActionEntry {
193
193
  * decision, so they never count as the action declaring its hints. */
194
194
  mcpAnnotationDefaults?: import("../action.js").ActionMcpToolAnnotations;
195
195
  deferLoading?: boolean;
196
+ /** See `searchHints` in `defineAction`. Read only by `tool-search`. */
197
+ searchHints?: readonly string[];
196
198
  eagerInputStreaming?: boolean;
197
199
  /** See `emptyListsEraseNothing` in `defineAction`. */
198
200
  emptyListsEraseNothing?: boolean;
@@ -23,6 +23,8 @@ type ToolSearchResult = {
23
23
  score: number;
24
24
  callable: boolean;
25
25
  planAvailability: "read" | "conditional" | "act-only";
26
+ /** Present on a tool that deletes or cancels; absent on every other. */
27
+ destructive?: true;
26
28
  parameters: ToolParameterSummary[];
27
29
  };
28
30
  type ToolSearchOptions = {
@@ -1,7 +1,7 @@
1
1
  import { isActionHiddenFromEveryAgentSurface } from "../action.js";
2
2
  import { parseMcpToolName } from "../mcp-client/manager.js";
3
3
  import { isMcpToolAllowedForRequest } from "../mcp-client/visibility.js";
4
- import { frameworkMcpTool } from "../mcp/framework-tools.js";
4
+ import { frameworkMcpTool, isFrameworkMcpTool, } from "../mcp/framework-tools.js";
5
5
  import { getRequestRunContext } from "../server/request-context.js";
6
6
  import { clipHead } from "./clip-text.js";
7
7
  export const TOOL_SEARCH_ACTION_NAME = "tool-search";
@@ -23,19 +23,19 @@ export function createToolSearchEntry(getRegistry, options = {}) {
23
23
  const defaultLimit = Math.max(1, Math.min(options.defaultLimit ?? DEFAULT_LIMIT, maxLimit));
24
24
  return frameworkMcpTool({
25
25
  tool: {
26
- description: "Find actions and connected MCP tools by capability or exact name. The system prompt's Tool index lists every deferred tool name grouped by topic; loading several exact names in one call is the fastest path. A targeted search returns concise details and adds matches allowed in the current mode, with their full schemas, to the NEXT model step, not this one, so request every tool you need in one call (`queries` and/or `names`) and call them on the next step. If every match is already callable, call it directly instead of searching again. Omit query for a bounded alphabetical menu; menu entries are informational until you search for a capability or name.",
26
+ description: "Find actions and connected MCP tools by what they do or by exact name. The system prompt's Tool index lists every deferred tool name grouped by topic; loading several exact names in one call is the fastest path. A targeted search returns concise details and adds matches allowed in the current mode, with their full schemas, to the NEXT model step, not this one, so request every tool you need in one call (`queries` and/or `names`) and call them on the next step. If every match is already callable, call it directly instead of searching again. Results rank by words, not intent: before calling one marked `destructive`, confirm the user wants that change. Omit query for a bounded alphabetical menu; menu entries are informational until you search for a capability or name.",
27
27
  parameters: {
28
28
  type: "object",
29
29
  properties: {
30
30
  query: {
31
31
  type: "string",
32
- description: "Capability or exact tool name to find. A targeted search adds matches allowed in the current mode, with their full schemas, to the next model step. Omit for a bounded alphabetical inventory.",
32
+ description: 'The task in a few plain words ("edit auto-archive rules") or an exact tool name; the user\'s own words work. Omit for a bounded alphabetical inventory.',
33
33
  },
34
34
  queries: {
35
35
  type: "array",
36
36
  items: { type: "string" },
37
37
  maxItems: MAX_QUERIES,
38
- description: `Up to ${MAX_QUERIES} capability searches run together; all matches load on the next step.`,
38
+ description: `Up to ${MAX_QUERIES} searches run together, one per task step; all matches load on the next step.`,
39
39
  },
40
40
  names: {
41
41
  type: "array",
@@ -195,6 +195,7 @@ function searchOnce(registry, target, args, options) {
195
195
  score: 0,
196
196
  callable: registry[name]?.allowInPlanMode !== false,
197
197
  planAvailability: getPlanAvailability(name, registry[name]),
198
+ ...destructiveMark(name, registry[name]),
198
199
  description: "Already returned by an earlier identical tool-search call.",
199
200
  parameters: [],
200
201
  })),
@@ -233,6 +234,7 @@ function searchOnce(registry, target, args, options) {
233
234
  score: 0,
234
235
  callable,
235
236
  planAvailability,
237
+ ...destructiveMark(name, entry),
236
238
  parameters: [],
237
239
  });
238
240
  continue;
@@ -246,6 +248,7 @@ function searchOnce(registry, target, args, options) {
246
248
  score: 0,
247
249
  callable,
248
250
  planAvailability,
251
+ ...destructiveMark(name, entry),
249
252
  parameters: parameters
250
253
  .slice(0, MAX_PARAMETER_COUNT)
251
254
  .map(boundParameterSummary),
@@ -258,7 +261,14 @@ function searchOnce(registry, target, args, options) {
258
261
  }
259
262
  rankable.push({
260
263
  candidate,
261
- terms: searchDocumentTerms({ name, source, description, parameters }),
264
+ prior: isFrameworkMcpTool(entry) ? FRAMEWORK_TOOL_PRIOR : 1,
265
+ fields: searchDocumentFields({
266
+ name,
267
+ source,
268
+ hints: entry.searchHints,
269
+ description,
270
+ parameters,
271
+ }),
262
272
  });
263
273
  }
264
274
  if (!listAll && !exactNames) {
@@ -519,67 +529,230 @@ function summarizeType(value) {
519
529
  const BM25_K1 = 1.2;
520
530
  const BM25_B = 0.75;
521
531
  const NAME_PHRASE_BONUS = 5;
522
- // Weighting by repetition keeps one BM25 pass: a name hit counts three times a
523
- // description hit, an MCP server hit twice.
524
- function searchDocumentTerms(doc) {
525
- const name = searchTerms(doc.name);
526
- const source = searchTerms(doc.source ?? "");
527
- return [
528
- ...name,
529
- ...name,
530
- ...name,
531
- ...source,
532
- ...source,
533
- ...searchTerms(doc.description),
534
- ...searchTerms(doc.parameters
535
- .map((p) => `${p.name} ${p.type ?? ""} ${p.description ?? ""}`)
536
- .join(" ")),
532
+ const SYNONYM_WEIGHT = 0.5;
533
+ // BM25F: each field is length-normalised on its own, then weighted, so a long
534
+ // description cannot drown a one-word name match. Search hints are words users
535
+ // say for the action, so they rank just below the name; the first sentence of
536
+ // a description says what the tool is for and outranks the caveats after it.
537
+ const FIELD_WEIGHTS = {
538
+ name: 5,
539
+ hints: 4,
540
+ source: 2,
541
+ summary: 2,
542
+ details: 1,
543
+ parameters: 0.5,
544
+ };
545
+ const SEARCH_FIELDS = Object.keys(FIELD_WEIGHTS);
546
+ // Framework tools (sharing, review comments, usage, settings) ship in every
547
+ // app and share its vocabulary: in Mail, "email" also names transactional
548
+ // email logs. A request made in an app is usually about that app's own work,
549
+ // so on similar text the app's actions rank first. Exact names still win.
550
+ const FRAMEWORK_TOOL_PRIOR = 0.8;
551
+ // Stemming would fold these nouns into an unrelated word ("news" into "new",
552
+ // which then expands to the create synonyms; "setting" into "set"), so they
553
+ // are indexed as themselves. Declared before the stemmed set below, which
554
+ // calls `stem` as the module loads.
555
+ const PROTECTED_TERMS = new Map([
556
+ ["news", "news"],
557
+ ["setting", "setting"],
558
+ ["settings", "setting"],
559
+ ["recording", "recording"],
560
+ ["recordings", "recording"],
561
+ ["mapping", "mapping"],
562
+ ["mappings", "mapping"],
563
+ ["heading", "heading"],
564
+ ["headings", "heading"],
565
+ ]);
566
+ // Verbs that lose data. "Remove" and "clear" also name reversible edits
567
+ // (removing a tag, clearing a filter), so they count only through a declared
568
+ // `destructiveHint`, as does "permanently remove" spelled any other way.
569
+ // Verbs that lose data or undo something another person relies on. "Remove"
570
+ // and "clear" also name reversible edits (removing a tag, clearing a filter),
571
+ // so they count only through a declared `destructiveHint`.
572
+ const DESTRUCTIVE_VERBS = new Set(["delete", "destroy", "trash", "purge", "cancel"].map(stem));
573
+ /** "delete", "deleteNode", "delete-node", "delete_node": the value leads with one. */
574
+ function leadsWithDestructiveVerb(value) {
575
+ const verb = searchTerms(value).at(0);
576
+ return verb !== undefined && DESTRUCTIVE_VERBS.has(verb);
577
+ }
578
+ /**
579
+ * The `destructive` mark every result carries, on every path that builds one.
580
+ * Ranking ignores it: whether the user wants the change ("delete it" or "don't
581
+ * delete it") is the agent's call, so a search finds the tool either way and
582
+ * the mark tells the agent to check.
583
+ *
584
+ * Declared annotations decide, `destructiveHint: false` included. Without
585
+ * them, a read-only tool is never destructive (a `status: "cancelled"` filter
586
+ * on a list is not a write), and any other tool is when an `enum` or `const`
587
+ * value anywhere in its input schema leads with a destructive verb
588
+ * (`operation: "delete"`, `op: "delete"`, `kind: "deleteNode"`) or its own
589
+ * name does, since most delete actions declare no annotations.
590
+ */
591
+ function destructiveMark(name, entry) {
592
+ if (!entry)
593
+ return {};
594
+ if (entry.mcpAnnotations) {
595
+ return entry.mcpAnnotations.destructiveHint ? { destructive: true } : {};
596
+ }
597
+ if (entry.readOnly === true)
598
+ return {};
599
+ return schemaHasDestructiveValue(entry.tool.parameters, 0) ||
600
+ leadsWithDestructiveVerb(parseMcpToolName(name)?.toolName ?? name)
601
+ ? { destructive: true }
602
+ : {};
603
+ }
604
+ // Deep enough for a list of tagged operations (properties → items → oneOf →
605
+ // properties → op), shallow enough that a huge or cyclic schema cannot stall a
606
+ // search.
607
+ const MAX_SCHEMA_DEPTH = 6;
608
+ function schemaHasDestructiveValue(schema, depth) {
609
+ if (depth > MAX_SCHEMA_DEPTH || !schema || typeof schema !== "object") {
610
+ return false;
611
+ }
612
+ const node = schema;
613
+ const literals = [
614
+ ...(Array.isArray(node.enum) ? node.enum : []),
615
+ node.const,
616
+ ].filter((value) => typeof value === "string");
617
+ if (literals.some(leadsWithDestructiveVerb))
618
+ return true;
619
+ const children = [
620
+ ...(node.properties && typeof node.properties === "object"
621
+ ? Object.values(node.properties)
622
+ : []),
623
+ ...(Array.isArray(node.items) ? node.items : [node.items]),
624
+ ...[node.oneOf, node.anyOf, node.allOf].flatMap((list) => Array.isArray(list) ? list : []),
537
625
  ];
626
+ return children.some((child) => schemaHasDestructiveValue(child, depth + 1));
627
+ }
628
+ function fieldTerms(value) {
629
+ const terms = searchTerms(value);
630
+ const counts = new Map();
631
+ for (const term of terms)
632
+ counts.set(term, (counts.get(term) ?? 0) + 1);
633
+ return { counts, length: terms.length };
634
+ }
635
+ function searchDocumentFields(doc) {
636
+ const sentenceEnd = doc.description.search(/[.!?](\s|$)/);
637
+ const summaryEnd = sentenceEnd < 0 ? doc.description.length : sentenceEnd + 1;
638
+ return {
639
+ name: fieldTerms(doc.name),
640
+ hints: fieldTerms((doc.hints ?? []).join(" ")),
641
+ source: fieldTerms(doc.source ?? ""),
642
+ summary: fieldTerms(doc.description.slice(0, summaryEnd)),
643
+ details: fieldTerms(doc.description.slice(summaryEnd)),
644
+ parameters: fieldTerms(doc.parameters.map((p) => `${p.name} ${p.description ?? ""}`).join(" ")),
645
+ };
646
+ }
647
+ // Common verbs and nouns users say in place of the words tool names use. They
648
+ // count for half of a literal match, so the user's own word still wins.
649
+ const SYNONYM_GROUPS = [
650
+ ["create", "make", "new", "add"],
651
+ ["delete", "remove", "trash", "erase", "clear"],
652
+ ["update", "change", "edit", "modify", "rename", "adjust"],
653
+ ["search", "find", "look", "lookup"],
654
+ ["list", "show"],
655
+ ["view", "see", "open"],
656
+ ["restore", "undo", "revert", "recover"],
657
+ ["export", "download"],
658
+ ["duplicate", "copy", "clone"],
659
+ ["reply", "respond", "answer"],
660
+ ["image", "picture", "photo"],
661
+ ["document", "doc", "page"],
662
+ ["email", "mail", "message"],
663
+ ["setting", "preference"],
664
+ ];
665
+ const SYNONYMS = (() => {
666
+ const map = new Map();
667
+ for (const group of SYNONYM_GROUPS) {
668
+ const stems = group.map(stem);
669
+ for (const word of stems) {
670
+ const others = stems.filter((other) => other !== word);
671
+ map.set(word, [...(map.get(word) ?? []), ...others]);
672
+ }
673
+ }
674
+ return map;
675
+ })();
676
+ function weightedQueryTerms(queryTokens) {
677
+ const weights = new Map(queryTokens.map((term) => [term, 1]));
678
+ for (const term of queryTokens) {
679
+ for (const synonym of SYNONYMS.get(term) ?? []) {
680
+ if (!weights.has(synonym))
681
+ weights.set(synonym, SYNONYM_WEIGHT);
682
+ }
683
+ }
684
+ return weights;
538
685
  }
539
686
  // Corpus statistics come from every tool that survived this call's filters, so
540
687
  // a word most tools share ("get", "list", "app") ranks below a rare one.
541
688
  function rankByBm25(query, queryTokens, docs) {
542
689
  if (queryTokens.length === 0 || docs.length === 0)
543
690
  return [];
544
- const counts = docs.map((doc) => {
545
- const termCounts = new Map();
546
- for (const term of doc.terms) {
547
- termCounts.set(term, (termCounts.get(term) ?? 0) + 1);
548
- }
549
- return termCounts;
550
- });
551
- const averageLength = docs.reduce((sum, doc) => sum + doc.terms.length, 0) / docs.length || 1;
552
- const idf = new Map(queryTokens.map((term) => {
553
- const frequency = counts.filter((c) => c.has(term)).length;
691
+ const queryTerms = weightedQueryTerms(queryTokens);
692
+ // Averaged over the tools that fill the field: most tools have no search
693
+ // hints, and counting them would make every hint list look too long.
694
+ const averageLength = Object.fromEntries(SEARCH_FIELDS.map((field) => {
695
+ const lengths = docs
696
+ .map((doc) => doc.fields[field].length)
697
+ .filter((length) => length > 0);
698
+ return [
699
+ field,
700
+ lengths.reduce((sum, length) => sum + length, 0) / lengths.length || 1,
701
+ ];
702
+ }));
703
+ const idf = new Map([...queryTerms.keys()].map((term) => {
704
+ const frequency = docs.filter((doc) => SEARCH_FIELDS.some((field) => doc.fields[field].counts.has(term))).length;
554
705
  return [
555
706
  term,
556
707
  Math.log(1 + (docs.length - frequency + 0.5) / (frequency + 0.5)),
557
708
  ];
558
709
  }));
559
710
  const wholeQuery = query.trim().toLowerCase();
560
- const phrase = queryTokens.length > 1 ? queryTokens.join(" ") : "";
561
- return docs.flatMap((doc, index) => {
711
+ return docs.flatMap((doc) => {
562
712
  const { candidate } = doc;
563
713
  if (candidate.name.toLowerCase() === wholeQuery) {
564
714
  return [{ ...candidate, score: EXACT_NAME_SCORE }];
565
715
  }
566
- const norm = BM25_K1 * (1 - BM25_B + (BM25_B * doc.terms.length) / averageLength);
567
716
  let score = 0;
568
- for (const term of queryTokens) {
569
- const count = counts[index].get(term);
570
- if (!count)
717
+ for (const [term, queryWeight] of queryTerms) {
718
+ let weightedFrequency = 0;
719
+ for (const field of SEARCH_FIELDS) {
720
+ const count = doc.fields[field].counts.get(term);
721
+ if (!count)
722
+ continue;
723
+ weightedFrequency +=
724
+ (FIELD_WEIGHTS[field] * count) /
725
+ (1 -
726
+ BM25_B +
727
+ (BM25_B * doc.fields[field].length) / averageLength[field]);
728
+ }
729
+ if (weightedFrequency === 0)
571
730
  continue;
572
731
  score +=
573
- (idf.get(term) ?? 0) * ((count * (BM25_K1 + 1)) / (count + norm));
732
+ queryWeight *
733
+ (idf.get(term) ?? 0) *
734
+ ((weightedFrequency * (BM25_K1 + 1)) / (weightedFrequency + BM25_K1));
574
735
  }
575
736
  if (score <= 0)
576
737
  return [];
577
- if (phrase && tokenize(candidate.name).join(" ").includes(phrase)) {
738
+ if (queryTokens.length > 1 &&
739
+ containsTokenRun(tokenize(candidate.name), queryTokens)) {
578
740
  score += NAME_PHRASE_BONUS;
579
741
  }
742
+ score *= doc.prior;
580
743
  return [{ ...candidate, score: Math.round(score * 100) / 100 }];
581
744
  });
582
745
  }
746
+ // Whole tokens in order, so "view email" never matches inside "preview-email"
747
+ // and "archive email" never matches inside "unarchive-email".
748
+ function containsTokenRun(tokens, run) {
749
+ for (let start = 0; start + run.length <= tokens.length; start++) {
750
+ if (run.every((token, offset) => tokens[start + offset] === token)) {
751
+ return true;
752
+ }
753
+ }
754
+ return false;
755
+ }
583
756
  // Suggestions are never loaded, so a query with no exact term still points the
584
757
  // model at real names instead of an empty result.
585
758
  function suggestCloseTools(docs, queryTokens) {
@@ -589,8 +762,13 @@ function suggestCloseTools(docs, queryTokens) {
589
762
  if (prefixes.length === 0)
590
763
  return [];
591
764
  return docs
592
- .map(({ candidate }) => {
593
- const terms = tokenize(`${candidate.name} ${candidate.description}`);
765
+ .map(({ candidate, fields }) => {
766
+ const terms = [
767
+ ...fields.name.counts.keys(),
768
+ ...fields.hints.counts.keys(),
769
+ ...fields.summary.counts.keys(),
770
+ ...fields.details.counts.keys(),
771
+ ];
594
772
  const overlap = prefixes.filter((prefix) => terms.some((term) => term.startsWith(prefix))).length;
595
773
  return { candidate, overlap };
596
774
  })
@@ -603,45 +781,79 @@ function suggestCloseTools(docs, queryTokens) {
603
781
  description: truncate(candidate.description, MAX_SUGGESTION_DESCRIPTION_CHARS),
604
782
  }));
605
783
  }
606
- const STOP_WORDS = new Set([
607
- "a",
608
- "an",
609
- "and",
610
- "are",
611
- "as",
612
- "at",
613
- "be",
614
- "by",
615
- "for",
616
- "from",
617
- "in",
618
- "is",
619
- "it",
620
- "of",
621
- "on",
622
- "or",
623
- "that",
624
- "the",
625
- "this",
626
- "to",
627
- "with",
628
- ]);
784
+ // Function words carry no capability, and conversational requests are full of
785
+ // them ("can you show me what I have"). Indexed text drops them too, so a
786
+ // description's "you can" never matches a request's.
787
+ const STOP_WORDS = new Set(("a about after again also am an and any are as at be been before being " +
788
+ "but by can could did do does doing done for from had has have having " +
789
+ "he her here hers him his how i if in into is it its just let lets me " +
790
+ "mine my myself no not now of off on once only or our ours please she " +
791
+ "should so some such than thank thanks that the their theirs them then " +
792
+ "there these they this those to too up us very want was we were what " +
793
+ "when where which while who whom why will with would you your yours").split(" "));
794
+ // Light, inflection-only stemming, the same on both sides of the match:
795
+ // plurals, "-ed" and "-ing" (undoubling "running" to "run"), and a final
796
+ // "-e", so "archived", "archiving" and "archive" meet at "archiv" and "saved"
797
+ // meets "save". Full Porter stemming was measured on the benchmark and lost a
798
+ // held-out case: it folds derivations together ("engineering" into "engine").
629
799
  function stem(term) {
630
- if (term.length > 4 && term.endsWith("ies"))
631
- return `${term.slice(0, -3)}y`;
632
- if (term.length > 4 && /(ches|shes|sses|xes|zes)$/.test(term))
633
- return term.slice(0, -2);
634
- if (term.length > 3 && term.endsWith("s") && !term.endsWith("ss"))
635
- return term.slice(0, -1);
636
- return term;
800
+ const protectedTerm = PROTECTED_TERMS.get(term);
801
+ if (protectedTerm)
802
+ return protectedTerm;
803
+ let stemmed = term;
804
+ if (stemmed.length > 4 && stemmed.endsWith("ies")) {
805
+ stemmed = `${stemmed.slice(0, -3)}y`;
806
+ }
807
+ else if (stemmed.length > 4 && /(ches|shes|sses|xes|zes)$/.test(stemmed)) {
808
+ stemmed = stemmed.slice(0, -2);
809
+ }
810
+ else if (stemmed.length > 3 &&
811
+ stemmed.endsWith("s") &&
812
+ !/(ss|us|is)$/.test(stemmed)) {
813
+ stemmed = stemmed.slice(0, -1);
814
+ }
815
+ if (/^[aeiou][^aeiouy](?:ing|ed)$/u.test(stemmed)) {
816
+ // "using" and "used" lost the "e" of "use", as "owing" did of "owe".
817
+ stemmed = `${stemmed.slice(0, 2)}e`;
818
+ }
819
+ else if (stemmed.length > 5 && stemmed.endsWith("ing")) {
820
+ stemmed = undoubleFinalConsonant(stemmed.slice(0, -3));
821
+ }
822
+ else if (stemmed.length > 4 && stemmed.endsWith("ied")) {
823
+ stemmed = `${stemmed.slice(0, -3)}y`;
824
+ }
825
+ else if (stemmed.length > 4 && stemmed.endsWith("ed")) {
826
+ stemmed = undoubleFinalConsonant(stemmed.slice(0, -2));
827
+ }
828
+ if (stemmed.length >= 4 && stemmed.endsWith("e")) {
829
+ stemmed = stemmed.slice(0, -1);
830
+ }
831
+ return stemmed;
832
+ }
833
+ // "running" and "stopped" double the consonant before the suffix, so their
834
+ // stems must drop it to meet "run" and "stop". Base words that end in a double
835
+ // letter ("fill", "dress", "buzz", "stuff") keep it, as do stems that would
836
+ // shrink to two letters ("adding" stays "add"). A long "-ell" stem is the
837
+ // British doubling of "cancelled" and "labelled"; "spell" and "sell" are short.
838
+ function undoubleFinalConsonant(stemmed) {
839
+ return (stemmed.length >= 4 && /([bcdgkmnprt])\1$/.test(stemmed)) ||
840
+ (stemmed.length >= 6 && stemmed.endsWith("ell"))
841
+ ? stemmed.slice(0, -1)
842
+ : stemmed;
637
843
  }
638
844
  function searchTerms(value) {
639
845
  return value
640
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
641
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
642
- .toLowerCase()
643
- .split(/[^a-z0-9]+/)
644
- .filter((term) => term.length > 0 && !STOP_WORDS.has(term))
846
+ .split(/[^A-Za-z0-9]+/)
847
+ .flatMap((word) => {
848
+ const parts = word
849
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
850
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
851
+ .toLowerCase()
852
+ .split(" ");
853
+ // Users type "PowerPoint" and "HubSpot" as one word, too.
854
+ return parts.length > 1 ? [...parts, word.toLowerCase()] : parts;
855
+ })
856
+ .filter((term) => (term.length > 1 || /\d/.test(term)) && !STOP_WORDS.has(term))
645
857
  .map(stem);
646
858
  }
647
859
  function tokenize(value) {
@@ -1,17 +1,17 @@
1
1
  {
2
- "@agent-native/agentkit": "0.224.0",
2
+ "@agent-native/agentkit": "0.225.0-nightly-20261105110131",
3
3
  "@agent-native/code-agents-ui": "0.1.75",
4
- "@agent-native/core": "0.224.0",
5
- "@agent-native/core-corpus": "0.224.0",
4
+ "@agent-native/core": "0.225.0-nightly-20261105110131",
5
+ "@agent-native/core-corpus": "0.225.0-nightly-20261105110131",
6
6
  "@agent-native/creative-context": "0.8.65",
7
- "@agent-native/dispatch": "0.42.0",
7
+ "@agent-native/dispatch": "0.42.1-nightly-20261105110131",
8
8
  "@agent-native/embedding": "0.2.0",
9
9
  "@agent-native/migrate": "0.0.7",
10
10
  "@agent-native/otel": "0.1.32",
11
11
  "@agent-native/pinpoint": "0.1.108",
12
12
  "@agent-native/recap-cli": "0.5.96",
13
- "@agent-native/scheduling": "0.2.52",
13
+ "@agent-native/scheduling": "0.2.53-nightly-20261105110131",
14
14
  "@agent-native/shared-app-config": "0.1.2",
15
- "@agent-native/skills": "0.4.0",
16
- "@agent-native/toolkit": "0.224.0"
15
+ "@agent-native/skills": "0.4.1-nightly-20261105110131",
16
+ "@agent-native/toolkit": "0.225.0-nightly-20261105110131"
17
17
  }
@@ -44,6 +44,27 @@ export function syncMcpActionEntries(manager, target, options = {}) {
44
44
  }
45
45
  }
46
46
  }
47
+ /**
48
+ * The upstream server's hints, so `tool-search` marks a destructive tool
49
+ * whatever it is called. Hints the server leaves out take the MCP spec's
50
+ * defaults: "destructiveHint: If true, the tool may perform destructive
51
+ * updates to its environment. ... Default: true", meaningful only when the
52
+ * tool is not read-only. Our own actions do not get this default.
53
+ */
54
+ function upstreamMcpAnnotations(tool) {
55
+ const hints = tool.annotations ?? {};
56
+ const readOnlyHint = hints.readOnlyHint === true;
57
+ return {
58
+ mcpAnnotations: {
59
+ readOnlyHint,
60
+ destructiveHint: !readOnlyHint && hints.destructiveHint !== false,
61
+ openWorldHint: hints.openWorldHint !== false,
62
+ ...(typeof hints.idempotentHint === "boolean"
63
+ ? { idempotentHint: hints.idempotentHint }
64
+ : {}),
65
+ },
66
+ };
67
+ }
47
68
  function mcpToolToActionEntry(manager, tool, options = {}) {
48
69
  const { authorize, ...resolvedEntry } = options.resolveActionEntry?.(tool) ?? {};
49
70
  return {
@@ -58,6 +79,7 @@ function mcpToolToActionEntry(manager, tool, options = {}) {
58
79
  description: "Plan mode allows MCP calls classified as read-only from annotations, operation names, and runtime arguments.",
59
80
  },
60
81
  ...(tool.annotations?.readOnlyHint === true ? { readOnly: true } : {}),
82
+ ...upstreamMcpAnnotations(tool),
61
83
  ...resolvedEntry,
62
84
  run: async (args, context) => {
63
85
  // Defense-in-depth: even if a cross-scope MCP tool somehow makes it
@@ -1,5 +1,6 @@
1
1
  import nodePath from "node:path";
2
2
  import "../authorization/check-action.js";
3
+ import { isActionDefinitionError, namedActionDefinitionError, } from "../action.js";
3
4
  import { CORE_ACTION_GROUPS } from "../framework-tools.js";
4
5
  import { frameworkMcpTool } from "../mcp/framework-tools.js";
5
6
  import { normalizeShellArgs, serializeCliArgs } from "../scripts/parse-args.js";
@@ -167,7 +168,7 @@ async function loadActionsIntoRegistry(actionsDir, registry, skipExisting) {
167
168
  tool: mod.tool,
168
169
  run: mod.run,
169
170
  ...(mod.http !== undefined ? { http: mod.http } : {}),
170
- ...preserveActionFlags(mod),
171
+ ...preserveActionFlags(mod, name),
171
172
  };
172
173
  }
173
174
  else if (mod.default &&
@@ -178,7 +179,7 @@ async function loadActionsIntoRegistry(actionsDir, registry, skipExisting) {
178
179
  tool: mod.default.tool,
179
180
  run: mod.default.run,
180
181
  ...(mod.default.http !== undefined ? { http: mod.default.http } : {}),
181
- ...preserveActionFlags(mod.default),
182
+ ...preserveActionFlags(mod.default, name),
182
183
  };
183
184
  }
184
185
  else if (typeof mod.default === "function") {
@@ -186,6 +187,11 @@ async function loadActionsIntoRegistry(actionsDir, registry, skipExisting) {
186
187
  }
187
188
  }
188
189
  catch (err) {
190
+ // A module that imports but defines its action wrongly is a bug in the
191
+ // action, not a CLI script: fail the load instead of skipping it.
192
+ if (isActionDefinitionError(err)) {
193
+ throw namedActionDefinitionError(err, name);
194
+ }
189
195
  const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
190
196
  console.warn(`[action-discovery] Skipped "${file}" — failed to import. If this is an ` +
191
197
  `agent action (not a CLI script), it will be missing from the agent's tools:\n${msg}`);
@@ -203,7 +209,7 @@ export function loadActionsFromStaticRegistry(modules) {
203
209
  tool: mod.tool,
204
210
  run: mod.run,
205
211
  ...(mod.http !== undefined ? { http: mod.http } : {}),
206
- ...preserveActionFlags(mod),
212
+ ...preserveActionFlags(mod, name),
207
213
  };
208
214
  continue;
209
215
  }
@@ -216,7 +222,7 @@ export function loadActionsFromStaticRegistry(modules) {
216
222
  tool: def.tool,
217
223
  run: def.run,
218
224
  ...(def.http !== undefined ? { http: def.http } : {}),
219
- ...preserveActionFlags(def),
225
+ ...preserveActionFlags(def, name),
220
226
  };
221
227
  continue;
222
228
  }
@@ -233,6 +239,8 @@ export async function autoDiscoverActions(from) {
233
239
  await loadActionsIntoRegistry(actionsDir, registry, false);
234
240
  }
235
241
  catch (err) {
242
+ if (isActionDefinitionError(err))
243
+ throw err;
236
244
  console.warn(`[autoDiscoverActions] Could not read actions directory: ${actionsDir} — ${err?.message}`);
237
245
  }
238
246
  if (Object.keys(registry).length === 0 && from) {
@@ -253,7 +261,11 @@ export async function autoDiscoverActions(from) {
253
261
  `Consider switching to loadActionsFromStaticRegistry(actionsRegistry) for production reliability.`);
254
262
  }
255
263
  }
256
- catch {
264
+ catch (error) {
265
+ // An action in the registry that defines itself wrongly is not "no
266
+ // registry": the app would boot with no template tools at all.
267
+ if (isActionDefinitionError(error))
268
+ throw error;
257
269
  // No generated registry available — registry stays empty.
258
270
  }
259
271
  }
@@ -271,13 +283,17 @@ export async function autoDiscoverActions(from) {
271
283
  await loadActionsIntoRegistry(ws.actionsDir, registry, true);
272
284
  }
273
285
  }
274
- catch {
286
+ catch (error) {
287
+ if (isActionDefinitionError(error))
288
+ throw error;
275
289
  // workspace-core discovery unavailable (e.g. edge runtime) — skip.
276
290
  }
277
291
  try {
278
292
  await mergeCoreSharingActions(registry);
279
293
  }
280
- catch {
294
+ catch (error) {
295
+ if (isActionDefinitionError(error))
296
+ throw error;
281
297
  // Ignore — templates without sharing still work.
282
298
  }
283
299
  return registry;
@@ -882,14 +898,17 @@ export async function mergeCoreSharingActions(registry) {
882
898
  // mcpApp) plus readOnly/parallelSafe/dedupe. Without this, the sharing
883
899
  // actions' `toolCallable: false` (audit-H5) is dropped and the
884
900
  // tools-iframe bridge 403 in action-routes.ts never fires.
885
- ...preserveActionFlags(def),
901
+ ...preserveActionFlags(def, name),
886
902
  ...(CORE_ACTION_GROUPS[name]
887
903
  ? { frameworkGroup: CORE_ACTION_GROUPS[name] }
888
904
  : {}),
889
905
  });
890
906
  }
891
907
  }
892
- catch {
908
+ catch (error) {
909
+ if (isActionDefinitionError(error)) {
910
+ throw namedActionDefinitionError(error, name);
911
+ }
893
912
  // Skip any sharing action that fails to import.
894
913
  }
895
914
  }
@@ -8,6 +8,7 @@ import { hasConfiguredA2ASecret, isLoopbackAddress, isTrustedLocalRuntime, } fro
8
8
  import { sanitizeA2ACorrelationId, sanitizeA2ACorrelationMetadata, } from "../a2a/correlation.js";
9
9
  import { a2aFilePartsToAttachments } from "../a2a/file-attachments.js";
10
10
  import { createA2AApproval, a2aTaskLeaseThreadId, A2A_TASK_CLAIM_LOST_ERROR_CODE, updateTaskStatusMessage, } from "../a2a/task-store.js";
11
+ import { ActionDefinitionError, isActionDefinitionError } from "../action.js";
11
12
  import { clientAbortReason, EXECUTION_LEASE_LOST_ABORT_REASON, } from "../agent/abort-reasons.js";
12
13
  import { canUpdateAgentAppModelDefaultSettings, normalizeAgentAppModelDefaultAppId, readAgentAppModelDefaultSettings, resetAgentAppModelDefaultSettings, writeAgentAppModelDefaultSettings, } from "../agent/app-model-defaults.js";
13
14
  import { approvalKeyWithArguments } from "../agent/approval-key.js";
@@ -77,7 +78,7 @@ import { AGENT_TEAM_PROCESS_RUN_PATH, getCurrentDelegationDepth, processAgentTea
77
78
  import { getSession, isLoopbackRequest, registerAuthPublicPaths, } from "./auth.js";
78
79
  import { captureError } from "./capture-error.js";
79
80
  import { chatTitleRequestFromBody, generateChatTitle } from "./chat-title.js";
80
- import { actionDefinition, discoveredActionMcpAnnotations, filesystemActionEntry, } from "./filesystem-action-entry.js";
81
+ import { discoveredActionMcpAnnotations, importDevActionEntry, } from "./filesystem-action-entry.js";
81
82
  import { getH3App, markDefaultPluginProvided, trackPluginInit, } from "./framework-request-handler.js";
82
83
  import { publicFrameworkPath } from "./framework-route-prefix.js";
83
84
  import { getOrigin } from "./google-oauth.js";
@@ -1078,7 +1079,9 @@ export function createAgentChatPlugin(options) {
1078
1079
  const { autoDiscoverActions } = await import("./action-discovery.js");
1079
1080
  templateScriptsAll = await autoDiscoverActions("auto");
1080
1081
  }
1081
- catch {
1082
+ catch (error) {
1083
+ if (isActionDefinitionError(error))
1084
+ throw error;
1082
1085
  // Filesystem discovery unavailable (serverless bundle) — skip.
1083
1086
  }
1084
1087
  }
@@ -1169,6 +1172,7 @@ export function createAgentChatPlugin(options) {
1169
1172
  // Auto-discover template action files and register as bash-based tools.
1170
1173
  // This ensures templates without a custom agent-chat plugin (e.g., analytics)
1171
1174
  // still have their domain actions available as tools.
1175
+ const definitionErrors = [];
1172
1176
  try {
1173
1177
  const pathMod = await import("path");
1174
1178
  const cwd = process.cwd();
@@ -1184,17 +1188,22 @@ export function createAgentChatPlugin(options) {
1184
1188
  // run function (not a shell wrapper). This makes HTTP endpoints
1185
1189
  // work correctly. Only fall back to shell wrapper if the import
1186
1190
  // fails (e.g., CLI-style scripts that throw at top level).
1191
+ // A definition error is rethrown below, past the catch that
1192
+ // skips unreadable discovery, so it fails the load instead.
1187
1193
  try {
1188
- const mod = await import(/* @vite-ignore */ filePath);
1189
- const def = actionDefinition(mod);
1190
- if (def) {
1191
- discoveredActionsAll[name] = filesystemActionEntry(def);
1194
+ const entry = await importDevActionEntry(filePath, name);
1195
+ if (entry) {
1196
+ discoveredActionsAll[name] = entry;
1192
1197
  continue;
1193
1198
  }
1194
1199
  }
1195
- catch {
1196
- // Fall through to shell wrapper for CLI-style scripts
1197
- // (and .ts files Node can't parse natively).
1200
+ catch (error) {
1201
+ if (isActionDefinitionError(error)) {
1202
+ definitionErrors.push(error);
1203
+ continue;
1204
+ }
1205
+ // Anything else falls through to the shell wrapper, as an
1206
+ // unimportable CLI script does.
1198
1207
  }
1199
1208
  // Static-parse the source for `http`, `agentTool: false`,
1200
1209
  // `needsApproval`, `persistInBrowser`, `uiOnly` and
@@ -1290,6 +1299,9 @@ export function createAgentChatPlugin(options) {
1290
1299
  console.log(`[agent-chat] Auto-discovered ${Object.keys(discoveredActionsAll).length} action(s): ${Object.keys(discoveredActionsAll).join(", ")}`);
1291
1300
  }
1292
1301
  catch { }
1302
+ if (definitionErrors.length > 0) {
1303
+ throw new ActionDefinitionError(definitionErrors.map((error) => error.message).join("\n"));
1304
+ }
1293
1305
  }
1294
1306
  // Agent-facing views of the discovered actions: actions that opted out of
1295
1307
  // agent exposure with `agentTool: false` are dropped from every tool
@@ -14,7 +14,7 @@ export declare function discoveredActionMcpAnnotations(readOnly: boolean): Reado
14
14
  * validates, asks for approval, and checks access the same way however it was
15
15
  * loaded. Each flag is kept only when its value has the right shape.
16
16
  */
17
- export declare function preserveActionFlags(entry: Record<string, unknown>): Partial<ActionEntry>;
17
+ export declare function preserveActionFlags(entry: Record<string, unknown>, actionName?: string): Partial<ActionEntry>;
18
18
  /**
19
19
  * The object a loaded action module defines its action on. Named `tool` and
20
20
  * `run` exports come first, as the built registry and the directory loader
@@ -28,3 +28,11 @@ export declare function actionDefinition(mod: Record<string, unknown>): Record<s
28
28
  * annotations when the module declares no valid ones.
29
29
  */
30
30
  export declare function filesystemActionEntry(def: Record<string, any>): ActionEntry;
31
+ /**
32
+ * The real entry for a dev action file, or `null` when the file should run as
33
+ * a CLI-script wrapper: it does not import (top-level CLI code, syntax Node
34
+ * cannot parse) or defines no action. A file that imports but defines its
35
+ * action wrongly throws an `ActionDefinitionError` naming it instead, so an
36
+ * invalid definition never silently changes how the action runs.
37
+ */
38
+ export declare function importDevActionEntry(filePath: string, actionName: string): Promise<ActionEntry | null>;
@@ -1,5 +1,5 @@
1
1
  import { isMcpReadOnlyTool } from "../action-call-classification.js";
2
- import { ACTION_EXECUTION_KEY } from "../action.js";
2
+ import { ACTION_EXECUTION_KEY, isActionDefinitionError, namedActionDefinitionError, validateSearchHints, } from "../action.js";
3
3
  import { carryFrameworkMcpTool } from "../mcp/framework-tools.js";
4
4
  // Without the key an app wrapper around `run` cannot be recognized as the same
5
5
  // action, and every validation failure reads as an unknown write.
@@ -44,7 +44,7 @@ function isMcpAnnotations(value) {
44
44
  * validates, asks for approval, and checks access the same way however it was
45
45
  * loaded. Each flag is kept only when its value has the right shape.
46
46
  */
47
- export function preserveActionFlags(entry) {
47
+ export function preserveActionFlags(entry, actionName) {
48
48
  const out = {};
49
49
  carryFrameworkMcpTool(entry, out);
50
50
  if (entry.schema &&
@@ -73,6 +73,11 @@ export function preserveActionFlags(entry) {
73
73
  if (typeof entry.deferLoading === "boolean") {
74
74
  out.deferLoading = entry.deferLoading;
75
75
  }
76
+ // Not dropped like the flags above: a malformed list would silently leave
77
+ // the action harder to find, so loading it fails instead.
78
+ const searchHints = validateSearchHints(entry.searchHints, actionName);
79
+ if (searchHints)
80
+ out.searchHints = searchHints;
76
81
  if (typeof entry.eagerInputStreaming === "boolean") {
77
82
  out.eagerInputStreaming = entry.eagerInputStreaming;
78
83
  }
@@ -204,3 +209,22 @@ export function filesystemActionEntry(def) {
204
209
  carryExecutionKey(def, out);
205
210
  return out;
206
211
  }
212
+ /**
213
+ * The real entry for a dev action file, or `null` when the file should run as
214
+ * a CLI-script wrapper: it does not import (top-level CLI code, syntax Node
215
+ * cannot parse) or defines no action. A file that imports but defines its
216
+ * action wrongly throws an `ActionDefinitionError` naming it instead, so an
217
+ * invalid definition never silently changes how the action runs.
218
+ */
219
+ export async function importDevActionEntry(filePath, actionName) {
220
+ try {
221
+ const def = actionDefinition(await import(/* @vite-ignore */ filePath));
222
+ return def ? filesystemActionEntry(def) : null;
223
+ }
224
+ catch (error) {
225
+ if (isActionDefinitionError(error)) {
226
+ throw namedActionDefinitionError(error, actionName);
227
+ }
228
+ return null;
229
+ }
230
+ }
@@ -66,7 +66,7 @@ ${sharedRule8(examples, options)}
66
66
  ${SHARED_RULE_9}
67
67
  10. **Native widgets** — For table/chart/graph/report requests, prefer actions labeled \`Native chat widget\`; use \`render-data-widget\` for already-summarized data (≤50 rows) instead of markdown tables. Above that, give the total plus the top rows — never retype a full result set as widget arguments.
68
68
  11. **Downloadable files** — Deliver files in chat, never just a path.
69
- 12. **Your tool list is not the whole surface** — Most app actions and connected MCP tools load on demand; find exact names via the Tool index or search the registry with \`tool-search\` before concluding a capability doesn't exist.
69
+ 12. **Your tool list is not the whole surface** — Most app actions and connected MCP tools load on demand; find names in the Tool index or search \`tool-search\` for the task in plain words before concluding a capability doesn't exist.
70
70
  ${sharedRule13(options)}
71
71
  ${SHARED_RULE_14}
72
72
  ${SHARED_RULE_15}
@@ -95,7 +95,7 @@ ${SHARED_RULE_9}
95
95
  ${ATTACHMENT_READ_RULE}
96
96
  10. **Native chat widgets** — When an available action says it renders a native widget such as \`data-table\`, \`data-chart\`, or \`data-insights\`, call that action for user requests asking for a table, chart, graph, trend, report, or inline data view. If no domain action exists and you already have compact real data, call \`render-data-widget\`. Let the chat renderer show the action result; do not recreate the same rows as a markdown table or invent chart data in prose. Add only a short human summary or next-step link around the widget. Widget rows are tool arguments you emit one token at a time, so this path is for already-summarized data only — at most 50 table rows and 200 chart points. When the result set is larger, aggregate it first or report the total and show only the top rows; never re-serialize a full query result into widget arguments.
97
97
  11. **Downloadable files** — When the user asks you to create or export a file, deliver it in the same chat turn. For compact tabular results, prefer \`render-data-widget\` so the user gets the native Download CSV action without a second stored copy. For a complete or durable file written through \`workspaceWrite\`, use a normal non-\`scratch/\` path and then call \`show-workspace-file\` with that path so chat renders a direct download card. Never finish by giving filesystem paths or telling the user to navigate elsewhere to find the file.
98
- 12. **Your tool list is not the whole surface** — Most app actions and connected MCP tools load on demand; find exact names via the Tool index or search the registry with \`tool-search\` before concluding a capability doesn't exist.
98
+ 12. **Your tool list is not the whole surface** — Most app actions and connected MCP tools load on demand; find names in the Tool index or search \`tool-search\` for the task in plain words before concluding a capability doesn't exist.
99
99
  ${sharedRule13(options)}
100
100
  ${SHARED_RULE_14}
101
101
  ${SHARED_RULE_15}
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72
 
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72
 
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72
 
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72
 
@@ -446,6 +446,7 @@ Every action the agent can see takes up a slot in the model's tool list, and a l
446
446
  - Before adding a new read action for every query or filter, consider a more general option first: the [provider API trio](/docs/template-dispatch-developers#calling-a-provider-directly) (`provider-api-catalog`, `provider-api-docs`, and `provider-api-request`) for provider data, or the dev `db-query` tool for app data.
447
447
  - Mark UI-only or programmatic actions [`agentTool: false`](/docs/actions-access-control#agent-tool). They stay callable from the frontend or over HTTP, without taking a slot in the model's tool list.
448
448
  - Mark the handful of actions the agent reaches for constantly `deferLoading: false`. Those are the schemas sent on the first model request; everything else loads on demand through `tool-search` when the agent needs it. As soon as one action opts out this way, every unmarked action is deferred, so mark the whole starter set at once rather than one action at a time. This is about what the first turn costs, not about access: a deferred action is callable the moment `tool-search` returns it. Deferred actions still appear by name in the prompt's tool index, grouped by the noun after the leading verb, so `verb-noun` names such as `list-dashboards` and `create-dashboard` are what let the agent find them. It is the per-action form of the plugin's `initialToolNames` array, and an app can use either.
449
+ - Give an action `searchHints` when users call its job something its name and description do not, such as `["auto-archive rule", "inbox rule"]` on an inbox-rules action. `tool-search` ranks these words just below the name, and they are never sent to the model, so they cost no context. Keep them short and factual (at most 12, each up to 60 characters), not a list of every request the action might serve.
449
450
  - Delete or hide actions the UI no longer uses, instead of leaving them exposed to the model.
450
451
 
451
452
  The repo includes an advisory helper, `node scripts/audit-template-actions.mjs [template ...]` (alias `pnpm actions:audit`). It scans a template's `actions/` folder and flags actions the UI may no longer use, along with groups of per-field actions that could be combined into one. It always exits with code 0 and never fails CI, and its heuristics are conservative, so treat its output as a suggestion, not an error.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.224.0",
3
+ "version": "0.225.0-nightly-20261105110131",
4
4
  "description": "The agentic application framework for building autonomous agents with intuitive UIs",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -760,8 +760,8 @@
760
760
  "yaml": "^2.9.0",
761
761
  "yjs": "^13.6.33",
762
762
  "zod": "^4.3.6",
763
- "@agent-native/recap-cli": "^0.5.96",
764
- "@agent-native/agentkit": "^0.224.0"
763
+ "@agent-native/agentkit": "^0.225.0-nightly-20261105110131",
764
+ "@agent-native/recap-cli": "^0.5.96"
765
765
  },
766
766
  "devDependencies": {
767
767
  "@ai-sdk/anthropic": "^3.0.71",
@@ -833,7 +833,7 @@
833
833
  "xlsx": "^0.18.5"
834
834
  },
835
835
  "peerDependencies": {
836
- "@agent-native/core-corpus": ">=0.196.0",
836
+ "@agent-native/core-corpus": "0.225.0-nightly-20261105110131",
837
837
  "@ai-sdk/anthropic": ">=3",
838
838
  "@ai-sdk/cohere": ">=3",
839
839
  "@ai-sdk/google": ">=3",
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72
 
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72
 
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72
 
@@ -66,7 +66,7 @@ Every agent-exposed action is a tool in the model's context window; more tools d
66
66
 
67
67
  ## Key Actions — One Index, Every Surface
68
68
 
69
- Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`.
69
+ Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
70
70
 
71
71
  When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
72
72