@k2b/cloud 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/define-app.ts +8 -1
  4. package/src/_internal/process-identity.ts +7 -1
  5. package/src/_internal/registry-validation.ts +3 -0
  6. package/src/_internal/registry.ts +1 -0
  7. package/src/_internal/runtime-context.ts +1 -0
  8. package/src/access/GroupCoverage.tsx +175 -0
  9. package/src/access/PermissionEditor.tsx +119 -99
  10. package/src/access/messages.ts +30 -0
  11. package/src/ai/admin.ts +1 -0
  12. package/src/ai/approval-routes.ts +5 -5
  13. package/src/ai/browser-code-contracts.ts +14 -2
  14. package/src/ai/browser.ts +8 -1
  15. package/src/ai/capabilities.ts +115 -33
  16. package/src/ai/chat/blocks.tsx +86 -179
  17. package/src/ai/chat/builtin-tools.tsx +83 -48
  18. package/src/ai/chat/file-tools.tsx +4 -1
  19. package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
  20. package/src/ai/chat/message-actions.tsx +6 -2
  21. package/src/ai/chat/message-utils.ts +17 -14
  22. package/src/ai/chat/messages.ts +330 -2
  23. package/src/ai/chat/presentation.tsx +278 -104
  24. package/src/ai/chat/tool-groups.ts +55 -35
  25. package/src/ai/chat/turn-layout.ts +141 -0
  26. package/src/ai/chat/turn-view.tsx +644 -0
  27. package/src/ai/client/controller.ts +60 -31
  28. package/src/ai/client/file-source.ts +20 -3
  29. package/src/ai/client/projection.ts +42 -6
  30. package/src/ai/code-mode-skill.ts +27 -27
  31. package/src/ai/code-runtime-tools.ts +10 -1
  32. package/src/ai/code-source-contracts.ts +54 -4
  33. package/src/ai/code-source-tools.ts +10 -3
  34. package/src/ai/credentials.ts +17 -3
  35. package/src/ai/data-analysis-skill.ts +2 -2
  36. package/src/ai/default-tools.ts +2 -2
  37. package/src/ai/executor.ts +177 -80
  38. package/src/ai/file-context.ts +14 -2
  39. package/src/ai/file-tools.ts +17 -3
  40. package/src/ai/files-store.ts +134 -11
  41. package/src/ai/grids-skill.ts +2 -2
  42. package/src/ai/index.ts +7 -0
  43. package/src/ai/memories.ts +14 -0
  44. package/src/ai/migrate.ts +125 -0
  45. package/src/ai/model-request-settings.ts +98 -0
  46. package/src/ai/protocol.ts +26 -4
  47. package/src/ai/provider-fetch.ts +67 -15
  48. package/src/ai/provider-retry.ts +105 -0
  49. package/src/ai/provider.ts +7 -1
  50. package/src/ai/quota-provider.ts +16 -7
  51. package/src/ai/request-headers.ts +117 -0
  52. package/src/ai/routes.ts +34 -6
  53. package/src/ai/runtime.ts +1 -1
  54. package/src/ai/settings.ts +19 -2
  55. package/src/ai/skill-seeds.ts +31 -3
  56. package/src/ai/skills.ts +26 -0
  57. package/src/ai/solid.ts +1 -1
  58. package/src/ai/store.ts +202 -59
  59. package/src/ai/stream.ts +182 -37
  60. package/src/ai/structured.ts +20 -5
  61. package/src/ai/system-prompt.ts +25 -0
  62. package/src/ai/timeline.ts +9 -11
  63. package/src/ai/tool-call-names.ts +45 -0
  64. package/src/ai/turn-policy.ts +247 -0
  65. package/src/ai/turn-timing.ts +31 -3
  66. package/src/ai/types.ts +36 -5
  67. package/src/api/admin-ai-quotas.ts +36 -1
  68. package/src/api/admin-core-settings.ts +16 -23
  69. package/src/api/admin-outgoing-mail.ts +62 -0
  70. package/src/api/index.ts +2 -0
  71. package/src/cli/admin/ai-quotas.ts +70 -1
  72. package/src/cli/admin/index.ts +6 -0
  73. package/src/cli/admin/outgoing-mail.ts +118 -0
  74. package/src/contracts/app.ts +2 -0
  75. package/src/contracts/index.ts +1 -0
  76. package/src/contracts/outgoing-mail.ts +77 -0
  77. package/src/contracts/registry.ts +4 -0
  78. package/src/services/index.ts +3 -0
  79. package/src/services/notifications/email.ts +16 -26
  80. package/src/services/outgoing-mail/index.ts +19 -0
  81. package/src/services/outgoing-mail/store.ts +286 -0
  82. package/src/services/outgoing-mail/test-send.ts +40 -0
  83. package/src/services/outgoing-mail/transport.ts +13 -0
  84. package/src/services/settings/core-settings.ts +1 -38
  85. package/src/services/settings/store.ts +5 -1
  86. package/src/shared/ai-model-request-settings.ts +21 -0
  87. package/src/shared/ai-platform-prompt.ts +1 -1
  88. package/src/shared/ai-request-options.ts +185 -0
  89. package/src/shared/app-presentation.ts +10 -2
  90. package/src/ssr/admin-navigation.ts +1 -1
  91. package/src/ssr/platform-messages.ts +2 -0
  92. package/src/ssr/workspace-navigation.ts +7 -1
  93. package/src/styles/effects.css +69 -0
