@k2b/cloud 0.27.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 +2 -2
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +198 -0
- package/src/ai/chat/presentation.tsx +66 -20
- 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 +55 -4
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +135 -59
- package/src/ai/index.ts +2 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/routes.ts +6 -0
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/skill-seeds.ts +4 -4
- package/src/ai/store.ts +46 -14
- package/src/ai/system-prompt.ts +4 -21
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +28 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +11 -1
- 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 -35
- 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 +103 -10
- 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 +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/shared/ai-platform-prompt.ts +48 -4
- 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/platform-messages.ts +1 -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
|
@@ -100,7 +100,11 @@ export const runManagedCodeTool =
|
|
|
100
100
|
const headers = new Headers({ authorization: `Bearer ${signed.token}`, "content-type": "application/json" });
|
|
101
101
|
headers.set(CODE_CAPABILITY_TOKEN_HEADER, callback.token);
|
|
102
102
|
if (context.locale) headers.set(LOCALE_HEADER, context.locale);
|
|
103
|
-
|
|
103
|
+
const timeZone = runConfig.timeZone ?? context.timeZone;
|
|
104
|
+
headers.set(
|
|
105
|
+
"cookie",
|
|
106
|
+
[`theme=${runConfig.theme ?? "light"}`, ...(timeZone ? [`${TIMEZONE_COOKIE}=${encodeURIComponent(timeZone)}`] : [])].join("; "),
|
|
107
|
+
);
|
|
104
108
|
const response = await fetch(new URL(`/_internal/assistant/tools/${name}`, app.baseUrl), {
|
|
105
109
|
method: "POST",
|
|
106
110
|
headers,
|
|
@@ -41,7 +41,7 @@ const DatabaseSql = z.object({
|
|
|
41
41
|
|
|
42
42
|
const CodeWriteInput = Id.extend({
|
|
43
43
|
expectedRevision: z.number().int().positive(),
|
|
44
|
-
entry: ArtifactPath.optional(),
|
|
44
|
+
entry: ArtifactPath.optional().describe("index.html for an app with an interface, or the script that code_run executes."),
|
|
45
45
|
files: z
|
|
46
46
|
.array(z.union([ArtifactFile, z.object({ path: ArtifactPath, fromFile: AiFileReference }).strict()]))
|
|
47
47
|
.min(1)
|
|
@@ -302,7 +302,7 @@ export const CODE_SOURCE_TOOLS = {
|
|
|
302
302
|
},
|
|
303
303
|
code_create: {
|
|
304
304
|
description:
|
|
305
|
-
"Create a private reusable App with
|
|
305
|
+
"Create a private reusable App: an index.html interface with optional style.css and app.js, published actions and optional persistence. Starts with a minimal index.html. For one-off analysis, use code_run with code instead. Returns id and entry path; write source with code_write. Optional icon uses the complete class, e.g. ti ti-chart-bar; omit it when unsure. Does not run or share anything.",
|
|
306
306
|
input: ArtifactCreate.pick({ title: true, description: true, icon: true }),
|
|
307
307
|
},
|
|
308
308
|
code_write: {
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import type { AiSkillTemplate } from "./skills";
|
|
3
3
|
export const ASSISTANT_DATA_ANALYSIS_SKILL = {
|
|
4
4
|
"key": "assistant:data-analysis",
|
|
5
|
-
"version":
|
|
5
|
+
"version": 8,
|
|
6
6
|
"name": "assistant-data-analysis",
|
|
7
|
-
"description": "Analyze source data, explain metrics and comparisons, and build evidence-backed reports or
|
|
8
|
-
"instructions": "# Analyze data and deliver an inspectable result\n\nStart with the question the reader needs to answer. Choose a direct answer,\nan
|
|
7
|
+
"description": "Analyze source data, explain metrics and comparisons, and build evidence-backed reports or HTML dashboards in Assistant Code Mode. Use for multi-step analysis, data exploration, and dashboards; a simple chart only needs the Code Mode charts reference.",
|
|
8
|
+
"instructions": "# Analyze data and deliver an inspectable result\n\nStart with the question the reader needs to answer. Choose a direct answer,\nan app shown in this chat, an exported file, or a reusable Studio app accordingly.\nFor a one-time visual analysis, prefer a chat app; filters and buttons do not by\nthemselves require a Studio App. Load `assistant-code-mode` for execution and read\nits `references/apps.md` and `references/charts.md` for interfaces and charts.\nLoading this skill does not install a library or grant access.\n\n## Keep a working plan\n\nFor work with several real steps, use `todo_write` to keep a short chat plan.\nReplace the full `todos` list each time; give each item a stable `id`, actionable\n`content`, and `status` (`pending`, `in_progress`, `completed`, or `cancelled`).\nAt most one step is active. Update as work changes, including user corrections;\nmark a step completed only after doing and checking it. Preserve exact commands\nwhen they matter. Skip this tool for a simple calculation or conversational reply.\n\nFor analysis, useful steps are reconcile source data, build the analysis, and\nverify totals, filters and conclusions. A rendered dashboard alone is not proof\nthat its numbers are correct. Keep blocked source work open and explain why.\n\n## Establish the data\n\nIdentify the actual source, unit of observation, time window, timezone, and\nlatest complete period. Read files or discover the relevant Cloud capabilities\nbefore selecting fields. External APIs use `cloud.http.fetch`; credentials are entered\nonly through the trusted `code_secret` dialog.\n\nInspect a bounded sample, missing values, duplicate keys, types, and coverage.\nReconcile totals and join cardinalities before drawing conclusions. Keep raw\nnumbers separate from display formatting. Distinguish zero from unavailable data;\nstate exclusions and denominator choices. Do not substitute fixture data for a\nblocked source unless the user requested a mockup.\n\nKeep the source query or transformation in the saved source or an accompanying\nfile so another run can reproduce the result. Preserve the source identity and\nretrieval timestamp, without credentials or credential-bearing URLs.\n\n## Build one consistent analysis\n\nDerive charts, metrics, and tables from the same reviewed data. Aggregate large\ninputs in a script before they reach an app; an app shows aggregates, not raw\nexports. Give exact values a table, for example in a `<details>` element under\nthe chart.\n\nChoose the simplest chart that answers the question. Use tables for exact\nrecords, bars for category comparisons, lines for ordered trends, and distributions\nfor spread. State units, comparison windows, and whether a percent is a fraction\nor an already scaled number. Do not imply causation from correlation.\n\nLead reports with the finding, then supporting evidence and limitations.\nLead dashboards with the important measurements, then trends and diagnostic\nbreakdowns. Use shared filters only when they affect all claimed views. Keep the\ninitial view useful without interaction. Avoid unrelated metrics added merely to\nfill a grid.\n\nShow the source context in the app: whether it is a snapshot, when the data was\nretrieved, its sources, and relevant notes. A timestamp records when the data\nwas retrieved; it does not prove that the upstream source is complete. Label\npartial or fixture data visibly.\n\n## Validate before delivery\n\nCompute in a script with `code_run` and inspect the result. Put the filter and\naggregation logic of an app into a module the script can import too\n(`lib/totals.js`), and test it there with filters, empty results and failed\nloads. Reconcile the values the app will show with the reviewed totals and check\nthat filters describe the data actually displayed. A successful schema\nvalidation does not establish analytical correctness. Apps are not rendered in\na test yet; read the static findings of `code_write` and `code_present`, and say\nwhat the person should check.\n\nValidate the logic the app actually uses, rather than pasting its formulas into a\nsecond test script. Keep an independent expectation from the input data: row\ncounts, unmatched joins, totals and representative boundary cases. For targets,\nstate their grain (for example month × region) and aggregate each target once;\nmultiple selected regions must sum their distinct targets. Compare inspected raw\nKPI values and plotted series with independent expectations. A formatted value\nmatching after rounding does not validate the raw ratio. Never round fractions before formatting them as percent. Preserve precision\nuntil display formatting. Test reset, one/multiple/all selections, empty results,\nand complete versus partial periods. A newly generated timestamp is not source\nfreshness: keep the real retrieval or file-snapshot timestamp stable.\n\nFor a Studio App data snapshot, export the validated dataset with `cloud.download` and\n`code_export`, then copy its exact path/version into the App's files with\n`code_file_copy` (see the Code Mode file transfers reference); importing private\nfiles into an App receives fresh review. Read it in the app with\n`JSON.parse(await (await cloud.files.read(\"data.json\")).text())`. Never rebuild a\ntruncated dataset by copying tool output. Keep transformations and source\nidentity alongside the snapshot.\n\nHuman approval and uncertain HTTP outcomes follow the Code Mode HTTP contract.\nDo not replay an external mutation to refresh a chart. Separate local filtering\nfrom external loading; an Apply button can avoid a request for every slider move.\n\n## Save, share, and hand off\n\nFor chat apps, test the numbers with `code_run`, then deliver with\n`code_present({title, files})`. A successful run alone is not visible to the\nuser. Retain source identity, reviewed input data and their real retrieval\ntimestamp. Put writes and external reloads behind explicit buttons; opening an\nold result must not repeat earlier actions. The user can download the current\nview as PDF or static HTML.\n\nFor reusable Studio Apps, reuse one Cloud resource for later revisions of the same report or dashboard.\nSave its source, test that revision, and use the normal Code Mode publication\nworkflow when publication is requested. Publishing a version and granting reader\naccess are separate operations. Preserve existing access; a dashboard request\ndoes not authorize broadening it. Personal secrets are never copied to readers.\n\nA published source version is not automatically a frozen data snapshot. A frozen\nreport must retain the reviewed data explicitly in its authorized resource or\noutput file. A live app must implement its loader and display the retrieval time;\nloading once is not continuous monitoring. No background refresh exists unless\nimplemented through an appropriate supported workflow.\n\nPresent the chat app, resource link or exported file, the data timestamp, and material\ncoverage limitations. Say whether it is a snapshot or reloads from its sources.\nIf publication fails, retain the tested resource and report the failed stage;\ndo not claim success or create a different public destination. Sites-specific\nhosting, access policies, and editor storage are not part of this Cloud workflow.",
|
|
9
9
|
"extraFrontmatter": {}
|
|
10
10
|
} satisfies AiSkillTemplate;
|
package/src/ai/default-tools.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { createCloudAiTranscribeAudioTool } from "./audio-tool";
|
|
|
3
3
|
import {
|
|
4
4
|
CODE_RUNTIME_TOOL_NAMES,
|
|
5
5
|
CodeActionInput,
|
|
6
|
+
CodeCheckInput,
|
|
6
7
|
CodeExportInput,
|
|
7
8
|
CodeInspectInput,
|
|
8
|
-
CodeInteractInput,
|
|
9
9
|
CodeOpenInput,
|
|
10
10
|
CodePresentInput,
|
|
11
11
|
CodeRunInput,
|
|
@@ -110,7 +110,7 @@ export const createCloudAiCodeTools = () => [
|
|
|
110
110
|
defineAiTool({
|
|
111
111
|
name: "code_secret",
|
|
112
112
|
description:
|
|
113
|
-
"Open a trusted Secret input dialog. The user enters the value directly into encrypted Assistant storage; only configured/name returns. Never ask for a credential in chat, survey
|
|
113
|
+
"Open a trusted Secret input dialog. The user enters the value directly into encrypted Assistant storage; only configured/name returns. Never ask for a credential in chat, survey or app fields. Personal secrets are scoped to this chat or resource and bound to the exact HTTPS origin, header and prefix. Use cloud.http.secret(name,{prefix}) in cloud.http.fetch headers; load assistant-code-mode for HTTP details. Web UI required for secret entry; stored secrets also work from CLI.",
|
|
114
114
|
inputSchema: CodeSecretInput,
|
|
115
115
|
outputSchema: z.object({ configured: z.boolean(), name: z.string() }),
|
|
116
116
|
approval: "never",
|
|
@@ -128,27 +128,18 @@ export const createCloudAiCodeTools = () => [
|
|
|
128
128
|
promptHint:
|
|
129
129
|
"For file analysis, data transformations or combining Cloud data, run a short one-off script with code_run. Load assistant-code-mode for its runtime APIs; no saved app is required.",
|
|
130
130
|
description:
|
|
131
|
-
"Run
|
|
131
|
+
"Run one-off script code OR a saved script resource in the isolated worker. Optional resourceId binds one-off code to existing app data (Manage required), without editing its source. Includes cloud.capabilities.run(name,input) to chain Cloud capabilities in JavaScript; load assistant-code-mode for its runtime APIs. Scripts read explicit chat inputPaths. Returns output, logs and captured files for agent inspection only. Apps with an index.html interface do not run here: show them with code_open or code_present. Runs use real shared and personal data; database writes and capability effects keep normal permissions and approvals.",
|
|
132
132
|
inputSchema: CodeRunInput,
|
|
133
133
|
outputSchema: z.json(),
|
|
134
134
|
approval: "never",
|
|
135
135
|
}).server(runManagedCodeTool("code_run")),
|
|
136
136
|
defineAiTool({
|
|
137
137
|
name: "code_inspect",
|
|
138
|
-
description:
|
|
139
|
-
"Inspect a test run: errors, logs, output, controls and pending modal. Use nodeId to inspect table rows or list items; follow pagination only as needed.",
|
|
138
|
+
description: "Inspect a script run: status, progress, errors, logs, output and captured files. Use waitMs to wait for background work.",
|
|
140
139
|
inputSchema: CodeInspectInput,
|
|
141
140
|
outputSchema: z.json(),
|
|
142
141
|
approval: "never",
|
|
143
142
|
}).server(runManagedCodeTool("code_inspect")),
|
|
144
|
-
defineAiTool({
|
|
145
|
-
name: "code_interact",
|
|
146
|
-
description:
|
|
147
|
-
"Operate a control or answer a pending modal using its exact snapshot ID. Buttons need only id; controls use event:{type:change,value:...} or event:{type:select,key:...}. Use answer for modal replies, or answer:null to cancel. Copy an interactions example from code_inspect; never send JSON as a string. Use steps:[{id,event?},...] for up to three known sequential interactions; stops on errors, modals or background work. Returns one resulting state with completedSteps and nextStep.",
|
|
148
|
-
inputSchema: CodeInteractInput,
|
|
149
|
-
outputSchema: z.json(),
|
|
150
|
-
approval: "never",
|
|
151
|
-
}).server(runManagedCodeTool("code_interact")),
|
|
152
143
|
defineAiTool({
|
|
153
144
|
name: "code_stop",
|
|
154
145
|
description: "Stop and release an isolated test run.",
|
|
@@ -158,15 +149,24 @@ export const createCloudAiCodeTools = () => [
|
|
|
158
149
|
}).server(runManagedCodeTool("code_stop")),
|
|
159
150
|
defineAiTool({
|
|
160
151
|
name: "code_open",
|
|
161
|
-
description:
|
|
152
|
+
description:
|
|
153
|
+
"Show the app beside the chat without starting it; HTML apps require a passing code_check for the current files and tables. For one-off calculations, return the result instead of opening an app.",
|
|
162
154
|
inputSchema: CodeOpenInput,
|
|
163
155
|
outputSchema: z.json(),
|
|
164
156
|
approval: "never",
|
|
165
157
|
}).client(),
|
|
158
|
+
defineAiTool({
|
|
159
|
+
name: "code_check",
|
|
160
|
+
description:
|
|
161
|
+
"Mandatory HTML app self-test: write files and steps.json (at most 20 main-flow steps), call code_check({id} OR {files}), look at EVERY screenshot with view_image, fix and check again, then code_open/code_present/code_publish. Runs desktop 1280×800 and phone 390×844 in opposite themes on separate throwaway copies of database, shared KV/files and only your own KV.user. Steps run in both views; reload keeps the copy. Match accessible role/name, label or text exactly, then case-insensitively, then by substring; ambiguous targets fail with candidates, placeholders are never names. Fill numbers with a dot. Upload file is an app-relative source path or chat file path/name. AI runs for real; HTTP, capability effects and approvals are unavailable; read-only granted capabilities run except in background turns. Returns passed, content hash (files, steps and tables), height, issues, calls, captured downloads, three PNG chat paths and an untrusted aria tree (4 KiB). Passing means not broken: inspect screenshots for cut-off, red, tight or doubled content. Self-test is a workflow guard, not a security mechanism. Aborting closes pages and discards copies. Bounded to 45 seconds, 1000 copied rows/16 MiB (schema only beyond), 64 downloads/250 MiB total/50 MiB per file.",
|
|
162
|
+
inputSchema: CodeCheckInput,
|
|
163
|
+
outputSchema: z.json(),
|
|
164
|
+
approval: "never",
|
|
165
|
+
}).server(runManagedCodeTool("code_check")),
|
|
166
166
|
defineAiTool({
|
|
167
167
|
name: "code_present",
|
|
168
168
|
description:
|
|
169
|
-
"
|
|
169
|
+
"Show an HTML app as a card in this chat after a passing code_check for exactly these files and table definitions. Pass files (index.html plus optional style.css and app.js) for a one-off app without saved data, or the id of a saved app to run it live with its data. The person starts the card with a click. Static problems such as CDN scripts, inline handlers or a missing index.html are rejected before anything is saved. For a reusable app beside the chat use code_open.",
|
|
170
170
|
inputSchema: CodePresentInput,
|
|
171
171
|
outputSchema: z.json(),
|
|
172
172
|
approval: "never",
|
|
@@ -193,7 +193,6 @@ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
|
|
|
193
193
|
"markdown_to_pdf",
|
|
194
194
|
"html_to_pdf",
|
|
195
195
|
"present",
|
|
196
|
-
"calculate",
|
|
197
196
|
"read_cloud_resource",
|
|
198
197
|
]);
|
|
199
198
|
|
package/src/ai/executor.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { isAssistantChatTurn } from "./assistant-models";
|
|
|
18
18
|
import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
|
|
19
19
|
import { createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
20
20
|
import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
|
|
21
|
+
import { aiChatMessages } from "./chat/messages";
|
|
22
|
+
import { aiTurnErrorText } from "./chat/turn-error";
|
|
21
23
|
import { aiChatTasks } from "./chat-tasks";
|
|
22
24
|
import { createCloudCompactFn } from "./compaction";
|
|
23
25
|
import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
@@ -26,6 +28,7 @@ import { aiMemories } from "./memories";
|
|
|
26
28
|
import { createCloudAiMemoryTool } from "./memory-tool";
|
|
27
29
|
import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
|
|
28
30
|
import { aiModelAccess } from "./model-access";
|
|
31
|
+
import { answerOpenToolCalls } from "./open-tool-calls";
|
|
29
32
|
import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
|
|
30
33
|
import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
|
|
31
34
|
import { aiProjects } from "./projects";
|
|
@@ -57,6 +60,14 @@ import { aiToolAudit } from "./tool-audit";
|
|
|
57
60
|
import { acceptCanonicalToolNames } from "./tool-call-names";
|
|
58
61
|
import { resolveAiToolResultMaxChars } from "./tool-result-budget";
|
|
59
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";
|
|
60
71
|
import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
|
|
61
72
|
import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
|
|
62
73
|
import type {
|
|
@@ -564,7 +575,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
564
575
|
// ---------------------------------------------------------------------------
|
|
565
576
|
|
|
566
577
|
type AttemptOutcome =
|
|
567
|
-
| { kind: "finished"; status: "completed" | "failed" | "aborted";
|
|
578
|
+
| { kind: "finished"; status: "completed" | "failed" | "aborted"; failure: AiTurnFailureInfo | null; timing?: LoopAggregate["timing"] }
|
|
568
579
|
| { kind: "suspended" };
|
|
569
580
|
|
|
570
581
|
export class AiTurnExecutor {
|
|
@@ -587,7 +598,14 @@ export class AiTurnExecutor {
|
|
|
587
598
|
pipeline.seedBaseline(claim.liveBlocks ?? []);
|
|
588
599
|
await pipeline.emitTurnStarted(claim.turn.modelProfileId ?? "");
|
|
589
600
|
await pipeline.emitBaseline();
|
|
590
|
-
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
|
+
);
|
|
591
609
|
return;
|
|
592
610
|
}
|
|
593
611
|
|
|
@@ -611,8 +629,9 @@ export class AiTurnExecutor {
|
|
|
611
629
|
turnId,
|
|
612
630
|
pipeline,
|
|
613
631
|
"failed",
|
|
614
|
-
error
|
|
632
|
+
aiTurnFailureFromThrown(error, "AI turn state could not be loaded."),
|
|
615
633
|
"chat",
|
|
634
|
+
claim.runConfig?.kind === "chat" ? claim.runConfig.locale : undefined,
|
|
616
635
|
);
|
|
617
636
|
return;
|
|
618
637
|
}
|
|
@@ -622,16 +641,39 @@ export class AiTurnExecutor {
|
|
|
622
641
|
await this.runChat(conversationId, turnId, claim, runConfig, pipeline, signal, false, attemptState);
|
|
623
642
|
}
|
|
624
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
|
+
*/
|
|
625
650
|
private async finalize(
|
|
626
651
|
conversationId: string,
|
|
627
652
|
turnId: string,
|
|
628
653
|
pipeline: StreamPipeline,
|
|
629
654
|
status: "completed" | "failed" | "aborted",
|
|
630
|
-
|
|
655
|
+
failure: AiTurnFailureInfo | null,
|
|
631
656
|
kind: AiTurnRunConfig["kind"] | null,
|
|
657
|
+
locale?: string,
|
|
632
658
|
) {
|
|
633
|
-
|
|
634
|
-
|
|
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
|
+
});
|
|
635
677
|
if (finalized === "completed") await pipeline.emitTurnFinished(status, error);
|
|
636
678
|
await pipeline.flush().catch(() => undefined);
|
|
637
679
|
if (finalized === "completed" && this.config.onTurnFinalized) {
|
|
@@ -686,7 +728,7 @@ export class AiTurnExecutor {
|
|
|
686
728
|
const subject = accessSubjectForActor(material.actor);
|
|
687
729
|
const project = subject ? await aiProjects.getByShortId(config.project.id, subject, "read") : null;
|
|
688
730
|
if (!project) {
|
|
689
|
-
throw new
|
|
731
|
+
throw new AiTurnFailure("not_allowed", "Project access is no longer available.");
|
|
690
732
|
}
|
|
691
733
|
resolvedProjectId = project.id;
|
|
692
734
|
}
|
|
@@ -709,7 +751,15 @@ export class AiTurnExecutor {
|
|
|
709
751
|
}
|
|
710
752
|
} catch (error) {
|
|
711
753
|
signal.removeEventListener("abort", onSignal);
|
|
712
|
-
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
|
+
);
|
|
713
763
|
return;
|
|
714
764
|
}
|
|
715
765
|
const { settings, resolved } = validated;
|
|
@@ -732,8 +782,12 @@ export class AiTurnExecutor {
|
|
|
732
782
|
turnId,
|
|
733
783
|
pipeline,
|
|
734
784
|
"failed",
|
|
735
|
-
|
|
785
|
+
{
|
|
786
|
+
error: { code: "not_allowed" },
|
|
787
|
+
detail: error instanceof Error ? error.message : "Cloud capability actor resolution failed",
|
|
788
|
+
},
|
|
736
789
|
"chat",
|
|
790
|
+
config.locale,
|
|
737
791
|
);
|
|
738
792
|
return;
|
|
739
793
|
}
|
|
@@ -865,8 +919,9 @@ export class AiTurnExecutor {
|
|
|
865
919
|
turnId,
|
|
866
920
|
pipeline,
|
|
867
921
|
"failed",
|
|
868
|
-
error
|
|
922
|
+
aiTurnFailureFromThrown(error, "Image preparation failed"),
|
|
869
923
|
"chat",
|
|
924
|
+
promptLocale,
|
|
870
925
|
);
|
|
871
926
|
return;
|
|
872
927
|
}
|
|
@@ -1088,10 +1143,18 @@ export class AiTurnExecutor {
|
|
|
1088
1143
|
const priorToolRounds = toolRoundState(turnMessages);
|
|
1089
1144
|
const quotaSubject = accessSubjectForActor(material.actor);
|
|
1090
1145
|
const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
|
|
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
|
+
};
|
|
1091
1151
|
const turnPolicy = applyAiTurnPolicy({
|
|
1092
1152
|
provider: acceptCanonicalToolNames(
|
|
1093
1153
|
retryTransientProviderErrors(
|
|
1094
|
-
|
|
1154
|
+
rememberProviderErrors(
|
|
1155
|
+
answerOpenToolCalls(assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId)),
|
|
1156
|
+
remember,
|
|
1157
|
+
),
|
|
1095
1158
|
{
|
|
1096
1159
|
deadline,
|
|
1097
1160
|
delaysMs: this.config.providerRetryDelaysMs,
|
|
@@ -1136,7 +1199,14 @@ export class AiTurnExecutor {
|
|
|
1136
1199
|
turnPolicy.noteSteering();
|
|
1137
1200
|
return steers.map((steer) => steer.text);
|
|
1138
1201
|
},
|
|
1139
|
-
tools:
|
|
1202
|
+
tools: async () => {
|
|
1203
|
+
try {
|
|
1204
|
+
return await turnPolicy.tools();
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
remember(aiTurnReasonFromThrown(error));
|
|
1207
|
+
throw error;
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1140
1210
|
...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
|
|
1141
1211
|
temperature: resolved.profile.temperature,
|
|
1142
1212
|
maxOutputTokens: resolved.profile.maxOutputTokens,
|
|
@@ -1178,6 +1248,7 @@ export class AiTurnExecutor {
|
|
|
1178
1248
|
appliedSteers,
|
|
1179
1249
|
noteToolRound: turnPolicy.noteToolRound,
|
|
1180
1250
|
noteToolCall: turnPolicy.noteToolCall,
|
|
1251
|
+
failureReason,
|
|
1181
1252
|
onBackgroundBlocked: (message) => {
|
|
1182
1253
|
backgroundError = message;
|
|
1183
1254
|
},
|
|
@@ -1202,15 +1273,17 @@ export class AiTurnExecutor {
|
|
|
1202
1273
|
: null;
|
|
1203
1274
|
if (timeout) {
|
|
1204
1275
|
outcome.status = "failed";
|
|
1205
|
-
outcome.
|
|
1276
|
+
outcome.failure = { error: timeout.turnError(), detail: "Run time limit reached." };
|
|
1206
1277
|
}
|
|
1207
1278
|
const finalized = await this.finalize(
|
|
1208
1279
|
conversationId,
|
|
1209
1280
|
turnId,
|
|
1210
1281
|
pipeline,
|
|
1211
1282
|
backgroundError ? "failed" : outcome.status,
|
|
1212
|
-
|
|
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,
|
|
1213
1285
|
"chat",
|
|
1286
|
+
promptLocale,
|
|
1214
1287
|
);
|
|
1215
1288
|
if (finalized === "pending_steering" && outcome.status === "completed" && !signal.aborted) {
|
|
1216
1289
|
await this.runChat(conversationId, turnId, claim, config, pipeline, signal, true);
|
|
@@ -1244,6 +1317,12 @@ export class AiTurnExecutor {
|
|
|
1244
1317
|
appliedSteers: AiTurnSteer[];
|
|
1245
1318
|
noteToolRound: () => void;
|
|
1246
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 };
|
|
1247
1326
|
onBackgroundBlocked?: (message: string) => void;
|
|
1248
1327
|
}): Promise<AttemptOutcome> {
|
|
1249
1328
|
const {
|
|
@@ -1260,6 +1339,7 @@ export class AiTurnExecutor {
|
|
|
1260
1339
|
appliedSteers,
|
|
1261
1340
|
noteToolRound,
|
|
1262
1341
|
noteToolCall,
|
|
1342
|
+
failureReason,
|
|
1263
1343
|
} = input;
|
|
1264
1344
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1265
1345
|
let lastIssueMessage: string | null = null;
|
|
@@ -1346,25 +1426,24 @@ export class AiTurnExecutor {
|
|
|
1346
1426
|
.catch(() => undefined);
|
|
1347
1427
|
}
|
|
1348
1428
|
const timing = aggregate.timing;
|
|
1349
|
-
if (event.reason === "aborted") return { kind: "finished", status: "aborted",
|
|
1350
|
-
if (event.reason === "stop") return { kind: "finished", status: "completed",
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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 };
|
|
1360
1441
|
}
|
|
1361
1442
|
}
|
|
1362
|
-
return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed",
|
|
1443
|
+
return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", failure: null };
|
|
1363
1444
|
} catch (error) {
|
|
1364
|
-
if (abortController.signal.aborted) return { kind: "finished", status: "aborted",
|
|
1365
|
-
|
|
1366
|
-
await pipeline.emitError(message).catch(() => undefined);
|
|
1367
|
-
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") };
|
|
1368
1447
|
} finally {
|
|
1369
1448
|
stopHeartbeat();
|
|
1370
1449
|
await pipeline.flush().catch(() => undefined);
|
|
@@ -1556,14 +1635,7 @@ export class AiTurnExecutor {
|
|
|
1556
1635
|
});
|
|
1557
1636
|
} catch (error) {
|
|
1558
1637
|
signal.removeEventListener("abort", onSignal);
|
|
1559
|
-
await this.finalize(
|
|
1560
|
-
conversationId,
|
|
1561
|
-
turnId,
|
|
1562
|
-
pipeline,
|
|
1563
|
-
"failed",
|
|
1564
|
-
error instanceof Error ? error.message : "AI compaction failed",
|
|
1565
|
-
"compact",
|
|
1566
|
-
);
|
|
1638
|
+
await this.finalize(conversationId, turnId, pipeline, "failed", aiTurnFailureFromThrown(error, "AI compaction failed"), "compact");
|
|
1567
1639
|
return;
|
|
1568
1640
|
}
|
|
1569
1641
|
const { settings, resolved } = validated;
|
|
@@ -1574,17 +1646,23 @@ export class AiTurnExecutor {
|
|
|
1574
1646
|
turnId,
|
|
1575
1647
|
leaseOwner: this.config.leaseOwner,
|
|
1576
1648
|
});
|
|
1649
|
+
const reason: { current: AiTurnFailureReason | null } = { current: null };
|
|
1577
1650
|
const loop = compact({
|
|
1578
1651
|
agentId: "cloud",
|
|
1579
1652
|
loopId: turnId,
|
|
1580
1653
|
store,
|
|
1581
|
-
provider:
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
+
),
|
|
1588
1666
|
force: true,
|
|
1589
1667
|
signal: abortController.signal,
|
|
1590
1668
|
compact: createCloudCompactFn({
|
|
@@ -1603,15 +1681,21 @@ export class AiTurnExecutor {
|
|
|
1603
1681
|
|
|
1604
1682
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1605
1683
|
let status: "completed" | "failed" | "aborted" = "failed";
|
|
1606
|
-
let
|
|
1684
|
+
let failure: AiTurnFailureInfo | null = null;
|
|
1685
|
+
let issueMessage: string | null = null;
|
|
1607
1686
|
try {
|
|
1608
1687
|
for await (const event of loop as AsyncIterable<CompactEvent>) {
|
|
1609
1688
|
if (event.type === "compaction_start") await pipeline.applyCompaction("running");
|
|
1610
1689
|
else if (event.type === "compaction_end") await pipeline.applyCompaction("completed");
|
|
1611
|
-
else if (event.type === "issue")
|
|
1690
|
+
else if (event.type === "issue") issueMessage = event.issue.message;
|
|
1612
1691
|
else if (event.type === "loop_end") {
|
|
1613
1692
|
status = event.reason === "stop" ? "completed" : event.reason === "aborted" ? "aborted" : "failed";
|
|
1614
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
|
+
};
|
|
1615
1699
|
}
|
|
1616
1700
|
}
|
|
1617
1701
|
} catch (err) {
|
|
@@ -1619,8 +1703,8 @@ export class AiTurnExecutor {
|
|
|
1619
1703
|
status = "aborted";
|
|
1620
1704
|
} else {
|
|
1621
1705
|
status = "failed";
|
|
1622
|
-
|
|
1623
|
-
|
|
1706
|
+
const thrown = aiTurnFailureFromThrown(err, "AI compaction failed");
|
|
1707
|
+
failure = reason.current ? { ...reason.current, detail: thrown.detail } : thrown;
|
|
1624
1708
|
}
|
|
1625
1709
|
} finally {
|
|
1626
1710
|
stopHeartbeat();
|
|
@@ -1629,9 +1713,9 @@ export class AiTurnExecutor {
|
|
|
1629
1713
|
|
|
1630
1714
|
if (abortController.signal.reason instanceof AiRunTimeout) {
|
|
1631
1715
|
status = "failed";
|
|
1632
|
-
|
|
1716
|
+
failure = { error: abortController.signal.reason.turnError(), detail: "Run time limit reached." };
|
|
1633
1717
|
}
|
|
1634
|
-
await this.finalize(conversationId, turnId, pipeline, status,
|
|
1718
|
+
await this.finalize(conversationId, turnId, pipeline, status, failure, "compact");
|
|
1635
1719
|
}
|
|
1636
1720
|
}
|
|
1637
1721
|
|
|
@@ -1838,14 +1922,6 @@ class StreamPipeline {
|
|
|
1838
1922
|
await this.saving;
|
|
1839
1923
|
}
|
|
1840
1924
|
|
|
1841
|
-
async emitError(message: string): Promise<void> {
|
|
1842
|
-
const seq = this.nextSeq();
|
|
1843
|
-
const block: AiTurnBlock = { id: `error-${this.attempt}-${seq}`, kind: "text", text: `⚠️ ${message}` };
|
|
1844
|
-
const event = this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent;
|
|
1845
|
-
this.blocks = applyWireEventToBlocks(this.blocks, event);
|
|
1846
|
-
await this.publish(event);
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
1925
|
/** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
|
|
1850
1926
|
async emitProviderRetry(): Promise<void> {
|
|
1851
1927
|
const seq = this.nextSeq();
|