@k2b/cloud 0.25.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 (93) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/define-app.ts +8 -1
  4. package/src/_internal/process-identity.ts +7 -1
  5. package/src/_internal/registry-validation.ts +3 -0
  6. package/src/_internal/registry.ts +1 -0
  7. package/src/_internal/runtime-context.ts +1 -0
  8. package/src/access/GroupCoverage.tsx +175 -0
  9. package/src/access/PermissionEditor.tsx +119 -99
  10. package/src/access/messages.ts +30 -0
  11. package/src/ai/admin.ts +1 -0
  12. package/src/ai/approval-routes.ts +5 -5
  13. package/src/ai/browser-code-contracts.ts +14 -2
  14. package/src/ai/browser.ts +8 -1
  15. package/src/ai/capabilities.ts +115 -33
  16. package/src/ai/chat/blocks.tsx +86 -179
  17. package/src/ai/chat/builtin-tools.tsx +83 -48
  18. package/src/ai/chat/file-tools.tsx +4 -1
  19. package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
  20. package/src/ai/chat/message-actions.tsx +6 -2
  21. package/src/ai/chat/message-utils.ts +17 -14
  22. package/src/ai/chat/messages.ts +330 -2
  23. package/src/ai/chat/presentation.tsx +278 -104
  24. package/src/ai/chat/tool-groups.ts +55 -35
  25. package/src/ai/chat/turn-layout.ts +141 -0
  26. package/src/ai/chat/turn-view.tsx +644 -0
  27. package/src/ai/client/controller.ts +60 -31
  28. package/src/ai/client/file-source.ts +20 -3
  29. package/src/ai/client/projection.ts +42 -6
  30. package/src/ai/code-mode-skill.ts +27 -27
  31. package/src/ai/code-runtime-tools.ts +10 -1
  32. package/src/ai/code-source-contracts.ts +54 -4
  33. package/src/ai/code-source-tools.ts +10 -3
  34. package/src/ai/credentials.ts +17 -3
  35. package/src/ai/data-analysis-skill.ts +2 -2
  36. package/src/ai/default-tools.ts +2 -2
  37. package/src/ai/executor.ts +177 -80
  38. package/src/ai/file-context.ts +14 -2
  39. package/src/ai/file-tools.ts +17 -3
  40. package/src/ai/files-store.ts +134 -11
  41. package/src/ai/grids-skill.ts +2 -2
  42. package/src/ai/index.ts +7 -0
  43. package/src/ai/memories.ts +14 -0
  44. package/src/ai/migrate.ts +125 -0
  45. package/src/ai/model-request-settings.ts +98 -0
  46. package/src/ai/protocol.ts +26 -4
  47. package/src/ai/provider-fetch.ts +67 -15
  48. package/src/ai/provider-retry.ts +105 -0
  49. package/src/ai/provider.ts +7 -1
  50. package/src/ai/quota-provider.ts +16 -7
  51. package/src/ai/request-headers.ts +117 -0
  52. package/src/ai/routes.ts +34 -6
  53. package/src/ai/runtime.ts +1 -1
  54. package/src/ai/settings.ts +19 -2
  55. package/src/ai/skill-seeds.ts +31 -3
  56. package/src/ai/skills.ts +26 -0
  57. package/src/ai/solid.ts +1 -1
  58. package/src/ai/store.ts +202 -59
  59. package/src/ai/stream.ts +182 -37
  60. package/src/ai/structured.ts +20 -5
  61. package/src/ai/system-prompt.ts +25 -0
  62. package/src/ai/timeline.ts +9 -11
  63. package/src/ai/tool-call-names.ts +45 -0
  64. package/src/ai/turn-policy.ts +247 -0
  65. package/src/ai/turn-timing.ts +31 -3
  66. package/src/ai/types.ts +36 -5
  67. package/src/api/admin-ai-quotas.ts +36 -1
  68. package/src/api/admin-core-settings.ts +16 -23
  69. package/src/api/admin-outgoing-mail.ts +62 -0
  70. package/src/api/index.ts +2 -0
  71. package/src/cli/admin/ai-quotas.ts +70 -1
  72. package/src/cli/admin/index.ts +6 -0
  73. package/src/cli/admin/outgoing-mail.ts +118 -0
  74. package/src/contracts/app.ts +2 -0
  75. package/src/contracts/index.ts +1 -0
  76. package/src/contracts/outgoing-mail.ts +77 -0
  77. package/src/contracts/registry.ts +4 -0
  78. package/src/services/index.ts +3 -0
  79. package/src/services/notifications/email.ts +16 -26
  80. package/src/services/outgoing-mail/index.ts +19 -0
  81. package/src/services/outgoing-mail/store.ts +286 -0
  82. package/src/services/outgoing-mail/test-send.ts +40 -0
  83. package/src/services/outgoing-mail/transport.ts +13 -0
  84. package/src/services/settings/core-settings.ts +1 -38
  85. package/src/services/settings/store.ts +5 -1
  86. package/src/shared/ai-model-request-settings.ts +21 -0
  87. package/src/shared/ai-platform-prompt.ts +1 -1
  88. package/src/shared/ai-request-options.ts +185 -0
  89. package/src/shared/app-presentation.ts +10 -2
  90. package/src/ssr/admin-navigation.ts +1 -1
  91. package/src/ssr/platform-messages.ts +2 -0
  92. package/src/ssr/workspace-navigation.ts +7 -1
  93. package/src/styles/effects.css +69 -0
