@k2b/cloud 0.26.0 → 0.28.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 (121) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/define-app.ts +8 -1
  3. package/src/_internal/process-identity.ts +7 -1
  4. package/src/_internal/registry-validation.ts +3 -0
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/ai/admin.ts +1 -0
  7. package/src/ai/browser-code-contracts.ts +46 -64
  8. package/src/ai/browser.ts +9 -1
  9. package/src/ai/capabilities.ts +82 -22
  10. package/src/ai/chat/blocks.tsx +16 -7
  11. package/src/ai/chat/builtin-tools.tsx +31 -23
  12. package/src/ai/chat/file-tools.tsx +4 -1
  13. package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
  14. package/src/ai/chat/message-actions.tsx +123 -101
  15. package/src/ai/chat/message-utils.ts +30 -1
  16. package/src/ai/chat/messages.ts +206 -2
  17. package/src/ai/chat/presentation.tsx +94 -28
  18. package/src/ai/chat/tool-groups.ts +1 -1
  19. package/src/ai/chat/turn-error.ts +21 -0
  20. package/src/ai/chat/turn-view.tsx +102 -16
  21. package/src/ai/chat/user-message.tsx +19 -14
  22. package/src/ai/chat/visual-tools.tsx +1 -1
  23. package/src/ai/client/controller.ts +83 -37
  24. package/src/ai/client/file-source.ts +20 -3
  25. package/src/ai/client/projection.ts +7 -2
  26. package/src/ai/code-mode-skill.ts +32 -36
  27. package/src/ai/code-runtime-tools.ts +14 -1
  28. package/src/ai/code-source-contracts.ts +56 -6
  29. package/src/ai/code-source-tools.ts +10 -3
  30. package/src/ai/credentials.ts +17 -3
  31. package/src/ai/data-analysis-skill.ts +3 -3
  32. package/src/ai/default-tools.ts +15 -16
  33. package/src/ai/executor.ts +281 -141
  34. package/src/ai/file-context.ts +14 -2
  35. package/src/ai/file-tools.ts +17 -3
  36. package/src/ai/files-store.ts +134 -11
  37. package/src/ai/grids-skill.ts +2 -2
  38. package/src/ai/index.ts +9 -0
  39. package/src/ai/memories.ts +14 -0
  40. package/src/ai/migrate.ts +125 -0
  41. package/src/ai/model-request-settings.ts +98 -0
  42. package/src/ai/open-tool-calls.ts +87 -0
  43. package/src/ai/protocol.ts +6 -0
  44. package/src/ai/provider.ts +7 -1
  45. package/src/ai/quota-provider.ts +2 -2
  46. package/src/ai/request-headers.ts +117 -0
  47. package/src/ai/routes.ts +40 -6
  48. package/src/ai/run-timeout.ts +4 -5
  49. package/src/ai/runtime.ts +9 -1
  50. package/src/ai/settings.ts +19 -2
  51. package/src/ai/skill-seeds.ts +35 -7
  52. package/src/ai/skills.ts +26 -0
  53. package/src/ai/solid.ts +1 -1
  54. package/src/ai/store.ts +155 -71
  55. package/src/ai/stream.ts +180 -37
  56. package/src/ai/structured.ts +20 -5
  57. package/src/ai/system-prompt.ts +8 -0
  58. package/src/ai/tool-call-names.ts +45 -0
  59. package/src/ai/turn-failure.ts +100 -0
  60. package/src/ai/turn-policy.ts +248 -0
  61. package/src/ai/types.ts +52 -4
  62. package/src/api/admin-ai-quotas.ts +36 -1
  63. package/src/api/admin-core-settings.ts +16 -23
  64. package/src/api/admin-outgoing-mail.ts +242 -0
  65. package/src/api/index.ts +2 -0
  66. package/src/browser/FileChooser.tsx +11 -0
  67. package/src/browser/file-chooser-messages.ts +2 -0
  68. package/src/cli/admin/ai-quotas.ts +70 -1
  69. package/src/cli/admin/index.ts +8 -0
  70. package/src/cli/admin/notifications.ts +6 -0
  71. package/src/cli/admin/outgoing-mail.ts +215 -0
  72. package/src/contracts/app.ts +2 -0
  73. package/src/contracts/index.ts +1 -0
  74. package/src/contracts/outgoing-mail.ts +265 -0
  75. package/src/contracts/registry.ts +2 -0
  76. package/src/services/help/store.ts +2 -1
  77. package/src/services/index.ts +13 -0
  78. package/src/services/notifications/batches.ts +139 -79
  79. package/src/services/notifications/channels.ts +33 -13
  80. package/src/services/notifications/dispatcher.ts +57 -5
  81. package/src/services/notifications/email-frame.fixture.html +51 -0
  82. package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
  83. package/src/services/notifications/email-mail.ts +101 -0
  84. package/src/services/notifications/index.ts +49 -36
  85. package/src/services/notifications/observability.ts +9 -1
  86. package/src/services/notifications/platform.ts +1 -1
  87. package/src/services/notifications/runtime.ts +9 -3
  88. package/src/services/outgoing-mail/admin.ts +84 -0
  89. package/src/services/outgoing-mail/attachments.ts +135 -0
  90. package/src/services/outgoing-mail/bulk.ts +11 -0
  91. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  92. package/src/services/outgoing-mail/drain.ts +60 -0
  93. package/src/services/outgoing-mail/enqueue.ts +180 -0
  94. package/src/services/outgoing-mail/index.ts +112 -0
  95. package/src/services/outgoing-mail/message.ts +14 -0
  96. package/src/services/outgoing-mail/messages.ts +431 -0
  97. package/src/services/outgoing-mail/retention.ts +48 -0
  98. package/src/services/outgoing-mail/runtime.ts +72 -0
  99. package/src/services/outgoing-mail/send.ts +130 -0
  100. package/src/services/outgoing-mail/store.ts +397 -0
  101. package/src/services/outgoing-mail/sync.ts +39 -0
  102. package/src/services/outgoing-mail/test-send.ts +40 -0
  103. package/src/services/outgoing-mail/transport.ts +17 -0
  104. package/src/services/pdf/markdown.ts +22 -4
  105. package/src/services/postgres.ts +15 -0
  106. package/src/services/settings/core-settings.ts +19 -38
  107. package/src/services/settings/store.ts +5 -1
  108. package/src/shared/ai-model-request-settings.ts +21 -0
  109. package/src/shared/ai-platform-prompt.ts +49 -5
  110. package/src/shared/ai-request-options.ts +185 -0
  111. package/src/shared/markdown/extensions/links.ts +28 -20
  112. package/src/shared/markdown/index.ts +14 -5
  113. package/src/shared/markdown/shared.ts +0 -7
  114. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  115. package/src/ssr/admin-navigation.ts +1 -1
  116. package/src/ssr/platform-messages.ts +3 -1
  117. package/src/ssr/workspace-navigation.ts +7 -1
  118. package/src/styles/effects.css +15 -17
  119. package/src/styles/tokens.css +2 -0
  120. package/src/styles/utilities-markdown-editor.css +4 -39
  121. package/src/styles/utilities-markdown-table.css +14 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -98,9 +98,9 @@
