@k2b/cloud 0.27.0 → 0.29.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 (155) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -1,10 +1,12 @@
1
- import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
1
+ import type { AssistantMessage, CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
2
2
  import { compact, nessi } from "@k2b/nessi";
3
3
  import { listCapabilities } from "../_internal/registry";
4
4
  import type { CapabilityActionReview } from "../contracts/capabilities";
5
5
  import type { AccessSubject, RequestActor } from "../server";
6
6
  import { createHelpReader } from "../services/help";
7
7
  import { logger } from "../services/logging";
8
+ import { getMandate } from "../services/mandates";
9
+ import type { MandatePolicyV1 } from "../services/mandates/policy";
8
10
  import { coreSettings } from "../services/settings/api";
9
11
  import { normalizeLocale } from "../shared/locale";
10
12
  import {
@@ -14,11 +16,13 @@ import {
14
16
  aiTurnAllowsRememberedApprovals,
15
17
  hasRememberedAiToolApproval,
16
18
  } from "./approvals";
17
- import { isAssistantChatTurn } from "./assistant-models";
19
+ import { aiChatAccessSubject, isAssistantChatTurn, resolveAssistantAudioModel } from "./assistant-models";
18
20
  import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
19
- import { createAiToolResolver, createRunToolStore } from "./capabilities";
21
+ import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
20
22
  import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
21
- import { aiChatTasks } from "./chat-tasks";
23
+ import { aiChatMessages } from "./chat/messages";
24
+ import { aiTurnErrorText } from "./chat/turn-error";
25
+ import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
22
26
  import { createCloudCompactFn } from "./compaction";
23
27
  import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
24
28
  import { aiFileStore } from "./files-store";
@@ -26,6 +30,7 @@ import { aiMemories } from "./memories";
26
30
  import { createCloudAiMemoryTool } from "./memory-tool";
27
31
  import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
28
32
  import { aiModelAccess } from "./model-access";
33
+ import { answerOpenToolCalls } from "./open-tool-calls";
29
34
  import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
30
35
  import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
31
36
  import { aiProjects } from "./projects";
@@ -57,6 +62,16 @@ import { aiToolAudit } from "./tool-audit";
57
62
  import { acceptCanonicalToolNames } from "./tool-call-names";
58
63
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
59
64
  import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
65
+ import { AiTranscriptionError } from "./transcription";
66
+ import {
67
+ AiTurnFailure,
68
+ type AiTurnFailureInfo,
69
+ type AiTurnFailureReason,
70
+ aiTurnFailureFromThrown,
71
+ aiTurnReasonFromThrown,
72
+ rememberProviderErrors,
73
+ } from "./turn-failure";
74
+ import { type AiTurnGuidance, loadAiTurnGuidance } from "./turn-guidance";
60
75
  import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
61
76
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
62
77
  import type {
@@ -80,10 +95,17 @@ const AI_COALESCE_MS = 25;
80
95
  const AI_COALESCE_MAX_CHARS = 512;
81
96
  const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
82
97
 
98
+ // Failed generation can contain calls that nessi deliberately never executes or resumes.
99
+ const isToolRound = (message: AssistantMessage): boolean =>
100
+ message.stopReason !== "error" &&
101
+ message.stopReason !== "interrupted" &&
102
+ message.stopReason !== "aborted" &&
103
+ message.content.some((block) => block.type === "tool_call");
104
+
83
105
  const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
84
106
  const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
85
107
  const rounds = messages.flatMap(({ message }) => {
86
- if (message.role !== "assistant") return [];
108
+ if (message.role !== "assistant" || !isToolRound(message)) return [];
87
109
  const callIds = message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : []));
88
110
  return callIds.length > 0 ? [callIds] : [];
89
111
  });
@@ -546,7 +568,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
546
568
  tools:
547
569
  source.kind === "default"
548
570
  ? [
549
- ...(await createConfiguredDefaultCloudAiTools()),
571
+ ...(await createConfiguredDefaultCloudAiTools({
572
+ accessSubject: aiChatAccessSubject(config.actor),
573
+ allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
574
+ })),
550
575
  // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
551
576
  ...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
552
577
  (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
@@ -564,7 +589,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
564
589
  // ---------------------------------------------------------------------------
565
590
 
566
591
  type AttemptOutcome =
567
- | { kind: "finished"; status: "completed" | "failed" | "aborted"; error: string | null; timing?: LoopAggregate["timing"] }
592
+ | { kind: "finished"; status: "completed" | "failed" | "aborted"; failure: AiTurnFailureInfo | null; timing?: LoopAggregate["timing"] }
568
593
  | { kind: "suspended" };
569
594
 
570
595
  export class AiTurnExecutor {
@@ -587,7 +612,14 @@ export class AiTurnExecutor {
587
612
  pipeline.seedBaseline(claim.liveBlocks ?? []);
588
613
  await pipeline.emitTurnStarted(claim.turn.modelProfileId ?? "");
589
614
  await pipeline.emitBaseline();
590
- await this.finalize(conversationId, turnId, pipeline, "failed", "AI turn is missing its run configuration.", null);
615
+ await this.finalize(
616
+ conversationId,
617
+ turnId,
618
+ pipeline,
619
+ "failed",
620
+ { error: { code: "failed" }, detail: "AI turn is missing its run configuration." },
621
+ null,
622
+ );
591
623
  return;
592
624
  }
593
625
 
@@ -611,8 +643,9 @@ export class AiTurnExecutor {
611
643
  turnId,
612
644
  pipeline,
613
645
  "failed",
614
- error instanceof Error ? error.message : "AI turn state could not be loaded.",
646
+ aiTurnFailureFromThrown(error, "AI turn state could not be loaded."),
615
647
  "chat",
648
+ claim.runConfig?.kind === "chat" ? claim.runConfig.locale : undefined,
616
649
  );
617
650
  return;
618
651
  }
@@ -622,16 +655,39 @@ export class AiTurnExecutor {
622
655
  await this.runChat(conversationId, turnId, claim, runConfig, pipeline, signal, false, attemptState);
623
656
  }
624
657
 
658
+ /**
659
+ * Ends the turn. A failure stores its reason twice: as a code on the turn's last message, which the chat words in
660
+ * the reader's language, and as text in the turn's language for readers without the chat view. The raw cause, such
661
+ * as a provider's own message, goes only to the log. A failed compaction left the chat as it was, so its text names
662
+ * only the reason.
663
+ */
625
664
  private async finalize(
626
665
  conversationId: string,
627
666
  turnId: string,
628
667
  pipeline: StreamPipeline,
629
668
  status: "completed" | "failed" | "aborted",
630
- error: string | null,
669
+ failure: AiTurnFailureInfo | null,
631
670
  kind: AiTurnRunConfig["kind"] | null,
671
+ locale?: string,
632
672
  ) {
633
- if (status === "failed" && error) log.error("AI turn failed", { conversationId, turnId, error });
634
- const finalized = await aiConversations.completeTurn({ conversationId, turnId, status, error, leaseOwner: this.config.leaseOwner });
673
+ const failed = status === "failed" ? (failure ?? { error: { code: "failed" as const }, detail: "AI turn failed" }) : null;
674
+ let error: string | null = null;
675
+ if (failed) {
676
+ log.error("AI turn failed", { conversationId, turnId, code: failed.error.code, error: failed.detail });
677
+ error = failed.message ?? null;
678
+ if (!error) {
679
+ const textLocale = normalizeLocale(locale ?? (await coreSettings.get<string>("app.locale")));
680
+ error = kind === "compact" ? aiChatMessages(textLocale).turnErrorReason(failed.error) : aiTurnErrorText(failed.error, textLocale);
681
+ }
682
+ }
683
+ const finalized = await aiConversations.completeTurn({
684
+ conversationId,
685
+ turnId,
686
+ status,
687
+ error,
688
+ turnError: failed?.error ?? null,
689
+ leaseOwner: this.config.leaseOwner,
690
+ });
635
691
  if (finalized === "completed") await pipeline.emitTurnFinished(status, error);
636
692
  await pipeline.flush().catch(() => undefined);
637
693
  if (finalized === "completed" && this.config.onTurnFinalized) {
@@ -686,7 +742,7 @@ export class AiTurnExecutor {
686
742
  const subject = accessSubjectForActor(material.actor);
687
743
  const project = subject ? await aiProjects.getByShortId(config.project.id, subject, "read") : null;
688
744
  if (!project) {
689
- throw new Error("Project access is no longer available.");
745
+ throw new AiTurnFailure("not_allowed", "Project access is no longer available.");
690
746
  }
691
747
  resolvedProjectId = project.id;
692
748
  }
@@ -709,7 +765,15 @@ export class AiTurnExecutor {
709
765
  }
710
766
  } catch (error) {
711
767
  signal.removeEventListener("abort", onSignal);
712
- await this.finalize(conversationId, turnId, pipeline, "failed", error instanceof Error ? error.message : "AI turn failed", "chat");
768
+ await this.finalize(
769
+ conversationId,
770
+ turnId,
771
+ pipeline,
772
+ "failed",
773
+ aiTurnFailureFromThrown(error, "AI turn failed"),
774
+ "chat",
775
+ config.locale,
776
+ );
713
777
  return;
714
778
  }
715
779
  const { settings, resolved } = validated;
@@ -732,8 +796,12 @@ export class AiTurnExecutor {
732
796
  turnId,
733
797
  pipeline,
734
798
  "failed",
735
- error instanceof Error ? error.message : "Cloud capability actor resolution failed",
799
+ {
800
+ error: { code: "not_allowed" },
801
+ detail: error instanceof Error ? error.message : "Cloud capability actor resolution failed",
802
+ },
736
803
  "chat",
804
+ config.locale,
737
805
  );
738
806
  return;
739
807
  }
@@ -801,6 +869,20 @@ export class AiTurnExecutor {
801
869
  (tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
802
870
  )
803
871
  : [];
872
+ let audioUnavailableInstruction: string | undefined;
873
+ if (
874
+ defaultToolSource &&
875
+ toolsSupported &&
876
+ [...(config.files?.attached ?? []), ...(config.files?.available ?? [])].some((file) => file.mediaType.startsWith("audio/")) &&
877
+ !sourceToolNames.includes("transcribe_audio")
878
+ ) {
879
+ try {
880
+ await resolveAssistantAudioModel(aiChatAccessSubject(config.actor), material.modelPolicy?.allowedDataBoundaries);
881
+ } catch (error) {
882
+ const reason = error instanceof AiTranscriptionError ? error.message : "Audio transcription is not available right now.";
883
+ audioUnavailableInstruction = `Audio files in this conversation cannot be transcribed: ${reason} Tell the user this reason when they ask about audio content.`;
884
+ }
885
+ }
804
886
  const offeredToolNames = new Set(activeTools.map((tool) => tool.def.name));
805
887
  // Built-ins that exist but this turn does not offer: client tools without their client, tools a
806
888
  // task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
@@ -865,8 +947,9 @@ export class AiTurnExecutor {
865
947
  turnId,
866
948
  pipeline,
867
949
  "failed",
868
- error instanceof Error ? error.message : "Image preparation failed",
950
+ aiTurnFailureFromThrown(error, "Image preparation failed"),
869
951
  "chat",
952
+ promptLocale,
870
953
  );
871
954
  return;
872
955
  }
@@ -908,6 +991,52 @@ export class AiTurnExecutor {
908
991
 
909
992
  const appliedSteers: AiTurnSteer[] = [];
910
993
 
994
+ let mandatePolicy: MandatePolicyV1 | null | undefined;
995
+ if (config.background && config.mandate) {
996
+ mandatePolicy = null;
997
+ try {
998
+ const mandate = await getMandate(config.mandate.id);
999
+ if (mandate && mandate.state === "active" && mandate.revision === config.mandate.revision) {
1000
+ mandatePolicy = mandate.policy;
1001
+ } else {
1002
+ backgroundError = "Scheduled task mandate is unavailable or changed; recreate or update the task.";
1003
+ }
1004
+ } catch {
1005
+ backgroundError = "Scheduled task mandate could not be loaded; retry or update the task.";
1006
+ }
1007
+ }
1008
+ if (backgroundError) {
1009
+ signal.removeEventListener("abort", onSignal);
1010
+ await this.finalize(
1011
+ conversationId,
1012
+ turnId,
1013
+ pipeline,
1014
+ "failed",
1015
+ { error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError },
1016
+ "chat",
1017
+ promptLocale,
1018
+ );
1019
+ return;
1020
+ }
1021
+ const authorizeBackground =
1022
+ config.background && config.mandate
1023
+ ? async (entry: AiCapabilityCatalogEntry, args: unknown) => {
1024
+ try {
1025
+ await aiChatTasks.authorizeCapability({
1026
+ mandate: config.mandate!,
1027
+ appId: entry.appId,
1028
+ capabilityId: entry.operation.localId,
1029
+ kind: entry.kind,
1030
+ input: args,
1031
+ approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1032
+ });
1033
+ } catch (error) {
1034
+ if (!(error instanceof AiChatTaskAuthorityError && error.code === "MANDATE_POLICY_DENIED"))
1035
+ backgroundError = error instanceof Error ? error.message : "Background capability access denied";
1036
+ throw error;
1037
+ }
1038
+ }
1039
+ : undefined;
911
1040
  const toolStore = config.background ? createRunToolStore(await aiConversations.getLoadedTools({ conversationId })) : aiConversations;
912
1041
  const tools = toolActor
913
1042
  ? createAiToolResolver({
@@ -915,6 +1044,7 @@ export class AiTurnExecutor {
915
1044
  actor: capabilityAuthority?.actor ?? toolActor,
916
1045
  staticTools: activeTools,
917
1046
  allowedTools,
1047
+ mandatePolicy,
918
1048
  unofferedTools,
919
1049
  runtimeContext: dynamicToolRuntimeContext,
920
1050
  store: toolStore,
@@ -940,25 +1070,7 @@ export class AiTurnExecutor {
940
1070
  }),
941
1071
  }
942
1072
  : {}),
943
- ...(config.background && config.mandate
944
- ? {
945
- authorizeBackground: async (entry, args) => {
946
- try {
947
- await aiChatTasks.authorizeCapability({
948
- mandate: config.mandate!,
949
- appId: entry.appId,
950
- capabilityId: entry.operation.localId,
951
- kind: entry.kind,
952
- input: args,
953
- approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
954
- });
955
- } catch (error) {
956
- backgroundError = error instanceof Error ? error.message : "Background capability access denied";
957
- throw error;
958
- }
959
- },
960
- }
961
- : {}),
1073
+ authorizeBackground,
962
1074
  onReview: (callId, review) => {
963
1075
  capabilityActionReviews.set(callId, review);
964
1076
  if (review.approvalScope) rememberableCapabilityApprovals.set(callId, review.approvalScope);
@@ -967,16 +1079,7 @@ export class AiTurnExecutor {
967
1079
  ? {
968
1080
  execute: async (entry, args, context) => {
969
1081
  try {
970
- if (config.background && config.mandate) {
971
- await aiChatTasks.authorizeCapability({
972
- mandate: config.mandate,
973
- appId: entry.appId,
974
- capabilityId: entry.operation.localId,
975
- kind: entry.kind,
976
- input: args,
977
- approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
978
- });
979
- }
1082
+ await authorizeBackground?.(entry, args);
980
1083
  const result = await executeAiCapability({
981
1084
  conversationId,
982
1085
  turnId,
@@ -1046,12 +1149,43 @@ export class AiTurnExecutor {
1046
1149
  skillSubject,
1047
1150
  activeTools.some((tool) => tool.def.name === "load_skill"),
1048
1151
  );
1049
- const workingPlan = (await aiConversations.getConversation({ conversationId }))?.todoPlan;
1152
+ const chat = await aiConversations.getConversation({ conversationId });
1153
+ const workingPlan = chat?.todoPlan;
1154
+ const skillCatalogOffered = activeTools.some((tool) => tool.def.name === "load_skill");
1155
+ const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator");
1156
+ // Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
1157
+ // attempt after an approval or a scheduled run gets neither.
1158
+ let guidance: AiTurnGuidance = {};
1159
+ if (isFresh && !config.background && defaultToolSource && user && chat) {
1160
+ try {
1161
+ guidance = await loadAiTurnGuidance(
1162
+ {
1163
+ chat,
1164
+ turnId,
1165
+ ownerUserId: user.id,
1166
+ input: config.input,
1167
+ instructions: [settings.globalInstructions, config.project?.instructions, memory?.text],
1168
+ skillOffers: skillCatalogOffered && skillCreatorAvailable && loadedSkills.length === 0,
1169
+ memoryOffers: memoryToolEnabled,
1170
+ hasAttachments: Boolean(config.files?.attached.length),
1171
+ },
1172
+ aiConversations,
1173
+ );
1174
+ } catch (error) {
1175
+ log.warn("AI turn guidance unavailable; continuing without it", {
1176
+ conversationId,
1177
+ turnId,
1178
+ error: error instanceof Error ? error.message : String(error),
1179
+ });
1180
+ }
1181
+ }
1050
1182
  const systemPrompt = composeAiSystemPrompt({
1051
1183
  globalInstructions: settings.globalInstructions,
1052
1184
  loadedSkills,
1053
1185
  turnInstructions: [
1054
1186
  material.systemPrompt,
1187
+ audioUnavailableInstruction,
1188
+ guidance.offerHint,
1055
1189
  workingPlan
1056
1190
  ? `Current working plan (new todo_write results supersede this):\n${JSON.stringify({ todos: workingPlan.todos })}`
1057
1191
  : undefined,
@@ -1067,7 +1201,7 @@ export class AiTurnExecutor {
1067
1201
  project: config.project,
1068
1202
  files: config.files,
1069
1203
  projectToolEnabled,
1070
- skills: activeTools.some((tool) => tool.def.name === "load_skill") ? skillCatalog.skills : undefined,
1204
+ skills: skillCatalogOffered ? skillCatalog.skills : undefined,
1071
1205
  omittedSkillCount: activeTools.some((tool) => tool.def.name === "search_skills") ? skillCatalog.omitted : 0,
1072
1206
  user,
1073
1207
  memoryEnabled: memoryActive,
@@ -1080,18 +1214,34 @@ export class AiTurnExecutor {
1080
1214
  timeZone,
1081
1215
  locale: promptLocale,
1082
1216
  interactive: !config.background,
1083
- skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
1217
+ skillCreatorAvailable,
1218
+ recentWork: guidance.recentWork,
1084
1219
  });
1085
1220
  // The turn policy counts the whole turn, including rounds that compaction archived.
1086
1221
  const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
1087
1222
  const turnBlocks = buildBlocksFromMessages(turnMessages);
1223
+ const providerNamesByCallId = new Map(
1224
+ turnMessages.flatMap(({ message }) =>
1225
+ message.role === "assistant"
1226
+ ? message.content.flatMap((part) => (part.type === "tool_call" ? [[part.id, part.name] as const] : []))
1227
+ : [],
1228
+ ),
1229
+ );
1088
1230
  const priorToolRounds = toolRoundState(turnMessages);
1089
1231
  const quotaSubject = accessSubjectForActor(material.actor);
1090
1232
  const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
1233
+ // The reason a failed turn names: the one the current model call or the turn's own checks gave.
1234
+ const failureReason: { current: AiTurnFailureReason | null } = { current: null };
1235
+ const remember = (reason: AiTurnFailureReason | null) => {
1236
+ failureReason.current = reason;
1237
+ };
1091
1238
  const turnPolicy = applyAiTurnPolicy({
1092
1239
  provider: acceptCanonicalToolNames(
1093
1240
  retryTransientProviderErrors(
1094
- assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
1241
+ rememberProviderErrors(
1242
+ answerOpenToolCalls(assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId)),
1243
+ remember,
1244
+ ),
1095
1245
  {
1096
1246
  deadline,
1097
1247
  delaysMs: this.config.providerRetryDelaysMs,
@@ -1111,7 +1261,7 @@ export class AiTurnExecutor {
1111
1261
  runBudgetMs: claim.turn.runBudgetMs ?? null,
1112
1262
  finishedToolCalls: turnBlocks
1113
1263
  .slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
1114
- .flatMap((block) => (block.kind === "tool" ? [block] : [])),
1264
+ .flatMap((block) => (block.kind === "tool" ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }] : [])),
1115
1265
  onDecision: (decision) =>
1116
1266
  decision.kind === "hint"
1117
1267
  ? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
@@ -1136,7 +1286,14 @@ export class AiTurnExecutor {
1136
1286
  turnPolicy.noteSteering();
1137
1287
  return steers.map((steer) => steer.text);
1138
1288
  },
1139
- tools: turnPolicy.tools,
1289
+ tools: async () => {
1290
+ try {
1291
+ return await turnPolicy.tools();
1292
+ } catch (error) {
1293
+ remember(aiTurnReasonFromThrown(error));
1294
+ throw error;
1295
+ }
1296
+ },
1140
1297
  ...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
1141
1298
  temperature: resolved.profile.temperature,
1142
1299
  maxOutputTokens: resolved.profile.maxOutputTokens,
@@ -1178,6 +1335,7 @@ export class AiTurnExecutor {
1178
1335
  appliedSteers,
1179
1336
  noteToolRound: turnPolicy.noteToolRound,
1180
1337
  noteToolCall: turnPolicy.noteToolCall,
1338
+ failureReason,
1181
1339
  onBackgroundBlocked: (message) => {
1182
1340
  backgroundError = message;
1183
1341
  },
@@ -1202,15 +1360,17 @@ export class AiTurnExecutor {
1202
1360
  : null;
1203
1361
  if (timeout) {
1204
1362
  outcome.status = "failed";
1205
- outcome.error = timeout.messageFor(promptLocale);
1363
+ outcome.failure = { error: timeout.turnError(), detail: "Run time limit reached." };
1206
1364
  }
1207
1365
  const finalized = await this.finalize(
1208
1366
  conversationId,
1209
1367
  turnId,
1210
1368
  pipeline,
1211
1369
  backgroundError ? "failed" : outcome.status,
1212
- backgroundError ?? outcome.error,
1370
+ // A background run keeps Cloud's own words for what its mandate blocked.
1371
+ backgroundError ? { error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError } : outcome.failure,
1213
1372
  "chat",
1373
+ promptLocale,
1214
1374
  );
1215
1375
  if (finalized === "pending_steering" && outcome.status === "completed" && !signal.aborted) {
1216
1376
  await this.runChat(conversationId, turnId, claim, config, pipeline, signal, true);
@@ -1244,6 +1404,12 @@ export class AiTurnExecutor {
1244
1404
  appliedSteers: AiTurnSteer[];
1245
1405
  noteToolRound: () => void;
1246
1406
  noteToolCall: (call: AiTurnPolicyToolCall) => void;
1407
+ /**
1408
+ * Why the turn would fail now, as a model call or the turn's own checks said. A new model call starts without one,
1409
+ * so a context overflow that compaction resolved never names a later failure. Only the model calls and checks set
1410
+ * it, in the order they run; the events here arrive later than that.
1411
+ */
1412
+ failureReason: { current: AiTurnFailureReason | null };
1247
1413
  onBackgroundBlocked?: (message: string) => void;
1248
1414
  }): Promise<AttemptOutcome> {
1249
1415
  const {
@@ -1260,6 +1426,7 @@ export class AiTurnExecutor {
1260
1426
  appliedSteers,
1261
1427
  noteToolRound,
1262
1428
  noteToolCall,
1429
+ failureReason,
1263
1430
  } = input;
1264
1431
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1265
1432
  let lastIssueMessage: string | null = null;
@@ -1333,7 +1500,7 @@ export class AiTurnExecutor {
1333
1500
  result: event.result,
1334
1501
  isError: event.isError === true,
1335
1502
  });
1336
- } else if (event.type === "turn_end" && event.message.content.some((block) => block.type === "tool_call")) {
1503
+ } else if (event.type === "turn_end" && !abortController.signal.aborted && isToolRound(event.message)) {
1337
1504
  noteToolRound();
1338
1505
  } else if (event.type === "issue") {
1339
1506
  lastIssueMessage = event.issue.message;
@@ -1346,25 +1513,24 @@ export class AiTurnExecutor {
1346
1513
  .catch(() => undefined);
1347
1514
  }
1348
1515
  const timing = aggregate.timing;
1349
- if (event.reason === "aborted") return { kind: "finished", status: "aborted", error: null, timing };
1350
- if (event.reason === "stop") return { kind: "finished", status: "completed", error: null, timing };
1351
- if (event.reason === "max_turns") {
1352
- return {
1353
- kind: "finished",
1354
- status: "failed",
1355
- error: "The model did not produce a final answer within its tool-round limit.",
1356
- timing,
1357
- };
1358
- }
1359
- return { kind: "finished", status: "failed", error: lastIssueMessage ?? `AI turn ended: ${event.reason}`, timing };
1516
+ if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
1517
+ if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
1518
+ const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
1519
+ const reason: AiTurnFailureReason =
1520
+ event.reason === "max_turns"
1521
+ ? { error: { code: "step_limit" } }
1522
+ : event.reason === "context_overflow"
1523
+ ? { error: { code: "context_full" } }
1524
+ : event.reason === "no_credits"
1525
+ ? { error: { code: "quota_exhausted" } }
1526
+ : (failureReason.current ?? { error: { code: "failed" } });
1527
+ return { kind: "finished", status: "failed", failure: { ...reason, detail }, timing };
1360
1528
  }
1361
1529
  }
1362
- return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", error: null };
1530
+ return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", failure: null };
1363
1531
  } catch (error) {
1364
- if (abortController.signal.aborted) return { kind: "finished", status: "aborted", error: null };
1365
- const message = error instanceof Error ? error.message : "AI turn failed";
1366
- await pipeline.emitError(message).catch(() => undefined);
1367
- return { kind: "finished", status: "failed", error: message };
1532
+ if (abortController.signal.aborted) return { kind: "finished", status: "aborted", failure: null };
1533
+ return { kind: "finished", status: "failed", failure: aiTurnFailureFromThrown(error, "AI turn failed") };
1368
1534
  } finally {
1369
1535
  stopHeartbeat();
1370
1536
  await pipeline.flush().catch(() => undefined);
@@ -1556,14 +1722,7 @@ export class AiTurnExecutor {
1556
1722
  });
1557
1723
  } catch (error) {
1558
1724
  signal.removeEventListener("abort", onSignal);
1559
- await this.finalize(
1560
- conversationId,
1561
- turnId,
1562
- pipeline,
1563
- "failed",
1564
- error instanceof Error ? error.message : "AI compaction failed",
1565
- "compact",
1566
- );
1725
+ await this.finalize(conversationId, turnId, pipeline, "failed", aiTurnFailureFromThrown(error, "AI compaction failed"), "compact");
1567
1726
  return;
1568
1727
  }
1569
1728
  const { settings, resolved } = validated;
@@ -1574,17 +1733,23 @@ export class AiTurnExecutor {
1574
1733
  turnId,
1575
1734
  leaseOwner: this.config.leaseOwner,
1576
1735
  });
1736
+ const reason: { current: AiTurnFailureReason | null } = { current: null };
1577
1737
  const loop = compact({
1578
1738
  agentId: "cloud",
1579
1739
  loopId: turnId,
1580
1740
  store,
1581
- provider: inferenceProvider(resolved.provider, resolved.profile, {
1582
- kind: "background",
1583
- task: "chat-compaction",
1584
- conversationId,
1585
- turnId,
1586
- appId: "core",
1587
- }),
1741
+ provider: rememberProviderErrors(
1742
+ inferenceProvider(resolved.provider, resolved.profile, {
1743
+ kind: "background",
1744
+ task: "chat-compaction",
1745
+ conversationId,
1746
+ turnId,
1747
+ appId: "core",
1748
+ }),
1749
+ (remembered) => {
1750
+ reason.current = remembered;
1751
+ },
1752
+ ),
1588
1753
  force: true,
1589
1754
  signal: abortController.signal,
1590
1755
  compact: createCloudCompactFn({
@@ -1603,15 +1768,21 @@ export class AiTurnExecutor {
1603
1768
 
1604
1769
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1605
1770
  let status: "completed" | "failed" | "aborted" = "failed";
1606
- let error: string | null = null;
1771
+ let failure: AiTurnFailureInfo | null = null;
1772
+ let issueMessage: string | null = null;
1607
1773
  try {
1608
1774
  for await (const event of loop as AsyncIterable<CompactEvent>) {
1609
1775
  if (event.type === "compaction_start") await pipeline.applyCompaction("running");
1610
1776
  else if (event.type === "compaction_end") await pipeline.applyCompaction("completed");
1611
- else if (event.type === "issue") error = event.issue.message;
1777
+ else if (event.type === "issue") issueMessage = event.issue.message;
1612
1778
  else if (event.type === "loop_end") {
1613
1779
  status = event.reason === "stop" ? "completed" : event.reason === "aborted" ? "aborted" : "failed";
1614
1780
  await pipeline.applyCompaction(status === "failed" ? "failed" : "completed", event.result);
1781
+ if (status === "failed")
1782
+ failure = {
1783
+ ...(reason.current ?? { error: { code: "failed" } }),
1784
+ detail: issueMessage ?? `AI compaction ended: ${event.reason}`,
1785
+ };
1615
1786
  }
1616
1787
  }
1617
1788
  } catch (err) {
@@ -1619,8 +1790,8 @@ export class AiTurnExecutor {
1619
1790
  status = "aborted";
1620
1791
  } else {
1621
1792
  status = "failed";
1622
- error = err instanceof Error ? err.message : "AI compaction failed";
1623
- await pipeline.emitError(error).catch(() => undefined);
1793
+ const thrown = aiTurnFailureFromThrown(err, "AI compaction failed");
1794
+ failure = reason.current ? { ...reason.current, detail: thrown.detail } : thrown;
1624
1795
  }
1625
1796
  } finally {
1626
1797
  stopHeartbeat();
@@ -1629,9 +1800,9 @@ export class AiTurnExecutor {
1629
1800
 
1630
1801
  if (abortController.signal.reason instanceof AiRunTimeout) {
1631
1802
  status = "failed";
1632
- error = abortController.signal.reason.messageFor();
1803
+ failure = { error: abortController.signal.reason.turnError(), detail: "Run time limit reached." };
1633
1804
  }
1634
- await this.finalize(conversationId, turnId, pipeline, status, error, "compact");
1805
+ await this.finalize(conversationId, turnId, pipeline, status, failure, "compact");
1635
1806
  }
1636
1807
  }
1637
1808
 
@@ -1838,14 +2009,6 @@ class StreamPipeline {
1838
2009
  await this.saving;
1839
2010
  }
1840
2011
 
1841
- async emitError(message: string): Promise<void> {
1842
- const seq = this.nextSeq();
1843
- const block: AiTurnBlock = { id: `error-${this.attempt}-${seq}`, kind: "text", text: `⚠️ ${message}` };
1844
- const event = this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent;
1845
- this.blocks = applyWireEventToBlocks(this.blocks, event);
1846
- await this.publish(event);
1847
- }
1848
-
1849
2012
  /** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
1850
2013
  async emitProviderRetry(): Promise<void> {
1851
2014
  const seq = this.nextSeq();
@@ -38,3 +38,7 @@ export const guessAiMediaType = (path: string): string => {
38
38
  const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
39
39
  return MEDIA_TYPES[ext] ?? "application/octet-stream";
40
40
  };
41
+
42
+ /** Image classification includes filenames when clients send a generic media type. */
43
+ export const isAiImage = (path: string, mediaType: string): boolean =>
44
+ mediaType.startsWith("image/") || guessAiMediaType(path).startsWith("image/");