@@ -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,
@@ -40,6 +42,7 @@ import {
40
42
  streamBlockId,
41
43
  toolBlockId,
42
44
  } from "./protocol";
45
+ import { retryTransientProviderErrors } from "./provider-retry";
43
46
  import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
44
47
  import { collectConversationResourceObservations } from "./resource-refs";
45
48
  import { AiRunTimeout } from "./run-timeout";
@@ -48,11 +51,13 @@ import { selectAiSkillCatalog } from "./skill-catalog";
48
51
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
49
52
  import { aiSkills } from "./skills";
50
53
  import { aiConversations } from "./store";
51
- import { publishAiWireEvent } from "./stream";
54
+ import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
52
55
  import { composeAiSystemPrompt } from "./system-prompt";
53
56
  import { aiToolAudit } from "./tool-audit";
57
+ import { acceptCanonicalToolNames } from "./tool-call-names";
54
58
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
55
59
  import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
60
+ import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
56
61
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
57
62
  import type {
58
63
  AiChatTurnRunConfig,
@@ -71,13 +76,9 @@ import { validateAiTurnRequest } from "./validate";
71
76
 
72
77
  const log = logger("ai:executor");
73
78
 
74
- const AI_TURN_LEASE_MS = 45_000;
75
79
  const AI_COALESCE_MS = 25;
76
80
  const AI_COALESCE_MAX_CHARS = 512;
77
- const AI_SNAPSHOT_INTERVAL_MS = 1_000;
78
81
  const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
79
- const AI_FINAL_TOOL_ROUND_PROMPT = `# Final response
80
- 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.`;
81
82
 
82
83
  const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
83
84
  const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
@@ -92,50 +93,6 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
92
93
  };
93
94
  };
94
95
 