98
98
  "@simplewebauthn/server": "14.0.3",
99
99
  "@tabler/icons": "3.48.0",
100
100
  "@tailwindcss/typography": "0.5.20",
101
- "@k2b/nessi": "0.12.1",
101
+ "@k2b/nessi": "0.15.0",
102
102
  "@k2b/ssr": "0.14.0",
103
- "@k2b/ui": "0.16.0",
103
+ "@k2b/ui": "0.18.0",
104
104
  "@k2b/stdlib": "0.27.0",
105
105
  "@k2b/sync": "7.0.0",
106
106
  "@nats-io/jetstream": "3.4.0",
@@ -40,6 +40,7 @@ import {
40
40
  CapabilityOriginSchema,
41
41
  } from "../contracts/capabilities";
42
42
  import { type BoundNotificationMap, bindNotificationDefinitions, type NotificationDefinitionMap } from "../contracts/notification-types";
43
+ import { type PlatformPermission, PlatformPermissionSchema } from "../contracts/outgoing-mail";
43
44
  import { isPwaPartId, PWA_AUTH_PATH, PWA_CANVAS_COLORS, PWA_MANIFEST_PATH, PWA_SHELL_APP_ID } from "../contracts/pwa";
44
45
  import type { AppRegistryEntry } from "../contracts/registry";
45
46
  import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
