@k2b/cloud 0.30.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 +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- 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 +64 -39
- 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/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -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 +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- 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/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- 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/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -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 +120 -2
- 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 +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- 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 +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- 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/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- 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/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- 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/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- 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/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- 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 +3 -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/server/widget-request.ts +27 -0
- 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 +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- 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/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- 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/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- 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/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
package/src/ai/executor.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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";
|
|
5
5
|
import type { AccessSubject, RequestActor } from "../server";
|
|
6
|
+
import { approvalAssurance, approvalScopeFromToolName, approvals } from "../services/approvals";
|
|
6
7
|
import { createHelpReader } from "../services/help";
|
|
7
8
|
import { logger } from "../services/logging";
|
|
8
9
|
import { getMandate } from "../services/mandates";
|
|
@@ -10,6 +11,7 @@ import type { MandatePolicyV1 } from "../services/mandates/policy";
|
|
|
10
11
|
import { coreSettings } from "../services/settings/api";
|
|
11
12
|
import { normalizeLocale } from "../shared/locale";
|
|
12
13
|
import {
|
|
14
|
+
AI_WEBSITE_APPROVAL_TOOL,
|
|
13
15
|
type AiToolApprovalContext,
|
|
14
16
|
aiToolAllowsAlways,
|
|
15
17
|
aiToolApprovalScope,
|
|
@@ -24,8 +26,21 @@ import { aiChatMessages } from "./chat/messages";
|
|
|
24
26
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
25
27
|
import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
|
|
26
28
|
import { createCloudCompactFn } from "./compaction";
|
|
27
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
createCloudAiCodeTools,
|
|
31
|
+
createCloudAiLocalBashTool,
|
|
32
|
+
createCloudAiLocalTools,
|
|
33
|
+
createConfiguredDefaultCloudAiTools,
|
|
34
|
+
} from "./default-tools";
|
|
28
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";
|
|
29
44
|
import { aiMemories } from "./memories";
|
|
30
45
|
import { createCloudAiMemoryTool } from "./memory-tool";
|
|
31
46
|
import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
|
|
@@ -40,6 +55,7 @@ import {
|
|
|
40
55
|
type AiWireEvent,
|
|
41
56
|
applyWireEventToBlocks,
|
|
42
57
|
buildBlocksFromMessages,
|
|
58
|
+
canonicalToolName,
|
|
43
59
|
compactionBlockId,
|
|
44
60
|
reconcileResolvedTurnActions,
|
|
45
61
|
steerAppliedBlockId,
|
|
@@ -52,16 +68,16 @@ import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
|
|
|
52
68
|
import { collectConversationResourceObservations } from "./resource-refs";
|
|
53
69
|
import { AiRunTimeout } from "./run-timeout";
|
|
54
70
|
import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
|
|
55
|
-
import { selectAiSkillCatalog } from "./skill-catalog";
|
|
71
|
+
import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
|
|
56
72
|
import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
|
|
57
73
|
import { aiSkills } from "./skills";
|
|
58
|
-
import { aiConversations } from "./store";
|
|
74
|
+
import { aiConversations, recordAiWebsiteReceipts } from "./store";
|
|
59
75
|
import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
|
|
60
76
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
61
77
|
import { aiToolAudit } from "./tool-audit";
|
|
62
78
|
import { acceptCanonicalToolNames } from "./tool-call-names";
|
|
63
79
|
import { resolveAiToolResultMaxChars } from "./tool-result-budget";
|
|
64
|
-
import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
80
|
+
import { type AiApprovalUse, aiApprovalAskedLater, aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
65
81
|
import { AiTranscriptionError } from "./transcription";
|
|
66
82
|
import {
|
|
67
83
|
AiTurnFailure,
|
|
@@ -75,6 +91,7 @@ import { type AiTurnGuidance, loadAiTurnGuidance } from "./turn-guidance";
|
|
|
75
91
|
import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
|
|
76
92
|
import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
|
|
77
93
|
import type {
|
|
94
|
+
AiApprovalTarget,
|
|
78
95
|
AiChatTurnRunConfig,
|
|
79
96
|
AiFrontendToolMode,
|
|
80
97
|
AiPendingTurnActionRecord,
|
|
@@ -85,6 +102,7 @@ import type {
|
|
|
85
102
|
AiTurnFinalizedEvent,
|
|
86
103
|
AiTurnRunConfig,
|
|
87
104
|
AiTurnSteer,
|
|
105
|
+
AiWebsiteReceipt,
|
|
88
106
|
} from "./types";
|
|
89
107
|
import { isAiImageMediaType } from "./types";
|
|
90
108
|
import { validateAiTurnRequest } from "./validate";
|
|
@@ -96,17 +114,18 @@ const AI_COALESCE_MAX_CHARS = 512;
|
|
|
96
114
|
const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
|
|
97
115
|
|
|
98
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.
|
|
99
118
|
const isToolRound = (message: AssistantMessage): boolean =>
|
|
100
119
|
message.stopReason !== "error" &&
|
|
101
120
|
message.stopReason !== "interrupted" &&
|
|
102
121
|
message.stopReason !== "aborted" &&
|
|
103
|
-
message.content.some((block) => block.type === "tool_call");
|
|
122
|
+
message.content.some((block) => block.type === "tool_call" && !isMalformedToolCallId(block.id));
|
|
104
123
|
|
|
105
124
|
const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
|
|
106
125
|
const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
|
|
107
126
|
const rounds = messages.flatMap(({ message }) => {
|
|
108
127
|
if (message.role !== "assistant" || !isToolRound(message)) return [];
|
|
109
|
-
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] : []));
|
|
110
129
|
return callIds.length > 0 ? [callIds] : [];
|
|
111
130
|
});
|
|
112
131
|
return {
|
|
@@ -267,6 +286,7 @@ export type ExecutorConfig = {
|
|
|
267
286
|
validateTurn?: typeof validateAiTurnRequest;
|
|
268
287
|
/** Runs after the durable turn state and final wire event are flushed. */
|
|
269
288
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
|
|
289
|
+
onApprovalRequested?: (event: { approvalId: string }) => Promise<void>;
|
|
270
290
|
/** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
|
|
271
291
|
providerRetryDelaysMs?: readonly number[];
|
|
272
292
|
};
|
|
@@ -274,6 +294,29 @@ export type ExecutorConfig = {
|
|
|
274
294
|
type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
|
|
275
295
|
type ValidatedTurn = { settings: Awaited<ReturnType<typeof validateAiTurnRequest>>["settings"]; resolved: ResolvedModel };
|
|
276
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
|
+
|
|
277
320
|
// ---------------------------------------------------------------------------
|
|
278
321
|
// Baseline rebuild — reconstruct the full active-turn view from persisted rounds
|
|
279
322
|
// ---------------------------------------------------------------------------
|
|
@@ -296,7 +339,15 @@ const rebuildBlocksFromMessages = (
|
|
|
296
339
|
callId: action.callId,
|
|
297
340
|
status: action.kind === "client_tool" ? "awaiting_client" : "awaiting_approval",
|
|
298
341
|
approval:
|
|
299
|
-
action.kind === "client_tool"
|
|
342
|
+
action.kind === "client_tool"
|
|
343
|
+
? undefined
|
|
344
|
+
: {
|
|
345
|
+
message: action.message,
|
|
346
|
+
review: action.review,
|
|
347
|
+
allowAlways: action.allowAlways,
|
|
348
|
+
allowChat: action.allowChat ?? action.allowAlways,
|
|
349
|
+
...(action.rememberToolName === AI_WEBSITE_APPROVAL_TOOL ? { website: action.approvalScope } : {}),
|
|
350
|
+
},
|
|
300
351
|
frontendMode: action.frontendMode,
|
|
301
352
|
};
|
|
302
353
|
}
|
|
@@ -381,9 +432,25 @@ const approvalReviewForCallId = (
|
|
|
381
432
|
*/
|
|
382
433
|
const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowRememberedApprovals = true) => {
|
|
383
434
|
const toolBlocks = new Map<string, Extract<AiTurnBlock, { kind: "tool" }>>();
|
|
435
|
+
/** Website receipts by the call that shows them and their index; every later state of that call keeps them. */
|
|
436
|
+
const receiptsByCall = new Map<string, Map<number, { method: string; url: string }>>();
|
|
384
437
|
for (const block of seedBlocks) {
|
|
385
|
-
if (block.kind
|
|
438
|
+
if (block.kind !== "tool") continue;
|
|
439
|
+
toolBlocks.set(block.callId, block);
|
|
440
|
+
// A pending nested approval shows on its parent call's block; receipts belong to that block.
|
|
441
|
+
const shownCallId = block.id === toolBlockId(block.callId) ? block.callId : (customApprovalParentCallId(block.callId) ?? block.callId);
|
|
442
|
+
if (block.receipts?.length) receiptsByCall.set(shownCallId, new Map(block.receipts.map((receipt, index) => [index, receipt])));
|
|
386
443
|
}
|
|
444
|
+
const receiptsOf = (callId: string) => {
|
|
445
|
+
const receipts = receiptsByCall.get(callId);
|
|
446
|
+
return receipts ? [...receipts].sort(([a], [b]) => a - b).map(([, receipt]) => receipt) : undefined;
|
|
447
|
+
};
|
|
448
|
+
const addReceipts = (callId: string, receipts: AiWebsiteReceipt[]) => {
|
|
449
|
+
const known = receiptsByCall.get(callId) ?? new Map<number, { method: string; url: string }>();
|
|
450
|
+
for (const { index, method, url } of receipts) known.set(index, { method, url });
|
|
451
|
+
receiptsByCall.set(callId, known);
|
|
452
|
+
return receiptsOf(callId) ?? [];
|
|
453
|
+
};
|
|
387
454
|
/** Real frontend mode per tool name — set once the turn's tools are prepared.
|
|
388
455
|
* Getting this wrong is not cosmetic: the client auto-resolves plain "client"
|
|
389
456
|
* blocks, so a mislabeled client_interaction tool (survey) would be answered
|
|
@@ -408,6 +475,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
408
475
|
const setApprovalPolicies = (items: PreparedAiTools["approvalPolicies"]) => {
|
|
409
476
|
approvalPolicies = items;
|
|
410
477
|
};
|
|
478
|
+
let approvalTargets: ReadonlyMap<string, AiApprovalTarget> = new Map();
|
|
479
|
+
const setApprovalTargets = (items: ReadonlyMap<string, AiApprovalTarget>) => {
|
|
480
|
+
approvalTargets = items;
|
|
481
|
+
};
|
|
411
482
|
let rejectedCallIds = new Set<string>();
|
|
412
483
|
const setRejectedCallIds = (items: Set<string>) => {
|
|
413
484
|
rejectedCallIds = items;
|
|
@@ -433,12 +504,14 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
433
504
|
name,
|
|
434
505
|
args: "args" in patch ? patch.args : existing?.args,
|
|
435
506
|
status: patch.status ?? existing?.status ?? "running",
|
|
507
|
+
at: patch.at ?? existing?.at,
|
|
436
508
|
result: "result" in patch ? patch.result : existing?.result,
|
|
437
509
|
isError: "isError" in patch ? patch.isError : existing?.isError,
|
|
438
|
-
approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false } : approval,
|
|
510
|
+
approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
|
|
439
511
|
frontendMode: patch.frontendMode ?? existing?.frontendMode,
|
|
440
512
|
presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
|
|
441
513
|
...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
|
|
514
|
+
...(receiptsOf(displayCallId) ? { receipts: receiptsOf(displayCallId) } : {}),
|
|
442
515
|
};
|
|
443
516
|
toolBlocks.set(callId, block);
|
|
444
517
|
return { type: "block_set", block };
|
|
@@ -484,6 +557,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
484
557
|
case "tool_action_request":
|
|
485
558
|
const displayCallId = event.kind === "custom_approval" ? (customApprovalParentCallId(event.callId) ?? event.callId) : event.callId;
|
|
486
559
|
const review = approvalReviewForCallId(approvalReviews, event.callId);
|
|
560
|
+
const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
|
|
561
|
+
const allowAlways = target
|
|
562
|
+
? target.always
|
|
563
|
+
: review?.approvalScope !== undefined || aiToolAllowsAlways(approvalPolicies.get(event.name));
|
|
487
564
|
return [
|
|
488
565
|
setTool(
|
|
489
566
|
event.callId,
|
|
@@ -497,7 +574,9 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
497
574
|
: {
|
|
498
575
|
message: event.message,
|
|
499
576
|
review,
|
|
500
|
-
allowAlways
|
|
577
|
+
allowAlways,
|
|
578
|
+
allowChat: target !== undefined || allowAlways,
|
|
579
|
+
...(target?.toolName === AI_WEBSITE_APPROVAL_TOOL ? { website: target.approvalScope } : {}),
|
|
501
580
|
},
|
|
502
581
|
frontendMode: event.kind === "client_tool" ? (frontendModes.get(event.name) ?? "client") : undefined,
|
|
503
582
|
},
|
|
@@ -509,6 +588,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
509
588
|
setTool(event.callId, {
|
|
510
589
|
name: event.name,
|
|
511
590
|
status: rejectedCallIds.has(event.callId) ? "rejected" : event.isError ? "failed" : "completed",
|
|
591
|
+
at: new Date().toISOString(),
|
|
512
592
|
result: event.result,
|
|
513
593
|
isError: Boolean(event.isError),
|
|
514
594
|
clearApproval: true,
|
|
@@ -520,7 +600,15 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
520
600
|
if (callId && toolBlocks.has(callId)) {
|
|
521
601
|
const existing = toolBlocks.get(callId);
|
|
522
602
|
if (existing && existing.status !== "completed" && existing.status !== "failed") {
|
|
523
|
-
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
|
+
];
|
|
524
612
|
}
|
|
525
613
|
}
|
|
526
614
|
return [];
|
|
@@ -534,16 +622,34 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
534
622
|
}
|
|
535
623
|
};
|
|
536
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
|
+
|
|
537
640
|
return {
|
|
538
641
|
translate,
|
|
642
|
+
storedMessage,
|
|
539
643
|
compaction,
|
|
540
644
|
setFrontendModes,
|
|
541
645
|
setPresentations,
|
|
542
646
|
setCanonicalNames,
|
|
543
647
|
setApprovalPolicies,
|
|
544
648
|
setApprovalReviews,
|
|
649
|
+
setApprovalTargets,
|
|
545
650
|
setRejectedCallIds,
|
|
546
651
|
setApprovedCallIds,
|
|
652
|
+
addReceipts,
|
|
547
653
|
};
|
|
548
654
|
};
|
|
549
655
|
|
|
@@ -573,7 +679,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
573
679
|
allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
|
|
574
680
|
})),
|
|
575
681
|
// Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
|
|
576
|
-
...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
|
|
682
|
+
...[createCloudAiLocalBashTool(), ...createCloudAiLocalTools(), ...createCloudAiCodeTools()].filter(
|
|
577
683
|
(tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
|
|
578
684
|
),
|
|
579
685
|
]
|
|
@@ -825,7 +931,22 @@ export class AiTurnExecutor {
|
|
|
825
931
|
const skillSubject =
|
|
826
932
|
defaultToolSource && resolved.profile.capabilities.includes("tools") ? accessSubjectForActor(material.actor) : null;
|
|
827
933
|
const availableSkills = skillSubject ? (await aiSkills.list(skillSubject)).filter((skill) => skill.enabled) : [];
|
|
828
|
-
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
|
+
);
|
|
829
950
|
const projectFiles =
|
|
830
951
|
project && resolvedProjectId && projectSubject
|
|
831
952
|
? {
|
|
@@ -854,7 +975,7 @@ export class AiTurnExecutor {
|
|
|
854
975
|
...material.tools,
|
|
855
976
|
...(memoryActive ? [createCloudAiMemoryTool(query)] : []),
|
|
856
977
|
...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject)] : []),
|
|
857
|
-
...(
|
|
978
|
+
...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills)] : []),
|
|
858
979
|
...(config.project && resolvedProjectId && projectSubject
|
|
859
980
|
? [
|
|
860
981
|
createCloudAiSearchProjectTool(resolvedProjectId, projectSubject),
|
|
@@ -887,7 +1008,13 @@ export class AiTurnExecutor {
|
|
|
887
1008
|
// Built-ins that exist but this turn does not offer: client tools without their client, tools a
|
|
888
1009
|
// task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
|
|
889
1010
|
const unofferedTools = [
|
|
890
|
-
...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
|
+
]),
|
|
891
1018
|
].filter((name) => !offeredToolNames.has(name));
|
|
892
1019
|
const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
|
|
893
1020
|
const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
|
|
@@ -900,9 +1027,14 @@ export class AiTurnExecutor {
|
|
|
900
1027
|
});
|
|
901
1028
|
}
|
|
902
1029
|
|
|
1030
|
+
const approvalTargets = new Map<string, AiApprovalTarget>();
|
|
1031
|
+
const linkedApprovals = new Map<string, string>();
|
|
1032
|
+
const freshApprovalRequired = new Set<string>();
|
|
903
1033
|
const dynamicToolRuntimeContext = {
|
|
904
1034
|
turnId,
|
|
905
1035
|
reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
|
|
1036
|
+
reportWebsiteReceipts: (callId: string, receipts: AiWebsiteReceipt[]) => pipeline.reportWebsiteReceipts(callId, receipts),
|
|
1037
|
+
localToolsEnabled: activeTools.some((tool) => tool.def.name === "local_upload"),
|
|
906
1038
|
attachedFilePaths: new Set(config.files?.attached.map((file) => file.path) ?? []),
|
|
907
1039
|
allowedDataBoundaries: material.modelPolicy?.allowedDataBoundaries,
|
|
908
1040
|
projectFiles,
|
|
@@ -910,6 +1042,35 @@ export class AiTurnExecutor {
|
|
|
910
1042
|
selectedModel: resolved,
|
|
911
1043
|
locale: promptLocale,
|
|
912
1044
|
timeZone,
|
|
1045
|
+
linkApproval: (approvalCallId: string, approvalId: string) => {
|
|
1046
|
+
linkedApprovals.set(approvalCallId, approvalId);
|
|
1047
|
+
},
|
|
1048
|
+
consumeApproval: async (approvalCallId: string, input: unknown): Promise<AiApprovalUse> => {
|
|
1049
|
+
const pending = await aiConversations.getPendingTurnAction({ conversationId, turnId, callId: approvalCallId });
|
|
1050
|
+
if (!pending?.approvalId) return "approved";
|
|
1051
|
+
const result = await approvals.consume(pending.approvalId, {
|
|
1052
|
+
input,
|
|
1053
|
+
review: approvalReviewForCallId(capabilityActionReviews, approvalCallId) ?? null,
|
|
1054
|
+
requester: { kind: "assistant_turn", ref: `${turnId}/${approvalCallId}` },
|
|
1055
|
+
});
|
|
1056
|
+
if (result.ok) return "approved";
|
|
1057
|
+
if (result.code === "ALREADY_CONSUMED") {
|
|
1058
|
+
// A later question of the same call proves this run resumes after it, not after a crash during the effect.
|
|
1059
|
+
const [open, resolved] = await Promise.all([
|
|
1060
|
+
aiConversations.listPendingActionRecords({ conversationId, turnId }),
|
|
1061
|
+
aiConversations.listResolvedPendingActions({ conversationId, turnId }),
|
|
1062
|
+
]);
|
|
1063
|
+
return aiApprovalAskedLater(
|
|
1064
|
+
approvalCallId,
|
|
1065
|
+
[...open, ...resolved].map((action) => action.callId),
|
|
1066
|
+
)
|
|
1067
|
+
? "approved"
|
|
1068
|
+
: "used";
|
|
1069
|
+
}
|
|
1070
|
+
freshApprovalRequired.add(customApprovalParentCallId(approvalCallId) ?? approvalCallId);
|
|
1071
|
+
return "changed";
|
|
1072
|
+
},
|
|
1073
|
+
describeApproval: (approvalCallId: string, target: AiApprovalTarget) => approvalTargets.set(approvalCallId, target),
|
|
913
1074
|
};
|
|
914
1075
|
const prepared = prepareAiTools({
|
|
915
1076
|
tools: activeTools,
|
|
@@ -920,6 +1081,7 @@ export class AiTurnExecutor {
|
|
|
920
1081
|
let backgroundError: string | null = null;
|
|
921
1082
|
const rememberableCapabilityApprovals = new Map<string, string>();
|
|
922
1083
|
const capabilityActionReviews = new Map<string, CapabilityActionReview>();
|
|
1084
|
+
pipeline.setApprovalTargets(approvalTargets);
|
|
923
1085
|
pipeline.setFrontendModes(prepared.frontendModes);
|
|
924
1086
|
pipeline.setCanonicalNames(prepared.canonicalNames);
|
|
925
1087
|
pipeline.setApprovalPolicies(prepared.approvalPolicies);
|
|
@@ -989,7 +1151,7 @@ export class AiTurnExecutor {
|
|
|
989
1151
|
await pipeline.emitBaseline();
|
|
990
1152
|
}
|
|
991
1153
|
|
|
992
|
-
const appliedSteers: AiTurnSteer[] = [];
|
|
1154
|
+
const appliedSteers: (AiTurnSteer | null)[] = [];
|
|
993
1155
|
|
|
994
1156
|
let mandatePolicy: MandatePolicyV1 | null | undefined;
|
|
995
1157
|
if (config.background && config.mandate) {
|
|
@@ -1043,6 +1205,7 @@ export class AiTurnExecutor {
|
|
|
1043
1205
|
conversationId,
|
|
1044
1206
|
actor: capabilityAuthority?.actor ?? toolActor,
|
|
1045
1207
|
staticTools: activeTools,
|
|
1208
|
+
...(activeTools.some((tool) => tool.def.name === "load_skill") ? { skillNames: availableSkills.map((skill) => skill.name) } : {}),
|
|
1046
1209
|
allowedTools,
|
|
1047
1210
|
mandatePolicy,
|
|
1048
1211
|
unofferedTools,
|
|
@@ -1151,8 +1314,8 @@ export class AiTurnExecutor {
|
|
|
1151
1314
|
);
|
|
1152
1315
|
const chat = await aiConversations.getConversation({ conversationId });
|
|
1153
1316
|
const workingPlan = chat?.todoPlan;
|
|
1154
|
-
const skillCatalogOffered = activeTools.some((tool) => tool.def.name
|
|
1155
|
-
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);
|
|
1156
1319
|
// Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
|
|
1157
1320
|
// attempt after an approval or a scheduled run gets neither.
|
|
1158
1321
|
let guidance: AiTurnGuidance = {};
|
|
@@ -1181,7 +1344,16 @@ export class AiTurnExecutor {
|
|
|
1181
1344
|
}
|
|
1182
1345
|
const systemPrompt = composeAiSystemPrompt({
|
|
1183
1346
|
globalInstructions: settings.globalInstructions,
|
|
1184
|
-
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),
|
|
1185
1357
|
turnInstructions: [
|
|
1186
1358
|
material.systemPrompt,
|
|
1187
1359
|
audioUnavailableInstruction,
|
|
@@ -1217,8 +1389,9 @@ export class AiTurnExecutor {
|
|
|
1217
1389
|
skillCreatorAvailable,
|
|
1218
1390
|
recentWork: guidance.recentWork,
|
|
1219
1391
|
});
|
|
1220
|
-
// The turn policy
|
|
1392
|
+
// The turn policy and repair budget count the whole turn, including rounds that compaction archived.
|
|
1221
1393
|
const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
|
|
1394
|
+
const malformedToolCallRepair = createMalformedToolCallRepair(countMalformedToolCallRepairs(turnMessages));
|
|
1222
1395
|
const turnBlocks = buildBlocksFromMessages(turnMessages);
|
|
1223
1396
|
const providerNamesByCallId = new Map(
|
|
1224
1397
|
turnMessages.flatMap(({ message }) =>
|
|
@@ -1261,7 +1434,11 @@ export class AiTurnExecutor {
|
|
|
1261
1434
|
runBudgetMs: claim.turn.runBudgetMs ?? null,
|
|
1262
1435
|
finishedToolCalls: turnBlocks
|
|
1263
1436
|
.slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
|
|
1264
|
-
.flatMap((block) =>
|
|
1437
|
+
.flatMap((block) =>
|
|
1438
|
+
block.kind === "tool" && !isMalformedToolCallId(block.callId)
|
|
1439
|
+
? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }]
|
|
1440
|
+
: [],
|
|
1441
|
+
),
|
|
1265
1442
|
onDecision: (decision) =>
|
|
1266
1443
|
decision.kind === "hint"
|
|
1267
1444
|
? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
|
|
@@ -1271,21 +1448,20 @@ export class AiTurnExecutor {
|
|
|
1271
1448
|
agentId: "cloud",
|
|
1272
1449
|
loopId: turnId,
|
|
1273
1450
|
...(isFresh ? { input: turnInput } : {}),
|
|
1274
|
-
provider: turnPolicy.provider,
|
|
1451
|
+
provider: withMalformedToolCallRepair(turnPolicy.provider, malformedToolCallRepair),
|
|
1275
1452
|
systemPrompt,
|
|
1276
|
-
store,
|
|
1277
|
-
steering:
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
turnPolicy.noteSteering
|
|
1287
|
-
|
|
1288
|
-
},
|
|
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
|
+
}),
|
|
1289
1465
|
tools: async () => {
|
|
1290
1466
|
try {
|
|
1291
1467
|
return await turnPolicy.tools();
|
|
@@ -1332,6 +1508,9 @@ export class AiTurnExecutor {
|
|
|
1332
1508
|
allowRememberedApprovals: aiTurnAllowsRememberedApprovals(config),
|
|
1333
1509
|
rememberableCapabilityApprovals,
|
|
1334
1510
|
capabilityActionReviews,
|
|
1511
|
+
approvalTargets,
|
|
1512
|
+
linkedApprovals,
|
|
1513
|
+
freshApprovalRequired,
|
|
1335
1514
|
appliedSteers,
|
|
1336
1515
|
noteToolRound: turnPolicy.noteToolRound,
|
|
1337
1516
|
noteToolCall: turnPolicy.noteToolCall,
|
|
@@ -1401,7 +1580,10 @@ export class AiTurnExecutor {
|
|
|
1401
1580
|
allowRememberedApprovals: boolean;
|
|
1402
1581
|
rememberableCapabilityApprovals: ReadonlyMap<string, string>;
|
|
1403
1582
|
capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
|
|
1404
|
-
|
|
1583
|
+
approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
|
|
1584
|
+
linkedApprovals: ReadonlyMap<string, string>;
|
|
1585
|
+
freshApprovalRequired?: ReadonlySet<string>;
|
|
1586
|
+
appliedSteers: (AiTurnSteer | null)[];
|
|
1405
1587
|
noteToolRound: () => void;
|
|
1406
1588
|
noteToolCall: (call: AiTurnPolicyToolCall) => void;
|
|
1407
1589
|
/**
|
|
@@ -1423,6 +1605,7 @@ export class AiTurnExecutor {
|
|
|
1423
1605
|
allowRememberedApprovals,
|
|
1424
1606
|
rememberableCapabilityApprovals,
|
|
1425
1607
|
capabilityActionReviews,
|
|
1608
|
+
approvalTargets,
|
|
1426
1609
|
appliedSteers,
|
|
1427
1610
|
noteToolRound,
|
|
1428
1611
|
noteToolCall,
|
|
@@ -1446,7 +1629,11 @@ export class AiTurnExecutor {
|
|
|
1446
1629
|
allowRememberedApprovals,
|
|
1447
1630
|
rememberableCapabilityApprovals,
|
|
1448
1631
|
capabilityActionReviews,
|
|
1632
|
+
approvalTargets,
|
|
1633
|
+
linkedApprovals: input.linkedApprovals,
|
|
1634
|
+
freshApprovalRequired: input.freshApprovalRequired,
|
|
1449
1635
|
onBackgroundBlocked: input.onBackgroundBlocked,
|
|
1636
|
+
signal: abortController.signal,
|
|
1450
1637
|
});
|
|
1451
1638
|
if (suspended) {
|
|
1452
1639
|
abortController.abort();
|
|
@@ -1549,7 +1736,11 @@ export class AiTurnExecutor {
|
|
|
1549
1736
|
allowRememberedApprovals: boolean;
|
|
1550
1737
|
rememberableCapabilityApprovals: ReadonlyMap<string, string>;
|
|
1551
1738
|
capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
|
|
1739
|
+
approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
|
|
1740
|
+
linkedApprovals: ReadonlyMap<string, string>;
|
|
1741
|
+
freshApprovalRequired?: ReadonlySet<string>;
|
|
1552
1742
|
onBackgroundBlocked?: (message: string) => void;
|
|
1743
|
+
signal: AbortSignal;
|
|
1553
1744
|
}): Promise<boolean> {
|
|
1554
1745
|
const {
|
|
1555
1746
|
event,
|
|
@@ -1562,6 +1753,7 @@ export class AiTurnExecutor {
|
|
|
1562
1753
|
allowRememberedApprovals,
|
|
1563
1754
|
rememberableCapabilityApprovals,
|
|
1564
1755
|
capabilityActionReviews,
|
|
1756
|
+
approvalTargets,
|
|
1565
1757
|
} = input;
|
|
1566
1758
|
const approvalPolicy = prepared.approvalPolicies.get(event.name);
|
|
1567
1759
|
const toolName = prepared.canonicalNames.get(event.name) ?? event.name;
|
|
@@ -1571,8 +1763,13 @@ export class AiTurnExecutor {
|
|
|
1571
1763
|
event.kind === "custom_approval"
|
|
1572
1764
|
? rememberableCapabilityApprovals.get(customApprovalParentCallId(event.callId) ?? event.callId)
|
|
1573
1765
|
: undefined;
|
|
1574
|
-
|
|
1575
|
-
|
|
1766
|
+
// A tool that names what it asks for (a code run's HTTP request or Action, a web page to read) already looked up
|
|
1767
|
+
// its own remembered approvals under its own rules; the approval is remembered for that target, not the tool.
|
|
1768
|
+
const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
|
|
1769
|
+
const approvalScope = target?.approvalScope ?? capabilityApprovalScope ?? aiToolApprovalScope(toolName, approvalPolicy);
|
|
1770
|
+
const allowAlways =
|
|
1771
|
+
allowRememberedApprovals && (target ? target.always : capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy));
|
|
1772
|
+
const allowChat = allowRememberedApprovals && (target !== undefined || allowAlways);
|
|
1576
1773
|
|
|
1577
1774
|
// Display-only client_view tools (cards, charts) need neither a browser nor input: Cloud answers them itself, in
|
|
1578
1775
|
// background runs too, whose transcript shows them later. The block goes from running straight to completed:
|
|
@@ -1609,8 +1806,15 @@ export class AiTurnExecutor {
|
|
|
1609
1806
|
}
|
|
1610
1807
|
|
|
1611
1808
|
// Remembered approvals resolve inline too.
|
|
1612
|
-
if (
|
|
1613
|
-
|
|
1809
|
+
if (
|
|
1810
|
+
event.kind !== "client_tool" &&
|
|
1811
|
+
!target &&
|
|
1812
|
+
allowAlways &&
|
|
1813
|
+
approvalContext &&
|
|
1814
|
+
// A stale decided snapshot needs another human decision, even if the first response also remembered it.
|
|
1815
|
+
!input.freshApprovalRequired?.has(customApprovalParentCallId(event.callId) ?? event.callId)
|
|
1816
|
+
) {
|
|
1817
|
+
const remembered = await hasRememberedAiToolApproval(approvalContext, { toolName, approvalScope, conversationId }).catch(() => false);
|
|
1614
1818
|
if (remembered) {
|
|
1615
1819
|
await aiToolAudit
|
|
1616
1820
|
.noteApprovalResolved({ turnId, callId: event.callId, approvalState: "approved_by_preference" })
|
|
@@ -1620,7 +1824,40 @@ export class AiTurnExecutor {
|
|
|
1620
1824
|
}
|
|
1621
1825
|
}
|
|
1622
1826
|
|
|
1827
|
+
const review = event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined;
|
|
1828
|
+
let approvalId: string | undefined;
|
|
1829
|
+
let presented = event;
|
|
1830
|
+
if (event.kind !== "client_tool") {
|
|
1831
|
+
const conversation = await aiConversations.getConversation({ conversationId });
|
|
1832
|
+
const owner = conversation?.createdByUserId;
|
|
1833
|
+
if (!owner) throw new Error("Approval requires a conversation owner");
|
|
1834
|
+
const linked = input.linkedApprovals.get(event.callId);
|
|
1835
|
+
if (linked) {
|
|
1836
|
+
const record = await approvals.get(linked);
|
|
1837
|
+
if (!record || record.status !== "pending" || record.requesterKind !== "studio_app" || record.approverUserId !== owner)
|
|
1838
|
+
throw new Error("Approval does not belong to an open Studio request of the conversation owner");
|
|
1839
|
+
approvalId = linked;
|
|
1840
|
+
// The card shows the question the effect's own record holds, never the text the relay sent.
|
|
1841
|
+
presented = { ...event, message: record.sentence };
|
|
1842
|
+
} else {
|
|
1843
|
+
const record = await approvals.request({
|
|
1844
|
+
...approvalScopeFromToolName(toolName),
|
|
1845
|
+
approvalScope,
|
|
1846
|
+
requester: { kind: "assistant_turn", ref: `${turnId}/${event.callId}`, appId: "assistant", title: conversation.title },
|
|
1847
|
+
subjectId: owner,
|
|
1848
|
+
approverUserId: owner,
|
|
1849
|
+
assurance: approvalAssurance(target !== undefined || capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy)),
|
|
1850
|
+
sentence: event.message ?? toolName,
|
|
1851
|
+
review,
|
|
1852
|
+
input: event.args,
|
|
1853
|
+
expiresInMs: AI_ACTION_BUDGET_MS,
|
|
1854
|
+
});
|
|
1855
|
+
if (!record.ok) throw new Error("This approval was already decided or has expired.");
|
|
1856
|
+
approvalId = record.id;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1623
1859
|
await aiConversations.savePendingTurnAction({
|
|
1860
|
+
approvalId,
|
|
1624
1861
|
turnId,
|
|
1625
1862
|
conversationId,
|
|
1626
1863
|
callId: event.callId,
|
|
@@ -1628,10 +1865,12 @@ export class AiTurnExecutor {
|
|
|
1628
1865
|
status: "pending",
|
|
1629
1866
|
name: toolName,
|
|
1630
1867
|
args: event.args,
|
|
1631
|
-
message:
|
|
1632
|
-
review
|
|
1868
|
+
message: presented.message,
|
|
1869
|
+
review,
|
|
1633
1870
|
approvalScope,
|
|
1634
1871
|
allowAlways,
|
|
1872
|
+
allowChat,
|
|
1873
|
+
...(target ? { rememberToolName: target.toolName } : {}),
|
|
1635
1874
|
frontendMode,
|
|
1636
1875
|
resolvedEvent: null,
|
|
1637
1876
|
});
|
|
@@ -1653,7 +1892,33 @@ export class AiTurnExecutor {
|
|
|
1653
1892
|
.catch(() => undefined);
|
|
1654
1893
|
}
|
|
1655
1894
|
|
|
1656
|
-
await pipeline.apply(
|
|
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
|
+
}
|
|
1657
1922
|
const suspended = await aiConversations.suspendTurn({
|
|
1658
1923
|
conversationId,
|
|
1659
1924
|
turnId,
|
|
@@ -1663,6 +1928,16 @@ export class AiTurnExecutor {
|
|
|
1663
1928
|
waitingBudgetMs: AI_ACTION_BUDGET_MS,
|
|
1664
1929
|
});
|
|
1665
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
|
+
}
|
|
1666
1941
|
return suspended;
|
|
1667
1942
|
}
|
|
1668
1943
|
|
|
@@ -1871,7 +2146,9 @@ class StreamPipeline {
|
|
|
1871
2146
|
this.blocks = this.allowRememberedApprovals
|
|
1872
2147
|
? blocks
|
|
1873
2148
|
: blocks.map((block) =>
|
|
1874
|
-
block.kind === "tool" && block.approval
|
|
2149
|
+
block.kind === "tool" && block.approval
|
|
2150
|
+
? { ...block, approval: { ...block.approval, allowAlways: false, allowChat: false } }
|
|
2151
|
+
: block,
|
|
1875
2152
|
);
|
|
1876
2153
|
}
|
|
1877
2154
|
|
|
@@ -1895,6 +2172,10 @@ class StreamPipeline {
|
|
|
1895
2172
|
this.mapper.setApprovalPolicies(policies);
|
|
1896
2173
|
}
|
|
1897
2174
|
|
|
2175
|
+
setApprovalTargets(targets: ReadonlyMap<string, AiApprovalTarget>): void {
|
|
2176
|
+
this.mapper.setApprovalTargets(targets);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
1898
2179
|
setRejectedCallIds(callIds: Set<string>): void {
|
|
1899
2180
|
this.mapper.setRejectedCallIds(callIds);
|
|
1900
2181
|
}
|
|
@@ -1913,6 +2194,8 @@ class StreamPipeline {
|
|
|
1913
2194
|
}
|
|
1914
2195
|
|
|
1915
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);
|
|
1916
2199
|
const seq = this.nextSeq();
|
|
1917
2200
|
await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
|
|
1918
2201
|
await this.maybeSnapshot();
|
|
@@ -1950,6 +2233,20 @@ class StreamPipeline {
|
|
|
1950
2233
|
await this.maybeSnapshot();
|
|
1951
2234
|
}
|
|
1952
2235
|
|
|
2236
|
+
/**
|
|
2237
|
+
* Shows requests a website approval let through on the call's block, and records them on its message before they go
|
|
2238
|
+
* out, so the chat keeps each receipt and its revoke action whether the call succeeds, fails, or the turn stops.
|
|
2239
|
+
*/
|
|
2240
|
+
async reportWebsiteReceipts(callId: string, receipts: AiWebsiteReceipt[]): Promise<void> {
|
|
2241
|
+
if (!receipts.length) return;
|
|
2242
|
+
await recordAiWebsiteReceipts({ conversationId: this.conversationId, turnId: this.turnId, callId, receipts });
|
|
2243
|
+
const merged = this.mapper.addReceipts(callId, receipts);
|
|
2244
|
+
const block = this.blocks.find((block) => block.kind === "tool" && block.id === toolBlockId(callId));
|
|
2245
|
+
if (block?.kind !== "tool") return;
|
|
2246
|
+
await this.emitOp({ type: "block_set", block: { ...block, receipts: merged } });
|
|
2247
|
+
await this.maybeSnapshot();
|
|
2248
|
+
}
|
|
2249
|
+
|
|
1953
2250
|
async reportToolProgress(callId: string, progress: string): Promise<void> {
|
|
1954
2251
|
const block = this.blocks.find((block) => block.kind === "tool" && block.callId === callId);
|
|
1955
2252
|
if (!block || block.kind !== "tool" || block.progress === progress) return;
|
|
@@ -2040,6 +2337,7 @@ class StreamPipeline {
|
|
|
2040
2337
|
}
|
|
2041
2338
|
|
|
2042
2339
|
export const __aiExecutorTest = {
|
|
2340
|
+
createChatSteering,
|
|
2043
2341
|
indexConversationToolSource,
|
|
2044
2342
|
StreamPipeline,
|
|
2045
2343
|
createEventMapper,
|