95
- const applyToolRoundPolicy = (input: {
96
- provider: Provider;
97
- tools: Tool[] | ToolResolver;
98
- maxToolRounds?: number;
99
- issuedToolRounds: number;
100
- completedToolRounds: number;
101
- }): { provider: Provider; tools: Tool[] | ToolResolver; maxTurns?: number; noteToolRound: () => void } => {
102
- const limit = Math.floor(input.maxToolRounds ?? 0);
103
- if (limit <= 0) return { provider: input.provider, tools: input.tools, noteToolRound: () => undefined };
104
-
105
- const issuedAtStart = Math.max(0, Math.floor(input.issuedToolRounds));
106
- let completed = Math.max(0, Math.floor(input.completedToolRounds));
107
- let finalSynthesis = completed >= limit;
108
- const tools: ToolResolver = async () => {
109
- finalSynthesis = completed >= limit;
110
- if (finalSynthesis) return [];
111
- return typeof input.tools === "function" ? input.tools() : input.tools;
112
- };
113
- const provider: Provider = {
114
- name: input.provider.name,
115
- family: input.provider.family,
116
- model: input.provider.model,
117
- contextWindow: input.provider.contextWindow,
118
- capabilities: input.provider.capabilities,
119
- complete: (request) => input.provider.complete(request),
120
- stream: async function* (request) {
121
- yield* input.provider.stream(
122
- finalSynthesis ? { ...request, systemPrompt: `${request.systemPrompt ?? ""}\n\n${AI_FINAL_TOOL_ROUND_PROMPT}`.trim() } : request,
123
- );
124
- },
125
- };
126
-
127
- // Nessi checks this before provider calls. The extra round is the tool-free
128
- // synthesis call after the last allowed tool-using round.
129
- return {
130
- provider,
131
- tools,
132
- maxTurns: Math.max(1, limit - issuedAtStart + 1),
133
- noteToolRound: () => {
134
- completed += 1;
135
- },
136
- };
137
- };
138
-
139
96
  const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
140
97
  try {
141
98
  await aiConversations.indexConversationResources(input);
@@ -161,6 +118,10 @@ const recordMemoryWorkflowEvidence = async (input: Parameters<typeof recordAiMem
161
118
  }
162
119
  };
163
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
+ */
164
125
  const indexConversationToolSource = async (input: {
165
126
  conversationId: string;
166
127
  turnId: string;
@@ -183,10 +144,45 @@ const indexConversationToolSource = async (input: {
183
144
  });
184
145
  }
185
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>) : {};
186
150
  if (input.name === "web_search") {
187
- const args = input.args;
188
- const query = typeof args === "object" && args !== null && "query" in args && typeof args.query === "string" ? args.query.trim() : "";
189
- 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
+ };
190
186
  } else if (input.name === "web_extract" && typeof input.result === "object" && input.result !== null) {
191
187
  const result = input.result as Record<string, unknown>;
192
188
  if (typeof result.url === "string" && result.url.trim()) {
@@ -249,6 +245,8 @@ export type ExecutorConfig = {
249
245
  validateTurn?: typeof validateAiTurnRequest;
250
246
  /** Runs after the durable turn state and final wire event are flushed. */
251
247
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
248
+ /** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
249
+ providerRetryDelaysMs?: readonly number[];
252
250
  };
253
251
 
254
252
  type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
@@ -392,6 +390,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
392
390
  const setRejectedCallIds = (items: Set<string>) => {
393
391
  rejectedCallIds = items;
394
392
  };
393
+ let approvedCallIds = new Set<string>();
394
+ const setApprovedCallIds = (items: Set<string>) => {
395
+ approvedCallIds = items;
396
+ };
395
397
  /** nessi stream block ids (turn-scoped) that belong to tool_call blocks — their deltas are raw args JSON. */
396
398
  const toolStreamIds = new Set<string>();
397
399
  /** kind per open Cloud stream block id, for delta create-if-missing. */
@@ -414,6 +416,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
414
416
  approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false } : approval,
415
417
  frontendMode: patch.frontendMode ?? existing?.frontendMode,
416
418
  presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
419
+ ...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
417
420
  };
418
421
  toolBlocks.set(callId, block);
419
422
  return { type: "block_set", block };
@@ -518,6 +521,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
518
521
  setApprovalPolicies,
519
522
  setApprovalReviews,
520
523
  setRejectedCallIds,
524
+ setApprovedCallIds,
521
525
  };
522
526
  };
523
527
 
@@ -543,8 +547,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
543
547
  source.kind === "default"
