@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
@@ -1,4 +1,4 @@
1
- import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent, Provider, Tool, ToolResolver } from "@k2b/nessi";
1
+ import type { 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";
@@ -15,8 +15,11 @@ import {
15
15
  hasRememberedAiToolApproval,
16
16
  } from "./approvals";
17
17
  import { isAssistantChatTurn } from "./assistant-models";
18
+ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
18
19
  import { createAiToolResolver, createRunToolStore } from "./capabilities";
19
20
  import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
21
+ import { aiChatMessages } from "./chat/messages";
22
+ import { aiTurnErrorText } from "./chat/turn-error";
20
23
  import { aiChatTasks } from "./chat-tasks";
21
24
  import { createCloudCompactFn } from "./compaction";
22
25
  import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
@@ -25,10 +28,12 @@ import { aiMemories } from "./memories";
25
28
  import { createCloudAiMemoryTool } from "./memory-tool";
26
29
  import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
27
30
  import { aiModelAccess } from "./model-access";
31
+ import { answerOpenToolCalls } from "./open-tool-calls";
28
32
  import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
29
33
  import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
30
34
  import { aiProjects } from "./projects";
31
35
  import {
36
+ AI_TURN_LEASE_MS,
32
37
  type AiTurnBlock,
33
38
  type AiWireEvent,
34
39
  applyWireEventToBlocks,
@@ -49,11 +54,21 @@ import { selectAiSkillCatalog } from "./skill-catalog";
49
54
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
50
55
  import { aiSkills } from "./skills";
51
56
  import { aiConversations } from "./store";
52
- import { publishAiWireEvent } from "./stream";
57
+ import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
53
58
  import { composeAiSystemPrompt } from "./system-prompt";
54
59
  import { aiToolAudit } from "./tool-audit";
60
+ import { acceptCanonicalToolNames } from "./tool-call-names";
55
61
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
56
62
  import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
63
+ import {
64
+ AiTurnFailure,
65
+ type AiTurnFailureInfo,
66
+ type AiTurnFailureReason,
67
+ aiTurnFailureFromThrown,
68
+ aiTurnReasonFromThrown,
69
+ rememberProviderErrors,
70
+ } from "./turn-failure";
71
+ import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
57
72
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
58
73
  import type {
59
74
  AiChatTurnRunConfig,
@@ -72,13 +87,9 @@ import { validateAiTurnRequest } from "./validate";
72
87
 
73
88
  const log = logger("ai:executor");
74
89
 
75
- const AI_TURN_LEASE_MS = 45_000;
76
90
  const AI_COALESCE_MS = 25;
77
91
  const AI_COALESCE_MAX_CHARS = 512;
78
- const AI_SNAPSHOT_INTERVAL_MS = 1_000;
79
92
  const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
80
- const AI_FINAL_TOOL_ROUND_PROMPT = `# Final response
81
- The configured tool-round budget has been reached, so no more tools are available in this turn. Answer the user's request now with the best result supported by the evidence already gathered. State any material uncertainty or incomplete part clearly.`;
82
93
 
83
94
  const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
84
95
  const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
@@ -93,50 +104,6 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
93
104
  };
94
105
  };
95
106
 
96
- const applyToolRoundPolicy = (input: {
97
- provider: Provider;
98
- tools: Tool[] | ToolResolver;
99
- maxToolRounds?: number;
100
- issuedToolRounds: number;
101
- completedToolRounds: number;
102
- }): { provider: Provider; tools: Tool[] | ToolResolver; maxTurns?: number; noteToolRound: () => void } => {
103
- const limit = Math.floor(input.maxToolRounds ?? 0);
104
- if (limit <= 0) return { provider: input.provider, tools: input.tools, noteToolRound: () => undefined };
105
-
106
- const issuedAtStart = Math.max(0, Math.floor(input.issuedToolRounds));
107
- let completed = Math.max(0, Math.floor(input.completedToolRounds));
108
- let finalSynthesis = completed >= limit;
109
- const tools: ToolResolver = async () => {
110
- finalSynthesis = completed >= limit;
111
- if (finalSynthesis) return [];
112
- return typeof input.tools === "function" ? input.tools() : input.tools;
113
- };
114
- const provider: Provider = {
115
- name: input.provider.name,
116
- family: input.provider.family,
117
- model: input.provider.model,
118
- contextWindow: input.provider.contextWindow,
119
- capabilities: input.provider.capabilities,
120
- complete: (request) => input.provider.complete(request),
121
- stream: async function* (request) {
122
- yield* input.provider.stream(
123
- finalSynthesis ? { ...request, systemPrompt: `${request.systemPrompt ?? ""}\n\n${AI_FINAL_TOOL_ROUND_PROMPT}`.trim() } : request,
124
- );
125
- },
126
- };
127
-
128
- // Nessi checks this before provider calls. The extra round is the tool-free
129
- // synthesis call after the last allowed tool-using round.
130
- return {
131
- provider,
132
- tools,
133
- maxTurns: Math.max(1, limit - issuedAtStart + 1),
134
- noteToolRound: () => {
135
- completed += 1;
136
- },
137
- };
138
- };
139
-
140
107
  const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
141
108
  try {
142
109
  await aiConversations.indexConversationResources(input);
@@ -162,6 +129,10 @@ const recordMemoryWorkflowEvidence = async (input: Parameters<typeof recordAiMem
162
129
  }
163
130
  };
164
131
 
132
+ /**
133
+ * Indexes what a finished tool call read or delivered; never throws. A browser tool ends here too, once the turn
134
+ * continues with the result the browser reported.
135
+ */
165
136
  const indexConversationToolSource = async (input: {
166
137
  conversationId: string;
167
138
  turnId: string;
@@ -184,10 +155,45 @@ const indexConversationToolSource = async (input: {
184
155
  });
185
156
  }
186
157
  let source: Parameters<typeof aiConversations.indexConversationSource>[0]["source"] | null = null;
158
+ const args = typeof input.args === "object" && input.args !== null ? (input.args as Record<string, unknown>) : {};
159
+ const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
160
+ const result = typeof input.result === "object" && input.result !== null ? (input.result as Record<string, unknown>) : {};
187
161
  if (input.name === "web_search") {
188
- const args = input.args;
189
- const query = typeof args === "object" && args !== null && "query" in args && typeof args.query === "string" ? args.query.trim() : "";
190
- source = { kind: "activity", key: "web_search", title: query || "Web search", preview: "Searched the web", icon: "ti ti-world" };
162
+ const query = text(args.query);
163
+ // One entry per query: each search is something the user may want to see that the assistant looked up.
164
+ const key = query ? `web_search:${query.replace(/\s+/gu, " ").toLocaleLowerCase()}` : "web_search";
165
+ source = { kind: "activity", key, title: query || "Web search", preview: "Searched the web", icon: "ti ti-world" };
166
+ } else if (input.name === "present" && text(result.path)) {
167
+ const path = text(result.path);
168
+ source = {
169
+ kind: "result",
170
+ key: path,
171
+ title: text(args.title) || path.slice(path.lastIndexOf("/") + 1),
172
+ preview: text(args.description) || undefined,
173
+ icon: "ti ti-file",
174
+ };
175
+ } else if (
176
+ input.name === "code_open" &&
177
+ text(args.id) &&
178
+ typeof input.result === "object" &&
179
+ input.result !== null &&
180
+ !("error" in input.result)
181
+ ) {
182
+ // The browser reports a failure as a result with an error, which reaches this point without isError.
183
+ source = {
184
+ kind: "result",
185
+ key: `assistant.artifact:${text(args.id)}`,
186
+ title: "Studio app",
187
+ icon: "ti ti-app-window",
188
+ ref: { type: "assistant.artifact", id: text(args.id) },
189
+ };
190
+ } else if (input.name === "code_present" && text(result.presentationId)) {
191
+ source = {
192
+ kind: "result",
193
+ key: `code_present:${input.callId}`,
194
+ title: text(args.title) || text(result.title) || "Visualization",
195
+ icon: "ti ti-chart-dots",
196
+ };
191
197
  } else if (input.name === "web_extract" && typeof input.result === "object" && input.result !== null) {
192
198
  const result = input.result as Record<string, unknown>;
193
199
  if (typeof result.url === "string" && result.url.trim()) {
@@ -552,8 +558,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
552
558
  source.kind === "default"
553
559
  ? [
554
560
  ...(await createConfiguredDefaultCloudAiTools()),
555
- ...(config.clientToolIds?.includes("local_bash") ? [createCloudAiLocalBashTool()] : []),
556
- ...createCloudAiCodeTools().filter((tool) => config.clientToolIds?.some((name) => name === tool.def.name)),
561
+ // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
562
+ ...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
563
+ (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
564
+ ),
557
565
  ]
558
566
  : [],
559
567
  toolApprovalContext: config.toolApprovalContext,
@@ -567,7 +575,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
567
575
  // ---------------------------------------------------------------------------
568
576
 
569
577
  type AttemptOutcome =
570
- | { kind: "finished"; status: "completed" | "failed" | "aborted"; error: string | null; timing?: LoopAggregate["timing"] }
578
+ | { kind: "finished"; status: "completed" | "failed" | "aborted"; failure: AiTurnFailureInfo | null; timing?: LoopAggregate["timing"] }
571
579
  | { kind: "suspended" };
572
580
 
573
581
  export class AiTurnExecutor {
@@ -590,7 +598,14 @@ export class AiTurnExecutor {
590
598
  pipeline.seedBaseline(claim.liveBlocks ?? []);
591
599
  await pipeline.emitTurnStarted(claim.turn.modelProfileId ?? "");
592
600
  await pipeline.emitBaseline();
593
- await this.finalize(conversationId, turnId, pipeline, "failed", "AI turn is missing its run configuration.", null);
601
+ await this.finalize(
602
+ conversationId,
603
+ turnId,
604
+ pipeline,
605
+ "failed",
606
+ { error: { code: "failed" }, detail: "AI turn is missing its run configuration." },
607
+ null,
608
+ );
594
609
  return;
595
610
  }
596
611
 
@@ -614,8 +629,9 @@ export class AiTurnExecutor {
614
629
  turnId,
615
630
  pipeline,
616
631
  "failed",
617
- error instanceof Error ? error.message : "AI turn state could not be loaded.",
632
+ aiTurnFailureFromThrown(error, "AI turn state could not be loaded."),
618
633
  "chat",
634
+ claim.runConfig?.kind === "chat" ? claim.runConfig.locale : undefined,
619
635
  );
620
636
  return;
621
637
  }
@@ -625,16 +641,39 @@ export class AiTurnExecutor {
625
641
  await this.runChat(conversationId, turnId, claim, runConfig, pipeline, signal, false, attemptState);
626
642
  }
627
643
 
644
+ /**
645
+ * Ends the turn. A failure stores its reason twice: as a code on the turn's last message, which the chat words in
646
+ * the reader's language, and as text in the turn's language for readers without the chat view. The raw cause, such
647
+ * as a provider's own message, goes only to the log. A failed compaction left the chat as it was, so its text names
648
+ * only the reason.
649
+ */
628
650
  private async finalize(
629
651
  conversationId: string,
630
652
  turnId: string,
631
653
  pipeline: StreamPipeline,
632
654
  status: "completed" | "failed" | "aborted",
633
- error: string | null,
655
+ failure: AiTurnFailureInfo | null,
634
656
  kind: AiTurnRunConfig["kind"] | null,
657
+ locale?: string,
635
658
  ) {
636
- if (status === "failed" && error) log.error("AI turn failed", { conversationId, turnId, error });
637
- const finalized = await aiConversations.completeTurn({ conversationId, turnId, status, error, leaseOwner: this.config.leaseOwner });
659
+ const failed = status === "failed" ? (failure ?? { error: { code: "failed" as const }, detail: "AI turn failed" }) : null;
660
+ let error: string | null = null;
661
+ if (failed) {
662
+ log.error("AI turn failed", { conversationId, turnId, code: failed.error.code, error: failed.detail });
663
+ error = failed.message ?? null;
664
+ if (!error) {
665
+ const textLocale = normalizeLocale(locale ?? (await coreSettings.get<string>("app.locale")));
666
+ error = kind === "compact" ? aiChatMessages(textLocale).turnErrorReason(failed.error) : aiTurnErrorText(failed.error, textLocale);
667
+ }
668
+ }
669
+ const finalized = await aiConversations.completeTurn({
670
+ conversationId,
671
+ turnId,
672
+ status,
673
+ error,
674
+ turnError: failed?.error ?? null,
675
+ leaseOwner: this.config.leaseOwner,
676
+ });
638
677
  if (finalized === "completed") await pipeline.emitTurnFinished(status, error);
639
678
  await pipeline.flush().catch(() => undefined);
640
679
  if (finalized === "completed" && this.config.onTurnFinalized) {
@@ -671,6 +710,7 @@ export class AiTurnExecutor {
671
710
  let resolvedProjectId: string | null = null;
672
711
  let chatId = config.chatId ?? "";
673
712
  let allowedTools: string[] | null = null;
713
+ let sourceToolNames: string[] = [];
674
714
  try {
675
715
  if (config.background && !config.mandate) throw new Error("Background execution requires its task mandate.");
676
716
  const [nextMaterial, conversation] = await Promise.all([
@@ -681,13 +721,14 @@ export class AiTurnExecutor {
681
721
  if (!conversation) throw new Error("Conversation is no longer available.");
682
722
  allowedTools = conversation.allowedTools ?? null;
683
723
  const allowed = allowedTools === null ? null : new Set(allowedTools);
724
+ sourceToolNames = material.tools.map((tool) => tool.def.name);
684
725
  if (allowed) material.tools = material.tools.filter((tool) => allowed.has(tool.def.name));
685
726
  chatId ||= conversation?.shortId ?? "";
686
727
  if (config.project) {
687
728
  const subject = accessSubjectForActor(material.actor);
688
729
  const project = subject ? await aiProjects.getByShortId(config.project.id, subject, "read") : null;
689
730
  if (!project) {
690
- throw new Error("Project access is no longer available.");
731
+ throw new AiTurnFailure("not_allowed", "Project access is no longer available.");
691
732
  }
692
733
  resolvedProjectId = project.id;
693
734
  }
@@ -710,7 +751,15 @@ export class AiTurnExecutor {
710
751
  }
711
752
  } catch (error) {
712
753
  signal.removeEventListener("abort", onSignal);
713
- await this.finalize(conversationId, turnId, pipeline, "failed", error instanceof Error ? error.message : "AI turn failed", "chat");
754
+ await this.finalize(
755
+ conversationId,
756
+ turnId,
757
+ pipeline,
758
+ "failed",
759
+ aiTurnFailureFromThrown(error, "AI turn failed"),
760
+ "chat",
761
+ config.locale,
762
+ );
714
763
  return;
715
764
  }
716
765
  const { settings, resolved } = validated;
@@ -733,8 +782,12 @@ export class AiTurnExecutor {
733
782
  turnId,
734
783
  pipeline,
735
784
  "failed",
736
- error instanceof Error ? error.message : "Cloud capability actor resolution failed",
785
+ {
786
+ error: { code: "not_allowed" },
787
+ detail: error instanceof Error ? error.message : "Cloud capability actor resolution failed",
788
+ },
737
789
  "chat",
790
+ config.locale,
738
791
  );
739
792
  return;
740
793
  }
@@ -802,6 +855,12 @@ export class AiTurnExecutor {
802
855
  (tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
803
856
  )
804
857
  : [];
858
+ const offeredToolNames = new Set(activeTools.map((tool) => tool.def.name));
859
+ // Built-ins that exist but this turn does not offer: client tools without their client, tools a
860
+ // task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
861
+ const unofferedTools = [
862
+ ...new Set([...sourceToolNames, ...runtimeTools.map((tool) => tool.def.name), ...CODE_RUNTIME_TOOL_NAMES, "local_bash"]),
863
+ ].filter((name) => !offeredToolNames.has(name));
805
864
  const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
806
865
  const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
807
866
 
@@ -860,8 +919,9 @@ export class AiTurnExecutor {
860
919
  turnId,
861
920
  pipeline,
862
921
  "failed",
863
- error instanceof Error ? error.message : "Image preparation failed",
922
+ aiTurnFailureFromThrown(error, "Image preparation failed"),
864
923
  "chat",
924
+ promptLocale,
865
925
  );
866
926
  return;
867
927
  }
@@ -910,6 +970,7 @@ export class AiTurnExecutor {
910
970
  actor: capabilityAuthority?.actor ?? toolActor,
911
971
  staticTools: activeTools,
912
972
  allowedTools,
973
+ unofferedTools,
913
974
  runtimeContext: dynamicToolRuntimeContext,
914
975
  store: toolStore,
915
976
  ...(capabilityAuthority ? { listRegistry: listCapabilities } : {}),
@@ -1073,32 +1134,57 @@ export class AiTurnExecutor {
1073
1134
  memory: memory?.text,
1074
1135
  timeZone,
1075
1136
  locale: promptLocale,
1137
+ interactive: !config.background,
1138
+ skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
1076
1139
  });
1077
- const priorToolRounds = toolRoundState(loopMessages);
1140
+ // The turn policy counts the whole turn, including rounds that compaction archived.
1141
+ const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
1142
+ const turnBlocks = buildBlocksFromMessages(turnMessages);
1143
+ const priorToolRounds = toolRoundState(turnMessages);
1078
1144
  const quotaSubject = accessSubjectForActor(material.actor);
1079
1145
  const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
1080
- const toolRoundPolicy = applyToolRoundPolicy({
1081
- provider: retryTransientProviderErrors(
1082
- assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
1083
- {
1084
- deadline,
1085
- delaysMs: this.config.providerRetryDelaysMs,
1086
- onRetry: async ({ retry, delayMs, issue }) => {
1087
- log.warn("AI provider call retried", { conversationId, turnId, retry, delayMs, kind: issue.kind, message: issue.message });
1088
- await pipeline.emitProviderRetry();
1146
+ // The reason a failed turn names: the one the current model call or the turn's own checks gave.
1147
+ const failureReason: { current: AiTurnFailureReason | null } = { current: null };
1148
+ const remember = (reason: AiTurnFailureReason | null) => {
1149
+ failureReason.current = reason;
1150
+ };
1151
+ const turnPolicy = applyAiTurnPolicy({
1152
+ provider: acceptCanonicalToolNames(
1153
+ retryTransientProviderErrors(
1154
+ rememberProviderErrors(
1155
+ answerOpenToolCalls(assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId)),
1156
+ remember,
1157
+ ),
1158
+ {
1159
+ deadline,
1160
+ delaysMs: this.config.providerRetryDelaysMs,
1161
+ onRetry: async ({ retry, delayMs, issue }) => {
1162
+ log.warn("AI provider call retried", { conversationId, turnId, retry, delayMs, kind: issue.kind, message: issue.message });
1163
+ await pipeline.emitProviderRetry();
1164
+ },
1089
1165
  },
1090
- },
1166
+ ),
1167
+ prepared.canonicalNames,
1091
1168
  ),
1092
1169
  tools,
1093
1170
  maxToolRounds: resolved.profile.maxToolRounds,
1094
1171
  issuedToolRounds: priorToolRounds.issued,
1095
1172
  completedToolRounds: priorToolRounds.completed,
1173
+ deadline,
1174
+ runBudgetMs: claim.turn.runBudgetMs ?? null,
1175
+ finishedToolCalls: turnBlocks
1176
+ .slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
1177
+ .flatMap((block) => (block.kind === "tool" ? [block] : [])),
1178
+ onDecision: (decision) =>
1179
+ decision.kind === "hint"
1180
+ ? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
1181
+ : log.info("AI turn answers without further tools", { conversationId, turnId, reason: decision.reason }),
1096
1182
  });
1097
1183
  const loop = nessi({
1098
1184
  agentId: "cloud",
1099
1185
  loopId: turnId,
1100
1186
  ...(isFresh ? { input: turnInput } : {}),
1101
- provider: toolRoundPolicy.provider,
1187
+ provider: turnPolicy.provider,
1102
1188
  systemPrompt,
1103
1189
  store,
1104
1190
  steering: async ({ signal: steeringSignal }) => {
@@ -1109,12 +1195,22 @@ export class AiTurnExecutor {
1109
1195
  leaseOwner: this.config.leaseOwner,
1110
1196
  });
1111
1197
  appliedSteers.push(...steers);
1112
- return steers.length > 0 ? steers.map((steer) => steer.text) : undefined;
1198
+ if (steers.length === 0) return undefined;
1199
+ turnPolicy.noteSteering();
1200
+ return steers.map((steer) => steer.text);
1201
+ },
1202
+ tools: async () => {
1203
+ try {
1204
+ return await turnPolicy.tools();
1205
+ } catch (error) {
1206
+ remember(aiTurnReasonFromThrown(error));
1207
+ throw error;
1208
+ }
1113
1209
  },
1114
- tools: toolRoundPolicy.tools,
1115
- ...(toolRoundPolicy.maxTurns === undefined ? {} : { maxTurns: toolRoundPolicy.maxTurns }),
1210
+ ...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
1116
1211
  temperature: resolved.profile.temperature,
1117
1212
  maxOutputTokens: resolved.profile.maxOutputTokens,
1213
+ reasoningEffort: resolved.profile.reasoningEffort,
1118
1214
  coalesce: { ms: AI_COALESCE_MS, maxChars: AI_COALESCE_MAX_CHARS },
1119
1215
  compact: config.background
1120
1216
  ? undefined
@@ -1150,7 +1246,9 @@ export class AiTurnExecutor {
1150
1246
  rememberableCapabilityApprovals,
1151
1247
  capabilityActionReviews,
1152
1248
  appliedSteers,
1153
- noteToolRound: toolRoundPolicy.noteToolRound,
1249
+ noteToolRound: turnPolicy.noteToolRound,
1250
+ noteToolCall: turnPolicy.noteToolCall,
1251
+ failureReason,
1154
1252
  onBackgroundBlocked: (message) => {
1155
1253
  backgroundError = message;
1156
1254
  },
@@ -1175,15 +1273,17 @@ export class AiTurnExecutor {
1175
1273
  : null;
1176
1274
  if (timeout) {
1177
1275
  outcome.status = "failed";
1178
- outcome.error = timeout.messageFor(promptLocale);
1276
+ outcome.failure = { error: timeout.turnError(), detail: "Run time limit reached." };
1179
1277
  }
1180
1278
  const finalized = await this.finalize(
1181
1279
  conversationId,
1182
1280
  turnId,
1183
1281
  pipeline,
1184
1282
  backgroundError ? "failed" : outcome.status,
1185
- backgroundError ?? outcome.error,
1283
+ // A background run keeps Cloud's own words for what its mandate blocked.
1284
+ backgroundError ? { error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError } : outcome.failure,
1186
1285
  "chat",
1286
+ promptLocale,
1187
1287
  );
1188
1288
  if (finalized === "pending_steering" && outcome.status === "completed" && !signal.aborted) {
1189
1289
  await this.runChat(conversationId, turnId, claim, config, pipeline, signal, true);
@@ -1216,6 +1316,13 @@ export class AiTurnExecutor {
1216
1316
  capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1217
1317
  appliedSteers: AiTurnSteer[];
1218
1318
  noteToolRound: () => void;
1319
+ noteToolCall: (call: AiTurnPolicyToolCall) => void;
1320
+ /**
1321
+ * Why the turn would fail now, as a model call or the turn's own checks said. A new model call starts without one,
1322
+ * so a context overflow that compaction resolved never names a later failure. Only the model calls and checks set
1323
+ * it, in the order they run; the events here arrive later than that.
1324
+ */
1325
+ failureReason: { current: AiTurnFailureReason | null };
1219
1326
  onBackgroundBlocked?: (message: string) => void;
1220
1327
  }): Promise<AttemptOutcome> {
1221
1328
  const {
@@ -1231,6 +1338,8 @@ export class AiTurnExecutor {
1231
1338
  capabilityActionReviews,
1232
1339
  appliedSteers,
1233
1340
  noteToolRound,
1341
+ noteToolCall,
1342
+ failureReason,
1234
1343
  } = input;
1235
1344
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1236
1345
  let lastIssueMessage: string | null = null;
@@ -1289,6 +1398,12 @@ export class AiTurnExecutor {
1289
1398
  .noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
1290
1399
  .catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
1291
1400
  const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
1401
+ // The policy keys calls by the name the model called, as the persisted calls it seeds from.
1402
+ noteToolCall(
1403
+ toolBlock?.kind === "tool"
1404
+ ? { ...toolBlock, name: event.name }
1405
+ : { name: event.name, status: event.isError ? "failed" : "completed", result: event.result },
1406
+ );
1292
1407
  await indexConversationToolSource({
1293
1408
  conversationId,
1294
1409
  turnId,
@@ -1311,25 +1426,24 @@ export class AiTurnExecutor {
1311
1426
  .catch(() => undefined);
1312
1427
  }
1313
1428
  const timing = aggregate.timing;
1314
- if (event.reason === "aborted") return { kind: "finished", status: "aborted", error: null, timing };
1315
- if (event.reason === "stop") return { kind: "finished", status: "completed", error: null, timing };
1316
- if (event.reason === "max_turns") {
1317
- return {
1318
- kind: "finished",
1319
- status: "failed",
1320
- error: "The model did not produce a final answer within its tool-round limit.",
1321
- timing,
1322
- };
1323
- }
1324
- return { kind: "finished", status: "failed", error: lastIssueMessage ?? `AI turn ended: ${event.reason}`, timing };
1429
+ if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
1430
+ if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
1431
+ const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
1432
+ const reason: AiTurnFailureReason =
1433
+ event.reason === "max_turns"
1434
+ ? { error: { code: "step_limit" } }
1435
+ : event.reason === "context_overflow"
1436
+ ? { error: { code: "context_full" } }
1437
+ : event.reason === "no_credits"
1438
+ ? { error: { code: "quota_exhausted" } }
1439
+ : (failureReason.current ?? { error: { code: "failed" } });
1440
+ return { kind: "finished", status: "failed", failure: { ...reason, detail }, timing };
1325
1441
  }
1326
1442
  }
1327
- return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", error: null };
1443
+ return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", failure: null };
1328
1444
  } catch (error) {
1329
- if (abortController.signal.aborted) return { kind: "finished", status: "aborted", error: null };
1330
- const message = error instanceof Error ? error.message : "AI turn failed";
1331
- await pipeline.emitError(message).catch(() => undefined);
1332
- return { kind: "finished", status: "failed", error: message };
1445
+ if (abortController.signal.aborted) return { kind: "finished", status: "aborted", failure: null };
1446
+ return { kind: "finished", status: "failed", failure: aiTurnFailureFromThrown(error, "AI turn failed") };
1333
1447
  } finally {
1334
1448
  stopHeartbeat();
1335
1449
  await pipeline.flush().catch(() => undefined);
@@ -1521,14 +1635,7 @@ export class AiTurnExecutor {
1521
1635
  });
1522
1636
  } catch (error) {
1523
1637
  signal.removeEventListener("abort", onSignal);
1524
- await this.finalize(
1525
- conversationId,
1526
- turnId,
1527
- pipeline,
1528
- "failed",
1529
- error instanceof Error ? error.message : "AI compaction failed",
1530
- "compact",
1531
- );
1638
+ await this.finalize(conversationId, turnId, pipeline, "failed", aiTurnFailureFromThrown(error, "AI compaction failed"), "compact");
1532
1639
  return;
1533
1640
  }
1534
1641
  const { settings, resolved } = validated;
@@ -1539,17 +1646,23 @@ export class AiTurnExecutor {
1539
1646
  turnId,
1540
1647
  leaseOwner: this.config.leaseOwner,
1541
1648
  });
1649
+ const reason: { current: AiTurnFailureReason | null } = { current: null };
1542
1650
  const loop = compact({
1543
1651
  agentId: "cloud",
1544
1652
  loopId: turnId,
1545
1653
  store,
1546
- provider: inferenceProvider(resolved.provider, resolved.profile, {
1547
- kind: "background",
1548
- task: "chat-compaction",
1549
- conversationId,
1550
- turnId,
1551
- appId: "core",
1552
- }),
1654
+ provider: rememberProviderErrors(
1655
+ inferenceProvider(resolved.provider, resolved.profile, {
1656
+ kind: "background",
1657
+ task: "chat-compaction",
1658
+ conversationId,
1659
+ turnId,
1660
+ appId: "core",
1661
+ }),
1662
+ (remembered) => {
1663
+ reason.current = remembered;
1664
+ },
1665
+ ),
1553
1666
  force: true,
1554
1667
  signal: abortController.signal,
1555
1668
  compact: createCloudCompactFn({
@@ -1568,15 +1681,21 @@ export class AiTurnExecutor {
1568
1681
 
1569
1682
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1570
1683
  let status: "completed" | "failed" | "aborted" = "failed";
1571
- let error: string | null = null;
1684
+ let failure: AiTurnFailureInfo | null = null;
1685
+ let issueMessage: string | null = null;
1572
1686
  try {
1573
1687
  for await (const event of loop as AsyncIterable<CompactEvent>) {
1574
1688
  if (event.type === "compaction_start") await pipeline.applyCompaction("running");
1575
1689
  else if (event.type === "compaction_end") await pipeline.applyCompaction("completed");
1576
- else if (event.type === "issue") error = event.issue.message;
1690
+ else if (event.type === "issue") issueMessage = event.issue.message;
1577
1691
  else if (event.type === "loop_end") {
1578
1692
  status = event.reason === "stop" ? "completed" : event.reason === "aborted" ? "aborted" : "failed";
1579
1693
  await pipeline.applyCompaction(status === "failed" ? "failed" : "completed", event.result);
1694
+ if (status === "failed")
1695
+ failure = {
1696
+ ...(reason.current ?? { error: { code: "failed" } }),
1697
+ detail: issueMessage ?? `AI compaction ended: ${event.reason}`,
1698
+ };
1580
1699
  }
1581
1700
  }
1582
1701
  } catch (err) {
@@ -1584,8 +1703,8 @@ export class AiTurnExecutor {
1584
1703
  status = "aborted";
1585
1704
  } else {
1586
1705
  status = "failed";
1587
- error = err instanceof Error ? err.message : "AI compaction failed";
1588
- await pipeline.emitError(error).catch(() => undefined);
1706
+ const thrown = aiTurnFailureFromThrown(err, "AI compaction failed");
1707
+ failure = reason.current ? { ...reason.current, detail: thrown.detail } : thrown;
1589
1708
  }
1590
1709
  } finally {
1591
1710
  stopHeartbeat();
@@ -1594,9 +1713,9 @@ export class AiTurnExecutor {
1594
1713
 
1595
1714
  if (abortController.signal.reason instanceof AiRunTimeout) {
1596
1715
  status = "failed";
1597
- error = abortController.signal.reason.messageFor();
1716
+ failure = { error: abortController.signal.reason.turnError(), detail: "Run time limit reached." };
1598
1717
  }
1599
- await this.finalize(conversationId, turnId, pipeline, status, error, "compact");
1718
+ await this.finalize(conversationId, turnId, pipeline, status, failure, "compact");
1600
1719
  }
1601
1720
  }
1602
1721
 
@@ -1620,6 +1739,9 @@ class StreamPipeline {
1620
1739
  readonly timing: ReturnType<typeof createTurnTimingRecorder>;
1621
1740
  private lastSnapshotAt = 0;
1622
1741
  private snapshotDirty = false;
1742
+ private snapshotTimer: ReturnType<typeof setTimeout> | undefined;
1743
+ /** The newest save of the live state. Saves run one after another, so an older one never lands last. */
1744
+ private saving: Promise<void> = Promise.resolve();
1623
1745
  private chain: Promise<void> = Promise.resolve();
1624
1746
 
1625
1747
  constructor(input: {
@@ -1651,6 +1773,8 @@ class StreamPipeline {
1651
1773
 
1652
1774
  private nextSeq(): number {
1653
1775
  this.seq += 1;
1776
+ // The saved state follows every event, so a reader that reloads it catches up with the live stream.
1777
+ this.snapshotDirty = true;
1654
1778
  return this.seq;
1655
1779
  }
1656
1780
 
@@ -1699,12 +1823,14 @@ class StreamPipeline {
1699
1823
  const seq = this.nextSeq();
1700
1824
  await this.publish(this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent);
1701
1825
  }
1702
- this.snapshotDirty = this.blocks.length > 0;
1826
+ // A new attempt's baseline is saved at once: a stream that reloads the turn must not wait for the first model event.
1827
+ await this.maybeSnapshot();
1703
1828
  }
1704
1829
 
1705
1830
  async emitMessage(message: AiStoredMessage): Promise<void> {
1706
1831
  const seq = this.nextSeq();
1707
1832
  await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
1833
+ await this.maybeSnapshot();
1708
1834
  }
1709
1835
 
1710
1836
  async emitTurnStarted(modelProfileId: string): Promise<void> {
@@ -1764,38 +1890,43 @@ class StreamPipeline {
1764
1890
  await this.publish(event);
1765
1891
  }
1766
1892
 
1893
+ /**
1894
+ * Saves the live state at most once per interval. A change inside the interval is saved when it ends, even if no
1895
+ * event follows, so the saved state lags the live stream by at most one interval.
1896
+ */
1767
1897
  private async maybeSnapshot(): Promise<void> {
1768
1898
  if (!this.snapshotDirty) return;
1769
- if (Date.now() - this.lastSnapshotAt < AI_SNAPSHOT_INTERVAL_MS) return;
1899
+ const wait = AI_LIVE_SNAPSHOT_INTERVAL_MS - (Date.now() - this.lastSnapshotAt);
1900
+ if (wait > 0) {
1901
+ this.snapshotTimer ??= setTimeout(() => {
1902
+ this.snapshotTimer = undefined;
1903
+ void this.maybeSnapshot();
1904
+ }, wait);
1905
+ return;
1906
+ }
1770
1907
  await this.persistSnapshot();
1771
1908
  }
1772
1909
 
1773
1910
  async persistSnapshot(): Promise<void> {
1911
+ this.cancelSnapshotTimer();
1774
1912
  this.lastSnapshotAt = Date.now();
1775
1913
  this.snapshotDirty = false;
1776
- await aiConversations
1777
- .saveTurnLiveState({
1778
- conversationId: this.conversationId,
1779
- turnId: this.turnId,
1780
- leaseOwner: this.leaseOwner,
1781
- blocks: this.blocks,
1782
- seq: this.seq,
1783
- })
1784
- .catch(() => undefined);
1785
- }
1786
-
1787
- async emitError(message: string): Promise<void> {
1788
- const seq = this.nextSeq();
1789
- const block: AiTurnBlock = { id: `error-${this.attempt}-${seq}`, kind: "text", text: `⚠️ ${message}` };
1790
- const event = this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent;
1791
- this.blocks = applyWireEventToBlocks(this.blocks, event);
1792
- await this.publish(event);
1914
+ const blocks = this.blocks;
1915
+ const seq = this.seq;
1916
+ this.saving = this.saving.then(() =>
1917
+ aiConversations
1918
+ .saveTurnLiveState({ conversationId: this.conversationId, turnId: this.turnId, leaseOwner: this.leaseOwner, blocks, seq })
1919
+ .then(() => undefined)
1920
+ .catch(() => undefined),
1921
+ );
1922
+ await this.saving;
1793
1923
  }
1794
1924
 
1795
1925
  /** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
1796
1926
  async emitProviderRetry(): Promise<void> {
1797
1927
  const seq = this.nextSeq();
1798
1928
  await this.publish(this.envelope({ type: "provider_retry" as const, seq }));
1929
+ await this.maybeSnapshot();
1799
1930
  }
1800
1931
 
1801
1932
  async emitTurnFinished(status: "completed" | "failed" | "aborted", error: string | null): Promise<void> {
@@ -1808,15 +1939,24 @@ class StreamPipeline {
1808
1939
  return this.ordered(() => publishAiWireEvent(event).catch(() => undefined));
1809
1940
  }
1810
1941
 
1942
+ private cancelSnapshotTimer(): void {
1943
+ if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
1944
+ this.snapshotTimer = undefined;
1945
+ }
1946
+
1947
+ /**
1948
+ * Waits for every publish and save. The attempt ends here: a later save would overwrite what suspension or the next
1949
+ * attempt saved.
1950
+ */
1811
1951
  async flush(): Promise<void> {
1812
- await this.chain;
1952
+ this.cancelSnapshotTimer();
1953
+ await Promise.all([this.chain, this.saving]);
1813
1954
  }
1814
1955
  }
1815
1956
 
1816
1957
  export const __aiExecutorTest = {
1817
1958
  indexConversationToolSource,
1818
1959
  StreamPipeline,
1819
- applyToolRoundPolicy,
1820
1960
  createEventMapper,
1821
1961
  rebuildAttemptBaseline,
1822
1962
  rebuildBlocksFromMessages,