@k2b/cloud 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +1 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +115 -33
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +83 -48
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +330 -2
- package/src/ai/chat/presentation.tsx +278 -104
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +644 -0
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +42 -6
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +177 -80
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +7 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/protocol.ts +26 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +16 -7
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +202 -59
- package/src/ai/stream.ts +182 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +36 -5
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +4 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +69 -0
package/src/ai/executor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent
|
|
1
|
+
import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
|
|
2
2
|
import { compact, nessi } from "@k2b/nessi";
|
|
3
3
|
import { listCapabilities } from "../_internal/registry";
|
|
4
4
|
import type { CapabilityActionReview } from "../contracts/capabilities";
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
hasRememberedAiToolApproval,
|
|
16
16
|
} from "./approvals";
|
|
17
17
|
import { isAssistantChatTurn } from "./assistant-models";
|
|
18
|
+
import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
|
|
18
19
|
import { createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
19
20
|
import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
|
|
20
21
|
import { aiChatTasks } from "./chat-tasks";
|
|
@@ -29,6 +30,7 @@ import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
|
|
|
29
30
|
import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
|
|
30
31
|
import { aiProjects } from "./projects";
|
|
31
32
|
import {
|
|
33
|
+
AI_TURN_LEASE_MS,
|
|
32
34
|
type AiTurnBlock,
|
|
33
35
|
type AiWireEvent,
|
|
34
36
|
applyWireEventToBlocks,
|
|
@@ -40,6 +42,7 @@ import {
|
|
|
40
42
|
streamBlockId,
|
|
41
43
|
toolBlockId,
|
|
42
44
|
} from "./protocol";
|
|
45
|
+
import { retryTransientProviderErrors } from "./provider-retry";
|
|
43
46
|
import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
|
|
44
47
|
import { collectConversationResourceObservations } from "./resource-refs";
|
|
45
48
|
import { AiRunTimeout } from "./run-timeout";
|
|
@@ -48,11 +51,13 @@ import { selectAiSkillCatalog } from "./skill-catalog";
|
|
|
48
51
|
import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
|
|
49
52
|
import { aiSkills } from "./skills";
|
|
50
53
|
import { aiConversations } from "./store";
|
|
51
|
-
import { publishAiWireEvent } from "./stream";
|
|
54
|
+
import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
|
|
52
55
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
53
56
|
import { aiToolAudit } from "./tool-audit";
|
|
57
|
+
import { acceptCanonicalToolNames } from "./tool-call-names";
|
|
54
58
|
import { resolveAiToolResultMaxChars } from "./tool-result-budget";
|
|
55
59
|
import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
60
|
+
import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
|
|
56
61
|
import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
|
|
57
62
|
import type {
|
|
58
63
|
AiChatTurnRunConfig,
|
|
@@ -71,13 +76,9 @@ import { validateAiTurnRequest } from "./validate";
|
|
|
71
76
|
|
|
72
77
|
const log = logger("ai:executor");
|
|
73
78
|
|
|
74
|
-
const AI_TURN_LEASE_MS = 45_000;
|
|
75
79
|
const AI_COALESCE_MS = 25;
|
|
76
80
|
const AI_COALESCE_MAX_CHARS = 512;
|
|
77
|
-
const AI_SNAPSHOT_INTERVAL_MS = 1_000;
|
|
78
81
|
const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
|
|
79
|
-
const AI_FINAL_TOOL_ROUND_PROMPT = `# Final response
|
|
80
|
-
The configured tool-round budget has been reached, so no more tools are available in this turn. Answer the user's request now with the best result supported by the evidence already gathered. State any material uncertainty or incomplete part clearly.`;
|
|
81
82
|
|
|
82
83
|
const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
|
|
83
84
|
const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
|
|
@@ -92,50 +93,6 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
|
|
|
92
93
|
};
|
|
93
94
|
};
|
|
94
95
|
|
|
95
|
-
const applyToolRoundPolicy = (input: {
|
|
96
|
-
provider: Provider;
|
|
97
|
-
tools: Tool[] | ToolResolver;
|
|
98
|
-
maxToolRounds?: number;
|
|
99
|
-
issuedToolRounds: number;
|
|
100
|
-
completedToolRounds: number;
|
|
101
|
-
}): { provider: Provider; tools: Tool[] | ToolResolver; maxTurns?: number; noteToolRound: () => void } => {
|
|
102
|
-
const limit = Math.floor(input.maxToolRounds ?? 0);
|
|
103
|
-
if (limit <= 0) return { provider: input.provider, tools: input.tools, noteToolRound: () => undefined };
|
|
104
|
-
|
|
105
|
-
const issuedAtStart = Math.max(0, Math.floor(input.issuedToolRounds));
|
|
106
|
-
let completed = Math.max(0, Math.floor(input.completedToolRounds));
|
|
107
|
-
let finalSynthesis = completed >= limit;
|
|
108
|
-
const tools: ToolResolver = async () => {
|
|
109
|
-
finalSynthesis = completed >= limit;
|
|
110
|
-
if (finalSynthesis) return [];
|
|
111
|
-
return typeof input.tools === "function" ? input.tools() : input.tools;
|
|
112
|
-
};
|
|
113
|
-
const provider: Provider = {
|
|
114
|
-
name: input.provider.name,
|
|
115
|
-
family: input.provider.family,
|
|
116
|
-
model: input.provider.model,
|
|
117
|
-
contextWindow: input.provider.contextWindow,
|
|
118
|
-
capabilities: input.provider.capabilities,
|
|
119
|
-
complete: (request) => input.provider.complete(request),
|
|
120
|
-
stream: async function* (request) {
|
|
121
|
-
yield* input.provider.stream(
|
|
122
|
-
finalSynthesis ? { ...request, systemPrompt: `${request.systemPrompt ?? ""}\n\n${AI_FINAL_TOOL_ROUND_PROMPT}`.trim() } : request,
|
|
123
|
-
);
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// Nessi checks this before provider calls. The extra round is the tool-free
|
|
128
|
-
// synthesis call after the last allowed tool-using round.
|
|
129
|
-
return {
|
|
130
|
-
provider,
|
|
131
|
-
tools,
|
|
132
|
-
maxTurns: Math.max(1, limit - issuedAtStart + 1),
|
|
133
|
-
noteToolRound: () => {
|
|
134
|
-
completed += 1;
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
};
|
|
138
|
-
|
|
139
96
|
const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
|
|
140
97
|
try {
|
|
141
98
|
await aiConversations.indexConversationResources(input);
|
|
@@ -161,6 +118,10 @@ const recordMemoryWorkflowEvidence = async (input: Parameters<typeof recordAiMem
|
|
|
161
118
|
}
|
|
162
119
|
};
|
|
163
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Indexes what a finished tool call read or delivered; never throws. A browser tool ends here too, once the turn
|
|
123
|
+
* continues with the result the browser reported.
|
|
124
|
+
*/
|
|
164
125
|
const indexConversationToolSource = async (input: {
|
|
165
126
|
conversationId: string;
|
|
166
127
|
turnId: string;
|
|
@@ -183,10 +144,45 @@ const indexConversationToolSource = async (input: {
|
|
|
183
144
|
});
|
|
184
145
|
}
|
|
185
146
|
let source: Parameters<typeof aiConversations.indexConversationSource>[0]["source"] | null = null;
|
|
147
|
+
const args = typeof input.args === "object" && input.args !== null ? (input.args as Record<string, unknown>) : {};
|
|
148
|
+
const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
|
149
|
+
const result = typeof input.result === "object" && input.result !== null ? (input.result as Record<string, unknown>) : {};
|
|
186
150
|
if (input.name === "web_search") {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
151
|
+
const query = text(args.query);
|
|
152
|
+
// One entry per query: each search is something the user may want to see that the assistant looked up.
|
|
153
|
+
const key = query ? `web_search:${query.replace(/\s+/gu, " ").toLocaleLowerCase()}` : "web_search";
|
|
154
|
+
source = { kind: "activity", key, title: query || "Web search", preview: "Searched the web", icon: "ti ti-world" };
|
|
155
|
+
} else if (input.name === "present" && text(result.path)) {
|
|
156
|
+
const path = text(result.path);
|
|
157
|
+
source = {
|
|
158
|
+
kind: "result",
|
|
159
|
+
key: path,
|
|
160
|
+
title: text(args.title) || path.slice(path.lastIndexOf("/") + 1),
|
|
161
|
+
preview: text(args.description) || undefined,
|
|
162
|
+
icon: "ti ti-file",
|
|
163
|
+
};
|
|
164
|
+
} else if (
|
|
165
|
+
input.name === "code_open" &&
|
|
166
|
+
text(args.id) &&
|
|
167
|
+
typeof input.result === "object" &&
|
|
168
|
+
input.result !== null &&
|
|
169
|
+
!("error" in input.result)
|
|
170
|
+
) {
|
|
171
|
+
// The browser reports a failure as a result with an error, which reaches this point without isError.
|
|
172
|
+
source = {
|
|
173
|
+
kind: "result",
|
|
174
|
+
key: `assistant.artifact:${text(args.id)}`,
|
|
175
|
+
title: "Studio app",
|
|
176
|
+
icon: "ti ti-app-window",
|
|
177
|
+
ref: { type: "assistant.artifact", id: text(args.id) },
|
|
178
|
+
};
|
|
179
|
+
} else if (input.name === "code_present" && text(result.presentationId)) {
|
|
180
|
+
source = {
|
|
181
|
+
kind: "result",
|
|
182
|
+
key: `code_present:${input.callId}`,
|
|
183
|
+
title: text(args.title) || text(result.title) || "Visualization",
|
|
184
|
+
icon: "ti ti-chart-dots",
|
|
185
|
+
};
|
|
190
186
|
} else if (input.name === "web_extract" && typeof input.result === "object" && input.result !== null) {
|
|
191
187
|
const result = input.result as Record<string, unknown>;
|
|
192
188
|
if (typeof result.url === "string" && result.url.trim()) {
|
|
@@ -249,6 +245,8 @@ export type ExecutorConfig = {
|
|
|
249
245
|
validateTurn?: typeof validateAiTurnRequest;
|
|
250
246
|
/** Runs after the durable turn state and final wire event are flushed. */
|
|
251
247
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
|
|
248
|
+
/** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
|
|
249
|
+
providerRetryDelaysMs?: readonly number[];
|
|
252
250
|
};
|
|
253
251
|
|
|
254
252
|
type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
|
|
@@ -392,6 +390,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
392
390
|
const setRejectedCallIds = (items: Set<string>) => {
|
|
393
391
|
rejectedCallIds = items;
|
|
394
392
|
};
|
|
393
|
+
let approvedCallIds = new Set<string>();
|
|
394
|
+
const setApprovedCallIds = (items: Set<string>) => {
|
|
395
|
+
approvedCallIds = items;
|
|
396
|
+
};
|
|
395
397
|
/** nessi stream block ids (turn-scoped) that belong to tool_call blocks — their deltas are raw args JSON. */
|
|
396
398
|
const toolStreamIds = new Set<string>();
|
|
397
399
|
/** kind per open Cloud stream block id, for delta create-if-missing. */
|
|
@@ -414,6 +416,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
414
416
|
approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false } : approval,
|
|
415
417
|
frontendMode: patch.frontendMode ?? existing?.frontendMode,
|
|
416
418
|
presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
|
|
419
|
+
...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
|
|
417
420
|
};
|
|
418
421
|
toolBlocks.set(callId, block);
|
|
419
422
|
return { type: "block_set", block };
|
|
@@ -518,6 +521,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
518
521
|
setApprovalPolicies,
|
|
519
522
|
setApprovalReviews,
|
|
520
523
|
setRejectedCallIds,
|
|
524
|
+
setApprovedCallIds,
|
|
521
525
|
};
|
|
522
526
|
};
|
|
523
527
|
|
|
@@ -543,8 +547,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
543
547
|
source.kind === "default"
|
|
544
548
|
? [
|
|
545
549
|
...(await createConfiguredDefaultCloudAiTools()),
|
|
546
|
-
|
|
547
|
-
...createCloudAiCodeTools().filter(
|
|
550
|
+
// Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
|
|
551
|
+
...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
|
|
552
|
+
(tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
|
|
553
|
+
),
|
|
548
554
|
]
|
|
549
555
|
: [],
|
|
550
556
|
toolApprovalContext: config.toolApprovalContext,
|
|
@@ -662,6 +668,7 @@ export class AiTurnExecutor {
|
|
|
662
668
|
let resolvedProjectId: string | null = null;
|
|
663
669
|
let chatId = config.chatId ?? "";
|
|
664
670
|
let allowedTools: string[] | null = null;
|
|
671
|
+
let sourceToolNames: string[] = [];
|
|
665
672
|
try {
|
|
666
673
|
if (config.background && !config.mandate) throw new Error("Background execution requires its task mandate.");
|
|
667
674
|
const [nextMaterial, conversation] = await Promise.all([
|
|
@@ -672,6 +679,7 @@ export class AiTurnExecutor {
|
|
|
672
679
|
if (!conversation) throw new Error("Conversation is no longer available.");
|
|
673
680
|
allowedTools = conversation.allowedTools ?? null;
|
|
674
681
|
const allowed = allowedTools === null ? null : new Set(allowedTools);
|
|
682
|
+
sourceToolNames = material.tools.map((tool) => tool.def.name);
|
|
675
683
|
if (allowed) material.tools = material.tools.filter((tool) => allowed.has(tool.def.name));
|
|
676
684
|
chatId ||= conversation?.shortId ?? "";
|
|
677
685
|
if (config.project) {
|
|
@@ -793,6 +801,12 @@ export class AiTurnExecutor {
|
|
|
793
801
|
(tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
|
|
794
802
|
)
|
|
795
803
|
: [];
|
|
804
|
+
const offeredToolNames = new Set(activeTools.map((tool) => tool.def.name));
|
|
805
|
+
// Built-ins that exist but this turn does not offer: client tools without their client, tools a
|
|
806
|
+
// task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
|
|
807
|
+
const unofferedTools = [
|
|
808
|
+
...new Set([...sourceToolNames, ...runtimeTools.map((tool) => tool.def.name), ...CODE_RUNTIME_TOOL_NAMES, "local_bash"]),
|
|
809
|
+
].filter((name) => !offeredToolNames.has(name));
|
|
796
810
|
const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
|
|
797
811
|
const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
|
|
798
812
|
|
|
@@ -829,6 +843,7 @@ export class AiTurnExecutor {
|
|
|
829
843
|
pipeline.setApprovalPolicies(prepared.approvalPolicies);
|
|
830
844
|
const toolPresentations = new Map<string, AiToolPresentation>();
|
|
831
845
|
const rejectedToolCallIds = new Set<string>();
|
|
846
|
+
const approvedToolCallIds = new Set<string>();
|
|
832
847
|
pipeline.setPresentations(toolPresentations);
|
|
833
848
|
pipeline.setApprovalReviews(capabilityActionReviews);
|
|
834
849
|
let turnInput = config.input;
|
|
@@ -868,17 +883,19 @@ export class AiTurnExecutor {
|
|
|
868
883
|
turnInput,
|
|
869
884
|
toolPresentations,
|
|
870
885
|
rejectedToolCallIds,
|
|
886
|
+
approvedToolCallIds,
|
|
871
887
|
});
|
|
872
888
|
|
|
873
889
|
const { loopMessages, pendingRecords, resolvedRecords, turnSteers } =
|
|
874
890
|
attemptState ?? (await loadChatAttemptState(conversationId, turnId));
|
|
875
891
|
for (const action of resolvedRecords) {
|
|
876
|
-
if (action.resolvedEvent?.type !== "approval_response"
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
);
|
|
892
|
+
if (action.resolvedEvent?.type !== "approval_response") continue;
|
|
893
|
+
// A decision on a custom approval belongs to the call that asked for it.
|
|
894
|
+
const callId = action.kind === "custom_approval" ? (customApprovalParentCallId(action.callId) ?? action.callId) : action.callId;
|
|
895
|
+
(action.resolvedEvent.approved ? approvedToolCallIds : rejectedToolCallIds).add(callId);
|
|
880
896
|
}
|
|
881
897
|
pipeline.setRejectedCallIds(rejectedToolCallIds);
|
|
898
|
+
pipeline.setApprovedCallIds(approvedToolCallIds);
|
|
882
899
|
const assistantMessages = loopMessages.filter((message) => message.message.role !== "user");
|
|
883
900
|
const isFresh = assistantMessages.length === 0 && resolvedRecords.length === 0 && !skipResolvedActions;
|
|
884
901
|
|
|
@@ -898,6 +915,7 @@ export class AiTurnExecutor {
|
|
|
898
915
|
actor: capabilityAuthority?.actor ?? toolActor,
|
|
899
916
|
staticTools: activeTools,
|
|
900
917
|
allowedTools,
|
|
918
|
+
unofferedTools,
|
|
901
919
|
runtimeContext: dynamicToolRuntimeContext,
|
|
902
920
|
store: toolStore,
|
|
903
921
|
...(capabilityAuthority ? { listRegistry: listCapabilities } : {}),
|
|
@@ -1061,21 +1079,49 @@ export class AiTurnExecutor {
|
|
|
1061
1079
|
memory: memory?.text,
|
|
1062
1080
|
timeZone,
|
|
1063
1081
|
locale: promptLocale,
|
|
1082
|
+
interactive: !config.background,
|
|
1083
|
+
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
|
|
1064
1084
|
});
|
|
1065
|
-
|
|
1085
|
+
// The turn policy counts the whole turn, including rounds that compaction archived.
|
|
1086
|
+
const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
|
|
1087
|
+
const turnBlocks = buildBlocksFromMessages(turnMessages);
|
|
1088
|
+
const priorToolRounds = toolRoundState(turnMessages);
|
|
1066
1089
|
const quotaSubject = accessSubjectForActor(material.actor);
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1090
|
+
const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
|
|
1091
|
+
const turnPolicy = applyAiTurnPolicy({
|
|
1092
|
+
provider: acceptCanonicalToolNames(
|
|
1093
|
+
retryTransientProviderErrors(
|
|
1094
|
+
assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
|
|
1095
|
+
{
|
|
1096
|
+
deadline,
|
|
1097
|
+
delaysMs: this.config.providerRetryDelaysMs,
|
|
1098
|
+
onRetry: async ({ retry, delayMs, issue }) => {
|
|
1099
|
+
log.warn("AI provider call retried", { conversationId, turnId, retry, delayMs, kind: issue.kind, message: issue.message });
|
|
1100
|
+
await pipeline.emitProviderRetry();
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
),
|
|
1104
|
+
prepared.canonicalNames,
|
|
1105
|
+
),
|
|
1069
1106
|
tools,
|
|
1070
1107
|
maxToolRounds: resolved.profile.maxToolRounds,
|
|
1071
1108
|
issuedToolRounds: priorToolRounds.issued,
|
|
1072
1109
|
completedToolRounds: priorToolRounds.completed,
|
|
1110
|
+
deadline,
|
|
1111
|
+
runBudgetMs: claim.turn.runBudgetMs ?? null,
|
|
1112
|
+
finishedToolCalls: turnBlocks
|
|
1113
|
+
.slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
|
|
1114
|
+
.flatMap((block) => (block.kind === "tool" ? [block] : [])),
|
|
1115
|
+
onDecision: (decision) =>
|
|
1116
|
+
decision.kind === "hint"
|
|
1117
|
+
? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
|
|
1118
|
+
: log.info("AI turn answers without further tools", { conversationId, turnId, reason: decision.reason }),
|
|
1073
1119
|
});
|
|
1074
1120
|
const loop = nessi({
|
|
1075
1121
|
agentId: "cloud",
|
|
1076
1122
|
loopId: turnId,
|
|
1077
1123
|
...(isFresh ? { input: turnInput } : {}),
|
|
1078
|
-
provider:
|
|
1124
|
+
provider: turnPolicy.provider,
|
|
1079
1125
|
systemPrompt,
|
|
1080
1126
|
store,
|
|
1081
1127
|
steering: async ({ signal: steeringSignal }) => {
|
|
@@ -1086,12 +1132,15 @@ export class AiTurnExecutor {
|
|
|
1086
1132
|
leaseOwner: this.config.leaseOwner,
|
|
1087
1133
|
});
|
|
1088
1134
|
appliedSteers.push(...steers);
|
|
1089
|
-
|
|
1135
|
+
if (steers.length === 0) return undefined;
|
|
1136
|
+
turnPolicy.noteSteering();
|
|
1137
|
+
return steers.map((steer) => steer.text);
|
|
1090
1138
|
},
|
|
1091
|
-
tools:
|
|
1092
|
-
...(
|
|
1139
|
+
tools: turnPolicy.tools,
|
|
1140
|
+
...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
|
|
1093
1141
|
temperature: resolved.profile.temperature,
|
|
1094
1142
|
maxOutputTokens: resolved.profile.maxOutputTokens,
|
|
1143
|
+
reasoningEffort: resolved.profile.reasoningEffort,
|
|
1095
1144
|
coalesce: { ms: AI_COALESCE_MS, maxChars: AI_COALESCE_MAX_CHARS },
|
|
1096
1145
|
compact: config.background
|
|
1097
1146
|
? undefined
|
|
@@ -1127,7 +1176,8 @@ export class AiTurnExecutor {
|
|
|
1127
1176
|
rememberableCapabilityApprovals,
|
|
1128
1177
|
capabilityActionReviews,
|
|
1129
1178
|
appliedSteers,
|
|
1130
|
-
noteToolRound:
|
|
1179
|
+
noteToolRound: turnPolicy.noteToolRound,
|
|
1180
|
+
noteToolCall: turnPolicy.noteToolCall,
|
|
1131
1181
|
onBackgroundBlocked: (message) => {
|
|
1132
1182
|
backgroundError = message;
|
|
1133
1183
|
},
|
|
@@ -1193,6 +1243,7 @@ export class AiTurnExecutor {
|
|
|
1193
1243
|
capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
|
|
1194
1244
|
appliedSteers: AiTurnSteer[];
|
|
1195
1245
|
noteToolRound: () => void;
|
|
1246
|
+
noteToolCall: (call: AiTurnPolicyToolCall) => void;
|
|
1196
1247
|
onBackgroundBlocked?: (message: string) => void;
|
|
1197
1248
|
}): Promise<AttemptOutcome> {
|
|
1198
1249
|
const {
|
|
@@ -1208,6 +1259,7 @@ export class AiTurnExecutor {
|
|
|
1208
1259
|
capabilityActionReviews,
|
|
1209
1260
|
appliedSteers,
|
|
1210
1261
|
noteToolRound,
|
|
1262
|
+
noteToolCall,
|
|
1211
1263
|
} = input;
|
|
1212
1264
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1213
1265
|
let lastIssueMessage: string | null = null;
|
|
@@ -1266,6 +1318,12 @@ export class AiTurnExecutor {
|
|
|
1266
1318
|
.noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
|
|
1267
1319
|
.catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
|
|
1268
1320
|
const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
|
|
1321
|
+
// The policy keys calls by the name the model called, as the persisted calls it seeds from.
|
|
1322
|
+
noteToolCall(
|
|
1323
|
+
toolBlock?.kind === "tool"
|
|
1324
|
+
? { ...toolBlock, name: event.name }
|
|
1325
|
+
: { name: event.name, status: event.isError ? "failed" : "completed", result: event.result },
|
|
1326
|
+
);
|
|
1269
1327
|
await indexConversationToolSource({
|
|
1270
1328
|
conversationId,
|
|
1271
1329
|
turnId,
|
|
@@ -1597,6 +1655,9 @@ class StreamPipeline {
|
|
|
1597
1655
|
readonly timing: ReturnType<typeof createTurnTimingRecorder>;
|
|
1598
1656
|
private lastSnapshotAt = 0;
|
|
1599
1657
|
private snapshotDirty = false;
|
|
1658
|
+
private snapshotTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1659
|
+
/** The newest save of the live state. Saves run one after another, so an older one never lands last. */
|
|
1660
|
+
private saving: Promise<void> = Promise.resolve();
|
|
1600
1661
|
private chain: Promise<void> = Promise.resolve();
|
|
1601
1662
|
|
|
1602
1663
|
constructor(input: {
|
|
@@ -1628,6 +1689,8 @@ class StreamPipeline {
|
|
|
1628
1689
|
|
|
1629
1690
|
private nextSeq(): number {
|
|
1630
1691
|
this.seq += 1;
|
|
1692
|
+
// The saved state follows every event, so a reader that reloads it catches up with the live stream.
|
|
1693
|
+
this.snapshotDirty = true;
|
|
1631
1694
|
return this.seq;
|
|
1632
1695
|
}
|
|
1633
1696
|
|
|
@@ -1667,17 +1730,23 @@ class StreamPipeline {
|
|
|
1667
1730
|
this.mapper.setRejectedCallIds(callIds);
|
|
1668
1731
|
}
|
|
1669
1732
|
|
|
1733
|
+
setApprovedCallIds(callIds: Set<string>): void {
|
|
1734
|
+
this.mapper.setApprovedCallIds(callIds);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1670
1737
|
async emitBaseline(): Promise<void> {
|
|
1671
1738
|
for (const block of this.blocks) {
|
|
1672
1739
|
const seq = this.nextSeq();
|
|
1673
1740
|
await this.publish(this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent);
|
|
1674
1741
|
}
|
|
1675
|
-
|
|
1742
|
+
// A new attempt's baseline is saved at once: a stream that reloads the turn must not wait for the first model event.
|
|
1743
|
+
await this.maybeSnapshot();
|
|
1676
1744
|
}
|
|
1677
1745
|
|
|
1678
1746
|
async emitMessage(message: AiStoredMessage): Promise<void> {
|
|
1679
1747
|
const seq = this.nextSeq();
|
|
1680
1748
|
await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
|
|
1749
|
+
await this.maybeSnapshot();
|
|
1681
1750
|
}
|
|
1682
1751
|
|
|
1683
1752
|
async emitTurnStarted(modelProfileId: string): Promise<void> {
|
|
@@ -1737,24 +1806,36 @@ class StreamPipeline {
|
|
|
1737
1806
|
await this.publish(event);
|
|
1738
1807
|
}
|
|
1739
1808
|
|
|
1809
|
+
/**
|
|
1810
|
+
* Saves the live state at most once per interval. A change inside the interval is saved when it ends, even if no
|
|
1811
|
+
* event follows, so the saved state lags the live stream by at most one interval.
|
|
1812
|
+
*/
|
|
1740
1813
|
private async maybeSnapshot(): Promise<void> {
|
|
1741
1814
|
if (!this.snapshotDirty) return;
|
|
1742
|
-
|
|
1815
|
+
const wait = AI_LIVE_SNAPSHOT_INTERVAL_MS - (Date.now() - this.lastSnapshotAt);
|
|
1816
|
+
if (wait > 0) {
|
|
1817
|
+
this.snapshotTimer ??= setTimeout(() => {
|
|
1818
|
+
this.snapshotTimer = undefined;
|
|
1819
|
+
void this.maybeSnapshot();
|
|
1820
|
+
}, wait);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1743
1823
|
await this.persistSnapshot();
|
|
1744
1824
|
}
|
|
1745
1825
|
|
|
1746
1826
|
async persistSnapshot(): Promise<void> {
|
|
1827
|
+
this.cancelSnapshotTimer();
|
|
1747
1828
|
this.lastSnapshotAt = Date.now();
|
|
1748
1829
|
this.snapshotDirty = false;
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
leaseOwner: this.leaseOwner,
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1830
|
+
const blocks = this.blocks;
|
|
1831
|
+
const seq = this.seq;
|
|
1832
|
+
this.saving = this.saving.then(() =>
|
|
1833
|
+
aiConversations
|
|
1834
|
+
.saveTurnLiveState({ conversationId: this.conversationId, turnId: this.turnId, leaseOwner: this.leaseOwner, blocks, seq })
|
|
1835
|
+
.then(() => undefined)
|
|
1836
|
+
.catch(() => undefined),
|
|
1837
|
+
);
|
|
1838
|
+
await this.saving;
|
|
1758
1839
|
}
|
|
1759
1840
|
|
|
1760
1841
|
async emitError(message: string): Promise<void> {
|
|
@@ -1765,6 +1846,13 @@ class StreamPipeline {
|
|
|
1765
1846
|
await this.publish(event);
|
|
1766
1847
|
}
|
|
1767
1848
|
|
|
1849
|
+
/** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
|
|
1850
|
+
async emitProviderRetry(): Promise<void> {
|
|
1851
|
+
const seq = this.nextSeq();
|
|
1852
|
+
await this.publish(this.envelope({ type: "provider_retry" as const, seq }));
|
|
1853
|
+
await this.maybeSnapshot();
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1768
1856
|
async emitTurnFinished(status: "completed" | "failed" | "aborted", error: string | null): Promise<void> {
|
|
1769
1857
|
const seq = this.nextSeq();
|
|
1770
1858
|
await this.publish(this.envelope({ type: "turn_finished" as const, seq, status, error }) as AiWireEvent);
|
|
@@ -1775,15 +1863,24 @@ class StreamPipeline {
|
|
|
1775
1863
|
return this.ordered(() => publishAiWireEvent(event).catch(() => undefined));
|
|
1776
1864
|
}
|
|
1777
1865
|
|
|
1866
|
+
private cancelSnapshotTimer(): void {
|
|
1867
|
+
if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
|
|
1868
|
+
this.snapshotTimer = undefined;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Waits for every publish and save. The attempt ends here: a later save would overwrite what suspension or the next
|
|
1873
|
+
* attempt saved.
|
|
1874
|
+
*/
|
|
1778
1875
|
async flush(): Promise<void> {
|
|
1779
|
-
|
|
1876
|
+
this.cancelSnapshotTimer();
|
|
1877
|
+
await Promise.all([this.chain, this.saving]);
|
|
1780
1878
|
}
|
|
1781
1879
|
}
|
|
1782
1880
|
|
|
1783
1881
|
export const __aiExecutorTest = {
|
|
1784
1882
|
indexConversationToolSource,
|
|
1785
1883
|
StreamPipeline,
|
|
1786
|
-
applyToolRoundPolicy,
|
|
1787
1884
|
createEventMapper,
|
|
1788
1885
|
rebuildAttemptBaseline,
|
|
1789
1886
|
rebuildBlocksFromMessages,
|
package/src/ai/file-context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Message } from "@k2b/nessi";
|
|
2
2
|
import { canonicalizeAiAttachmentMarkers, parseAiAttachmentMarkers } from "./attachments";
|
|
3
|
-
import { aiFileStore } from "./files-store";
|
|
3
|
+
import { type AiFileStat, aiFileStore, isAiWorkingFilePath } from "./files-store";
|
|
4
4
|
import {
|
|
5
5
|
AI_FILE_MANIFEST_MAX_ITEMS,
|
|
6
6
|
AI_IMAGE_INPUT_MAX_BYTES,
|
|
@@ -52,9 +52,21 @@ export const snapshotAiConversationFiles = async (
|
|
|
52
52
|
throw new Error("Attached images exceed the 40 MB total image input limit.");
|
|
53
53
|
}
|
|
54
54
|
if (all.length === 0 && attached.length === 0) return undefined;
|
|
55
|
-
return { attached,
|
|
55
|
+
return { attached, ...aiConversationFileManifest(all) };
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The chat files a turn lists for the model, at most `AI_FILE_MANIFEST_MAX_ITEMS`. Working files below `/temp/` come
|
|
60
|
+
* last, so intermediate steps never push uploads and results out of the list.
|
|
61
|
+
*/
|
|
62
|
+
export const aiConversationFileManifest = (files: readonly AiFileStat[]): { available: AiFileStat[]; total: number } => ({
|
|
63
|
+
available: [...files.filter((file) => !isAiWorkingFilePath(file.path)), ...files.filter((file) => isAiWorkingFilePath(file.path))].slice(
|
|
64
|
+
0,
|
|
65
|
+
AI_FILE_MANIFEST_MAX_ITEMS,
|
|
66
|
+
),
|
|
67
|
+
total: files.length,
|
|
68
|
+
});
|
|
69
|
+
|
|
58
70
|
export const canonicalizeAiConversationAttachments = <T extends Message>(
|
|
59
71
|
message: T,
|
|
60
72
|
snapshot: AiConversationFileSnapshot | undefined,
|
package/src/ai/file-tools.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
mountAiProjectFilePath,
|
|
15
15
|
mountAiSkillFilePath,
|
|
16
16
|
} from "./file-mount";
|
|
17
|
-
import { aiFileStore, guessAiMediaType, normalizeAiFilePath } from "./files-store";
|
|
17
|
+
import { aiFileStore, guessAiMediaType, isAiWorkingFilePath, normalizeAiFilePath } from "./files-store";
|
|
18
18
|
import { defineAiTool } from "./tools";
|
|
19
19
|
|
|
20
20
|
const FILE_READ_MAX_BYTES = 64 * 1024;
|
|
@@ -123,7 +123,7 @@ export const createCloudAiListFilesTool = () =>
|
|
|
123
123
|
defineAiTool({
|
|
124
124
|
name: "list_files",
|
|
125
125
|
description:
|
|
126
|
-
"List persistent conversation files, shared Project files, and loaded skill files. Project files are read-only below /project; loaded skills are read-only below /skills/<name>. Files are ordered newest first.",
|
|
126
|
+
"List persistent conversation files, shared Project files, and loaded skill files. Project files are read-only below /project; loaded skills are read-only below /skills/<name>. Files are ordered newest first; working files below /temp follow all other files. List /temp to see only them.",
|
|
127
127
|
inputSchema: CloudAiListFilesInputSchema,
|
|
128
128
|
outputSchema: CloudAiListFilesOutputSchema,
|
|
129
129
|
approval: "never",
|
|
@@ -150,7 +150,12 @@ export const createCloudAiListFilesTool = () =>
|
|
|
150
150
|
...skillFiles
|
|
151
151
|
.filter((file) => projectPathMatchesPrefix(file.path, skillPrefix ?? ""))
|
|
152
152
|
.map((file) => ({ ...file, path: mountAiSkillFilePath(file.path), origin: "skill" as const })),
|
|
153
|
-
].sort(
|
|
153
|
+
].sort(
|
|
154
|
+
(a, b) =>
|
|
155
|
+
Number(isAiWorkingFilePath(a.path)) - Number(isAiWorkingFilePath(b.path)) ||
|
|
156
|
+
b.updatedAt.localeCompare(a.updatedAt) ||
|
|
157
|
+
a.path.localeCompare(b.path),
|
|
158
|
+
);
|
|
154
159
|
return { files: files.slice(0, 200), truncated: files.length > 200 };
|
|
155
160
|
});
|
|
156
161
|
|
|
@@ -341,6 +346,15 @@ export const createCloudAiWriteFileTool = () =>
|
|
|
341
346
|
export const CloudAiPresentInputSchema = z.object({
|
|
342
347
|
path: z.string().trim().min(1).describe("Absolute conversation file path."),
|
|
343
348
|
title: z.string().trim().min(1).max(120).optional(),
|
|
349
|
+
description: z
|
|
350
|
+
.string()
|
|
351
|
+
.trim()
|
|
352
|
+
.min(1)
|
|
353
|
+
.max(160)
|
|
354
|
+
.optional()
|
|
355
|
+
.describe(
|
|
356
|
+
"One sentence in the user's language that says what the file is and what it is for, without repeating its name. Shown next to the file in the chat's results.",
|
|
357
|
+
),
|
|
344
358
|
});
|
|
345
359
|
export const CloudAiPresentOutputSchema = z.object({ path: z.string(), size: z.number(), mediaType: z.string() });
|
|
346
360
|
|