544
548
  ? [
545
549
  ...(await createConfiguredDefaultCloudAiTools()),
546
- ...(config.clientToolIds?.includes("local_bash") ? [createCloudAiLocalBashTool()] : []),
547
- ...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
+ ),
548
554
  ]
549
555
  : [],
550
556
  toolApprovalContext: config.toolApprovalContext,
@@ -662,6 +668,7 @@ export class AiTurnExecutor {
662
668
  let resolvedProjectId: string | null = null;
663
669
  let chatId = config.chatId ?? "";
664
670
  let allowedTools: string[] | null = null;
671
+ let sourceToolNames: string[] = [];
665
672
  try {
666
673
  if (config.background && !config.mandate) throw new Error("Background execution requires its task mandate.");
667
674
  const [nextMaterial, conversation] = await Promise.all([
@@ -672,6 +679,7 @@ export class AiTurnExecutor {
672
679
  if (!conversation) throw new Error("Conversation is no longer available.");
673
680
  allowedTools = conversation.allowedTools ?? null;
674
681
  const allowed = allowedTools === null ? null : new Set(allowedTools);
682
+ sourceToolNames = material.tools.map((tool) => tool.def.name);
675
683
  if (allowed) material.tools = material.tools.filter((tool) => allowed.has(tool.def.name));
676
684
  chatId ||= conversation?.shortId ?? "";
677
685
  if (config.project) {
@@ -793,6 +801,12 @@ export class AiTurnExecutor {
793
801
  (tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
794
802
  )
795
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));
796
810
  const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
797
811
  const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
798
812
 
@@ -829,6 +843,7 @@ export class AiTurnExecutor {
829
843
  pipeline.setApprovalPolicies(prepared.approvalPolicies);
830
844
  const toolPresentations = new Map<string, AiToolPresentation>();
831
845
  const rejectedToolCallIds = new Set<string>();
846
+ const approvedToolCallIds = new Set<string>();
832
847
  pipeline.setPresentations(toolPresentations);
833
848
  pipeline.setApprovalReviews(capabilityActionReviews);
834
849
  let turnInput = config.input;
@@ -868,17 +883,19 @@ export class AiTurnExecutor {
868
883
  turnInput,
869
884
  toolPresentations,
870
885
  rejectedToolCallIds,
886
+ approvedToolCallIds,
871
887
  });
872
888
 
873
889
  const { loopMessages, pendingRecords, resolvedRecords, turnSteers } =
874
890
  attemptState ?? (await loadChatAttemptState(conversationId, turnId));
875
891
  for (const action of resolvedRecords) {
876
- if (action.resolvedEvent?.type !== "approval_response" || action.resolvedEvent.approved) continue;
877
- rejectedToolCallIds.add(
878
- action.kind === "custom_approval" ? (customApprovalParentCallId(action.callId) ?? action.callId) : action.callId,
879
- );
892
+ if (action.resolvedEvent?.type !== "approval_response") continue;
893
+ // A decision on a custom approval belongs to the call that asked for it.
894
+ const callId = action.kind === "custom_approval" ? (customApprovalParentCallId(action.callId) ?? action.callId) : action.callId;
895
+ (action.resolvedEvent.approved ? approvedToolCallIds : rejectedToolCallIds).add(callId);
880
896
  }
881
897
  pipeline.setRejectedCallIds(rejectedToolCallIds);
898
+ pipeline.setApprovedCallIds(approvedToolCallIds);
882
899
  const assistantMessages = loopMessages.filter((message) => message.message.role !== "user");
883
900
  const isFresh = assistantMessages.length === 0 && resolvedRecords.length === 0 && !skipResolvedActions;
884
901
 
@@ -898,6 +915,7 @@ export class AiTurnExecutor {
898
915
  actor: capabilityAuthority?.actor ?? toolActor,
899
916
  staticTools: activeTools,
900
917
  allowedTools,
918
+ unofferedTools,
901
919
  runtimeContext: dynamicToolRuntimeContext,
902
920
  store: toolStore,
903
921
  ...(capabilityAuthority ? { listRegistry: listCapabilities } : {}),
@@ -1061,21 +1079,49 @@ export class AiTurnExecutor {
1061
1079
  memory: memory?.text,
1062
1080
  timeZone,
1063
1081
  locale: promptLocale,
1082
+ interactive: !config.background,
1083
+ skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
1064
1084
  });
1065
- 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);
1066
1089
  const quotaSubject = accessSubjectForActor(material.actor);
1067
- const toolRoundPolicy = applyToolRoundPolicy({
1068
- provider: assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
1090
+ const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
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
+ },
1102
+ },
1103
+ ),
1104
+ prepared.canonicalNames,
1105
+ ),
1069
1106
  tools,
1070
1107
  maxToolRounds: resolved.profile.maxToolRounds,
1071
1108
  issuedToolRounds: priorToolRounds.issued,
1072
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 }),
1073
1119
  });