@@ -201,6 +202,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
201
202
  * prefix-trie from these strings.
202
203
  */
203
204
  routes: readonly string[];
205
+ /** Platform services requested by this application, visible to administrators. */
206
+ platformPermissions?: readonly PlatformPermission[];
204
207
  /**
205
208
  * Gateway-relative URL at which this app's OpenAPI 3.x JSON spec is
206
209
  * served, e.g. `"/api/notebooks/openapi.json"`. Opt-in: only set this
@@ -471,6 +474,9 @@ export const defineApp = <
471
474
  // ── 2. Meta ───────────────────────────────────────────────────────────
472
475
  const baseMeta: AppMeta = {
473
476
  id: opts.id,
477
+ platformPermissions: opts.platformPermissions
478
+ ? opts.platformPermissions.map((permission) => PlatformPermissionSchema.parse(permission))
479
+ : undefined,
474
480
  name: opts.name,
475
481
  icon: opts.icon,
476
482
  description: opts.description,
@@ -528,7 +534,7 @@ export const defineApp = <
528
534
  }
529
535
  })());
530
536
  try {
531
- bindProcessApplicationId(meta.id);
537
+ bindProcessApplicationId(meta.id, meta.platformPermissions);
532
538
  cleanup.unshift(clearProcessApplicationId);
533
539
  const startedAt = Date.now();
534
540
 
@@ -587,6 +593,7 @@ export const defineApp = <
587
593
  ? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
588
594
  : undefined,
589
595
  widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
596
+ platformPermissions: meta.platformPermissions ? [...meta.platformPermissions] : undefined,
590
597
  settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
591
598
  openapi: advertiseOpenapi ? opts.openapi : undefined,
592
599
  cliModules: cliModules.length > 0 ? cliModules : undefined,
@@ -1,15 +1,21 @@
1
+ import type { PlatformPermission } from "../contracts/outgoing-mail";
2
+
1
3
  /** Identity of the application started in this process, derived from its declaration. */
2
4
  let applicationId: string | undefined;
5
+ let platformPermissions: readonly PlatformPermission[] = [];
6
+ export const getProcessPlatformPermissions = (): readonly PlatformPermission[] => platformPermissions;
3
7
 
4
8
  export const getProcessApplicationId = (): string | undefined => applicationId;
5
9
 
6
- export const bindProcessApplicationId = (id: string): void => {
10
+ export const bindProcessApplicationId = (id: string, permissions: readonly PlatformPermission[] = []): void => {
7
11
  if (applicationId !== undefined && applicationId !== id) {
8
12
  throw new Error(`This process already hosts application "${applicationId}"`);
9
13
  }
10
14
  applicationId = id;
15
+ platformPermissions = [...permissions];
11
16
  };
12
17
 
13
18
  export const clearProcessApplicationId = (): void => {
14
19
  applicationId = undefined;
20
+ platformPermissions = [];
15
21
  };
@@ -1,4 +1,5 @@
1
1
  import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
2
+ import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
2
3
  import type { AppRegistryEntry } from "../contracts/registry";
3
4
 
4
5
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -118,6 +119,8 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
118
119
  return invalid("widgets", "an array of valid widgets");
119
120
  }
120
121
  }
122
+ if (value.platformPermissions !== undefined && !PlatformPermissionSchema.array().safeParse(value.platformPermissions).success)
123
+ return invalid("platformPermissions", "an array of platform permissions");
121
124
  if (value.settingKeys !== undefined && !isStringArray(value.settingKeys)) return invalid("settingKeys", "an array of strings");
