@k2b/cloud 0.26.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 (76) 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 +14 -2
  8. package/src/ai/browser.ts +8 -1
  9. package/src/ai/capabilities.ts +82 -22
  10. package/src/ai/chat/builtin-tools.tsx +31 -23
  11. package/src/ai/chat/file-tools.tsx +4 -1
  12. package/src/ai/chat/live-turn.browser-harness.tsx +11 -4
  13. package/src/ai/chat/messages.ts +8 -2
  14. package/src/ai/chat/presentation.tsx +29 -9
  15. package/src/ai/chat/turn-view.tsx +47 -12
  16. package/src/ai/client/controller.ts +60 -31
  17. package/src/ai/client/file-source.ts +20 -3
  18. package/src/ai/client/projection.ts +7 -2
  19. package/src/ai/code-mode-skill.ts +27 -27
  20. package/src/ai/code-runtime-tools.ts +10 -1
  21. package/src/ai/code-source-contracts.ts +54 -4
  22. package/src/ai/code-source-tools.ts +10 -3
  23. package/src/ai/credentials.ts +17 -3
  24. package/src/ai/data-analysis-skill.ts +2 -2
  25. package/src/ai/default-tools.ts +2 -2
  26. package/src/ai/executor.ts +148 -84
  27. package/src/ai/file-context.ts +14 -2
  28. package/src/ai/file-tools.ts +17 -3
  29. package/src/ai/files-store.ts +134 -11
  30. package/src/ai/grids-skill.ts +2 -2
  31. package/src/ai/index.ts +7 -0
  32. package/src/ai/memories.ts +14 -0
  33. package/src/ai/migrate.ts +125 -0
  34. package/src/ai/model-request-settings.ts +98 -0
  35. package/src/ai/protocol.ts +6 -0
  36. package/src/ai/provider.ts +7 -1
  37. package/src/ai/quota-provider.ts +2 -2
  38. package/src/ai/request-headers.ts +117 -0
  39. package/src/ai/routes.ts +34 -6
  40. package/src/ai/runtime.ts +1 -1
  41. package/src/ai/settings.ts +19 -2
  42. package/src/ai/skill-seeds.ts +31 -3
  43. package/src/ai/skills.ts +26 -0
  44. package/src/ai/solid.ts +1 -1
  45. package/src/ai/store.ts +109 -57
  46. package/src/ai/stream.ts +180 -37
  47. package/src/ai/structured.ts +20 -5
  48. package/src/ai/system-prompt.ts +25 -0
  49. package/src/ai/tool-call-names.ts +45 -0
  50. package/src/ai/turn-policy.ts +247 -0
  51. package/src/ai/types.ts +24 -3
  52. package/src/api/admin-ai-quotas.ts +36 -1
  53. package/src/api/admin-core-settings.ts +16 -23
  54. package/src/api/admin-outgoing-mail.ts +62 -0
  55. package/src/api/index.ts +2 -0
  56. package/src/cli/admin/ai-quotas.ts +70 -1
  57. package/src/cli/admin/index.ts +6 -0
  58. package/src/cli/admin/outgoing-mail.ts +118 -0
  59. package/src/contracts/app.ts +2 -0
  60. package/src/contracts/index.ts +1 -0
  61. package/src/contracts/outgoing-mail.ts +77 -0
  62. package/src/contracts/registry.ts +2 -0
  63. package/src/services/index.ts +3 -0
  64. package/src/services/notifications/email.ts +16 -26
  65. package/src/services/outgoing-mail/index.ts +19 -0
  66. package/src/services/outgoing-mail/store.ts +286 -0
  67. package/src/services/outgoing-mail/test-send.ts +40 -0
  68. package/src/services/outgoing-mail/transport.ts +13 -0
  69. package/src/services/settings/core-settings.ts +1 -38
  70. package/src/services/settings/store.ts +5 -1
  71. package/src/shared/ai-model-request-settings.ts +21 -0
  72. package/src/shared/ai-platform-prompt.ts +1 -1
  73. package/src/shared/ai-request-options.ts +185 -0
  74. package/src/ssr/admin-navigation.ts +1 -1
  75. package/src/ssr/platform-messages.ts +2 -0
  76. package/src/ssr/workspace-navigation.ts +7 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.26.0",
3
+ "version": "0.27.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.17.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";
@@ -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
 
@@ -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
@@ -145,6 +145,12 @@ function ListAppsView(props: { block: ToolBlock }) {
145
145
  );
146
146
  }
147
147
 