@@ -29,6 +29,21 @@ export const accessMessages = i18n.define({
29
29
  lastManager: ({ level }: { level: string }) =>
30
30
  `The last entry with “${level}” access can't be lowered or removed. Give another person or group “${level}” access first.`,
31
31
  lastManagerOption: ({ level }: { level: string }) => `Available once another person or group has “${level}” access`,
32
+ groupMembers: "Members",
33
+ groupMembersOf: ({ name }: { name: string }) => `Members of ${name}`,
34
+ groupMemberCount: ({ count }: { count: number }) =>
35
+ count === 0
36
+ ? "No one receives access through this group right now."
37
+ : count === 1
38
+ ? "1 person receives access through this group right now."
39
+ : `${count.toLocaleString("en")} people receive access through this group right now.`,
40
+ groupMembersLoading: "Loading members…",
41
+ groupMembersHidden: "Your account can't see who belongs to this group.",
42
+ groupMembersFailed: "Members couldn't be loaded.",
43
+ retry: "Try again",
44
+ showMoreMembers: ({ count }: { count: number }) => `Show ${count.toLocaleString("en")} more`,
45
+ directoryGroupHint:
46
+ "This group comes from the directory. Local accounts such as guests can't be members, so give them access directly.",
32
47
  read: "Read",
33
48
  readDescription: "Read this resource through the app API.",
34
49
  write: "Write",
@@ -92,6 +107,21 @@ export const accessMessages = i18n.define({
92
107
  lastManager: ({ level }) =>
93
108
  `Der letzte Eintrag mit Zugriff „${level}“ kann nicht herabgestuft oder entfernt werden. Gib zuerst einer anderen Person oder Gruppe Zugriff „${level}“.`,
94
109
  lastManagerOption: ({ level }) => `Möglich, sobald eine weitere Person oder Gruppe Zugriff „${level}“ hat`,
110
+ groupMembers: "Mitglieder",
111
+ groupMembersOf: ({ name }) => `Mitglieder von ${name}`,
112
+ groupMemberCount: ({ count }) =>
113
+ count === 0
114
+ ? "Über diese Gruppe erhält gerade niemand Zugriff."
115
+ : count === 1
116
+ ? "Über diese Gruppe erhält gerade 1 Person Zugriff."
117
+ : `Über diese Gruppe erhalten gerade ${count.toLocaleString("de")} Personen Zugriff.`,
118
+ groupMembersLoading: "Mitglieder werden geladen…",
119
+ groupMembersHidden: "Dein Konto kann nicht sehen, wer zu dieser Gruppe gehört.",
120
+ groupMembersFailed: "Die Mitglieder konnten nicht geladen werden.",
121
+ retry: "Erneut versuchen",
122
+ showMoreMembers: ({ count }) => `${count.toLocaleString("de")} weitere anzeigen`,
123
+ directoryGroupHint:
124
+ "Diese Gruppe stammt aus dem Verzeichnis. Lokale Konten wie Gäste können nicht Mitglied sein; gib ihnen direkt Zugriff.",
95
125
  read: "Lesen",
96
126
  readDescription: "Diese Ressource über die App-API lesen.",
97
127
  write: "Schreiben",
package/src/ai/admin.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { quotaAdminConfig, quotaReport } from "./quota-report";
2
2
  export { AiQuotaError, aiQuotas } from "./quotas";
3
+ export { listAiRequestHeaderNames } from "./request-headers";
3
4
  /** Server-only AI administration and usage accounting. */
4
5
 
5
6
  export { type AiModelAccessDraft, type AiModelAccessMap, type AiModelAccessState, aiModelAccess } from "./model-access";
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { listCapabilities } from "../_internal/registry";
5
5
  import { ErrorResponseSchema } from "../contracts";
6
6
  import type { CapabilityRegistryEntry } from "../contracts/registry";
7
- import { type AuthContext, auth, expectUserBackedActor, jsonResponse, rateLimit, requiresAuth, v } from "../server";
7
+ import { type AuthContext, auth, expectUserBackedActor, getLocale, jsonResponse, rateLimit, requiresAuth, v } from "../server";
8
8
  import { type AiToolApprovalPreference, listAiToolApprovalPreferences, revokeAiToolApprovalPreference } from "./approvals";
9
9
  import { buildAiCapabilityCatalog } from "./capabilities";
10
10
 
@@ -70,7 +70,7 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
70
70
  describeRoute({
71
71
  tags: ["AI"],
72
72
  summary: "List remembered AI approvals",
73
- description: "Lists AI tool approvals remembered for the current user.",
73
+ description: "Lists AI tool approvals remembered for the current user, with titles in the request locale.",
74
74
  ...requiresAuth,
75
75
  responses: {
76
76
  200: jsonResponse(AiApprovalPreferenceListSchema, "Remembered approvals"),
@@ -84,16 +84,16 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
84
84
  listPreferences(user.id),
85
85
  registry().catch(() => [] as CapabilityRegistryEntry[]),
86
86
  ]);
87
- const catalogByName = new Map(buildAiCapabilityCatalog(capabilities).map((entry) => [entry.name, entry]));
87
+ const catalogByName = new Map(buildAiCapabilityCatalog(capabilities, getLocale(c)).map((entry) => [entry.name, entry]));
88
88
  const approvals: AiApprovalPreferenceView[] = preferences.map((preference) => {
89
89
  const capability = catalogByName.get(preference.toolName);
90
90
  return {
91
91
  ...preference,
92
- title: capability?.title ?? fallbackTitle(preference.toolName),
92
+ title: capability?.display.title ?? fallbackTitle(preference.toolName),
93
93
  app: capability
94
94
  ? {
95
95
  id: capability.appId,
96
- name: capability.appName,
96
+ name: capability.display.appName,
97
97
  icon: capability.app.appIcon,
98
98
  ...(capability.app.appAccent ? { accent: capability.app.appAccent } : {}),
99
99
  }
@@ -23,7 +23,7 @@ export const CodeRunInput = z
23
23
  resourceId: id
24
24
  .optional()
25
25
  .describe(
26
- "Optional data context for one-off code. Requires Manage; uses this resource database and shared files/KV without changing its source. Local storage stays temporary.",
26
+ "Optional data context for one-off code. Requires Manage; uses this resource database and shared files/KV without changing its source. Test runs use real shared and personal data; writes and capability effects keep normal permissions and approvals.",
27
27
  ),
28
28
  version: z
29
29
  .number()
@@ -71,7 +71,10 @@ export const CodeActionInput = z
71
71
  .positive()
72
72
  .optional()
73
73
  .describe("Exact draft revision returned by code_actions({draft:true}); Manage required. Supply this OR publishedVersion."),
74
- input: z.json().describe("Action input matching the discovered inputSchema."),
74
+ input: z
75
+ .record(z.string(), z.json())
76
+ .default({})
77
+ .describe("Action input object matching the discovered inputSchema. Pass the object itself, never JSON text."),
75
78
  })
76
79
  .strict()
77
80
  .refine(
@@ -162,6 +165,15 @@ export const CodeSecretInput = z
162
165
  })
163
166
  .strict();
164
167
 
168
+ /**
169
+ * A runtime call that did not complete: rejected input, timeout, lost run or host failure.
170
+ * Hosts return it as data because a client tool result carries no error flag; the managed
171
+ * server tools report it to the model as a tool error. A run snapshot whose code failed is a
172
+ * completed call and keeps its own `status` and `error`.
173
+ */
174
+ export const CodeToolFailure = z.object({ failed: z.literal(true), error: z.string(), guidance: z.string().optional() });
175
+ export type CodeToolFailure = z.infer<typeof CodeToolFailure>;
176
+
165
177
  // Internal bridge envelope. Each model-facing tool receives only its own flat schema.
166
178
  export const CodeRuntimeInput = z.discriminatedUnion("operation", [
167
179
  CodeRunInput.safeExtend({ operation: z.literal("run") }),
package/src/ai/browser.ts CHANGED
@@ -1,4 +1,11 @@
1
- export { CODE_RUNTIME_TOOL_NAMES, CodeActionInput, CodeResourceId, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
1
+ export {
2
+ CODE_RUNTIME_TOOL_NAMES,
3
+ CodeActionInput,
4
+ CodeResourceId,
5
+ CodeRuntimeInput,
6
+ CodeToolFailure,
7
+ parseCodeToolInput,
8
+ } from "./browser-code-contracts";
2
9
  export { type AiTaskRequest, type AiTaskRequestInput, AiTaskRequestSchema } from "./task-contracts";
3
10
  export { type AiTodoPlan, parseAiTodoPlan } from "./todo-contracts";
4
11
 
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Tool, ToolContext, ToolResolver } from "@k2b/nessi";
3
3
  import { z } from "zod";
4
+ import { resolveCapabilityOperationTitle } from "../_internal/capabilities";
4
5
  import { HELP_READ_MAX_CHARS, HELP_SEARCH_MAX_LIMIT, readHelpArticle } from "../_internal/help-catalog";
5
6
  import {
6
7
  type CapabilityActionManifest,
@@ -13,6 +14,7 @@ import {
13
14
  import type { CapabilityRegistryEntry } from "../contracts/registry";
14
15
  import type { RequestActor } from "../server";
15
16
  import { createHelpReader, type HelpReaderFactory } from "../services/help";
17
+ import { resolveAppIdentityPresentation } from "../shared/app-presentation";
16
18
  import { recordRejectedAiCapability } from "./capability-execution";
17
19
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
18
20
  import { createCodeSourceTool } from "./code-source-tools";
@@ -44,6 +46,12 @@ export type AiCapabilityAppCatalogItem = {
44
46
  export type AiCapabilityCatalogEntry = AiCapabilityCatalogItem & {
45
47
  app: CapabilityRegistryEntry;
46
48
  operation: CapabilityQueryManifest | CapabilityActionManifest;
49
+ /**
50
+ * What people see in tool rows and approvals, in the locale the catalog was built for. The model keeps
51
+ * the base `title`, `description`, and schemas: they are prompt text, and stable prompts do not change
52
+ * with the reader's language.
53
+ */
54
+ display: { appName: string; title: string };
47
55
  };
48
56
 
49
57
  export type AiToolKind = "builtin" | AiCapabilityKind;
@@ -64,6 +72,16 @@ type AiToolCatalogEntry = AiToolCatalogItem & {
64
72
 
65
73
  export type AiRememberableCapabilityApprovals = ReadonlyMap<string, string>;
66
74
 
75
+ /**
76
+ * Why `load_tools` could not make a requested name callable:
77
+ * - `unknown`: no tool has this exact name;
78
+ * - `not_offered_in_turn`: the tool exists, but this turn's client or task does not provide it;
79
+ * - `not_allowed`: the conversation's fixed tool scope excludes it;
80
+ * - `app_offline`: the app operation is not in the live registry now.
81
+ */
82
+ export const AI_TOOL_UNAVAILABLE_REASONS = ["unknown", "not_offered_in_turn", "not_allowed", "app_offline"] as const;
83
+ export type AiToolUnavailableReason = (typeof AI_TOOL_UNAVAILABLE_REASONS)[number];
84
+
67
85
  const DEFAULT_SEARCH_LIMIT = 10;
68
86
  const DEFAULT_APP_LIST_LIMIT = 20;
69
87
  const MAX_APP_LIST_LIMIT = 25;
@@ -109,8 +127,11 @@ export const buildAiCapabilityAppCatalog = (apps: readonly CapabilityRegistryEnt
109
127
  });
110
128
  };
111
129
 
112
- /** Build one deterministic, immutable view of the current live registry. */
113
- export const buildAiCapabilityCatalog = (apps: CapabilityRegistryEntry[]): AiCapabilityCatalogEntry[] => {
130
+ /**
131
+ * Build one deterministic, immutable view of the current live registry. `locale` selects the human
132
+ * presentation in `display`; model-facing fields always stay in the app's base presentation.
133
+ */
134
+ export const buildAiCapabilityCatalog = (apps: CapabilityRegistryEntry[], locale?: string): AiCapabilityCatalogEntry[] => {
114
135
  const entries = [...apps]
115
136
  .sort(
116
137
  (left, right) =>
@@ -119,12 +140,18 @@ export const buildAiCapabilityCatalog = (apps: CapabilityRegistryEntry[]): AiCap
119
140
  left.appName.localeCompare(right.appName) ||
120
141
  left.endpoint.localeCompare(right.endpoint),
121
142
  )
122
- .flatMap((app) => [
123
- ...app.manifest.actions.map((operation) => ({ app, operation, kind: "action" as const })),
124
- ...app.manifest.queries.map((operation) => ({ app, operation, kind: "query" as const })),
125
- ])
143
+ .flatMap((app) => {
144
+ const appName = locale
145
+ ? resolveAppIdentityPresentation({ name: app.appName, description: app.appDescription, presentation: app.appPresentation }, locale)
146
+ .name
147
+ : app.appName;
148
+ return [
149
+ ...app.manifest.actions.map((operation) => ({ app, appName, operation, kind: "action" as const })),
150
+ ...app.manifest.queries.map((operation) => ({ app, appName, operation, kind: "query" as const })),
151
+ ];
152
+ })
126
153
  .map(
127
- ({ app, operation, kind }): AiCapabilityCatalogEntry => ({
154
+ ({ app, appName, operation, kind }): AiCapabilityCatalogEntry => ({
128
155
  name: aiCapabilityId(app.appId, operation.localId),
129
156
  providerName: aiCapabilityToolName(app.appId, kind, operation.localId),
130
157
  appId: app.appId,
@@ -135,6 +162,12 @@ export const buildAiCapabilityCatalog = (apps: CapabilityRegistryEntry[]): AiCap
135
162
  description: operation.description,
136
163
  app,
137
164
  operation,
165
+ display: {
166
+ appName,
167
+ title: locale
168
+ ? resolveCapabilityOperationTitle(operation, kind === "action" ? "actions" : "queries", app.presentation, locale)
169
+ : operation.title,
170
+ },
138
171
  }),
139
172
  )
140
173
  .sort((left, right) => left.name.localeCompare(right.name));
@@ -344,15 +377,16 @@ export const createAiHelpTools = (help: HelpReaderFactory = createHelpReader, lo
344
377
  return [search, read];
345
378
  };
346
379
 
380
+ /** `null` when the registry could not be read. */
347
381
  const resolveCapabilityRegistry = async (
348
382
  listRegistry: () => Promise<CapabilityRegistryEntry[]>,
349
383
  onError?: (error: unknown) => void,
350
- ): Promise<CapabilityRegistryEntry[]> => {
384
+ ): Promise<CapabilityRegistryEntry[] | null> => {
351
385
  try {
352
386
  return await listRegistry();
353
387
  } catch (error) {
354
388
  onError?.(error);
355
- return [];
389
+ return null;
356
390
  }
357
391
  };
358
392
 
@@ -430,6 +464,9 @@ const ToolCatalogItemSchema = z
430
464
 
431
465
  type ToolStateStore = Pick<AiConversationService, "loadTools">;
432
466
 
467
+ /** `call` is the name to call the tool by; app operations get a provider-safe name. */
468
+ const LoadedToolSchema = z.object({ name: z.string(), call: z.string() }).strict();
469
+
433
470
  export const createAiToolMetaTools = (input: {
434
471
  apps: readonly CapabilityRegistryEntry[];
435
472
  catalog: readonly AiToolCatalogEntry[];
@@ -438,6 +475,10 @@ export const createAiToolMetaTools = (input: {
438
475
  store: ToolStateStore;
439
476
  maxLoadedTools?: number;
440
477
  unavailableLoadedNames?: readonly string[];
478
+ /** False when this turn offers no app operations at all. */
479
+ appOperationsOffered?: boolean;
480
+ /** Why a requested name that is not in `catalog` cannot be loaded; defaults to `unknown`. */
481
+ unavailableReason?: (name: string) => AiToolUnavailableReason;
441
482
  }): AiRuntimeTool[] => {
442
483
  const apps = buildAiCapabilityAppCatalog(input.apps);
443
484
  const directoryEntries: string[] = [];
@@ -453,17 +494,17 @@ export const createAiToolMetaTools = (input: {
453
494
  const liveAppDirectory =
454
495
  directoryEntries.length > 0
455
496
  ? ` Live capability apps: ${directoryEntries.join(", ")}${hiddenAppCount > 0 ? `, and ${hiddenAppCount} more` : ""}.`
456
- : " No live capability apps are visible in this provider turn; retry discovery later instead of claiming a permanent product limitation.";
497
+ : input.appOperationsOffered === false
498
+ ? " App operations are not offered in this turn."
499
+ : " No Cloud app publishes operations right now. Do not search for app operations again in this turn; tell the user which app is not reachable instead of claiming a permanent product limitation.";
457
500
  const unavailableLoadedNames = input.unavailableLoadedNames ?? [];
458
501
  const unavailableLoadedNotice =
459
502
  unavailableLoadedNames.length > 0
460
- ? ` Previously loaded tools currently absent from the live catalog: ${unavailableLoadedNames
461
- .slice(-MAX_UNAVAILABLE_LOADED_NAMES)
462
- .join(", ")}${
503
+ ? ` Previously loaded tools that are not available now: ${unavailableLoadedNames.slice(-MAX_UNAVAILABLE_LOADED_NAMES).join(", ")}${
463
504
  unavailableLoadedNames.length > MAX_UNAVAILABLE_LOADED_NAMES
464
505
  ? `, and ${unavailableLoadedNames.length - MAX_UNAVAILABLE_LOADED_NAMES} more`
465
506
  : ""
466
- }. Treat them as temporarily unavailable; do not infer a permanent product limitation or search repeatedly.`
507
+ }. Do not search for them again in this turn; continue without them or tell the user what is missing.`
467
508
  : "";
468
509
  const search = defineAiTool({
469
510
  name: "search_tools",
@@ -501,38 +542,52 @@ export const createAiToolMetaTools = (input: {
501
542
  const load = defineAiTool({
502
543
  name: "load_tools",
503
544
  description:
504
- "Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first.",
545
+ "Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first. Call each loaded tool by the `call` name returned for it. `unavailable` gives a reason per name: unknown means no tool has this exact name, so look it up once with search_tools instead of guessing; not_offered_in_turn means this client or task does not provide the tool; not_allowed means this chat's fixed tool scope excludes it; app_offline means its app is not reachable now. For the last three, do not search or load the name again in this turn.",
505
546
  inputSchema: z.object({ names: z.array(z.string().trim().min(1)).min(1).max(25) }).strict(),
506
547
  outputSchema: z
507
548
  .object({
508
- loaded: z.array(z.string()),
509
- alreadyLoaded: z.array(z.string()),
510
- missing: z.array(z.string()),
549
+ loaded: z.array(LoadedToolSchema),
550
+ alreadyLoaded: z.array(LoadedToolSchema),
551
+ unavailable: z.array(z.object({ name: z.string(), reason: z.enum(AI_TOOL_UNAVAILABLE_REASONS) }).strict()),
511
552
  evicted: z.array(z.string()),
512
553
  titles: z.record(z.string(), z.string()),
513
554
  })
514
555
  .strict(),
515
556
  approval: "never",
516
557
  }).server(async ({ names }) => {
517
- const available = new Set(input.catalog.map((entry) => entry.name));
558
+ const catalogByName = new Map(input.catalog.map((entry) => [entry.name, entry]));
559
+ // A provider name the model saw in an earlier call loads its operation, too.
560
+ const byProviderName = new Map(input.catalog.flatMap((entry) => (entry.capability ? [[entry.capability.providerName, entry]] : [])));
518
561
  const requested = [...new Set(names)];
519
- const eager = requested.filter((name) => input.eagerNames.has(name));
520
- const valid = requested.filter((name) => available.has(name) && !input.eagerNames.has(name));
521
- const missing = requested.filter((name) => !available.has(name));
562
+ const found = requested.flatMap((name) => {
563
+ const entry = catalogByName.get(name) ?? byProviderName.get(name);
564
+ return entry ? [entry.name] : [];
565
+ });
566
+ const eager = [...new Set(found.filter((name) => input.eagerNames.has(name)))];
567
+ const valid = [...new Set(found.filter((name) => !input.eagerNames.has(name)))];
568
+ const unavailable = requested
569
+ .filter((name) => !catalogByName.has(name) && !byProviderName.has(name))
570
+ .map((name) => ({ name, reason: input.unavailableReason?.(name) ?? ("unknown" as const) }));
522
571
  const updated = await input.store.loadTools({
523
572
  conversationId: input.conversationId,
524
573
  names: valid,
525
574
  maxLoadedTools: input.maxLoadedTools,
526
575
  });
576
+ const callable = (name: string) => ({ name, call: catalogByName.get(name)?.capability?.providerName ?? name });
527
577
  const alreadyLoaded = [...new Set([...eager, ...updated.alreadyLoaded])];
528
- const catalogByName = new Map(input.catalog.map((entry) => [entry.name, entry]));
529
578
  const titles = Object.fromEntries(
530
579
  [...new Set([...updated.loaded, ...alreadyLoaded, ...updated.evicted])].flatMap((name) => {
531
580
  const entry = catalogByName.get(name);
532
581
  return entry ? [[name, entry.title]] : [];
533
582
  }),
534
583
  );
535
- return { ...updated, alreadyLoaded, missing, titles };
584
+ return {
585
+ loaded: updated.loaded.map(callable),
586
+ alreadyLoaded: alreadyLoaded.map(callable),
587
+ unavailable,
588
+ evicted: updated.evicted,
589
+ titles,
590
+ };
536
591
  });
537
592
 
538
593
  return [search, load, listApps];
@@ -566,8 +621,7 @@ export const createLoadedAiCapabilityTools = (input: {
566
621
  if (!input.authorizeBackground && entry.kind === "action" && (entry.operation as CapabilityActionManifest).approval !== "none") {
567
622
  const review = (await input.review?.(entry, args, context)) ?? null;
568
623
  if (review && context.callId) input.onReview?.(context.callId, review);
569
- const message =
570
- review?.message ?? `${entry.appName}: ${entry.title}\nReview the validated arguments below before running this Action.`;
624
+ const message = review?.message ?? `${entry.display.appName}: ${entry.display.title}`;
571
625
  if (!(await context.requestApproval(message))) {
572
626
  if (input.actor.kind === "user") {
573
627
  await recordRejectedAiCapability({ entry, actor: input.actor, args }).catch(() => undefined);
@@ -635,6 +689,8 @@ export const createAiToolResolver =
635
689
  actor: RequestActor;
636
690
  staticTools: AiRuntimeTool[];
637
691
  allowedTools?: readonly string[] | null;
692
+ /** Built-in tools that exist but that this turn does not offer, such as client tools its client did not declare or tools outside `allowedTools`. */
693
+ unofferedTools?: readonly string[];
638
694
  runtimeContext?: Omit<AiToolPreparationContext, "actor" | "conversationId">;
639
695
  store: Pick<AiConversationService, "getLoadedTools" | "loadTools">;
640
696
  listRegistry?: () => Promise<CapabilityRegistryEntry[]>;
@@ -653,10 +709,11 @@ export const createAiToolResolver =
653
709
  }) => void;
654
710
  }): ToolResolver =>
655
711
  async (): Promise<Tool[]> => {
656
- const [registry, persistedLoadedNames] = await Promise.all([
712
+ const [liveRegistry, persistedLoadedNames] = await Promise.all([
657
713
  input.listRegistry ? resolveCapabilityRegistry(input.listRegistry, input.onCapabilityRegistryError) : [],
658
714
  input.store.getLoadedTools({ conversationId: input.conversationId }),
659
715
  ]);
716
+ const registry = liveRegistry ?? [];
660
717
  const configuredLimit = Math.floor(input.maxLoadedTools ?? 0);
661
718
  const loadedNames = configuredLimit > 0 ? persistedLoadedNames.slice(-configuredLimit) : persistedLoadedNames;
662
719
  if (loadedNames.length !== persistedLoadedNames.length) {
@@ -667,18 +724,41 @@ export const createAiToolResolver =
667
724
  });
668
725
  }
669
726
  const allowed = input.allowedTools == null ? null : new Set(input.allowedTools);
670
- const capabilityCatalog = buildAiCapabilityCatalog(registry).filter((entry) => !allowed || allowed.has(entry.name));
727
+ const fullCapabilityCatalog = buildAiCapabilityCatalog(registry, input.locale);
728
+ const capabilityCatalog = fullCapabilityCatalog.filter((entry) => !allowed || allowed.has(entry.name));
671
729
  const helpTools = input.help ? createAiHelpTools(input.help, input.locale) : [];
672
730
  const resourceTool =
673
731
  input.execute && (capabilityCatalog.length > 0 || input.staticTools.some((tool) => tool.def.name === "code_read"))
674
732
  ? createAiResourceReaderTool({ apps: registry, catalog: capabilityCatalog, execute: input.execute })
675
733
  : null;
676
- const builtIns = [...input.staticTools, ...helpTools, ...(resourceTool ? [resourceTool] : [])].filter(
677
- (tool) => !allowed || allowed.has(tool.def.name),
678
- );
734
+ const allBuiltIns = [...input.staticTools, ...helpTools, ...(resourceTool ? [resourceTool] : [])];
735
+ const builtIns = allBuiltIns.filter((tool) => !allowed || allowed.has(tool.def.name));
679
736
  const catalog = buildAiToolCatalog(builtIns, capabilityCatalog);
680
737
  const catalogNames = new Set(catalog.map((entry) => entry.name));
681
738
  const unavailableLoadedNames = loadedNames.filter((name) => !catalogNames.has(name));
739
+ const outOfScope = new Set([
740
+ ...allBuiltIns.map((tool) => tool.def.name),
741
+ ...fullCapabilityCatalog.flatMap((entry) => [entry.name, entry.providerName]),
742
+ ...(input.unofferedTools ?? []),
743
+ ]);
744
+ const unoffered = new Set(input.unofferedTools);
745
+ // A model may name a loaded operation by the provider name it called it by; app IDs contain no dot.
746
+ const loadedByProviderName = new Map(
747
+ persistedLoadedNames.flatMap((id) => {
748
+ const dot = id.indexOf(".");
749
+ if (dot < 1) return [];
750
+ return (["query", "action"] as const).map((kind) => [aiCapabilityToolName(id.slice(0, dot), kind, id.slice(dot + 1)), id] as const);
751
+ }),
752
+ );
753
+ // App operation IDs always contain a dot; built-in names never do.
754
+ const unavailableReason = (requested: string): AiToolUnavailableReason => {
755
+ const name = loadedByProviderName.get(requested) ?? requested;
756
+ if (allowed && outOfScope.has(name) && !allowed.has(name)) return "not_allowed";
757
+ if (unoffered.has(name)) return "not_offered_in_turn";
758
+ if (!name.includes(".")) return "unknown";
759
+ if (!input.listRegistry) return "not_offered_in_turn";
760
+ return liveRegistry === null || persistedLoadedNames.includes(name) ? "app_offline" : "unknown";
761
+ };
682
762
  const eagerNames = new Set(builtIns.map((tool) => tool.def.name).filter((name) => !CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES.has(name)));
683
763
  const activeBuiltIns = builtIns.filter((tool) => eagerNames.has(tool.def.name) || loadedNames.includes(tool.def.name));
684
764
  const runtimeTools = [
@@ -690,6 +770,8 @@ export const createAiToolResolver =
690
770
  store: input.store,
691
771
  maxLoadedTools: input.maxLoadedTools,
692
772
  unavailableLoadedNames,
773
+ appOperationsOffered: Boolean(input.listRegistry),
774
+ unavailableReason,
693
775
  }),
694
776
  ...activeBuiltIns,
695
777
  ...(input.execute
@@ -719,10 +801,10 @@ export const createAiToolResolver =
719
801
  presentations.set(entry.providerName, {
720
802
  kind: "capability",
721
803
  appId: entry.appId,
722
- appName: entry.appName,
804
+ appName: entry.display.appName,
723
805
  appIcon: entry.app.appIcon,
724
806
  appAccent: entry.app.appAccent,
725
- title: entry.title,
807
+ title: entry.display.title,
726
808
  capabilityKind: entry.kind,
727
809
  });
728
810
  // Rememberable scopes are resolved by the owning app for each concrete