@k2b/cloud 0.31.0 → 0.32.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 +6 -4
- package/src/_internal/define-app.ts +3 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +7 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/global.css +5 -2
package/src/ai/default-tools.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { runManagedCodeTool } from "./code-runtime-tools";
|
|
|
18
18
|
import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
|
|
19
19
|
import { createCodeSourceTools } from "./code-source-tools";
|
|
20
20
|
import {
|
|
21
|
+
CLOUD_AI_GROUP_MAX_CHARTS,
|
|
21
22
|
CloudAiCardInputSchema,
|
|
22
23
|
CloudAiCardOutputSchema,
|
|
23
24
|
CloudAiChartInputSchema,
|
|
@@ -39,6 +40,22 @@ import {
|
|
|
39
40
|
} from "./file-tools";
|
|
40
41
|
import { createCloudAiWebExtractTool, createCloudAiWebSearchTool, isCloudAiFirecrawlConfigured } from "./firecrawl-tools";
|
|
41
42
|
import { createCloudAiHtmlToPdfTool } from "./html-pdf-tool";
|
|
43
|
+
import {
|
|
44
|
+
CloudAiLoadLocalSkillInputSchema,
|
|
45
|
+
CloudAiLoadLocalSkillOutputSchema,
|
|
46
|
+
CloudAiLocalDownloadInputSchema,
|
|
47
|
+
CloudAiLocalDownloadOutputSchema,
|
|
48
|
+
CloudAiLocalListInputSchema,
|
|
49
|
+
CloudAiLocalListOutputSchema,
|
|
50
|
+
CloudAiLocalMoveInputSchema,
|
|
51
|
+
CloudAiLocalMoveOutputSchema,
|
|
52
|
+
CloudAiLocalReadInputSchema,
|
|
53
|
+
CloudAiLocalReadOutputSchema,
|
|
54
|
+
CloudAiLocalUploadInputSchema,
|
|
55
|
+
CloudAiLocalUploadOutputSchema,
|
|
56
|
+
CloudAiLocalWriteInputSchema,
|
|
57
|
+
CloudAiLocalWriteOutputSchema,
|
|
58
|
+
} from "./local-tool-contracts";
|
|
42
59
|
import { createCloudAiMarkdownToPdfTool } from "./markdown-pdf-tool";
|
|
43
60
|
import { createAiTodoTool } from "./todo-tool";
|
|
44
61
|
import { defineAiTool } from "./tools";
|
|
@@ -72,23 +89,22 @@ export const createCloudAiCardTool = () =>
|
|
|
72
89
|
defineAiTool({
|
|
73
90
|
name: "card",
|
|
74
91
|
description:
|
|
75
|
-
"
|
|
92
|
+
"Show one highlight figure you already have, such as a status, metric or short result, as a stat tile in the chat. Keep the value to a short figure or status and the caption to one line. Several figures computed in code_run go through cloud.view.stats in the run, not several card calls; tables, lists and longer explanations stay Markdown. Keep all fields flat; do not pass arrays or nested objects.",
|
|
76
93
|
inputSchema: CloudAiCardInputSchema,
|
|
77
94
|
outputSchema: CloudAiCardOutputSchema,
|
|
78
95
|
approval: "never",
|
|
79
|
-
promptHint: "show one
|
|
96
|
+
promptHint: "show one highlight figure already known; several computed figures use cloud.view.stats in code_run.",
|
|
80
97
|
}).clientView();
|
|
81
98
|
|
|
82
99
|
export const createCloudAiChartTool = () =>
|
|
83
100
|
defineAiTool({
|
|
84
101
|
name: "chart",
|
|
85
|
-
description:
|
|
86
|
-
"Show one chart in the chat from data you already have: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge or sparkline. Cloud draws it with the renderer of cloud.chart() in Studio apps and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Aggregate first. Compute derived numbers with calculate or code_run before charting them. Say what the chart shows in title and put the unit, period or source in subtitle. The chart has no state, filters or actions: for interaction show an app in the chat with code_present, and build a saved Studio App only when data must be kept or the app reused. After the chart, state the finding instead of repeating its values.",
|
|
102
|
+
description: `Show one chart in the chat from a few values you already have that did not come from a code run, such as figures from the conversation, a web page or a small capability result: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge, sparkline, boxplot, heatmap, barGauge or stat. Several related measures go into one call as a group of small charts (charts, at most ${CLOUD_AI_GROUP_MAX_CHARTS}); linkX shares the x cursor between line charts with the same kind of x. There is no second y axis: compare measures with different units as a group. Cloud draws it with the renderer of cloud.chart() and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Values computed in code_run never go here: the script shows them itself with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer, and you present the returned path. Aggregate first. Say what the chart shows in title and put the unit, period or source in subtitle; formats are data ({ style: "percent" } takes fractions). The chart has no state, filters or actions: filters, steps and comparison over computed rows come from cloud.view.explorer in code_run; for other interaction show an app in the chat with code_present, and build a saved Studio App only when data must be kept or the app reused. After the chart, state the finding instead of repeating its values.`,
|
|
87
103
|
inputSchema: CloudAiChartInputSchema,
|
|
88
104
|
outputSchema: CloudAiChartOutputSchema,
|
|
89
105
|
approval: "never",
|
|
90
106
|
promptHint:
|
|
91
|
-
"show
|
|
107
|
+
"show a few values you already have (not from a code run) as a chart in the chat. Code results use cloud.view.* and present; filters, steps and comparison use cloud.view.explorer in code_run; interaction beyond that needs a chat app (code_present).",
|
|
92
108
|
}).clientView();
|
|
93
109
|
|
|
94
110
|
export const createCloudAiSurveyTool = () =>
|
|
@@ -118,14 +134,73 @@ export const createCloudAiLocalBashTool = () =>
|
|
|
118
134
|
defineAiTool({
|
|
119
135
|
name: "local_bash",
|
|
120
136
|
description:
|
|
121
|
-
"Run one Bash command on the user's
|
|
137
|
+
"Run one Bash command in the workspace folder on the user's computer, as the user's OS account. Use it only when no file or Cloud tool fits; the client may ask the user first, and a run does not prove that the user saw the command.",
|
|
122
138
|
inputSchema: CloudAiLocalBashInputSchema,
|
|
123
139
|
outputSchema: CloudAiLocalBashOutputSchema,
|
|
124
140
|
approval: "never",
|
|
125
|
-
promptHint:
|
|
126
|
-
"use local_bash only when work on the user's local CLI computer is necessary; every command requires local confirmation and its result must be checked.",
|
|
141
|
+
promptHint: "use local_bash only when no local file tool or Cloud tool fits; check status, exit code, and output.",
|
|
127
142
|
}).client();
|
|
128
143
|
|
|
144
|
+
/** File tools a local client runs in the user's workspace folder; the client applies its path rules and questions. */
|
|
145
|
+
export const createCloudAiLocalTools = () => [
|
|
146
|
+
defineAiTool({
|
|
147
|
+
name: "local_list",
|
|
148
|
+
description:
|
|
149
|
+
"List files and folders in the user's local workspace, optionally recursive or filtered by a case-insensitive glob such as **/*.pdf, in pages of up to 1000 entries with size and modification time. Folders outside the workspace ask the user first.",
|
|
150
|
+
inputSchema: CloudAiLocalListInputSchema,
|
|
151
|
+
outputSchema: CloudAiLocalListOutputSchema,
|
|
152
|
+
approval: "never",
|
|
153
|
+
}).client(),
|
|
154
|
+
defineAiTool({
|
|
155
|
+
name: "local_read",
|
|
156
|
+
description:
|
|
157
|
+
"Read a local text file with line numbers, 16 KiB by default and 64 KiB at most per call; continue with nextOffset. PDFs, images, and office or spreadsheet files are uploaded to this chat instead, and the result names the chat path for read_file or view_image.",
|
|
158
|
+
inputSchema: CloudAiLocalReadInputSchema,
|
|
159
|
+
outputSchema: CloudAiLocalReadOutputSchema,
|
|
160
|
+
approval: "never",
|
|
161
|
+
}).client(),
|
|
162
|
+
defineAiTool({
|
|
163
|
+
name: "local_upload",
|
|
164
|
+
description:
|
|
165
|
+
"Upload up to 64 local files to this chat and return their chat paths, so Cloud tools such as read_file, view_image, and code mode can use them. Uploads stay in the chat until it is deleted; each file can have up to 50 MB.",
|
|
166
|
+
inputSchema: CloudAiLocalUploadInputSchema,
|
|
167
|
+
outputSchema: CloudAiLocalUploadOutputSchema,
|
|
168
|
+
approval: "never",
|
|
169
|
+
}).client(),
|
|
170
|
+
defineAiTool({
|
|
171
|
+
name: "local_download",
|
|
172
|
+
description:
|
|
173
|
+
"Save a chat file, text or binary, into the local workspace and create missing folders. It never overwrites an existing file unless replace is true, which asks the user.",
|
|
174
|
+
inputSchema: CloudAiLocalDownloadInputSchema,
|
|
175
|
+
outputSchema: CloudAiLocalDownloadOutputSchema,
|
|
176
|
+
approval: "never",
|
|
177
|
+
}).client(),
|
|
178
|
+
defineAiTool({
|
|
179
|
+
name: "local_write",
|
|
180
|
+
description:
|
|
181
|
+
"Write a local text file of up to 100 KiB: create makes a new file and fails if one exists, append adds to an existing file, and replace overwrites one. Append and replace ask the user.",
|
|
182
|
+
inputSchema: CloudAiLocalWriteInputSchema,
|
|
183
|
+
outputSchema: CloudAiLocalWriteOutputSchema,
|
|
184
|
+
approval: "never",
|
|
185
|
+
}).client(),
|
|
186
|
+
defineAiTool({
|
|
187
|
+
name: "local_move",
|
|
188
|
+
description:
|
|
189
|
+
"Rename or move up to 64 files or folders inside the workspace in one batch, which the user confirms once as a list. It never overwrites: if a target exists, nothing moves and the result names the conflicts.",
|
|
190
|
+
inputSchema: CloudAiLocalMoveInputSchema,
|
|
191
|
+
outputSchema: CloudAiLocalMoveOutputSchema,
|
|
192
|
+
approval: "never",
|
|
193
|
+
}).client(),
|
|
194
|
+
defineAiTool({
|
|
195
|
+
name: "load_local_skill",
|
|
196
|
+
description:
|
|
197
|
+
"Load a Skill marked [local] in the Skills section by its exact name. Returns its instructions, folder, and file list; read referenced files with local_read.",
|
|
198
|
+
inputSchema: CloudAiLoadLocalSkillInputSchema,
|
|
199
|
+
outputSchema: CloudAiLoadLocalSkillOutputSchema,
|
|
200
|
+
approval: "never",
|
|
201
|
+
}).client(),
|
|
202
|
+
];
|
|
203
|
+
|
|
129
204
|
export const createCloudAiCodeTools = () => [
|
|
130
205
|
defineAiTool({
|
|
131
206
|
name: "code_secret",
|
|
@@ -146,16 +221,17 @@ export const createCloudAiCodeTools = () => [
|
|
|
146
221
|
defineAiTool({
|
|
147
222
|
name: "code_run",
|
|
148
223
|
promptHint:
|
|
149
|
-
"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.",
|
|
224
|
+
"For file analysis, data transformations or combining Cloud data, run a short one-off script with code_run; its computed views come from cloud.view.* in the script and present. Load assistant-code-mode for its runtime APIs; no saved app is required.",
|
|
150
225
|
description:
|
|
151
|
-
"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
|
|
226
|
+
"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, captured files and view references for agent inspection only. Show computed views with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer in the script, then present({path}) each returned view; never retype run values into chart arguments, files or Markdown. 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.",
|
|
152
227
|
inputSchema: CodeRunInput,
|
|
153
228
|
outputSchema: z.json(),
|
|
154
229
|
approval: "never",
|
|
155
230
|
}).server(runManagedCodeTool("code_run")),
|
|
156
231
|
defineAiTool({
|
|
157
232
|
name: "code_inspect",
|
|
158
|
-
description:
|
|
233
|
+
description:
|
|
234
|
+
"Inspect a script run: status, progress, errors, logs, output, captured files and view references. Use waitMs to wait for background work.",
|
|
159
235
|
inputSchema: CodeInspectInput,
|
|
160
236
|
outputSchema: z.json(),
|
|
161
237
|
approval: "never",
|
package/src/ai/executor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssistantMessage, CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
|
|
1
|
+
import type { AssistantMessage, CompactEvent, LoopAggregate, Message, 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";
|
|
@@ -26,8 +26,21 @@ import { aiChatMessages } from "./chat/messages";
|
|
|
26
26
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
27
27
|
import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
|
|
28
28
|
import { createCloudCompactFn } from "./compaction";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
createCloudAiCodeTools,
|
|
31
|
+
createCloudAiLocalBashTool,
|
|
32
|
+
createCloudAiLocalTools,
|
|
33
|
+
createConfiguredDefaultCloudAiTools,
|
|
34
|
+
} from "./default-tools";
|
|
30
35
|
import { aiFileStore } from "./files-store";
|
|
36
|
+
import { AI_LOCAL_TOOL_NAMES } from "./local-tool-contracts";
|
|
37
|
+
import {
|
|
38
|
+
countMalformedToolCallRepairs,
|
|
39
|
+
createMalformedToolCallRepair,
|
|
40
|
+
isMalformedToolCallId,
|
|
41
|
+
withMalformedToolCallRepair,
|
|
42
|
+
withMalformedToolCallResults,
|
|
43
|
+
} from "./malformed-tool-call-repair";
|
|
31
44
|
import { aiMemories } from "./memories";
|
|
32
45
|
import { createCloudAiMemoryTool } from "./memory-tool";
|
|
33
46
|
import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
|
|
@@ -42,6 +55,7 @@ import {
|
|
|
42
55
|
type AiWireEvent,
|
|
43
56
|
applyWireEventToBlocks,
|
|
44
57
|
buildBlocksFromMessages,
|
|
58
|
+
canonicalToolName,
|
|
45
59
|
compactionBlockId,
|
|
46
60
|
reconcileResolvedTurnActions,
|
|
47
61
|
steerAppliedBlockId,
|
|
@@ -54,7 +68,7 @@ import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
|
|
|
54
68
|
import { collectConversationResourceObservations } from "./resource-refs";
|
|
55
69
|
import { AiRunTimeout } from "./run-timeout";
|
|
56
70
|
import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
|
|
57
|
-
import { selectAiSkillCatalog } from "./skill-catalog";
|
|
71
|
+
import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
|
|
58
72
|
import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
|
|
59
73
|
import { aiSkills } from "./skills";
|
|
60
74
|
import { aiConversations, recordAiWebsiteReceipts } from "./store";
|
|
@@ -100,17 +114,18 @@ const AI_COALESCE_MAX_CHARS = 512;
|
|
|
100
114
|
const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
|
|
101
115
|
|
|
102
116
|
// Failed generation can contain calls that nessi deliberately never executes or resumes.
|
|
117
|
+
// Cloud's repair calls are skipped too: the live run never counts them.
|
|
103
118
|
const isToolRound = (message: AssistantMessage): boolean =>
|
|
104
119
|
message.stopReason !== "error" &&
|
|
105
120
|
message.stopReason !== "interrupted" &&
|
|
106
121
|
message.stopReason !== "aborted" &&
|
|
107
|
-
message.content.some((block) => block.type === "tool_call");
|
|
122
|
+
message.content.some((block) => block.type === "tool_call" && !isMalformedToolCallId(block.id));
|
|
108
123
|
|
|
109
124
|
const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
|
|
110
125
|
const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
|
|
111
126
|
const rounds = messages.flatMap(({ message }) => {
|
|
112
127
|
if (message.role !== "assistant" || !isToolRound(message)) return [];
|
|
113
|
-
const callIds = message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : []));
|
|
128
|
+
const callIds = message.content.flatMap((block) => (block.type === "tool_call" && !isMalformedToolCallId(block.id) ? [block.id] : []));
|
|
114
129
|
return callIds.length > 0 ? [callIds] : [];
|
|
115
130
|
});
|
|
116
131
|
return {
|
|
@@ -271,6 +286,7 @@ export type ExecutorConfig = {
|
|
|
271
286
|
validateTurn?: typeof validateAiTurnRequest;
|
|
272
287
|
/** Runs after the durable turn state and final wire event are flushed. */
|
|
273
288
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
|
|
289
|
+
onApprovalRequested?: (event: { approvalId: string }) => Promise<void>;
|
|
274
290
|
/** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
|
|
275
291
|
providerRetryDelaysMs?: readonly number[];
|
|
276
292
|
};
|
|
@@ -278,6 +294,29 @@ export type ExecutorConfig = {
|
|
|
278
294
|
type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
|
|
279
295
|
type ValidatedTurn = { settings: Awaited<ReturnType<typeof validateAiTurnRequest>>["settings"]; resolved: ResolvedModel };
|
|
280
296
|
|
|
297
|
+
// One entry per steering text returned to nessi, including repairs that have no user steer to acknowledge.
|
|
298
|
+
const createChatSteering =
|
|
299
|
+
(input: {
|
|
300
|
+
repair: ReturnType<typeof createMalformedToolCallRepair>;
|
|
301
|
+
appliedSteers: (AiTurnSteer | null)[];
|
|
302
|
+
takePendingSteers: () => Promise<AiTurnSteer[]>;
|
|
303
|
+
noteSteering: () => void;
|
|
304
|
+
}) =>
|
|
305
|
+
async ({ signal }: { signal: AbortSignal }) => {
|
|
306
|
+
if (signal.aborted) return undefined;
|
|
307
|
+
// Exhaustion fails before consuming any pending user input.
|
|
308
|
+
const repairText = input.repair.takeSteering();
|
|
309
|
+
const steers = await input.takePendingSteers();
|
|
310
|
+
input.appliedSteers.push(...steers);
|
|
311
|
+
if (steers.length > 0) input.noteSteering();
|
|
312
|
+
const texts = steers.map((steer) => steer.text);
|
|
313
|
+
if (repairText) {
|
|
314
|
+
texts.push(repairText);
|
|
315
|
+
input.appliedSteers.push(null);
|
|
316
|
+
}
|
|
317
|
+
return texts.length > 0 ? texts : undefined;
|
|
318
|
+
};
|
|
319
|
+
|
|
281
320
|
// ---------------------------------------------------------------------------
|
|
282
321
|
// Baseline rebuild — reconstruct the full active-turn view from persisted rounds
|
|
283
322
|
// ---------------------------------------------------------------------------
|
|
@@ -465,6 +504,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
465
504
|
name,
|
|
466
505
|
args: "args" in patch ? patch.args : existing?.args,
|
|
467
506
|
status: patch.status ?? existing?.status ?? "running",
|
|
507
|
+
at: patch.at ?? existing?.at,
|
|
468
508
|
result: "result" in patch ? patch.result : existing?.result,
|
|
469
509
|
isError: "isError" in patch ? patch.isError : existing?.isError,
|
|
470
510
|
approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
|
|
@@ -548,6 +588,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
548
588
|
setTool(event.callId, {
|
|
549
589
|
name: event.name,
|
|
550
590
|
status: rejectedCallIds.has(event.callId) ? "rejected" : event.isError ? "failed" : "completed",
|
|
591
|
+
at: new Date().toISOString(),
|
|
551
592
|
result: event.result,
|
|
552
593
|
isError: Boolean(event.isError),
|
|
553
594
|
clearApproval: true,
|
|
@@ -559,7 +600,15 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
559
600
|
if (callId && toolBlocks.has(callId)) {
|
|
560
601
|
const existing = toolBlocks.get(callId);
|
|
561
602
|
if (existing && existing.status !== "completed" && existing.status !== "failed") {
|
|
562
|
-
return [
|
|
603
|
+
return [
|
|
604
|
+
setTool(callId, {
|
|
605
|
+
status: "failed",
|
|
606
|
+
at: new Date().toISOString(),
|
|
607
|
+
result: event.issue.message,
|
|
608
|
+
isError: true,
|
|
609
|
+
clearApproval: true,
|
|
610
|
+
}),
|
|
611
|
+
];
|
|
563
612
|
}
|
|
564
613
|
}
|
|
565
614
|
return [];
|
|
@@ -573,8 +622,24 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
573
622
|
}
|
|
574
623
|
};
|
|
575
624
|
|
|
625
|
+
const storedMessage = (message: Message, at: string): BlockOp[] =>
|
|
626
|
+
message.role === "tool_result" && isMalformedToolCallId(message.callId)
|
|
627
|
+
? [
|
|
628
|
+
setTool(message.callId, {
|
|
629
|
+
name: canonicalToolName(message.name),
|
|
630
|
+
args: {},
|
|
631
|
+
status: "failed",
|
|
632
|
+
at,
|
|
633
|
+
result: message.result,
|
|
634
|
+
isError: true,
|
|
635
|
+
clearApproval: true,
|
|
636
|
+
}),
|
|
637
|
+
]
|
|
638
|
+
: [];
|
|
639
|
+
|
|
576
640
|
return {
|
|
577
641
|
translate,
|
|
642
|
+
storedMessage,
|
|
578
643
|
compaction,
|
|
579
644
|
setFrontendModes,
|
|
580
645
|
setPresentations,
|
|
@@ -614,7 +679,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
614
679
|
allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
|
|
615
680
|
})),
|
|
616
681
|
// Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
|
|
617
|
-
...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
|
|
682
|
+
...[createCloudAiLocalBashTool(), ...createCloudAiLocalTools(), ...createCloudAiCodeTools()].filter(
|
|
618
683
|
(tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
|
|
619
684
|
),
|
|
620
685
|
]
|
|
@@ -866,7 +931,22 @@ export class AiTurnExecutor {
|
|
|
866
931
|
const skillSubject =
|
|
867
932
|
defaultToolSource && resolved.profile.capabilities.includes("tools") ? accessSubjectForActor(material.actor) : null;
|
|
868
933
|
const availableSkills = skillSubject ? (await aiSkills.list(skillSubject)).filter((skill) => skill.enabled) : [];
|
|
869
|
-
const
|
|
934
|
+
const localClient = !config.background && !config.mandate ? config.client : undefined;
|
|
935
|
+
const localContext = localClient ? config.localContext : undefined;
|
|
936
|
+
const localSkills = (localContext?.skills ?? []).map((skill) => ({
|
|
937
|
+
name: skill.name,
|
|
938
|
+
description: skill.description,
|
|
939
|
+
manualOnly: skill.manualOnly,
|
|
940
|
+
source: "local" as const,
|
|
941
|
+
}));
|
|
942
|
+
const skillCatalog = selectAiSkillCatalog(
|
|
943
|
+
mergeAiSkillCatalog(
|
|
944
|
+
availableSkills.map(({ name, description, manualOnly }) => ({ name, description, manualOnly })),
|
|
945
|
+
localSkills,
|
|
946
|
+
),
|
|
947
|
+
resolved.provider.contextWindow ?? 0,
|
|
948
|
+
query,
|
|
949
|
+
);
|
|
870
950
|
const projectFiles =
|
|
871
951
|
project && resolvedProjectId && projectSubject
|
|
872
952
|
? {
|
|
@@ -895,7 +975,7 @@ export class AiTurnExecutor {
|
|
|
895
975
|
...material.tools,
|
|
896
976
|
...(memoryActive ? [createCloudAiMemoryTool(query)] : []),
|
|
897
977
|
...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject)] : []),
|
|
898
|
-
...(
|
|
978
|
+
...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills)] : []),
|
|
899
979
|
...(config.project && resolvedProjectId && projectSubject
|
|
900
980
|
? [
|
|
901
981
|
createCloudAiSearchProjectTool(resolvedProjectId, projectSubject),
|
|
@@ -928,7 +1008,13 @@ export class AiTurnExecutor {
|
|
|
928
1008
|
// Built-ins that exist but this turn does not offer: client tools without their client, tools a
|
|
929
1009
|
// task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
|
|
930
1010
|
const unofferedTools = [
|
|
931
|
-
...new Set([
|
|
1011
|
+
...new Set([
|
|
1012
|
+
...sourceToolNames,
|
|
1013
|
+
...runtimeTools.map((tool) => tool.def.name),
|
|
1014
|
+
...CODE_RUNTIME_TOOL_NAMES,
|
|
1015
|
+
"local_bash",
|
|
1016
|
+
...AI_LOCAL_TOOL_NAMES,
|
|
1017
|
+
]),
|
|
932
1018
|
].filter((name) => !offeredToolNames.has(name));
|
|
933
1019
|
const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
|
|
934
1020
|
const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
|
|
@@ -948,6 +1034,7 @@ export class AiTurnExecutor {
|
|
|
948
1034
|
turnId,
|
|
949
1035
|
reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
|
|
950
1036
|
reportWebsiteReceipts: (callId: string, receipts: AiWebsiteReceipt[]) => pipeline.reportWebsiteReceipts(callId, receipts),
|
|
1037
|
+
localToolsEnabled: activeTools.some((tool) => tool.def.name === "local_upload"),
|
|
951
1038
|
attachedFilePaths: new Set(config.files?.attached.map((file) => file.path) ?? []),
|
|
952
1039
|
allowedDataBoundaries: material.modelPolicy?.allowedDataBoundaries,
|
|
953
1040
|
projectFiles,
|
|
@@ -1064,7 +1151,7 @@ export class AiTurnExecutor {
|
|
|
1064
1151
|
await pipeline.emitBaseline();
|
|
1065
1152
|
}
|
|
1066
1153
|
|
|
1067
|
-
const appliedSteers: AiTurnSteer[] = [];
|
|
1154
|
+
const appliedSteers: (AiTurnSteer | null)[] = [];
|
|
1068
1155
|
|
|
1069
1156
|
let mandatePolicy: MandatePolicyV1 | null | undefined;
|
|
1070
1157
|
if (config.background && config.mandate) {
|
|
@@ -1118,6 +1205,7 @@ export class AiTurnExecutor {
|
|
|
1118
1205
|
conversationId,
|
|
1119
1206
|
actor: capabilityAuthority?.actor ?? toolActor,
|
|
1120
1207
|
staticTools: activeTools,
|
|
1208
|
+
...(activeTools.some((tool) => tool.def.name === "load_skill") ? { skillNames: availableSkills.map((skill) => skill.name) } : {}),
|
|
1121
1209
|
allowedTools,
|
|
1122
1210
|
mandatePolicy,
|
|
1123
1211
|
unofferedTools,
|
|
@@ -1226,8 +1314,8 @@ export class AiTurnExecutor {
|
|
|
1226
1314
|
);
|
|
1227
1315
|
const chat = await aiConversations.getConversation({ conversationId });
|
|
1228
1316
|
const workingPlan = chat?.todoPlan;
|
|
1229
|
-
const skillCatalogOffered = activeTools.some((tool) => tool.def.name
|
|
1230
|
-
const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator");
|
|
1317
|
+
const skillCatalogOffered = activeTools.some((tool) => ["load_skill", "load_local_skill"].includes(tool.def.name));
|
|
1318
|
+
const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly);
|
|
1231
1319
|
// Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
|
|
1232
1320
|
// attempt after an approval or a scheduled run gets neither.
|
|
1233
1321
|
let guidance: AiTurnGuidance = {};
|
|
@@ -1256,7 +1344,16 @@ export class AiTurnExecutor {
|
|
|
1256
1344
|
}
|
|
1257
1345
|
const systemPrompt = composeAiSystemPrompt({
|
|
1258
1346
|
globalInstructions: settings.globalInstructions,
|
|
1259
|
-
loadedSkills,
|
|
1347
|
+
loadedSkills: [...loadedSkills, ...(localContext?.selectedSkills ?? []).map((skill) => ({ ...skill, source: "local" as const }))],
|
|
1348
|
+
localClient: localClient
|
|
1349
|
+
? {
|
|
1350
|
+
client: localClient,
|
|
1351
|
+
cloudUrl: (await coreSettings.get<string>("app.url")) || "",
|
|
1352
|
+
instructions: settings.localClientInstructions,
|
|
1353
|
+
}
|
|
1354
|
+
: undefined,
|
|
1355
|
+
workspaceInstructions: localContext?.instructions,
|
|
1356
|
+
localSkillsAvailable: skillCatalogOffered && localSkills.some((skill) => !skill.manualOnly),
|
|
1260
1357
|
turnInstructions: [
|
|
1261
1358
|
material.systemPrompt,
|
|
1262
1359
|
audioUnavailableInstruction,
|
|
@@ -1292,8 +1389,9 @@ export class AiTurnExecutor {
|
|
|
1292
1389
|
skillCreatorAvailable,
|
|
1293
1390
|
recentWork: guidance.recentWork,
|
|
1294
1391
|
});
|
|
1295
|
-
// The turn policy
|
|
1392
|
+
// The turn policy and repair budget count the whole turn, including rounds that compaction archived.
|
|
1296
1393
|
const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
|
|
1394
|
+
const malformedToolCallRepair = createMalformedToolCallRepair(countMalformedToolCallRepairs(turnMessages));
|
|
1297
1395
|
const turnBlocks = buildBlocksFromMessages(turnMessages);
|
|
1298
1396
|
const providerNamesByCallId = new Map(
|
|
1299
1397
|
turnMessages.flatMap(({ message }) =>
|
|
@@ -1336,7 +1434,11 @@ export class AiTurnExecutor {
|
|
|
1336
1434
|
runBudgetMs: claim.turn.runBudgetMs ?? null,
|
|
1337
1435
|
finishedToolCalls: turnBlocks
|
|
1338
1436
|
.slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
|
|
1339
|
-
.flatMap((block) =>
|
|
1437
|
+
.flatMap((block) =>
|
|
1438
|
+
block.kind === "tool" && !isMalformedToolCallId(block.callId)
|
|
1439
|
+
? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }]
|
|
1440
|
+
: [],
|
|
1441
|
+
),
|
|
1340
1442
|
onDecision: (decision) =>
|
|
1341
1443
|
decision.kind === "hint"
|
|
1342
1444
|
? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
|
|
@@ -1346,21 +1448,20 @@ export class AiTurnExecutor {
|
|
|
1346
1448
|
agentId: "cloud",
|
|
1347
1449
|
loopId: turnId,
|
|
1348
1450
|
...(isFresh ? { input: turnInput } : {}),
|
|
1349
|
-
provider: turnPolicy.provider,
|
|
1451
|
+
provider: withMalformedToolCallRepair(turnPolicy.provider, malformedToolCallRepair),
|
|
1350
1452
|
systemPrompt,
|
|
1351
|
-
store,
|
|
1352
|
-
steering:
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
turnPolicy.noteSteering
|
|
1362
|
-
|
|
1363
|
-
},
|
|
1453
|
+
store: withMalformedToolCallResults(store, malformedToolCallRepair),
|
|
1454
|
+
steering: createChatSteering({
|
|
1455
|
+
repair: malformedToolCallRepair,
|
|
1456
|
+
appliedSteers,
|
|
1457
|
+
takePendingSteers: () =>
|
|
1458
|
+
aiConversations.takePendingTurnSteers({
|
|
1459
|
+
conversationId,
|
|
1460
|
+
turnId,
|
|
1461
|
+
leaseOwner: this.config.leaseOwner,
|
|
1462
|
+
}),
|
|
1463
|
+
noteSteering: turnPolicy.noteSteering,
|
|
1464
|
+
}),
|
|
1364
1465
|
tools: async () => {
|
|
1365
1466
|
try {
|
|
1366
1467
|
return await turnPolicy.tools();
|
|
@@ -1482,7 +1583,7 @@ export class AiTurnExecutor {
|
|
|
1482
1583
|
approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
|
|
1483
1584
|
linkedApprovals: ReadonlyMap<string, string>;
|
|
1484
1585
|
freshApprovalRequired?: ReadonlySet<string>;
|
|
1485
|
-
appliedSteers: AiTurnSteer[];
|
|
1586
|
+
appliedSteers: (AiTurnSteer | null)[];
|
|
1486
1587
|
noteToolRound: () => void;
|
|
1487
1588
|
noteToolCall: (call: AiTurnPolicyToolCall) => void;
|
|
1488
1589
|
/**
|
|
@@ -1532,6 +1633,7 @@ export class AiTurnExecutor {
|
|
|
1532
1633
|
linkedApprovals: input.linkedApprovals,
|
|
1533
1634
|
freshApprovalRequired: input.freshApprovalRequired,
|
|
1534
1635
|
onBackgroundBlocked: input.onBackgroundBlocked,
|
|
1636
|
+
signal: abortController.signal,
|
|
1535
1637
|
});
|
|
1536
1638
|
if (suspended) {
|
|
1537
1639
|
abortController.abort();
|
|
@@ -1638,6 +1740,7 @@ export class AiTurnExecutor {
|
|
|
1638
1740
|
linkedApprovals: ReadonlyMap<string, string>;
|
|
1639
1741
|
freshApprovalRequired?: ReadonlySet<string>;
|
|
1640
1742
|
onBackgroundBlocked?: (message: string) => void;
|
|
1743
|
+
signal: AbortSignal;
|
|
1641
1744
|
}): Promise<boolean> {
|
|
1642
1745
|
const {
|
|
1643
1746
|
event,
|
|
@@ -1790,6 +1893,32 @@ export class AiTurnExecutor {
|
|
|
1790
1893
|
}
|
|
1791
1894
|
|
|
1792
1895
|
await pipeline.apply(presented);
|
|
1896
|
+
if (
|
|
1897
|
+
event.kind === "client_tool" &&
|
|
1898
|
+
frontendMode === "client" &&
|
|
1899
|
+
(toolName === "local_bash" || AI_LOCAL_TOOL_NAMES.some((name) => name === toolName))
|
|
1900
|
+
) {
|
|
1901
|
+
await pipeline.flush();
|
|
1902
|
+
// One wait stays inside a heartbeat interval, keeping lease renewal and abort detection on their cadence.
|
|
1903
|
+
const deadline = performance.now() + this.config.heartbeatMs / 2;
|
|
1904
|
+
while (!input.signal.aborted && performance.now() < deadline) {
|
|
1905
|
+
const action = await aiConversations.getPendingTurnAction({ conversationId, turnId, callId: event.callId });
|
|
1906
|
+
if (action?.status === "resolved" && action.resolvedEvent) {
|
|
1907
|
+
await aiConversations.markPendingTurnActionConsumedInline({ conversationId, turnId, callId: event.callId });
|
|
1908
|
+
loop.push(action.resolvedEvent);
|
|
1909
|
+
return false;
|
|
1910
|
+
}
|
|
1911
|
+
await new Promise<void>((resolve) => {
|
|
1912
|
+
const done = () => {
|
|
1913
|
+
clearTimeout(timer);
|
|
1914
|
+
input.signal.removeEventListener("abort", done);
|
|
1915
|
+
resolve();
|
|
1916
|
+
};
|
|
1917
|
+
const timer = setTimeout(done, 10);
|
|
1918
|
+
input.signal.addEventListener("abort", done, { once: true });
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1793
1922
|
const suspended = await aiConversations.suspendTurn({
|
|
1794
1923
|
conversationId,
|
|
1795
1924
|
turnId,
|
|
@@ -1799,6 +1928,16 @@ export class AiTurnExecutor {
|
|
|
1799
1928
|
waitingBudgetMs: AI_ACTION_BUDGET_MS,
|
|
1800
1929
|
});
|
|
1801
1930
|
await pipeline.flush().catch(() => undefined);
|
|
1931
|
+
if (suspended && approvalId && this.config.onApprovalRequested) {
|
|
1932
|
+
await this.config.onApprovalRequested({ approvalId }).catch((error) => {
|
|
1933
|
+
log.warn("AI approval requested hook failed", {
|
|
1934
|
+
conversationId,
|
|
1935
|
+
turnId,
|
|
1936
|
+
approvalId,
|
|
1937
|
+
error: error instanceof Error ? error.message : "AI approval requested hook failed",
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1802
1941
|
return suspended;
|
|
1803
1942
|
}
|
|
1804
1943
|
|
|
@@ -2055,6 +2194,8 @@ class StreamPipeline {
|
|
|
2055
2194
|
}
|
|
2056
2195
|
|
|
2057
2196
|
async emitMessage(message: AiStoredMessage): Promise<void> {
|
|
2197
|
+
// A malformed call shows once its error result is saved, at the end of its response as in history.
|
|
2198
|
+
for (const op of this.mapper.storedMessage(message.message, message.createdAt)) await this.emitOp(op);
|
|
2058
2199
|
const seq = this.nextSeq();
|
|
2059
2200
|
await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
|
|
2060
2201
|
await this.maybeSnapshot();
|
|
@@ -2196,6 +2337,7 @@ class StreamPipeline {
|
|
|
2196
2337
|
}
|
|
2197
2338
|
|
|
2198
2339
|
export const __aiExecutorTest = {
|
|
2340
|
+
createChatSteering,
|
|
2199
2341
|
indexConversationToolSource,
|
|
2200
2342
|
StreamPipeline,
|
|
2201
2343
|
createEventMapper,
|