148
+ /** Entries are `{name}` records; turns stored before #528 hold plain names and a `missing` list. */
149
+ const toolNames = (value: unknown): string[] =>
150
+ Array.isArray(value)
151
+ ? value.flatMap((item) => (typeof item === "string" ? [item] : isRecord(item) && typeof item.name === "string" ? [item.name] : []))
152
+ : [];
153
+
148
154
  function LoadToolsView(props: { block: ToolBlock }) {
149
155
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
150
156
  const titles = (): Record<string, unknown> => {
@@ -152,31 +158,33 @@ function LoadToolsView(props: { block: ToolBlock }) {
152
158
  return isRecord(value) ? value : {};
153
159
  };
154
160
  const t = useMessages();
155
- const groups = () =>
156
- (
157
- [
158
- [t().toolsLoaded, result().loaded],
159
- [t().toolsAlreadyLoaded, result().alreadyLoaded],
160
- [t().toolsMissing, result().missing],
161
- [t().toolsEvicted, result().evicted],
162
- ] as const
163
- ).flatMap(([label, value]) =>
164
- Array.isArray(value) && value.length > 0 ? [{ label, names: value.filter((name): name is string => typeof name === "string") }] : [],
165
- );
166
- const loadedCount = () => {
167
- const value = result().loaded;
168
- return Array.isArray(value) ? value.length : 0;
169
- };
161
+ const unavailableLabel = (reason: unknown) =>
162
+ reason === "not_offered_in_turn"
163
+ ? t().toolNotOffered
164
+ : reason === "not_allowed"
165
+ ? t().toolNotAllowed
166
+ : reason === "app_offline"
167
+ ? t().toolAppOffline
168
+ : t().toolUnknown;
169
+ const rows = () => [
170
+ ...toolNames(result().loaded).map((name) => ({ name, label: t().toolsLoaded })),
171
+ ...toolNames(result().alreadyLoaded).map((name) => ({ name, label: t().toolsAlreadyLoaded })),
172
+ ...records(result().unavailable).flatMap((item) =>
173
+ typeof item.name === "string" ? [{ name: item.name, label: unavailableLabel(item.reason) }] : [],
174
+ ),
175
+ ...toolNames(result().missing).map((name) => ({ name, label: t().toolUnknown })),
176
+ ...toolNames(result().evicted).map((name) => ({ name, label: t().toolsEvicted })),
177
+ ];
170
178
  return (
171
- <CompletedActivity block={props.block} label={t().loadTools} description={t().loadedCount({ count: loadedCount() })}>
172
- <Show when={groups().length > 0}>
179
+ <CompletedActivity
180
+ block={props.block}
181
+ label={t().loadTools}
182
+ description={t().loadedCount({ count: toolNames(result().loaded).length })}
183
+ >
184
+ <Show when={rows().length > 0}>
173
185
  <ResultList>
174
- <For each={groups()}>
175
- {(group) => (
176
- <For each={group.names}>
177
- {(name) => <ResultRow icon={aiToolIcon(name)} title={text(titles()[name]) || name} meta={group.label} />}
178
- </For>
179
- )}
186
+ <For each={rows()}>
187
+ {(row) => <ResultRow icon={aiToolIcon(row.name)} title={text(titles()[row.name]) || row.name} meta={row.label} />}
180
188
  </For>
181
189
  </ResultList>
182
190
  </Show>
@@ -40,7 +40,10 @@ export function PresentToolBlock(props: { block: ToolBlock }) {
40
40
  </Show>
41
41
  }
42
42
  >
43
- <div class="inline-flex min-h-7 max-w-full items-center gap-1.5 py-1 text-xs leading-none text-dimmed">
43
+ <div
44
+ data-call-id={props.block.callId}
45
+ class="inline-flex min-h-7 max-w-full items-center gap-1.5 py-1 text-xs leading-none text-dimmed"
46
+ >
44
47
  <i class={`ti ${icon()} shrink-0 text-base leading-none`} aria-hidden="true" />
45
48
  <Show
46
49
  when={actions.onOpenFile}
@@ -1,5 +1,5 @@
1
1
  import { Chat } from "@k2b/ui";
2
- import { createMemo } from "solid-js";
2
+ import { createMemo, createSignal } from "solid-js";
3
3
  import { createStore, reconcile } from "solid-js/store";
4
4
  import { render } from "solid-js/web";
5
5
  import { emptyProjection, reduceProjection, visibleMessages } from "../client/projection";
@@ -8,11 +8,14 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
8
8
 
9
9
  /**
10
10
  * The live chat timeline for a browser test, folded from wire events like the
11
- * controller does. `window.emit(event)` applies one event, and
12
- * `window.steer(text)` appends a pending steer as the controller does.
11
+ * controller does. `window.emit(event)` applies one event,
12
+ * `window.steer(text)` appends a pending steer as the controller does, and
13
+ * `window.reconnect(on)` sets whether the stream reconnects.
13
14
  */
14
15
  const [state, setState] = createStore(emptyProjection());
16
+ const [reconnecting, setReconnecting] = createSignal(false);
15
17
  Object.assign(window, {
18
+ reconnect: (on: boolean) => setReconnecting(on),
16
19
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
17
20
  steer: (text: string) =>
18
21
  setState("activeTurn", "blocks", (blocks): AiTurnBlock[] => [
@@ -28,7 +31,11 @@ render(
28
31
  () => (
29
32
  <AiChatActionsProvider actions={{}}>
30
33
  {(() => {
31
- const items = createAiChatTimeline({ messages: createMemo(() => visibleMessages(state)), activeTurn: () => state.activeTurn });
34
+ const items = createAiChatTimeline({
35
+ messages: createMemo(() => visibleMessages(state)),
36
+ activeTurn: () => state.activeTurn,
37
+ reconnecting,
38
+ });
32
39
  return <Chat.Timeline items={items()} />;
33
40
  })()}
34
41
  </AiChatActionsProvider>
@@ -160,7 +160,10 @@ const messages = i18n.define({
160
160
  loadedCount: ({ count }: { count: number }) => `${count} loaded`,
161
161
  toolsLoaded: "Loaded",
162
162
  toolsAlreadyLoaded: "Already loaded",
163
- toolsMissing: "Missing",
163
+ toolUnknown: "Not found",
164
+ toolNotOffered: "Not available here",
165
+ toolNotAllowed: "Not allowed",
166
+ toolAppOffline: "App not reachable",
164
167
  toolsEvicted: "Evicted",
165
168
  loadedSkill: ({ name }: { name: string }) => `Loaded skill ${name}`,
166
169
  help: "Help",
@@ -347,7 +350,10 @@ const messages = i18n.define({
347
350
  loadedCount: ({ count }) => `${count} geladen`,
348
351
  toolsLoaded: "Geladen",
349
352
  toolsAlreadyLoaded: "Bereits geladen",
350
- toolsMissing: "Nicht gefunden",
353
+ toolUnknown: "Nicht gefunden",
354
+ toolNotOffered: "Hier nicht verfügbar",
355
+ toolNotAllowed: "Nicht erlaubt",
356
+ toolAppOffline: "App nicht erreichbar",
351
357
  toolsEvicted: "Entladen",
352
358
  loadedSkill: ({ name }) => `Skill ${name} geladen`,
353
359
  help: "Hilfe",
@@ -13,7 +13,7 @@ import {
13
13
  Show,
14
14
  } from "solid-js";
15
15
  import type { AiActiveTurn } from "../client/projection";
16
- import { type AiActiveTurnSegment, splitActiveTurnBlocks } from "../protocol";
16
+ import { type AiActiveTurnSegment, type AiTurnBlock, splitActiveTurnBlocks } from "../protocol";
17
17
  import { type AiAssistantTimelineItem, buildAiMessageTimeline } from "../timeline";
18
18
  import type { AiConversationTimelineEntry, AiStoredMessage } from "../types";
19
19
  import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
@@ -299,6 +299,7 @@ const activeItems = (
299
299
  actions: AiChatActions,
300
300
  views: TurnViews,
301
301
  duration: Accessor<AiTurnDuration | null>,
302
+ reconnecting: boolean,
302
303
  ): ChatTimelineItem[] => {
303
304
  if (!turn) return [];
304
305
  const segments = splitActiveTurnBlocks(turn.blocks).flatMap((segment): (AiActiveTurnSegment | SurveySegment)[] =>
@@ -314,6 +315,14 @@ const activeItems = (
314
315
  // Until the model takes up the steering message, the segment above it is still the one at work.
315
316
  const above = steerTail ? segments.findLastIndex((segment, index) => segment.type === "assistant" && index < lastAssistant) : -1;
316
317
  const working = above >= 0 ? above : lastAssistant;
318
+ const phaseOf = (index: number): AiTurnPhase =>
319
+ index !== working ? "completed" : turn.status === "waiting_for_action" ? "waiting" : "running";
320
+ const layoutOf = (blocks: AiTurnBlock[], index: number) =>
321
+ layoutAiTurn(blocks, { phase: phaseOf(index), codePresentations: Boolean(actions.renderCodePresentation) });
322
+ // The work line says in place that the turn reconnects. A turn without one yet says it in a row at its end, where a
323
+ // row that comes and goes moves nothing above it.
324
+ const workingSegment = segments[working];
325
+ const reconnectInLine = reconnecting && workingSegment?.type === "assistant" && layoutOf(workingSegment.blocks, working).showWork;
317
326
  const segmentId = createSegmentIds();
318
327
  let opener = "start";
319
328
 
@@ -339,8 +348,8 @@ const activeItems = (
339
348
 
340
349
  const last = index === lastAssistant;
341
350
  const live = index === working;
342
- const phase: AiTurnPhase = !live ? "completed" : turn.status === "waiting_for_action" ? "waiting" : "running";
343
- const layout = layoutAiTurn(segment.blocks, { phase, codePresentations: Boolean(actions.renderCodePresentation) });
351
+ const phase = phaseOf(index);
352
+ const layout = layoutOf(segment.blocks, index);
344
353
  // Consecutive steering messages leave empty segments between them; history has none. The last segment stays, since
345
354
  // it carries the progress indicator.
346
355
  if (!last && isEmptyLayout(layout)) return [];
@@ -358,7 +367,7 @@ const activeItems = (
358
367
  layout,
359
368
  earlier: !live,
360
369
  duration: live ? duration : () => null,
361
- retrying: last && turn.providerRetry === true,
370
+ reconnect: live && reconnectInLine ? "line" : last && reconnecting && !reconnectInLine ? "row" : undefined,
362
371
  }),
363
372
  };
364
373
  });
@@ -367,9 +376,10 @@ const activeItems = (
367
376
  /**
368
377
  * Work time of the active turn: wall time minus time spent waiting for the user. A state snapshot seeds it after a
369
378
  * reconnect; the clock stands while the turn waits for an approval or an answer and continues from the same value
370
- * afterwards.
379
+ * afterwards. While `paused`, the shown time stands too and then jumps to the true work time; the clock itself keeps
380
+ * running, so a step that starts right after the pause is timed from the true work time.
371
381
  */
372
- const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDuration | null> => {
382
+ const createActiveTurnClock = (source: AiChatTimelineSource, paused: Accessor<boolean>): Accessor<AiTurnDuration | null> => {
373
383
  const [now, setNow] = createSignal(Date.now());
374
384
  const running = createMemo(() => source.activeTurn() !== null);
375
385
  createEffect(() => {
@@ -379,7 +389,7 @@ const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDur
379
389
  onCleanup(() => window.clearInterval(timer));
380
390
  });
381
391
  let state: { turnId: string; seed: string; startedAt: number; waitMs: number; waitingSince: number | null } | null = null;
382
- return createMemo(() => {
392
+ const duration = createMemo(() => {
383
393
  const turn = source.activeTurn();
384
394
  const time = now();
385
395
  if (!turn) {
@@ -411,11 +421,19 @@ const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDur
411
421
  waitingMs: state.waitingSince === null ? null : Math.max(0, time - state.waitingSince),
412
422
  };
413
423
  });
424
+ let shown: AiTurnDuration | null = null;
425
+ return createMemo(() => {
426
+ const current = duration();
427
+ if (!paused() || !shown || !current) shown = current;
428
+ return shown;
429
+ });
414
430
  };
415
431
 
416
432
  export type AiChatTimelineSource = {
417
433
  messages: Accessor<readonly AiStoredMessage[]>;
418
434
  activeTurn: Accessor<AiActiveTurn | null>;
435
+ /** The conversation stream lost its connection and reconnects, such as `streamStatus() === "reconnecting"`. */
436
+ reconnecting?: Accessor<boolean>;
419
437
  };
420
438
 
421
439
  /**
@@ -428,9 +446,11 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
428
446
  const locale = useLocale();
429
447
  const disclosureState = createAiToolDisclosureState();
430
448
  const views = createTurnViews(disclosureState);
431
- const clock = createActiveTurnClock(source);
449
+ // While the stream reconnects or a model call waits for its retry, the live turn says so and its clock stands.
450
+ const reconnecting = createMemo(() => source.activeTurn()?.providerRetry === true || source.reconnecting?.() === true);
451
+ const clock = createActiveTurnClock(source, reconnecting);
432
452
  const stored = createMemo(() => storedItems(source.messages(), actions, views, locale()));
433
- const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock));
453
+ const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock, reconnecting()));
434
454
  return createMemo(() => {
435
455
  const items = [...stored(), ...active()];
436
456
  views.retain(new Set(items.map((item) => item.id)));