1074
1120
  const loop = nessi({
1075
1121
  agentId: "cloud",
1076
1122
  loopId: turnId,
1077
1123
  ...(isFresh ? { input: turnInput } : {}),
1078
- provider: toolRoundPolicy.provider,
1124
+ provider: turnPolicy.provider,
1079
1125
  systemPrompt,
1080
1126
  store,
1081
1127
  steering: async ({ signal: steeringSignal }) => {
@@ -1086,12 +1132,15 @@ export class AiTurnExecutor {
1086
1132
  leaseOwner: this.config.leaseOwner,
1087
1133
  });
1088
1134
  appliedSteers.push(...steers);
1089
- 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);
1090
1138
  },
1091
- tools: toolRoundPolicy.tools,
1092
- ...(toolRoundPolicy.maxTurns === undefined ? {} : { maxTurns: toolRoundPolicy.maxTurns }),
1139
+ tools: turnPolicy.tools,
1140
+ ...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
1093
1141
  temperature: resolved.profile.temperature,
1094
1142
  maxOutputTokens: resolved.profile.maxOutputTokens,
1143
+ reasoningEffort: resolved.profile.reasoningEffort,
1095
1144
  coalesce: { ms: AI_COALESCE_MS, maxChars: AI_COALESCE_MAX_CHARS },
1096
1145
  compact: config.background
1097
1146
  ? undefined