122
125
  if (
123
126
  value.cliModules !== undefined &&
@@ -15,6 +15,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
15
15
  const searchTags = searchQueries?.flatMap((query) => query.universalSearch!.tags.flatMap((tag) => [tag.tag, ...(tag.aliases ?? [])]));
16
16
  return {
17
17
  id: e.id,
18
+ platformPermissions: e.platformPermissions ? [...e.platformPermissions] : undefined,
18
19
  name: e.name,
19
20
  icon: e.icon,
20
21
  description: e.description,
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";
@@ -10,20 +10,20 @@ export const CODE_RUNTIME_TOOL_NAMES = [
10
10
  "code_run",
11
11
  "code_action",
12
12
  "code_inspect",
13
- "code_interact",
14
13
  "code_stop",
15
14
  "code_open",
16
15
  "code_export",
17
16
  "code_present",
17
+ "code_check",
18
18
  "code_secret",
19
19
  ] as const;
20
20
  export const CodeRunInput = z
21
21
  .object({
22
- id: id.optional(),
22
+ id: id.optional().describe("Saved script resource to run; an app with an index.html interface opens with code_open instead."),
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. 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()
@@ -36,14 +36,12 @@ export const CodeRunInput = z
36
36
  .min(1)
37
37
  .max(1024 * 1024)
38
38
  .optional()
39
- .describe("One-off JavaScript/TypeScript entry exporting one function. Use code OR a saved resource id; no title or icon needed."),
39
+ .describe("One-off JavaScript/TypeScript script exporting one function. Use code OR a saved resource id; no title or icon needed."),
40
40
  inputPaths: z
41
41
  .array(z.string().min(1).max(500))
42
42
  .max(64)
43
43
  .default([])
44
- .describe(
45
- "Explicit current-chat input files. Scripts can read them; app test runs expose them only through the simulated picker. User apps never receive chat files.",
46
- ),
44
+ .describe("Explicit current-chat input files; the script reads them from its context files. Apps never receive chat files."),
47
45
  })
48
46
  .strict()
49
47
  .refine((input) => Number(input.id !== undefined) + Number(input.code !== undefined) === 1, "Provide exactly one of id or code")
@@ -71,7 +69,10 @@ export const CodeActionInput = z
71
69
  .positive()
72
70
  .optional()
73
71
  .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."),
72
+ input: z
73
+ .record(z.string(), z.json())
74
+ .default({})
75
+ .describe("Action input object matching the discovered inputSchema. Pass the object itself, never JSON text."),
75
76
  })
76
77
  .strict()
77
78
  .refine(
@@ -88,66 +89,38 @@ export const CodeInspectInput = z
88
89
  .max(30000)
89
90
  .default(0)
90
91
  .describe("Wait up to this duration for background work to finish before returning its real state; never restarts work."),
91
- nodeId: z.string().min(1).max(80).optional(),
92
- offset: z.number().int().min(0).default(0),
93
- limit: z.number().int().min(1).max(100).default(20),
94
92
  })
95
93
  .strict();
96
- const CodeInteraction = z
94
+ export const CodeStopInput = z.object({ runId }).strict();
95
+ export const CodeOpenInput = z.object({ id }).strict();
96
+ const AppFilePath = z
97
+ .string()
98
+ .min(1)
99
+ .max(180)
100
+ .regex(/^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*$/)
101
+ .refine((path) => path.split("/").every((part) => part !== "." && part !== ".."), "Expected a relative path without . or ..");
102
+ export const CodePresentInput = z
97
103
  .object({
98
- id: z.string().min(1).max(80).describe("Control ID or pending modal ID returned by the snapshot."),
99
- event: z
100
- .discriminatedUnion("type", [
101
- z
102
- .object({
103
- type: z.literal("change"),
104
- value: z.union([
105
- z.string(),
106
- z.number().finite(),
107
- z.boolean(),
108
- z.null(),
109
- z.array(z.string()),
110
- z.object({ start: z.iso.date().nullable(), end: z.iso.date().nullable() }).strict(),
111
- ]),
112
- })
113
- .strict(),
114
- z.object({ type: z.literal("select"), key: z.string().nullable() }).strict(),
115
- z.object({ type: z.literal("view"), value: z.enum(["chart", "table"]) }).strict(),
116
- z.object({ type: z.literal("refresh") }).strict(),
117
- z.object({ type: z.literal("request"), request: z.record(z.string(), z.json()) }).strict(),
118
- ])
104
+ id: id
119
105
  .optional()
120
- .describe(
121
- "An event object, never a JSON-encoded string. Omit to activate a button. Example: {type:'change',value:['Sued']} or {type:'view',value:'table'}.",
122
- ),
123
- answer: z.json().optional().describe("Only for the pending modal ID: the modal answer, or null to cancel. Do not use for controls."),
124
- })
125
- .strict();
126
- const validInteraction = (input: z.infer<typeof CodeInteraction>) => input.event === undefined || input.answer === undefined;
127
- export const CodeInteractInput = CodeInteraction.partial({ id: true })
128
- .extend({
129
- runId,
130
- // Three normal 15-second callback budgets share the existing 45-second call budget.
131
- steps: z
132
- .array(CodeInteraction.refine(validInteraction, "Supply event OR answer"))
106
+ .describe("Saved app with an index.html interface; the card runs it live with its data. Omit title to use the app title."),
107
+ files: z
108
+ .array(z.object({ path: AppFilePath, content: z.string().max(1024 * 1024) }).strict())
133
109
  .min(1)
134
- .max(3)
110
+ .max(64)
135
111
  .optional()
136
- .describe(
137
- "Up to three sequential interactions in one call. Use steps OR the top-level id/event/answer. Stops on an error, pending modal or background work; returns completedSteps and nextStep.",
138
- ),
112
+ .describe("One-off app for this chat: index.html plus optional style.css, app.js and other modules. It has no saved data."),
113
+ title: z.string().trim().min(1).max(120).optional().describe("Card title; required with files."),
139
114
  })
