@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
@@ -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,6 +15,7 @@ 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";
20
21
  import { aiChatTasks } from "./chat-tasks";
@@ -29,6 +30,7 @@ import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
29
30
  import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
30
31
  import { aiProjects } from "./projects";
31
32
  import {
33
+ AI_TURN_LEASE_MS,
32
34
  type AiTurnBlock,
33
35
  type AiWireEvent,
34
36
  applyWireEventToBlocks,
@@ -49,11 +51,13 @@ import { selectAiSkillCatalog } from "./skill-catalog";
49
51
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
50
52
  import { aiSkills } from "./skills";
51
53
  import { aiConversations } from "./store";
52
- import { publishAiWireEvent } from "./stream";
54
+ import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
53
55
  import { composeAiSystemPrompt } from "./system-prompt";
54
56
  import { aiToolAudit } from "./tool-audit";
57
+ import { acceptCanonicalToolNames } from "./tool-call-names";
55
58
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
56
59
  import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
60
+ import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
57
61
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
58
62
  import type {
59
63
  AiChatTurnRunConfig,
@@ -72,13 +76,9 @@ import { validateAiTurnRequest } from "./validate";
72
76
 
73
77
  const log = logger("ai:executor");
74
78
 
75
- const AI_TURN_LEASE_MS = 45_000;
76
79
  const AI_COALESCE_MS = 25;
77
80
  const AI_COALESCE_MAX_CHARS = 512;
78
- const AI_SNAPSHOT_INTERVAL_MS = 1_000;
79
81
  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
82
 
83
83
  const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
84
84
  const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
@@ -93,50 +93,6 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
93
93
  };
94
94
  };
95
95
 
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
96
  const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
141
97
  try {
142
98
  await aiConversations.indexConversationResources(input);
@@ -162,6 +118,10 @@ const recordMemoryWorkflowEvidence = async (input: Parameters<typeof recordAiMem
162
118
  }
163
119
  };
164
120
 