@@ -1127,7 +1176,8 @@ export class AiTurnExecutor {
1127
1176
  rememberableCapabilityApprovals,
1128
1177
  capabilityActionReviews,
1129
1178
  appliedSteers,
1130
- noteToolRound: toolRoundPolicy.noteToolRound,
1179
+ noteToolRound: turnPolicy.noteToolRound,
1180
+ noteToolCall: turnPolicy.noteToolCall,
1131
1181
  onBackgroundBlocked: (message) => {
1132
1182
  backgroundError = message;
1133
1183
  },
@@ -1193,6 +1243,7 @@ export class AiTurnExecutor {
1193
1243
  capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1194
1244
  appliedSteers: AiTurnSteer[];
1195
1245
  noteToolRound: () => void;
1246
+ noteToolCall: (call: AiTurnPolicyToolCall) => void;
1196
1247
  onBackgroundBlocked?: (message: string) => void;
1197
1248
  }): Promise<AttemptOutcome> {
1198
1249
  const {
@@ -1208,6 +1259,7 @@ export class AiTurnExecutor {
1208
1259
  capabilityActionReviews,
1209
1260
  appliedSteers,
1210
1261
  noteToolRound,
1262
+ noteToolCall,
1211
1263
  } = input;
1212
1264
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1213
1265
  let lastIssueMessage: string | null = null;
@@ -1266,6 +1318,12 @@ export class AiTurnExecutor {
1266
1318
  .noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
1267
1319
  .catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
1268
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
+ );
1269
1327
  await indexConversationToolSource({
1270
1328
  conversationId,
1271
1329
  turnId,
@@ -1597,6 +1655,9 @@ class StreamPipeline {
1597
1655
  readonly timing: ReturnType<typeof createTurnTimingRecorder>;
1598
1656
  private lastSnapshotAt = 0;
1599
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();
1600
1661
  private chain: Promise<void> = Promise.resolve();
1601
1662
 
1602
1663
  constructor(input: {
@@ -1628,6 +1689,8 @@ class StreamPipeline {
1628
1689
 
1629
1690
  private nextSeq(): number {
1630
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;
1631
1694
  return this.seq;
1632
1695
  }
1633
1696
 
@@ -1667,17 +1730,23 @@ class StreamPipeline {
1667
1730
  this.mapper.setRejectedCallIds(callIds);
1668
1731
  }
1669
1732
 
1733
+ setApprovedCallIds(callIds: Set<string>): void {
1734
+ this.mapper.setApprovedCallIds(callIds);
1735
+ }
1736
+
1670
1737
  async emitBaseline(): Promise<void> {
1671
1738
  for (const block of this.blocks) {
1672
1739
  const seq = this.nextSeq();
1673
1740
  await this.publish(this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent);
1674
1741
  }
1675
- 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();
1676
1744
  }
1677
1745
 
1678
1746
  async emitMessage(message: AiStoredMessage): Promise<void> {
1679
1747
  const seq = this.nextSeq();
1680
1748
  await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
1749
+ await this.maybeSnapshot();
1681
1750
  }
1682
1751
 
1683
1752
  async emitTurnStarted(modelProfileId: string): Promise<void> {
@@ -1737,24 +1806,36 @@ class StreamPipeline {
1737
1806
  await this.publish(event);
1738
1807
  }
1739
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
+ */
1740
1813
  private async maybeSnapshot(): Promise<void> {
1741
1814
  if (!this.snapshotDirty) return;
1742
- 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
+ }
1743
1823
  await this.persistSnapshot();
1744
1824
  }
1745
1825
 
1746
1826
  async persistSnapshot(): Promise<void> {
1827
+ this.cancelSnapshotTimer();
1747
1828
  this.lastSnapshotAt = Date.now();
1748
1829
  this.snapshotDirty = false;
1749
- await aiConversations
1750
- .saveTurnLiveState({
1751
- conversationId: this.conversationId,
1752
- turnId: this.turnId,
1753
- leaseOwner: this.leaseOwner,
1754
- blocks: this.blocks,
1755
- seq: this.seq,
1756
- })
1757
- .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;
1758
1839
  }
1759
1840
 
1760
1841
  async emitError(message: string): Promise<void> {
@@ -1765,6 +1846,13 @@ class StreamPipeline {
1765
1846
  await this.publish(event);
1766
1847
  }
1767
1848
 
1849
+ /** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
1850
+ async emitProviderRetry(): Promise<void> {
1851
+ const seq = this.nextSeq();
1852
+ await this.publish(this.envelope({ type: "provider_retry" as const, seq }));
1853
+ await this.maybeSnapshot();
1854
+ }
1855
+
1768
1856
  async emitTurnFinished(status: "completed" | "failed" | "aborted", error: string | null): Promise<void> {
1769
1857
  const seq = this.nextSeq();
1770
1858
  await this.publish(this.envelope({ type: "turn_finished" as const, seq, status, error }) as AiWireEvent);
@@ -1775,15 +1863,24 @@ class StreamPipeline {
1775
1863
  return this.ordered(() => publishAiWireEvent(event).catch(() => undefined));
1776
1864
  }
1777
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
+ */
1778
1875
  async flush(): Promise<void> {
1779
- await this.chain;
1876
+ this.cancelSnapshotTimer();
1877
+ await Promise.all([this.chain, this.saving]);
1780
1878
  }
1781
1879
  }
1782
1880
 
1783
1881
  export const __aiExecutorTest = {
1784
1882
  indexConversationToolSource,
1785
1883
  StreamPipeline,
1786
- applyToolRoundPolicy,
1787
1884
  createEventMapper,
1788
1885
  rebuildAttemptBaseline,
1789
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