140
115
  .strict()
141
- .refine(
142
- (input) =>
143
- input.steps
144
- ? input.id === undefined && input.event === undefined && input.answer === undefined
145
- : input.id !== undefined && (input.event === undefined || input.answer === undefined),
146
- "Supply steps OR one id with event/answer.",
147
- );
148
- export const CodeStopInput = z.object({ runId }).strict();
149
- export const CodeOpenInput = z.object({ id }).strict();
150
- export const CodePresentInput = z.object({ runId, title: z.string().trim().min(1).max(120) }).strict();
116
+ .refine((input) => Number(input.id !== undefined) + Number(input.files !== undefined) === 1, "Provide exactly one of id or files")
117
+ .refine((input) => input.id !== undefined || input.title !== undefined, "A one-off app needs a title")
118
+ .refine((input) => !input.files || input.files.some((file) => file.path === "index.html"), "files must contain index.html");
119
+ export const CodeCheckInput = z
120
+ .object({ id: id.optional(), files: CodePresentInput.shape.files })
121
+ .strict()
122
+ .refine((input) => Number(input.id !== undefined) + Number(input.files !== undefined) === 1, "Provide exactly one of id or files")
123
+ .refine((input) => !input.files || input.files.some((file) => file.path === "index.html"), "files must contain index.html");
151
124
  export const CodeExportInput = z
152
125
  .object({ runId, name: z.string().min(1).max(180).describe("Captured output filename returned by the snapshot.") })
153
126
  .strict();
@@ -162,16 +135,25 @@ export const CodeSecretInput = z
162
135
  })
163
136
  .strict();
164
137
 
138
+ /**
139
+ * A runtime call that did not complete: rejected input, timeout, lost run or host failure.
140
+ * Hosts return it as data because a client tool result carries no error flag; the managed
141
+ * server tools report it to the model as a tool error. A run snapshot whose code failed is a
142
+ * completed call and keeps its own `status` and `error`.
143
+ */
144
+ export const CodeToolFailure = z.object({ failed: z.literal(true), error: z.string(), guidance: z.string().optional() });
145
+ export type CodeToolFailure = z.infer<typeof CodeToolFailure>;
146
+
165
147
  // Internal bridge envelope. Each model-facing tool receives only its own flat schema.
166
148
  export const CodeRuntimeInput = z.discriminatedUnion("operation", [
167
149
  CodeRunInput.safeExtend({ operation: z.literal("run") }),
168
150
  CodeActionInput.safeExtend({ operation: z.literal("action") }),
169
151
  CodeInspectInput.extend({ operation: z.literal("inspect") }),
170
- CodeInteractInput.safeExtend({ operation: z.literal("interact") }),
171
152
  CodeStopInput.extend({ operation: z.literal("stop") }),
172
153
  CodeOpenInput.extend({ operation: z.literal("open") }),
173
154
  CodeSecretInput.extend({ operation: z.literal("secret") }),
174
- CodePresentInput.extend({ operation: z.literal("present") }),
155
+ CodePresentInput.safeExtend({ operation: z.literal("present") }),
156
+ CodeCheckInput.safeExtend({ operation: z.literal("check") }),
175
157
  CodeExportInput.extend({ operation: z.literal("export") }),
176
158
  ]);
177
159
  export type CodeRuntimeInput = z.infer<typeof CodeRuntimeInput>;