121
+ /**
122
+ * Indexes what a finished tool call read or delivered; never throws. A browser tool ends here too, once the turn
123
+ * continues with the result the browser reported.
124
+ */
165
125
  const indexConversationToolSource = async (input: {
166
126
  conversationId: string;
167
127
  turnId: string;
@@ -184,10 +144,45 @@ const indexConversationToolSource = async (input: {
184
144
  });
185
145
  }
186
146
  let source: Parameters<typeof aiConversations.indexConversationSource>[0]["source"] | null = null;
147
+ const args = typeof input.args === "object" && input.args !== null ? (input.args as Record<string, unknown>) : {};
148
+ const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
149
+ const result = typeof input.result === "object" && input.result !== null ? (input.result as Record<string, unknown>) : {};
187
150
  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" };
151
+ const query = text(args.query);
152
+ // One entry per query: each search is something the user may want to see that the assistant looked up.
153
+ const key = query ? `web_search:${query.replace(/\s+/gu, " ").toLocaleLowerCase()}` : "web_search";
154
+ source = { kind: "activity", key, title: query || "Web search", preview: "Searched the web", icon: "ti ti-world" };
155
+ } else if (input.name === "present" && text(result.path)) {
156
+ const path = text(result.path);
157
+ source = {
158
+ kind: "result",
159
+ key: path,
160
+ title: text(args.title) || path.slice(path.lastIndexOf("/") + 1),
161
+ preview: text(args.description) || undefined,
162
+ icon: "ti ti-file",
163
+ };
164
+ } else if (
165
+ input.name === "code_open" &&
166
+ text(args.id) &&
167
+ typeof input.result === "object" &&
168
+ input.result !== null &&
169
+ !("error" in input.result)
170
+ ) {
171
+ // The browser reports a failure as a result with an error, which reaches this point without isError.
172
+ source = {
173
+ kind: "result",
174
+ key: `assistant.artifact:${text(args.id)}`,
175
+ title: "Studio app",
176
+ icon: "ti ti-app-window",
177
+ ref: { type: "assistant.artifact", id: text(args.id) },
178
+ };
179
+ } else if (input.name === "code_present" && text(result.presentationId)) {
180
+ source = {
181
+ kind: "result",
182
+ key: `code_present:${input.callId}`,
183
+ title: text(args.title) || text(result.title) || "Visualization",
184
+ icon: "ti ti-chart-dots",
185
+ };
191
186
  } else if (input.name === "web_extract" && typeof input.result === "object" && input.result !== null) {
192
187
  const result = input.result as Record<string, unknown>;
193
188
  if (typeof result.url === "string" && result.url.trim()) {
@@ -552,8 +547,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
552
547
  source.kind === "default"
553
548
  ? [
554
549
  ...(await createConfiguredDefaultCloudAiTools()),
555
- ...(config.clientToolIds?.includes("local_bash") ? [createCloudAiLocalBashTool()] : []),
556
- ...createCloudAiCodeTools().filter((tool) => config.clientToolIds?.some((name) => name === tool.def.name)),
550
+ // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
551
+ ...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
552
+ (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
553
+ ),
557
554
  ]
558
555
  : [],
559
556
  toolApprovalContext: config.toolApprovalContext,
@@ -671,6 +668,7 @@ export class AiTurnExecutor {
671
668
  let resolvedProjectId: string | null = null;
672
669
  let chatId = config.chatId ?? "";
673
670
  let allowedTools: string[] | null = null;
671
+ let sourceToolNames: string[] = [];
674
672
  try {
675
673
  if (config.background && !config.mandate) throw new Error("Background execution requires its task mandate.");
676
674
  const [nextMaterial, conversation] = await Promise.all([
@@ -681,6 +679,7 @@ export class AiTurnExecutor {
681
679
  if (!conversation) throw new Error("Conversation is no longer available.");
682
680
  allowedTools = conversation.allowedTools ?? null;
683
681
  const allowed = allowedTools === null ? null : new Set(allowedTools);
682
+ sourceToolNames = material.tools.map((tool) => tool.def.name);
684
683
  if (allowed) material.tools = material.tools.filter((tool) => allowed.has(tool.def.name));
685
684
  chatId ||= conversation?.shortId ?? "";
686
685
  if (config.project) {
@@ -802,6 +801,12 @@ export class AiTurnExecutor {
802
801
  (tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
803
802
  )
804
803
  : [];
804
+ const offeredToolNames = new Set(activeTools.map((tool) => tool.def.name));
805
+ // Built-ins that exist but this turn does not offer: client tools without their client, tools a
806
+ // task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
807
+ const unofferedTools = [
808
+ ...new Set([...sourceToolNames, ...runtimeTools.map((tool) => tool.def.name), ...CODE_RUNTIME_TOOL_NAMES, "local_bash"]),
809
+ ].filter((name) => !offeredToolNames.has(name));
805
810
  const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
806
811
  const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
807
812
 
@@ -910,6 +915,7 @@ export class AiTurnExecutor {
910
915
  actor: capabilityAuthority?.actor ?? toolActor,
911
916
  staticTools: activeTools,
912
917
  allowedTools,
918
+ unofferedTools,
913
919
  runtimeContext: dynamicToolRuntimeContext,
914
920
  store: toolStore,
915
921
  ...(capabilityAuthority ? { listRegistry: listCapabilities } : {}),
@@ -1073,32 +1079,49 @@ export class AiTurnExecutor {
1073
1079
  memory: memory?.text,
1074
1080
  timeZone,
1075
1081
  locale: promptLocale,
1082
+ interactive: !config.background,
1083
+ skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
1076
1084
  });
1077
- const priorToolRounds = toolRoundState(loopMessages);
1085
+ // The turn policy counts the whole turn, including rounds that compaction archived.
1086
+ const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
1087
+ const turnBlocks = buildBlocksFromMessages(turnMessages);
1088
+ const priorToolRounds = toolRoundState(turnMessages);
1078
1089
  const quotaSubject = accessSubjectForActor(material.actor);
1079
1090
  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();
1091
+ const turnPolicy = applyAiTurnPolicy({
1092
+ provider: acceptCanonicalToolNames(
1093
+ retryTransientProviderErrors(
1094
+ assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
1095
+ {
1096
+ deadline,
1097
+ delaysMs: this.config.providerRetryDelaysMs,
1098
+ onRetry: async ({ retry, delayMs, issue }) => {
1099
+ log.warn("AI provider call retried", { conversationId, turnId, retry, delayMs, kind: issue.kind, message: issue.message });
1100
+ await pipeline.emitProviderRetry();
1101
+ },
1089
1102
  },
1090
- },
1103
+ ),
1104
+ prepared.canonicalNames,
1091
1105
  ),
1092
1106
  tools,
1093
1107
  maxToolRounds: resolved.profile.maxToolRounds,
1094
1108
  issuedToolRounds: priorToolRounds.issued,
1095
1109
  completedToolRounds: priorToolRounds.completed,
1110
+ deadline,
1111
+ runBudgetMs: claim.turn.runBudgetMs ?? null,
1112
+ finishedToolCalls: turnBlocks
1113
+ .slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
1114
+ .flatMap((block) => (block.kind === "tool" ? [block] : [])),
1115
+ onDecision: (decision) =>
1116
+ decision.kind === "hint"
1117
+ ? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
1118
+ : log.info("AI turn answers without further tools", { conversationId, turnId, reason: decision.reason }),
1096
1119
  });
1097
1120
  const loop = nessi({
1098
1121
  agentId: "cloud",
1099
1122
  loopId: turnId,
1100
1123
  ...(isFresh ? { input: turnInput } : {}),
1101
- provider: toolRoundPolicy.provider,
1124
+ provider: turnPolicy.provider,
1102
1125
  systemPrompt,
1103
1126
  store,
1104
1127
  steering: async ({ signal: steeringSignal }) => {
@@ -1109,12 +1132,15 @@ export class AiTurnExecutor {
1109
1132
  leaseOwner: this.config.leaseOwner,
1110
1133
  });
1111
1134
  appliedSteers.push(...steers);
1112
- return steers.length > 0 ? steers.map((steer) => steer.text) : undefined;
1135
+ if (steers.length === 0) return undefined;
1136
+ turnPolicy.noteSteering();
1137
+ return steers.map((steer) => steer.text);
1113
1138
  },
1114
- tools: toolRoundPolicy.tools,
1115
- ...(toolRoundPolicy.maxTurns === undefined ? {} : { maxTurns: toolRoundPolicy.maxTurns }),
1139
+ tools: turnPolicy.tools,
1140
+ ...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
1116
1141
  temperature: resolved.profile.temperature,
1117
1142
  maxOutputTokens: resolved.profile.maxOutputTokens,
1143
+ reasoningEffort: resolved.profile.reasoningEffort,
1118
1144
  coalesce: { ms: AI_COALESCE_MS, maxChars: AI_COALESCE_MAX_CHARS },
1119
1145
  compact: config.background
1120
1146
  ? undefined
@@ -1150,7 +1176,8 @@ export class AiTurnExecutor {
1150
1176
  rememberableCapabilityApprovals,
1151
1177
  capabilityActionReviews,
1152
1178
  appliedSteers,
1153
- noteToolRound: toolRoundPolicy.noteToolRound,
1179
+ noteToolRound: turnPolicy.noteToolRound,
1180
+ noteToolCall: turnPolicy.noteToolCall,
1154
1181
  onBackgroundBlocked: (message) => {
1155
1182
  backgroundError = message;
1156
1183
  },
@@ -1216,6 +1243,7 @@ export class AiTurnExecutor {
1216
1243
  capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1217
1244
  appliedSteers: AiTurnSteer[];
1218
1245
  noteToolRound: () => void;
1246
+ noteToolCall: (call: AiTurnPolicyToolCall) => void;
1219
1247
  onBackgroundBlocked?: (message: string) => void;
1220
1248
  }): Promise<AttemptOutcome> {
1221
1249
  const {
@@ -1231,6 +1259,7 @@ export class AiTurnExecutor {
1231
1259
  capabilityActionReviews,
1232
1260
  appliedSteers,
1233
1261
  noteToolRound,
1262
+ noteToolCall,
1234
1263
  } = input;
1235
1264
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1236
1265
  let lastIssueMessage: string | null = null;
@@ -1289,6 +1318,12 @@ export class AiTurnExecutor {
1289
1318
  .noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
1290
1319
  .catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
1291
1320
  const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
1321
+ // The policy keys calls by the name the model called, as the persisted calls it seeds from.
1322
+ noteToolCall(
1323
+ toolBlock?.kind === "tool"
1324
+ ? { ...toolBlock, name: event.name }
1325
+ : { name: event.name, status: event.isError ? "failed" : "completed", result: event.result },
1326
+ );
1292
1327
  await indexConversationToolSource({
1293
1328
  conversationId,
1294
1329
  turnId,
@@ -1620,6 +1655,9 @@ class StreamPipeline {
1620
1655
  readonly timing: ReturnType<typeof createTurnTimingRecorder>;
1621
1656
  private lastSnapshotAt = 0;
1622
1657
  private snapshotDirty = false;
1658
+ private snapshotTimer: ReturnType<typeof setTimeout> | undefined;
1659
+ /** The newest save of the live state. Saves run one after another, so an older one never lands last. */
1660
+ private saving: Promise<void> = Promise.resolve();
1623
1661
  private chain: Promise<void> = Promise.resolve();
1624
1662
 
1625
1663
  constructor(input: {
@@ -1651,6 +1689,8 @@ class StreamPipeline {
1651
1689
 
1652
1690
  private nextSeq(): number {
1653
1691
  this.seq += 1;
1692
+ // The saved state follows every event, so a reader that reloads it catches up with the live stream.
1693
+ this.snapshotDirty = true;
1654
1694
  return this.seq;
1655
1695
  }
1656
1696
 
@@ -1699,12 +1739,14 @@ class StreamPipeline {
1699
1739
  const seq = this.nextSeq();
1700
1740
  await this.publish(this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent);
1701
1741
  }
1702
- this.snapshotDirty = this.blocks.length > 0;
1742
+ // A new attempt's baseline is saved at once: a stream that reloads the turn must not wait for the first model event.
1743
+ await this.maybeSnapshot();
1703
1744
  }
1704
1745
 
1705
1746
  async emitMessage(message: AiStoredMessage): Promise<void> {
1706
1747
  const seq = this.nextSeq();
1707
1748
  await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
1749
+ await this.maybeSnapshot();
1708
1750
  }
1709
1751
 
1710
1752
  async emitTurnStarted(modelProfileId: string): Promise<void> {
@@ -1764,24 +1806,36 @@ class StreamPipeline {
1764
1806
  await this.publish(event);
1765
1807
  }
1766
1808
 
1809
+ /**
1810
+ * Saves the live state at most once per interval. A change inside the interval is saved when it ends, even if no
1811
+ * event follows, so the saved state lags the live stream by at most one interval.
1812
+ */
1767
1813
  private async maybeSnapshot(): Promise<void> {
1768
1814
  if (!this.snapshotDirty) return;
1769
- if (Date.now() - this.lastSnapshotAt < AI_SNAPSHOT_INTERVAL_MS) return;
1815
+ const wait = AI_LIVE_SNAPSHOT_INTERVAL_MS - (Date.now() - this.lastSnapshotAt);
1816
+ if (wait > 0) {
1817
+ this.snapshotTimer ??= setTimeout(() => {
1818
+ this.snapshotTimer = undefined;
1819
+ void this.maybeSnapshot();
1820
+ }, wait);
1821
+ return;
1822
+ }
1770
1823
  await this.persistSnapshot();
1771
1824
  }
1772
1825
 
1773
1826
  async persistSnapshot(): Promise<void> {
1827
+ this.cancelSnapshotTimer();
1774
1828
  this.lastSnapshotAt = Date.now();
1775
1829
  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);
1830
+ const blocks = this.blocks;
1831
+ const seq = this.seq;
1832
+ this.saving = this.saving.then(() =>
1833
+ aiConversations
1834
+ .saveTurnLiveState({ conversationId: this.conversationId, turnId: this.turnId, leaseOwner: this.leaseOwner, blocks, seq })
1835
+ .then(() => undefined)
1836
+ .catch(() => undefined),
1837
+ );
1838
+ await this.saving;
1785
1839
  }
1786
1840
 
1787
1841
  async emitError(message: string): Promise<void> {
@@ -1796,6 +1850,7 @@ class StreamPipeline {
1796
1850
  async emitProviderRetry(): Promise<void> {
1797
1851
  const seq = this.nextSeq();
1798
1852
  await this.publish(this.envelope({ type: "provider_retry" as const, seq }));
1853
+ await this.maybeSnapshot();
1799
1854
  }
1800
1855
 
1801
1856
  async emitTurnFinished(status: "completed" | "failed" | "aborted", error: string | null): Promise<void> {
@@ -1808,15 +1863,24 @@ class StreamPipeline {
1808
1863
  return this.ordered(() => publishAiWireEvent(event).catch(() => undefined));
1809
1864
  }
1810
1865
 
1866
+ private cancelSnapshotTimer(): void {
1867
+ if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
1868
+ this.snapshotTimer = undefined;
1869
+ }
1870
+
1871
+ /**
1872
+ * Waits for every publish and save. The attempt ends here: a later save would overwrite what suspension or the next
1873
+ * attempt saved.
1874
+ */
1811
1875
  async flush(): Promise<void> {
1812
- await this.chain;
1876
+ this.cancelSnapshotTimer();
1877
+ await Promise.all([this.chain, this.saving]);
1813
1878
  }
1814
1879
  }
1815
1880
 
1816
1881
  export const __aiExecutorTest = {
1817
1882
  indexConversationToolSource,
1818
1883
  StreamPipeline,
1819
- applyToolRoundPolicy,
1820
1884
  createEventMapper,
1821
1885
  rebuildAttemptBaseline,
1822
1886
  rebuildBlocksFromMessages,
@@ -1,6 +1,6 @@
1
1
  import type { Message } from "@k2b/nessi";
2
2
  import { canonicalizeAiAttachmentMarkers, parseAiAttachmentMarkers } from "./attachments";
3
- import { aiFileStore } from "./files-store";
3
+ import { type AiFileStat, aiFileStore, isAiWorkingFilePath } from "./files-store";
4
4
  import {
5
5
  AI_FILE_MANIFEST_MAX_ITEMS,
6
6
  AI_IMAGE_INPUT_MAX_BYTES,
@@ -52,9 +52,21 @@ export const snapshotAiConversationFiles = async (
52
52
  throw new Error("Attached images exceed the 40 MB total image input limit.");
53
53
  }
54
54
  if (all.length === 0 && attached.length === 0) return undefined;
55
- return { attached, available: all.slice(0, AI_FILE_MANIFEST_MAX_ITEMS), total: all.length };
55
+ return { attached, ...aiConversationFileManifest(all) };
56
56
  };
57
57
 
58
+ /**
59
+ * The chat files a turn lists for the model, at most `AI_FILE_MANIFEST_MAX_ITEMS`. Working files below `/temp/` come
60
+ * last, so intermediate steps never push uploads and results out of the list.
61
+ */
62
+ export const aiConversationFileManifest = (files: readonly AiFileStat[]): { available: AiFileStat[]; total: number } => ({
63
+ available: [...files.filter((file) => !isAiWorkingFilePath(file.path)), ...files.filter((file) => isAiWorkingFilePath(file.path))].slice(
64
+ 0,
65
+ AI_FILE_MANIFEST_MAX_ITEMS,
66
+ ),
67
+ total: files.length,
68
+ });
69
+
58
70
  export const canonicalizeAiConversationAttachments = <T extends Message>(
59
71
  message: T,
60
72
  snapshot: AiConversationFileSnapshot | undefined,
@@ -14,7 +14,7 @@ import {
14
14
  mountAiProjectFilePath,
15
15
  mountAiSkillFilePath,
16
16
  } from "./file-mount";
17
- import { aiFileStore, guessAiMediaType, normalizeAiFilePath } from "./files-store";
17
+ import { aiFileStore, guessAiMediaType, isAiWorkingFilePath, normalizeAiFilePath } from "./files-store";
18
18
  import { defineAiTool } from "./tools";
19
19
 
20
20
  const FILE_READ_MAX_BYTES = 64 * 1024;
@@ -123,7 +123,7 @@ export const createCloudAiListFilesTool = () =>
123
123
  defineAiTool({
124
124
  name: "list_files",
125
125
  description:
126
- "List persistent conversation files, shared Project files, and loaded skill files. Project files are read-only below /project; loaded skills are read-only below /skills/<name>. Files are ordered newest first.",
126
+ "List persistent conversation files, shared Project files, and loaded skill files. Project files are read-only below /project; loaded skills are read-only below /skills/<name>. Files are ordered newest first; working files below /temp follow all other files. List /temp to see only them.",
127
127
  inputSchema: CloudAiListFilesInputSchema,
128
128
  outputSchema: CloudAiListFilesOutputSchema,
129
129
  approval: "never",
@@ -150,7 +150,12 @@ export const createCloudAiListFilesTool = () =>
150
150
  ...skillFiles
151
151
  .filter((file) => projectPathMatchesPrefix(file.path, skillPrefix ?? ""))
152
152
  .map((file) => ({ ...file, path: mountAiSkillFilePath(file.path), origin: "skill" as const })),
153
- ].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.path.localeCompare(b.path));
153
+ ].sort(
154
+ (a, b) =>
155
+ Number(isAiWorkingFilePath(a.path)) - Number(isAiWorkingFilePath(b.path)) ||
156
+ b.updatedAt.localeCompare(a.updatedAt) ||
157
+ a.path.localeCompare(b.path),
158
+ );
154
159
  return { files: files.slice(0, 200), truncated: files.length > 200 };
155
160
  });
156
161
 
@@ -341,6 +346,15 @@ export const createCloudAiWriteFileTool = () =>
341
346
  export const CloudAiPresentInputSchema = z.object({
342
347
  path: z.string().trim().min(1).describe("Absolute conversation file path."),
343
348
  title: z.string().trim().min(1).max(120).optional(),
349
+ description: z
350
+ .string()
351
+ .trim()
352
+ .min(1)
353
+ .max(160)
354
+ .optional()
355
+ .describe(
356
+ "One sentence in the user's language that says what the file is and what it is for, without repeating its name. Shown next to the file in the chat's results.",
357
+ ),
344
358
  });
345
359
  export const CloudAiPresentOutputSchema = z.object({ path: z.string(), size: z.number(), mediaType: z.string() });
346
360