@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.
- package/package.json +3 -3
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +46 -64
- package/src/ai/browser.ts +9 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +206 -2
- package/src/ai/chat/presentation.tsx +94 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +102 -16
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +83 -37
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +32 -36
- package/src/ai/code-runtime-tools.ts +14 -1
- package/src/ai/code-source-contracts.ts +56 -6
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +281 -141
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +9 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/protocol.ts +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +40 -6
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +9 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +35 -7
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +155 -71
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +8 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +248 -0
- package/src/ai/types.ts +52 -4
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +242 -0
- package/src/api/index.ts +2 -0
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +8 -0
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +215 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +265 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +13 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +112 -0
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +397 -0
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +17 -0
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +19 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +49 -5
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +3 -1
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +15 -17
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
package/src/ai/executor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
556
|
-
...createCloudAiCodeTools().filter(
|
|
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";
|
|
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(
|
|
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
|
|
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
|
-
|
|
655
|
+
failure: AiTurnFailureInfo | null,
|
|
634
656
|
kind: AiTurnRunConfig["kind"] | null,
|
|
657
|
+
locale?: string,
|
|
635
658
|
) {
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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",
|
|
1315
|
-
if (event.reason === "stop") return { kind: "finished", status: "completed",
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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",
|
|
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",
|
|
1330
|
-
|
|
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:
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
|
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")
|
|
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
|
-
|
|
1588
|
-
|
|
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
|
-
|
|
1716
|
+
failure = { error: abortController.signal.reason.turnError(), detail: "Run time limit reached." };
|
|
1598
1717
|
}
|
|
1599
|
-
await this.finalize(conversationId, turnId, pipeline, status,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
leaseOwner: this.leaseOwner,
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
-
|
|
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,
|