@k2b/cloud 0.26.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/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +11 -4
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/presentation.tsx +29 -9
- package/src/ai/chat/turn-view.tsx +47 -12
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- 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 +148 -84
- 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 +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +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 +109 -57
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/types.ts +24 -3
- 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 +2 -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/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
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,
|
|
@@ -49,11 +51,13 @@ import { selectAiSkillCatalog } from "./skill-catalog";
|
|
|
49
51
|
import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
|
|
50
52
|
import { aiSkills } from "./skills";
|
|
51
53
|
import { aiConversations } from "./store";
|
|
52
|
-
import { publishAiWireEvent } from "./stream";
|
|
54
|
+
import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
|
|
53
55
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
54
56
|
import { aiToolAudit } from "./tool-audit";
|
|
57
|
+
import { acceptCanonicalToolNames } from "./tool-call-names";
|
|
55
58
|
import { resolveAiToolResultMaxChars } from "./tool-result-budget";
|
|
56
59
|
import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
60
|
+
import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
|
|
57
61
|
import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
|
|
58
62
|
import type {
|
|
59
63
|
AiChatTurnRunConfig,
|
|
@@ -72,13 +76,9 @@ import { validateAiTurnRequest } from "./validate";
|
|
|
72
76
|
|
|
73
77
|
const log = logger("ai:executor");
|
|
74
78
|
|
|
75
|
-
const AI_TURN_LEASE_MS = 45_000;
|
|
76
79
|
const AI_COALESCE_MS = 25;
|
|
77
80
|
const AI_COALESCE_MAX_CHARS = 512;
|
|
78
|
-
const AI_SNAPSHOT_INTERVAL_MS = 1_000;
|
|
79
81
|
const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
|
|
80
|
-
const AI_FINAL_TOOL_ROUND_PROMPT = `# Final response
|
|
81
|
-
The configured tool-round budget has been reached, so no more tools are available in this turn. Answer the user's request now with the best result supported by the evidence already gathered. State any material uncertainty or incomplete part clearly.`;
|
|
82
82
|
|
|
83
83
|
const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
|
|
84
84
|
const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
|
|
@@ -93,50 +93,6 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
|
|
|
93
93
|
};
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
-
const applyToolRoundPolicy = (input: {
|
|
97
|
-
provider: Provider;
|
|
98
|
-
tools: Tool[] | ToolResolver;
|
|
99
|
-
maxToolRounds?: number;
|
|
100
|
-
issuedToolRounds: number;
|
|
101
|
-
completedToolRounds: number;
|
|
102
|
-
}): { provider: Provider; tools: Tool[] | ToolResolver; maxTurns?: number; noteToolRound: () => void } => {
|
|
103
|
-
const limit = Math.floor(input.maxToolRounds ?? 0);
|
|
104
|
-
if (limit <= 0) return { provider: input.provider, tools: input.tools, noteToolRound: () => undefined };
|
|
105
|
-
|
|
106
|
-
const issuedAtStart = Math.max(0, Math.floor(input.issuedToolRounds));
|
|
107
|
-
let completed = Math.max(0, Math.floor(input.completedToolRounds));
|
|
108
|
-
let finalSynthesis = completed >= limit;
|
|
109
|
-
const tools: ToolResolver = async () => {
|
|
110
|
-
finalSynthesis = completed >= limit;
|
|
111
|
-
if (finalSynthesis) return [];
|
|
112
|
-
return typeof input.tools === "function" ? input.tools() : input.tools;
|
|
113
|
-
};
|
|
114
|
-
const provider: Provider = {
|
|
115
|
-
name: input.provider.name,
|
|
116
|
-
family: input.provider.family,
|
|
117
|
-
model: input.provider.model,
|
|
118
|
-
contextWindow: input.provider.contextWindow,
|
|
119
|
-
capabilities: input.provider.capabilities,
|
|
120
|
-
complete: (request) => input.provider.complete(request),
|
|
121
|
-
stream: async function* (request) {
|
|
122
|
-
yield* input.provider.stream(
|
|
123
|
-
finalSynthesis ? { ...request, systemPrompt: `${request.systemPrompt ?? ""}\n\n${AI_FINAL_TOOL_ROUND_PROMPT}`.trim() } : request,
|
|
124
|
-
);
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
// Nessi checks this before provider calls. The extra round is the tool-free
|
|
129
|
-
// synthesis call after the last allowed tool-using round.
|
|
130
|
-
return {
|
|
131
|
-
provider,
|
|
132
|
-
tools,
|
|
133
|
-
maxTurns: Math.max(1, limit - issuedAtStart + 1),
|
|
134
|
-
noteToolRound: () => {
|
|
135
|
-
completed += 1;
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
};
|
|
139
|
-
|
|
140
96
|
const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
|
|
141
97
|
try {
|
|
142
98
|
await aiConversations.indexConversationResources(input);
|
|
@@ -162,6 +118,10 @@ const recordMemoryWorkflowEvidence = async (input: Parameters<typeof recordAiMem
|
|
|
162
118
|
}
|
|
163
119
|
};
|
|
164
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
|
+
*/
|
|
165
125
|
const indexConversationToolSource = async (input: {
|
|
166
126
|
conversationId: string;
|
|
167
127
|
turnId: string;
|
|
@@ -184,10 +144,45 @@ const indexConversationToolSource = async (input: {
|
|
|
184
144
|
});
|
|
185
145
|
}
|
|
186
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>) : {};
|
|
187
150
|
if (input.name === "web_search") {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
};
|
|
191
186
|
} else if (input.name === "web_extract" && typeof input.result === "object" && input.result !== null) {
|
|
192
187
|
const result = input.result as Record<string, unknown>;
|
|
193
188
|
if (typeof result.url === "string" && result.url.trim()) {
|
|
@@ -552,8 +547,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
552
547
|
source.kind === "default"
|
|
553
548
|
? [
|
|
554
549
|
...(await createConfiguredDefaultCloudAiTools()),
|
|
555
|
-
|
|
556
|
-
...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
|
+
),
|
|
557
554
|
]
|
|
558
555
|
: [],
|
|
559
556
|
toolApprovalContext: config.toolApprovalContext,
|
|
@@ -671,6 +668,7 @@ export class AiTurnExecutor {
|
|
|
671
668
|
let resolvedProjectId: string | null = null;
|
|
672
669
|
let chatId = config.chatId ?? "";
|
|
673
670
|
let allowedTools: string[] | null = null;
|
|
671
|
+
let sourceToolNames: string[] = [];
|
|
674
672
|
try {
|
|
675
673
|
if (config.background && !config.mandate) throw new Error("Background execution requires its task mandate.");
|
|
676
674
|
const [nextMaterial, conversation] = await Promise.all([
|
|
@@ -681,6 +679,7 @@ export class AiTurnExecutor {
|
|
|
681
679
|
if (!conversation) throw new Error("Conversation is no longer available.");
|
|
682
680
|
allowedTools = conversation.allowedTools ?? null;
|
|
683
681
|
const allowed = allowedTools === null ? null : new Set(allowedTools);
|
|
682
|
+
sourceToolNames = material.tools.map((tool) => tool.def.name);
|
|
684
683
|
if (allowed) material.tools = material.tools.filter((tool) => allowed.has(tool.def.name));
|
|
685
684
|
chatId ||= conversation?.shortId ?? "";
|
|
686
685
|
if (config.project) {
|
|
@@ -802,6 +801,12 @@ export class AiTurnExecutor {
|
|
|
802
801
|
(tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
|
|
803
802
|
)
|
|
804
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));
|
|
805
810
|
const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
|
|
806
811
|
const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
|
|
807
812
|
|
|
@@ -910,6 +915,7 @@ export class AiTurnExecutor {
|
|
|
910
915
|
actor: capabilityAuthority?.actor ?? toolActor,
|
|
911
916
|
staticTools: activeTools,
|
|
912
917
|
allowedTools,
|
|
918
|
+
unofferedTools,
|
|
913
919
|
runtimeContext: dynamicToolRuntimeContext,
|
|
914
920
|
store: toolStore,
|
|
915
921
|
...(capabilityAuthority ? { listRegistry: listCapabilities } : {}),
|
|
@@ -1073,32 +1079,49 @@ export class AiTurnExecutor {
|
|
|
1073
1079
|
memory: memory?.text,
|
|
1074
1080
|
timeZone,
|
|
1075
1081
|
locale: promptLocale,
|
|
1082
|
+
interactive: !config.background,
|
|
1083
|
+
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
|
|
1076
1084
|
});
|
|
1077
|
-
|
|
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);
|
|
1078
1089
|
const quotaSubject = accessSubjectForActor(material.actor);
|
|
1079
1090
|
const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
|
|
1080
|
-
const
|
|
1081
|
-
provider:
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
+
},
|
|
1089
1102
|
},
|
|
1090
|
-
|
|
1103
|
+
),
|
|
1104
|
+
prepared.canonicalNames,
|
|
1091
1105
|
),
|
|
1092
1106
|
tools,
|
|
1093
1107
|
maxToolRounds: resolved.profile.maxToolRounds,
|
|
1094
1108
|
issuedToolRounds: priorToolRounds.issued,
|
|
1095
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 }),
|
|
1096
1119
|
});
|
|
1097
1120
|
const loop = nessi({
|
|
1098
1121
|
agentId: "cloud",
|
|
1099
1122
|
loopId: turnId,
|
|
1100
1123
|
...(isFresh ? { input: turnInput } : {}),
|
|
1101
|
-
provider:
|
|
1124
|
+
provider: turnPolicy.provider,
|
|
1102
1125
|
systemPrompt,
|
|
1103
1126
|
store,
|
|
1104
1127
|
steering: async ({ signal: steeringSignal }) => {
|
|
@@ -1109,12 +1132,15 @@ export class AiTurnExecutor {
|
|
|
1109
1132
|
leaseOwner: this.config.leaseOwner,
|
|
1110
1133
|
});
|
|
1111
1134
|
appliedSteers.push(...steers);
|
|
1112
|
-
|
|
1135
|
+
if (steers.length === 0) return undefined;
|
|
1136
|
+
turnPolicy.noteSteering();
|
|
1137
|
+
return steers.map((steer) => steer.text);
|
|
1113
1138
|
},
|
|
1114
|
-
tools:
|
|
1115
|
-
...(
|
|
1139
|
+
tools: turnPolicy.tools,
|
|
1140
|
+
...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
|
|
1116
1141
|
temperature: resolved.profile.temperature,
|
|
1117
1142
|
maxOutputTokens: resolved.profile.maxOutputTokens,
|
|
1143
|
+
reasoningEffort: resolved.profile.reasoningEffort,
|
|
1118
1144
|
coalesce: { ms: AI_COALESCE_MS, maxChars: AI_COALESCE_MAX_CHARS },
|
|
1119
1145
|
compact: config.background
|
|
1120
1146
|
? undefined
|
|
@@ -1150,7 +1176,8 @@ export class AiTurnExecutor {
|
|
|
1150
1176
|
rememberableCapabilityApprovals,
|
|
1151
1177
|
capabilityActionReviews,
|
|
1152
1178
|
appliedSteers,
|
|
1153
|
-
noteToolRound:
|
|
1179
|
+
noteToolRound: turnPolicy.noteToolRound,
|
|
1180
|
+
noteToolCall: turnPolicy.noteToolCall,
|
|
1154
1181
|
onBackgroundBlocked: (message) => {
|
|
1155
1182
|
backgroundError = message;
|
|
1156
1183
|
},
|
|
@@ -1216,6 +1243,7 @@ export class AiTurnExecutor {
|
|
|
1216
1243
|
capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
|
|
1217
1244
|
appliedSteers: AiTurnSteer[];
|
|
1218
1245
|
noteToolRound: () => void;
|
|
1246
|
+
noteToolCall: (call: AiTurnPolicyToolCall) => void;
|
|
1219
1247
|
onBackgroundBlocked?: (message: string) => void;
|
|
1220
1248
|
}): Promise<AttemptOutcome> {
|
|
1221
1249
|
const {
|
|
@@ -1231,6 +1259,7 @@ export class AiTurnExecutor {
|
|
|
1231
1259
|
capabilityActionReviews,
|
|
1232
1260
|
appliedSteers,
|
|
1233
1261
|
noteToolRound,
|
|
1262
|
+
noteToolCall,
|
|
1234
1263
|
} = input;
|
|
1235
1264
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1236
1265
|
let lastIssueMessage: string | null = null;
|
|
@@ -1289,6 +1318,12 @@ export class AiTurnExecutor {
|
|
|
1289
1318
|
.noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
|
|
1290
1319
|
.catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
|
|
1291
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
|
+
);
|
|
1292
1327
|
await indexConversationToolSource({
|
|
1293
1328
|
conversationId,
|
|
1294
1329
|
turnId,
|
|
@@ -1620,6 +1655,9 @@ class StreamPipeline {
|
|
|
1620
1655
|
readonly timing: ReturnType<typeof createTurnTimingRecorder>;
|
|
1621
1656
|
private lastSnapshotAt = 0;
|
|
1622
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();
|
|
1623
1661
|
private chain: Promise<void> = Promise.resolve();
|
|
1624
1662
|
|
|
1625
1663
|
constructor(input: {
|
|
@@ -1651,6 +1689,8 @@ class StreamPipeline {
|
|
|
1651
1689
|
|
|
1652
1690
|
private nextSeq(): number {
|
|
1653
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;
|
|
1654
1694
|
return this.seq;
|
|
1655
1695
|
}
|
|
1656
1696
|
|
|
@@ -1699,12 +1739,14 @@ class StreamPipeline {
|
|
|
1699
1739
|
const seq = this.nextSeq();
|
|
1700
1740
|
await this.publish(this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent);
|
|
1701
1741
|
}
|
|
1702
|
-
|
|
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();
|
|
1703
1744
|
}
|
|
1704
1745
|
|
|
1705
1746
|
async emitMessage(message: AiStoredMessage): Promise<void> {
|
|
1706
1747
|
const seq = this.nextSeq();
|
|
1707
1748
|
await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
|
|
1749
|
+
await this.maybeSnapshot();
|
|
1708
1750
|
}
|
|
1709
1751
|
|
|
1710
1752
|
async emitTurnStarted(modelProfileId: string): Promise<void> {
|
|
@@ -1764,24 +1806,36 @@ class StreamPipeline {
|
|
|
1764
1806
|
await this.publish(event);
|
|
1765
1807
|
}
|
|
1766
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
|
+
*/
|
|
1767
1813
|
private async maybeSnapshot(): Promise<void> {
|
|
1768
1814
|
if (!this.snapshotDirty) return;
|
|
1769
|
-
|
|
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
|
+
}
|
|
1770
1823
|
await this.persistSnapshot();
|
|
1771
1824
|
}
|
|
1772
1825
|
|
|
1773
1826
|
async persistSnapshot(): Promise<void> {
|
|
1827
|
+
this.cancelSnapshotTimer();
|
|
1774
1828
|
this.lastSnapshotAt = Date.now();
|
|
1775
1829
|
this.snapshotDirty = false;
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
leaseOwner: this.leaseOwner,
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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;
|
|
1785
1839
|
}
|
|
1786
1840
|
|
|
1787
1841
|
async emitError(message: string): Promise<void> {
|
|
@@ -1796,6 +1850,7 @@ class StreamPipeline {
|
|
|
1796
1850
|
async emitProviderRetry(): Promise<void> {
|
|
1797
1851
|
const seq = this.nextSeq();
|
|
1798
1852
|
await this.publish(this.envelope({ type: "provider_retry" as const, seq }));
|
|
1853
|
+
await this.maybeSnapshot();
|
|
1799
1854
|
}
|
|
1800
1855
|
|
|
1801
1856
|
async emitTurnFinished(status: "completed" | "failed" | "aborted", error: string | null): Promise<void> {
|
|
@@ -1808,15 +1863,24 @@ class StreamPipeline {
|
|
|
1808
1863
|
return this.ordered(() => publishAiWireEvent(event).catch(() => undefined));
|
|
1809
1864
|
}
|
|
1810
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
|
+
*/
|
|
1811
1875
|
async flush(): Promise<void> {
|
|
1812
|
-
|
|
1876
|
+
this.cancelSnapshotTimer();
|
|
1877
|
+
await Promise.all([this.chain, this.saving]);
|
|
1813
1878
|
}
|
|
1814
1879
|
}
|
|
1815
1880
|
|
|
1816
1881
|
export const __aiExecutorTest = {
|
|
1817
1882
|
indexConversationToolSource,
|
|
1818
1883
|
StreamPipeline,
|
|
1819
|
-
applyToolRoundPolicy,
|
|
1820
1884
|
createEventMapper,
|
|
1821
1885
|
rebuildAttemptBaseline,
|
|
1822
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
|
|