@@ -185,12 +167,12 @@ export function parseCodeToolInput(name: string, args: unknown): CodeRuntimeInpu
185
167
  return { operation: "run", ...CodeRunInput.parse(args) };
186
168
  case "code_inspect":
187
169
  return { operation: "inspect", ...CodeInspectInput.parse(args) };
188
- case "code_interact":
189
- return { operation: "interact", ...CodeInteractInput.parse(args) };
190
170
  case "code_stop":
191
171
  return { operation: "stop", ...CodeStopInput.parse(args) };
192
172
  case "code_open":
193
173
  return { operation: "open", ...CodeOpenInput.parse(args) };
174
+ case "code_check":
175
+ return { operation: "check", ...CodeCheckInput.parse(args) };
194
176
  case "code_present":
195
177
  return { operation: "present", ...CodePresentInput.parse(args) };
196
178
  case "code_export":
package/src/ai/browser.ts CHANGED
@@ -1,4 +1,12 @@
1
- export { CODE_RUNTIME_TOOL_NAMES, CodeActionInput, CodeResourceId, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
1
+ export {
2
+ CODE_RUNTIME_TOOL_NAMES,
3
+ CodeActionInput,
4
+ CodeCheckInput,
5
+ CodeResourceId,
6
+ CodeRuntimeInput,
7
+ CodeToolFailure,
8
+ parseCodeToolInput,
9
+ } from "./browser-code-contracts";
2
10
  export { type AiTaskRequest, type AiTaskRequestInput, AiTaskRequestSchema } from "./task-contracts";
3
11
  export { type AiTodoPlan, parseAiTodoPlan } from "./todo-contracts";
4
12
 
@@ -72,6 +72,16 @@ type AiToolCatalogEntry = AiToolCatalogItem & {
72
72
 
73
73
  export type AiRememberableCapabilityApprovals = ReadonlyMap<string, string>;
74
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
+
75
85
  const DEFAULT_SEARCH_LIMIT = 10;
76
86
  const DEFAULT_APP_LIST_LIMIT = 20;
77
87
  const MAX_APP_LIST_LIMIT = 25;
@@ -367,15 +377,16 @@ export const createAiHelpTools = (help: HelpReaderFactory = createHelpReader, lo
367
377
  return [search, read];
368
378
  };
369
379
 
380
+ /** `null` when the registry could not be read. */
370
381
  const resolveCapabilityRegistry = async (
371
382
  listRegistry: () => Promise<CapabilityRegistryEntry[]>,
372
383
  onError?: (error: unknown) => void,
373
- ): Promise<CapabilityRegistryEntry[]> => {
384
+ ): Promise<CapabilityRegistryEntry[] | null> => {
374
385
  try {
375
386
  return await listRegistry();
376
387
  } catch (error) {
377
388
  onError?.(error);
378
- return [];
389
+ return null;
379
390
  }
380
391
  };
381
392
 
@@ -453,6 +464,9 @@ const ToolCatalogItemSchema = z
453
464
 
454
465
  type ToolStateStore = Pick<AiConversationService, "loadTools">;
455
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
+
456
470
  export const createAiToolMetaTools = (input: {
457
471
  apps: readonly CapabilityRegistryEntry[];
458
472
  catalog: readonly AiToolCatalogEntry[];
@@ -461,6 +475,10 @@ export const createAiToolMetaTools = (input: {
461
475
  store: ToolStateStore;
462
476
  maxLoadedTools?: number;
463
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;
464
482
  }): AiRuntimeTool[] => {
465
483
  const apps = buildAiCapabilityAppCatalog(input.apps);
466
484
  const directoryEntries: string[] = [];
@@ -476,17 +494,17 @@ export const createAiToolMetaTools = (input: {
476
494
  const liveAppDirectory =
477
495
  directoryEntries.length > 0
478
496
  ? ` Live capability apps: ${directoryEntries.join(", ")}${hiddenAppCount > 0 ? `, and ${hiddenAppCount} more` : ""}.`
479
- : " 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.";
480
500
  const unavailableLoadedNames = input.unavailableLoadedNames ?? [];
481
501
  const unavailableLoadedNotice =
482
502
  unavailableLoadedNames.length > 0
483
- ? ` Previously loaded tools currently absent from the live catalog: ${unavailableLoadedNames
484
- .slice(-MAX_UNAVAILABLE_LOADED_NAMES)
485
- .join(", ")}${
503
+ ? ` Previously loaded tools that are not available now: ${unavailableLoadedNames.slice(-MAX_UNAVAILABLE_LOADED_NAMES).join(", ")}${
486
504
  unavailableLoadedNames.length > MAX_UNAVAILABLE_LOADED_NAMES
487
505
  ? `, and ${unavailableLoadedNames.length - MAX_UNAVAILABLE_LOADED_NAMES} more`
488
506
  : ""
489
- }. 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.`
490
508
  : "";
491
509
  const search = defineAiTool({
492
510
  name: "search_tools",
@@ -524,38 +542,52 @@ export const createAiToolMetaTools = (input: {
524
542
  const load = defineAiTool({
525
543
  name: "load_tools",
526
544
  description:
527
- "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.",
528
546
  inputSchema: z.object({ names: z.array(z.string().trim().min(1)).min(1).max(25) }).strict(),
529
547
  outputSchema: z
530
548
  .object({
531
- loaded: z.array(z.string()),
532
- alreadyLoaded: z.array(z.string()),
533
- 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()),
534
552
  evicted: z.array(z.string()),
535
553
  titles: z.record(z.string(), z.string()),
536
554
  })
537
555
  .strict(),
538
556
  approval: "never",
539
557
  }).server(async ({ names }) => {
540
- 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]] : [])));
541
561
  const requested = [...new Set(names)];
542
- const eager = requested.filter((name) => input.eagerNames.has(name));
543
- const valid = requested.filter((name) => available.has(name) && !input.eagerNames.has(name));
544
- 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) }));
545
571
  const updated = await input.store.loadTools({
546
572
  conversationId: input.conversationId,
547
573
  names: valid,
548
574
  maxLoadedTools: input.maxLoadedTools,
549
575
  });
576
+ const callable = (name: string) => ({ name, call: catalogByName.get(name)?.capability?.providerName ?? name });
550
577
  const alreadyLoaded = [...new Set([...eager, ...updated.alreadyLoaded])];
551
- const catalogByName = new Map(input.catalog.map((entry) => [entry.name, entry]));
552
578
  const titles = Object.fromEntries(
553
579
  [...new Set([...updated.loaded, ...alreadyLoaded, ...updated.evicted])].flatMap((name) => {
554
580
  const entry = catalogByName.get(name);
555
581
  return entry ? [[name, entry.title]] : [];
556
582
  }),
557
583
  );
558
- 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
+ };
559
591
  });
560
592
 
561
593
  return [search, load, listApps];
@@ -657,6 +689,8 @@ export const createAiToolResolver =
657
689
  actor: RequestActor;
658
690
  staticTools: AiRuntimeTool[];
659
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[];
660
694
  runtimeContext?: Omit<AiToolPreparationContext, "actor" | "conversationId">;
661
695
  store: Pick<AiConversationService, "getLoadedTools" | "loadTools">;
662
696
  listRegistry?: () => Promise<CapabilityRegistryEntry[]>;
@@ -675,10 +709,11 @@ export const createAiToolResolver =
675
709
  }) => void;
676
710
  }): ToolResolver =>
677
711
  async (): Promise<Tool[]> => {
678
- const [registry, persistedLoadedNames] = await Promise.all([
712
+ const [liveRegistry, persistedLoadedNames] = await Promise.all([
679
713
  input.listRegistry ? resolveCapabilityRegistry(input.listRegistry, input.onCapabilityRegistryError) : [],
680
714
  input.store.getLoadedTools({ conversationId: input.conversationId }),
681
715
  ]);
716
+ const registry = liveRegistry ?? [];
682
717
  const configuredLimit = Math.floor(input.maxLoadedTools ?? 0);
683
718
  const loadedNames = configuredLimit > 0 ? persistedLoadedNames.slice(-configuredLimit) : persistedLoadedNames;
684
719
  if (loadedNames.length !== persistedLoadedNames.length) {
@@ -689,18 +724,41 @@ export const createAiToolResolver =
689
724
  });
690
725
  }
691
726
  const allowed = input.allowedTools == null ? null : new Set(input.allowedTools);
692
- const capabilityCatalog = buildAiCapabilityCatalog(registry, input.locale).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));
693
729
  const helpTools = input.help ? createAiHelpTools(input.help, input.locale) : [];
694
730
  const resourceTool =
695
731
  input.execute && (capabilityCatalog.length > 0 || input.staticTools.some((tool) => tool.def.name === "code_read"))
696
732
  ? createAiResourceReaderTool({ apps: registry, catalog: capabilityCatalog, execute: input.execute })
697
733
  : null;
698
- const builtIns = [...input.staticTools, ...helpTools, ...(resourceTool ? [resourceTool] : [])].filter(
699
- (tool) => !allowed || allowed.has(tool.def.name),
700
- );
734
+ const allBuiltIns = [...input.staticTools, ...helpTools, ...(resourceTool ? [resourceTool] : [])];
735
+ const builtIns = allBuiltIns.filter((tool) => !allowed || allowed.has(tool.def.name));
701
736
  const catalog = buildAiToolCatalog(builtIns, capabilityCatalog);
702
737
  const catalogNames = new Set(catalog.map((entry) => entry.name));
703
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
+ };
704
762
  const eagerNames = new Set(builtIns.map((tool) => tool.def.name).filter((name) => !CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES.has(name)));
705
763
  const activeBuiltIns = builtIns.filter((tool) => eagerNames.has(tool.def.name) || loadedNames.includes(tool.def.name));
706
764
  const runtimeTools = [
@@ -712,6 +770,8 @@ export const createAiToolResolver =
712
770
  store: input.store,
713
771
  maxLoadedTools: input.maxLoadedTools,
714
772
  unavailableLoadedNames,
773
+ appOperationsOffered: Boolean(input.listRegistry),
774
+ unavailableReason,
715
775
  }),
716
776
  ...activeBuiltIns,
717
777
  ...(input.execute
@@ -283,7 +283,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
283
283
  <div class="mt-4 flex flex-col gap-4 text-xs leading-5 text-secondary">
284
284
  <p class="whitespace-pre-wrap">{description()}</p>
285
285
  <Show when={inlineReviewDetails().length > 0}>
286
- <dl class="grid gap-x-5 gap-y-1.5 border-t border-[var(--k2b-border)] pt-3 sm:grid-cols-[max-content_minmax(0,1fr)]">
286
+ <dl class="grid gap-x-5 gap-y-1.5 sm:grid-cols-[max-content_minmax(0,1fr)]">
287
287
  <For each={inlineReviewDetails()}>
288
288
  {(detail) => (
289
289
  <>
@@ -313,7 +313,12 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
313
313
  </pre>
314
314
  }
315
315
  >
316
- <div class="max-h-72 overflow-auto text-xs text-secondary" role="region" tabIndex={0} aria-label={detail.label}>
316
+ <div
317
+ class="-mx-2 max-h-72 overflow-auto px-2 text-xs text-secondary"
318
+ role="region"
319
+ tabIndex={0}
320
+ aria-label={detail.label}
321
+ >
317
322
  <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
318
323
  </div>
319
324
  </Show>
@@ -323,10 +328,12 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
323
328
  </div>
324
329
  </Show>
325
330
  </div>
326
- <footer
327
- class="flex min-h-12 flex-wrap items-center gap-2 border-t border-[var(--k2b-border)] bg-[var(--k2b-surface-subtle)] px-4 py-2.5"
328
- data-ai-approval-footer
329
- >
331
+ {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
332
+ <footer class="flex min-h-12 flex-wrap items-center gap-2 px-4 pb-3" data-ai-approval-footer>
333
+ {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
334
+ <span class="k2b-sr-only" role="status" aria-live="polite">
335
+ {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
336
+ </span>
330
337
  <Show when={reviewLinks().length > 0}>
331
338
  <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
332
339
  <For each={reviewLinks()}>
@@ -339,7 +346,9 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
339
346
  </nav>
340
347
  </Show>
341
348
  <Show when={approval.error()}>
342
- <p class="text-xs text-red-700 dark:text-red-300">{t().approvalFailed}</p>
349
+ <p class="text-xs text-red-700 dark:text-red-300" role="alert">
350
+ {t().approvalFailed}
351
+ </p>
343
352
  </Show>
344
353
  <div class="ml-auto shrink-0">
345
354
  <Show