@k2b/cloud 0.27.0 → 0.29.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 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +113 -24
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
- package/src/ai/chat/message-actions.tsx +124 -102
- package/src/ai/chat/message-utils.ts +57 -6
- package/src/ai/chat/messages.ts +214 -0
- package/src/ai/chat/presentation.tsx +67 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +69 -10
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/compaction.ts +22 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +23 -17
- package/src/ai/executor.ts +262 -99
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +3 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +26 -10
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +14 -6
- package/src/ai/store.ts +102 -34
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +9 -22
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +104 -0
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +29 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +82 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +66 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/help/store.ts +2 -1
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +12 -1
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +88 -6
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +33 -5
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +103 -10
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +48 -4
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/platform-messages.ts +1 -1
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +38 -17
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
- package/src/styles/utilities-navigation.css +41 -0
package/src/ai/routes.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
err,
|
|
11
11
|
fail,
|
|
12
12
|
getLocale,
|
|
13
|
+
getTimeZone,
|
|
13
14
|
ok,
|
|
14
15
|
type RequestActor,
|
|
15
16
|
rateLimit,
|
|
@@ -17,12 +18,15 @@ import {
|
|
|
17
18
|
v,
|
|
18
19
|
} from "../server";
|
|
19
20
|
import { isRequestCredentialCurrent } from "../server/middleware/auth";
|
|
21
|
+
import { ImageMetadataError } from "../services/image-metadata";
|
|
20
22
|
import { logger } from "../services/logging";
|
|
21
23
|
import { coreSettings } from "../services/settings/api";
|
|
24
|
+
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
22
25
|
import type { AiToolApprovalContext } from "./approvals";
|
|
23
|
-
import { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
|
|
26
|
+
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
24
27
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
25
28
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
29
|
+
import { aiChatMessages } from "./chat/messages";
|
|
26
30
|
import { getAiChatQuotas } from "./chat-quotas";
|
|
27
31
|
import { createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
28
32
|
import { enqueueAiDictation } from "./dictation-runtime";
|
|
@@ -56,7 +60,6 @@ import {
|
|
|
56
60
|
queuedMessageAccepted,
|
|
57
61
|
updateQueuedMessage,
|
|
58
62
|
} from "./message-queue";
|
|
59
|
-
import { aiModelAccess } from "./model-access";
|
|
60
63
|
import { personalAiModelPolicy } from "./personal-agent";
|
|
61
64
|
import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
|
|
62
65
|
import { aiProjects } from "./projects";
|
|
@@ -82,7 +85,6 @@ import { aiConversations } from "./store";
|
|
|
82
85
|
import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
|
|
83
86
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
84
87
|
import { aiToolPromptHints } from "./tools";
|
|
85
|
-
import { resolveAiAudioModel } from "./transcription";
|
|
86
88
|
import type { AiConversation, AiModelPolicy, AiTurn } from "./types";
|
|
87
89
|
|
|
88
90
|
/** Everything a resolved, authorized request needs to run against the shared runtime. */
|
|
@@ -418,7 +420,10 @@ export const aiRoutes = (() => {
|
|
|
418
420
|
const skillCatalog = selectAiSkillCatalog(availableSkills, previewProfile?.contextWindow ?? 0, "");
|
|
419
421
|
const tools = toolsSupported
|
|
420
422
|
? [
|
|
421
|
-
...(await createConfiguredDefaultCloudAiTools(
|
|
423
|
+
...(await createConfiguredDefaultCloudAiTools({
|
|
424
|
+
accessSubject: c.get("accessSubject"),
|
|
425
|
+
allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
|
|
426
|
+
})),
|
|
422
427
|
...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
|
|
423
428
|
...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
|
|
424
429
|
...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
|
|
@@ -524,7 +529,14 @@ export const aiRoutes = (() => {
|
|
|
524
529
|
return respond(c, fail(err.internal("The live capability catalog is unavailable.")));
|
|
525
530
|
}
|
|
526
531
|
}
|
|
527
|
-
const builtInNames = new Set(
|
|
532
|
+
const builtInNames = new Set(
|
|
533
|
+
(
|
|
534
|
+
await createConfiguredDefaultCloudAiTools({
|
|
535
|
+
accessSubject: c.get("accessSubject"),
|
|
536
|
+
allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
|
|
537
|
+
})
|
|
538
|
+
).map((tool) => tool.def.name),
|
|
539
|
+
);
|
|
528
540
|
const preloadTools = (body.preloadTools ?? []).map((requested) => {
|
|
529
541
|
if ("name" in requested) return builtInNames.has(requested.name) ? requested.name : null;
|
|
530
542
|
return (
|
|
@@ -884,6 +896,8 @@ export const aiRoutes = (() => {
|
|
|
884
896
|
userMessage: message,
|
|
885
897
|
actor: ctx.actor,
|
|
886
898
|
locale: getLocale(c),
|
|
899
|
+
theme: readThemeFromCookieHeader(c.req.header("cookie")),
|
|
900
|
+
timeZone: getTimeZone(c),
|
|
887
901
|
requestedModelId: body.modelProfileId ?? project?.defaultModelProfileId ?? undefined,
|
|
888
902
|
modelPolicy: ctx.modelPolicy,
|
|
889
903
|
project: project ?? undefined,
|
|
@@ -991,6 +1005,8 @@ export const aiRoutes = (() => {
|
|
|
991
1005
|
userMessage: message,
|
|
992
1006
|
actor: ctx.actor,
|
|
993
1007
|
locale: getLocale(c),
|
|
1008
|
+
theme: readThemeFromCookieHeader(c.req.header("cookie")),
|
|
1009
|
+
timeZone: getTimeZone(c),
|
|
994
1010
|
requestedModelId,
|
|
995
1011
|
modelPolicy: ctx.modelPolicy,
|
|
996
1012
|
systemPrompt,
|
|
@@ -1172,11 +1188,7 @@ export const aiRoutes = (() => {
|
|
|
1172
1188
|
conversationId: conversation.id,
|
|
1173
1189
|
userId: ctx.ownerUserId,
|
|
1174
1190
|
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
1175
|
-
resolveModel:
|
|
1176
|
-
const model = await resolveAiAudioModel({ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries });
|
|
1177
|
-
await aiModelAccess.assertAllowed(model.profile.id, c.get("accessSubject"));
|
|
1178
|
-
return model;
|
|
1179
|
-
},
|
|
1191
|
+
resolveModel: () => resolveAssistantAudioModel(c.get("accessSubject"), ctx.modelPolicy.allowedDataBoundaries),
|
|
1180
1192
|
});
|
|
1181
1193
|
await enqueueAiDictation(dictation.id).catch(() => undefined); // The DB sweep heals failed delivery.
|
|
1182
1194
|
return respond(c, ok(dictation));
|
|
@@ -1306,6 +1318,8 @@ export const aiRoutes = (() => {
|
|
|
1306
1318
|
});
|
|
1307
1319
|
return respond(c, ok({ file: stat }));
|
|
1308
1320
|
} catch (error) {
|
|
1321
|
+
if (error instanceof ImageMetadataError)
|
|
1322
|
+
return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
|
|
1309
1323
|
logger("ai:files").warn("Conversation upload failed", {
|
|
1310
1324
|
code: "file_upload_failed",
|
|
1311
1325
|
conversationId: conversation.id,
|
|
@@ -1366,6 +1380,8 @@ export const aiRoutes = (() => {
|
|
|
1366
1380
|
allowUserOverwrite: existing?.origin === "user",
|
|
1367
1381
|
});
|
|
1368
1382
|
} catch (error) {
|
|
1383
|
+
if (error instanceof ImageMetadataError)
|
|
1384
|
+
return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
|
|
1369
1385
|
return respond(c, fail(err.badInput(error instanceof Error ? error.message : "Failed to write file")));
|
|
1370
1386
|
}
|
|
1371
1387
|
return respond(c, ok({ file: await aiFileStore.stat({ conversationId: conversation.id, path }) }));
|
package/src/ai/run-timeout.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
import type { AiTurnError } from "./types";
|
|
2
|
+
|
|
1
3
|
/** Preserve why execution stopped instead of reporting an operator deadline as a user abort. */
|
|
2
4
|
export class AiRunTimeout extends Error {
|
|
3
5
|
constructor(readonly budgetMs: number | null) {
|
|
4
6
|
super("AI_RUN_TIMEOUT");
|
|
5
7
|
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return locale?.startsWith("de")
|
|
9
|
-
? `Laufzeitlimit${minutes ? ` von ${minutes} Minuten` : ""} erreicht. Du kannst die Aufgabe mit einer neuen Nachricht fortsetzen.`
|
|
10
|
-
: `Run time limit${minutes ? ` of ${minutes} minutes` : ""} reached. You can continue the task with a new message.`;
|
|
8
|
+
turnError(): AiTurnError {
|
|
9
|
+
return this.budgetMs ? { code: "time_limit", limitMinutes: Math.round(this.budgetMs / 60_000) } : { code: "time_limit" };
|
|
11
10
|
}
|
|
12
11
|
}
|
package/src/ai/runtime.ts
CHANGED
|
@@ -94,6 +94,8 @@ export type SubmitAiChatTurnInput = {
|
|
|
94
94
|
userMessage: Message;
|
|
95
95
|
actor?: RequestActor;
|
|
96
96
|
locale?: string;
|
|
97
|
+
theme?: "light" | "dark";
|
|
98
|
+
timeZone?: string;
|
|
97
99
|
modelPolicy?: AiModelPolicy;
|
|
98
100
|
requestedModelId?: string;
|
|
99
101
|
/** Optional instructions that apply only to this turn. */
|
|
@@ -155,6 +157,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
|
155
157
|
chatId: input.chatId,
|
|
156
158
|
actor: input.actor,
|
|
157
159
|
...(input.locale ? { locale: input.locale } : {}),
|
|
160
|
+
...(input.theme ? { theme: input.theme } : {}),
|
|
161
|
+
...(input.timeZone ? { timeZone: input.timeZone } : {}),
|
|
158
162
|
modelPolicy: input.modelPolicy,
|
|
159
163
|
requestedModelId,
|
|
160
164
|
systemPrompt: input.systemPrompt,
|
|
@@ -190,6 +194,8 @@ export const deliverAiInterChatMessage = async (input: {
|
|
|
190
194
|
chatId?: string;
|
|
191
195
|
actor: RequestActor;
|
|
192
196
|
locale?: string;
|
|
197
|
+
theme?: "light" | "dark";
|
|
198
|
+
timeZone?: string;
|
|
193
199
|
modelPolicy?: AiModelPolicy;
|
|
194
200
|
systemPrompt?: string;
|
|
195
201
|
project?: AiChatTurnRunConfig["project"];
|
|
@@ -209,6 +215,8 @@ export const deliverAiInterChatMessage = async (input: {
|
|
|
209
215
|
chatId: input.chatId,
|
|
210
216
|
actor: input.actor,
|
|
211
217
|
...(input.locale ? { locale: input.locale } : {}),
|
|
218
|
+
...(input.theme ? { theme: input.theme } : {}),
|
|
219
|
+
...(input.timeZone ? { timeZone: input.timeZone } : {}),
|
|
212
220
|
modelPolicy: input.modelPolicy,
|
|
213
221
|
systemPrompt: input.systemPrompt,
|
|
214
222
|
project: input.project,
|
package/src/ai/settings.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { coreSettings } from "../services";
|
|
3
|
+
import { stripImageDataUrlMetadata } from "../services/image-metadata";
|
|
3
4
|
import { AiModelPricingSchema } from "../shared/ai-costs";
|
|
4
5
|
import {
|
|
5
6
|
AI_REQUEST_HEADERS_PROVIDER_ERROR,
|
|
@@ -152,6 +153,18 @@ const normalizeProfile = (raw: z.infer<typeof ModelProfileSchema>): AiModelProfi
|
|
|
152
153
|
};
|
|
153
154
|
};
|
|
154
155
|
|
|
156
|
+
/** Sanitize every submitted model logo, including unchanged images from stored profiles. */
|
|
157
|
+
export const prepareAiModelProfileImages = (rawJson: string): string => {
|
|
158
|
+
const raw: unknown = JSON.parse(rawJson);
|
|
159
|
+
if (!Array.isArray(raw)) return rawJson;
|
|
160
|
+
return JSON.stringify(
|
|
161
|
+
raw.map((profile: unknown) => {
|
|
162
|
+
if (typeof profile !== "object" || profile === null || !("image" in profile) || typeof profile.image !== "string") return profile;
|
|
163
|
+
return { ...profile, image: stripImageDataUrlMetadata(profile.image) };
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
155
168
|
export const parseAiModelProfiles = (rawJson: string): { profiles: AiModelProfile[]; error?: AiSettingsError } => {
|
|
156
169
|
let raw: unknown;
|
|
157
170
|
try {
|
|
@@ -468,13 +481,18 @@ export const selectAiModelProfile = (
|
|
|
468
481
|
const modelId = resolvePolicyModelId(state, policy, requestedModelId);
|
|
469
482
|
const profile = state.profiles.find((candidate) => candidate.id === modelId);
|
|
470
483
|
if (!profile || !matchesPolicy(profile, policy)) {
|
|
471
|
-
throw Object.assign(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
484
|
+
throw Object.assign(
|
|
485
|
+
new Error(
|
|
486
|
+
`AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
|
|
487
|
+
),
|
|
488
|
+
{
|
|
489
|
+
aiError: {
|
|
490
|
+
code: "model_policy_mismatch",
|
|
491
|
+
message: `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
|
|
492
|
+
fields: { modelProfileId: "Choose an allowed enabled model profile." },
|
|
493
|
+
} satisfies AiSettingsError,
|
|
494
|
+
},
|
|
495
|
+
);
|
|
478
496
|
}
|
|
479
497
|
return profile;
|
|
480
498
|
};
|
|
@@ -497,8 +515,19 @@ const visionModelPolicy = (modelId: string, allowedDataBoundaries?: AiDataBounda
|
|
|
497
515
|
export const resolveAiVisionModel = async (allowedDataBoundaries?: AiDataBoundary[]): Promise<AiResolvedModel> => {
|
|
498
516
|
const state = await readAiSettingsState();
|
|
499
517
|
if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
|
|
518
|
+
if (!state.enabled) {
|
|
519
|
+
throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
|
|
520
|
+
aiError: {
|
|
521
|
+
code: "ai_disabled",
|
|
522
|
+
message: "AI is disabled. An administrator can enable AI in the AI settings.",
|
|
523
|
+
} satisfies AiSettingsError,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
500
526
|
const modelId = (state.visionModelId ?? "").trim();
|
|
501
|
-
if (!modelId)
|
|
527
|
+
if (!modelId)
|
|
528
|
+
throw new Error(
|
|
529
|
+
"Image inspection is not set up: no vision model is configured. An administrator can configure a vision model in the AI settings.",
|
|
530
|
+
);
|
|
502
531
|
return resolveAiModelFromState(state, visionModelPolicy(modelId, allowedDataBoundaries));
|
|
503
532
|
};
|
|
504
533
|
|
|
@@ -520,8 +549,11 @@ export const resolveAiModelFromState = async (
|
|
|
520
549
|
): Promise<AiResolvedModel> => {
|
|
521
550
|
if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
|
|
522
551
|
if (!state.enabled) {
|
|
523
|
-
throw Object.assign(new Error("AI is disabled."), {
|
|
524
|
-
aiError: {
|
|
552
|
+
throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
|
|
553
|
+
aiError: {
|
|
554
|
+
code: "ai_disabled",
|
|
555
|
+
message: "AI is disabled. An administrator can enable AI in the AI settings.",
|
|
556
|
+
} satisfies AiSettingsError,
|
|
525
557
|
});
|
|
526
558
|
}
|
|
527
559
|
|
|
@@ -529,13 +561,18 @@ export const resolveAiModelFromState = async (
|
|
|
529
561
|
|
|
530
562
|
const credential = providerSupportsCredential(profile.provider) ? await getAiCredential(profile.id) : null;
|
|
531
563
|
if (providerRequiresCredential(profile.provider) && !credential?.trim()) {
|
|
532
|
-
throw Object.assign(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
564
|
+
throw Object.assign(
|
|
565
|
+
new Error(
|
|
566
|
+
`AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
|
|
567
|
+
),
|
|
568
|
+
{
|
|
569
|
+
aiError: {
|
|
570
|
+
code: "missing_provider_credential",
|
|
571
|
+
message: `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
|
|
572
|
+
fields: { "ai.model_profiles_json": "Enter the provider API key on this model profile." },
|
|
573
|
+
} satisfies AiSettingsError,
|
|
574
|
+
},
|
|
575
|
+
);
|
|
539
576
|
}
|
|
540
577
|
|
|
541
578
|
const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.id) : {};
|
package/src/ai/skill-seeds.ts
CHANGED
|
@@ -223,6 +223,8 @@ Use these defaults unless the user asks otherwise or a more specific loaded Skil
|
|
|
223
223
|
- For text lookup across Spaces, use \`spaces.item.search\`. For overdue, assigned or inactive tasks use \`spaces.task.focus\`; filter at the server instead of enumerating every Space.
|
|
224
224
|
- For a date-bounded calendar use \`spaces.event.agenda\`. Follow every cursor, including after an empty page: pagination groups series with their overrides. Collect all pages and sort by startsAt for a chronological agenda. Use returned occurrences; do not expand recurrence rules yourself or equate a series anchor with the next occurrence.
|
|
225
225
|
- Select a writable Space through \`spaces.space.browse\`. A known Space can be listed directly with \`spaces.task.list\` or \`spaces.event.list\`. Read \`spaces.space.read\` only when column/tag IDs or configuration are needed.
|
|
226
|
+
- List entries show at most three assignees and tags: when \`assigneeCount\` is larger than the number of \`assignees\`, state the total (for example "3 of 11") or read the item before naming everyone. \`relationsTruncated\` signals that a relation preview is partial.
|
|
227
|
+
- Use each entry's \`overdue\` to call a task overdue; a completed task is never overdue, so never infer it from \`deadline\` alone.
|
|
226
228
|
- Read a selected \`spaces.item.read\` before changing its content or deleting it. Use a task for work and an event only with explicit valid start and end. Select assignees from \`spaces.space.assignee.list\`, never inferred names or invented IDs.
|
|
227
229
|
|
|
228
230
|
## Make focused changes
|
|
@@ -235,6 +237,12 @@ Use these defaults unless the user asks otherwise or a more specific loaded Skil
|
|
|
235
237
|
- Use \`spaces.item.reference.find\` to find existing items for a source resource. Link on creation through references rather than a redundant second mutation. For an existing item use \`spaces.item.reference.add\`; use \`spaces.item.link-candidate.search\` when a writable target is unknown.
|
|
236
238
|
- Use \`spaces.event.create\` for interactive creation. Never blindly retry an uncertain mutation.
|
|
237
239
|
|
|
240
|
+
## Templates
|
|
241
|
+
|
|
242
|
+
- When the user names a template or the Space has one for the request, find it with \`spaces.template.list\`; pass the person's IANA timeZone when known. Each template carries its date rule and the next proposed local dates.
|
|
243
|
+
- Create from it with \`spaces.task.create\` or \`spaces.event.create\` and \`templateId\`. Set \`date\` to the proposal the user means ("next Wednesday" is the matching proposal) or to an explicit date; without \`date\` the first proposal applies. Give only the fields the user wants to change; the template fills the rest, including its checklist.
|
|
244
|
+
- Never guess a date from a vague request; if the proposals do not settle it, ask. Managing templates (\`spaces.template.create\`, \`spaces.template.update\`, \`spaces.template.delete\`) needs Space admin access.
|
|
245
|
+
|
|
238
246
|
## Calendar mail and cross-app work
|
|
239
247
|
|
|
240
248
|
Before importing, responding to or preparing emailed calendar invitations, read /skills/cloud-spaces/references/calendar-mail.md. Preparation, attaching to a draft and commit are distinct from sending.
|
|
@@ -257,6 +265,7 @@ Use these defaults unless the user asks otherwise or a more specific loaded Skil
|
|
|
257
265
|
|
|
258
266
|
- For a saved place, use location search or list and pass the returned location ID to \`weather.forecast.current\` for current conditions or \`weather.forecast.get\` for hourly and daily outlooks.
|
|
259
267
|
- For an unsaved German city, use \`weather.city.search\`, choose an unambiguous candidate, and pass its coordinates directly to a forecast. Save it with \`weather.location.create\` only when the user asks.
|
|
268
|
+
- If city search is unavailable, use known coordinates with \`weather.forecast.current\` or \`weather.forecast.get\` and \`source.kind = "coordinates"\`; an administrator can configure the Geo API URL in Weather settings.
|
|
260
269
|
- If multiple city candidates remain plausible, ask which one instead of choosing silently. Delete a saved location only when explicitly requested.
|
|
261
270
|
|
|
262
271
|
## Reporting defaults
|
|
@@ -475,11 +484,10 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
475
484
|
references: [{ path: "references/query-tasks.md", content: CLOUD_GRIDS_QUERY_REFERENCE }],
|
|
476
485
|
},
|
|
477
486
|
{
|
|
478
|
-
version:
|
|
487
|
+
version: 3,
|
|
479
488
|
key: "assistant:cloud-assistant",
|
|
480
489
|
name: "cloud-assistant",
|
|
481
|
-
description:
|
|
482
|
-
"Use for work involving Cloud Assistant itself: finding or reading earlier conversations, recovering resources used in chats, messaging another conversation, or creating and managing reminders and recurring scheduled chat work.",
|
|
490
|
+
description: `Use for work involving Cloud Assistant itself: finding or reading earlier conversations, recovering resources used in chats, messaging another conversation, or creating and managing reminders and recurring scheduled chat work. Also use when a request refers to earlier work, such as "like last time", "as last week", or "the report you made me".`,
|
|
483
491
|
instructions: CLOUD_ASSISTANT_INSTRUCTIONS,
|
|
484
492
|
},
|
|
485
493
|
{
|
|
@@ -524,16 +532,16 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
524
532
|
instructions: CLOUD_CONTACTS_INSTRUCTIONS,
|
|
525
533
|
},
|
|
526
534
|
{
|
|
527
|
-
version:
|
|
535
|
+
version: 4,
|
|
528
536
|
key: "spaces:cloud-spaces",
|
|
529
537
|
name: "cloud-spaces",
|
|
530
538
|
description:
|
|
531
|
-
"Use for work involving Cloud Spaces: finding, reading, creating, or updating tasks and events, assignees, blockers, comments, tags, linked Cloud resources, and calendar invitations. Load it whenever a request involves a Space, task, work item, deadline, event, calendar entry, or shared work queue.",
|
|
539
|
+
"Use for work involving Cloud Spaces: finding, reading, creating, or updating tasks and events, templates, assignees, blockers, comments, tags, linked Cloud resources, and calendar invitations. Load it whenever a request involves a Space, task, work item, deadline, event, calendar entry, or shared work queue.",
|
|
532
540
|
instructions: CLOUD_SPACES_INSTRUCTIONS,
|
|
533
541
|
references: [{ path: "references/calendar-mail.md", content: CLOUD_SPACES_CALENDAR_REFERENCE }],
|
|
534
542
|
},
|
|
535
543
|
{
|
|
536
|
-
version:
|
|
544
|
+
version: 2,
|
|
537
545
|
key: "weather:cloud-weather",
|
|
538
546
|
name: "cloud-weather",
|
|
539
547
|
description:
|
package/src/ai/store.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { type SQL, sql } from "bun";
|
|
|
4
4
|
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import { aiTurnErrorText } from "./chat/turn-error";
|
|
7
8
|
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
8
9
|
import type { AiTurnBlock } from "./protocol";
|
|
10
|
+
import { AiRunTimeout } from "./run-timeout";
|
|
9
11
|
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
10
12
|
import { parseAiTodoPlan } from "./todo-contracts";
|
|
11
13
|
import { activeTurnWaits } from "./turn-timing";
|
|
@@ -29,6 +31,7 @@ import type {
|
|
|
29
31
|
AiToolPresentation,
|
|
30
32
|
AiTurn,
|
|
31
33
|
AiTurnClaim,
|
|
34
|
+
AiTurnError,
|
|
32
35
|
AiTurnRunConfig,
|
|
33
36
|
AiTurnStatus,
|
|
34
37
|
AiTurnSteer,
|
|
@@ -112,6 +115,7 @@ type ConversationRow = {
|
|
|
112
115
|
latest_browser_pending?: boolean;
|
|
113
116
|
latest_turn_error?: string | null;
|
|
114
117
|
latest_turn_completed_at?: Date | string | null;
|
|
118
|
+
latest_turn_short_id?: string | null;
|
|
115
119
|
enrich_fail_count: number | null;
|
|
116
120
|
project_id: string | null;
|
|
117
121
|
draft_content: unknown;
|
|
@@ -552,6 +556,7 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
|
|
|
552
556
|
lastUsedAt: iso(row.last_used_at),
|
|
553
557
|
runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
|
|
554
558
|
runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
|
|
559
|
+
runTurnId: row.latest_turn_short_id ?? null,
|
|
555
560
|
unreadCompletion:
|
|
556
561
|
Boolean(
|
|
557
562
|
row.background_received_at &&
|
|
@@ -723,10 +728,10 @@ const loadConversationSummary = async (input: {
|
|
|
723
728
|
latest.status AS latest_turn_status,
|
|
724
729
|
${browserWorkPending()} AS latest_browser_pending,
|
|
725
730
|
latest.error AS latest_turn_error,
|
|
726
|
-
latest.completed_at AS latest_turn_completed_at
|
|
731
|
+
latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
|
|
727
732
|
FROM ai.conversations conversation
|
|
728
733
|
LEFT JOIN LATERAL (
|
|
729
|
-
SELECT status, error, completed_at, live_blocks
|
|
734
|
+
SELECT short_id, status, error, completed_at, live_blocks
|
|
730
735
|
FROM ai.turns
|
|
731
736
|
WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
|
|
732
737
|
ORDER BY created_at DESC, id DESC
|
|
@@ -784,13 +789,30 @@ const messageSearchText = (message: Message): string => {
|
|
|
784
789
|
* time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
|
|
785
790
|
* never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
|
|
786
791
|
* limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
|
|
792
|
+
* A failure's reason goes to the turn's last message, also when that is the user's own, so history can say why the
|
|
793
|
+
* turn failed and what to do next.
|
|
787
794
|
*/
|
|
788
795
|
const recordTurnEnd = async (
|
|
789
796
|
db: typeof sql,
|
|
790
|
-
input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
|
|
797
|
+
input: { conversationId: string; turnId: string; reason: "aborted" | "error"; turnError?: AiTurnError | null },
|
|
791
798
|
): Promise<void> => {
|
|
792
799
|
const replaces = input.reason === "error" ? "aborted" : null;
|
|
793
800
|
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
801
|
+
if (input.turnError) {
|
|
802
|
+
await db`
|
|
803
|
+
UPDATE ${db(table)}
|
|
804
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object('turnError', ${JSON.stringify(input.turnError)}::text::jsonb)
|
|
805
|
+
WHERE id = (
|
|
806
|
+
SELECT id
|
|
807
|
+
FROM ${db(table)}
|
|
808
|
+
WHERE conversation_id = ${input.conversationId}
|
|
809
|
+
AND loop_id = ${input.turnId}::text
|
|
810
|
+
AND kind = 'message'
|
|
811
|
+
ORDER BY seq DESC
|
|
812
|
+
LIMIT 1
|
|
813
|
+
)
|
|
814
|
+
`;
|
|
815
|
+
}
|
|
794
816
|
await db`
|
|
795
817
|
UPDATE ${db(table)}
|
|
796
818
|
SET loop_done_reason = ${input.reason}
|
|
@@ -915,7 +937,7 @@ const insertMessageLocked = async (
|
|
|
915
937
|
// 'auto' is reserved for enrichment-set titles.
|
|
916
938
|
await db`
|
|
917
939
|
UPDATE ai.conversations
|
|
918
|
-
SET title = ${title}, updated_at = now()
|
|
940
|
+
SET title = CASE WHEN title_source = 'default' THEN ${title} ELSE title END, updated_at = now()
|
|
919
941
|
WHERE id = ${input.conversationId}
|
|
920
942
|
`;
|
|
921
943
|
} else {
|
|
@@ -1184,10 +1206,10 @@ export const aiConversations: AiConversationService = {
|
|
|
1184
1206
|
latest.status AS latest_turn_status,
|
|
1185
1207
|
${browserWorkPending()} AS latest_browser_pending,
|
|
1186
1208
|
latest.error AS latest_turn_error,
|
|
1187
|
-
latest.completed_at AS latest_turn_completed_at
|
|
1209
|
+
latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
|
|
1188
1210
|
FROM ai.conversations conversation
|
|
1189
1211
|
LEFT JOIN LATERAL (
|
|
1190
|
-
SELECT status, error, completed_at, live_blocks
|
|
1212
|
+
SELECT short_id, status, error, completed_at, live_blocks
|
|
1191
1213
|
FROM ai.turns
|
|
1192
1214
|
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1193
1215
|
ORDER BY created_at DESC, id DESC
|
|
@@ -1240,12 +1262,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1240
1262
|
SELECT conversation.*, ${effectiveDone()} AS is_done,
|
|
1241
1263
|
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
1242
1264
|
latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
|
|
1243
|
-
latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
|
|
1265
|
+
latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id,
|
|
1244
1266
|
jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
|
|
1245
1267
|
'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
|
|
1246
1268
|
FROM ai.conversations conversation
|
|
1247
1269
|
LEFT JOIN LATERAL (
|
|
1248
|
-
SELECT status, error, completed_at, live_blocks FROM ai.turns
|
|
1270
|
+
SELECT short_id, status, error, completed_at, live_blocks FROM ai.turns
|
|
1249
1271
|
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
|
|
1250
1272
|
) latest ON TRUE
|
|
1251
1273
|
LEFT JOIN LATERAL (
|
|
@@ -1287,10 +1309,10 @@ export const aiConversations: AiConversationService = {
|
|
|
1287
1309
|
latest.status AS latest_turn_status,
|
|
1288
1310
|
${browserWorkPending()} AS latest_browser_pending,
|
|
1289
1311
|
latest.error AS latest_turn_error,
|
|
1290
|
-
latest.completed_at AS latest_turn_completed_at
|
|
1312
|
+
latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
|
|
1291
1313
|
FROM ai.conversations conversation
|
|
1292
1314
|
LEFT JOIN LATERAL (
|
|
1293
|
-
SELECT status, error, completed_at, live_blocks
|
|
1315
|
+
SELECT short_id, status, error, completed_at, live_blocks
|
|
1294
1316
|
FROM ai.turns
|
|
1295
1317
|
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1296
1318
|
ORDER BY created_at DESC, id DESC
|
|
@@ -1926,32 +1948,68 @@ export const aiConversations: AiConversationService = {
|
|
|
1926
1948
|
},
|
|
1927
1949
|
|
|
1928
1950
|
archiveConversation: async (input) => {
|
|
1951
|
+
// Archiving considers pending learning work once, just like learning being
|
|
1952
|
+
// off at completion. Restoring cannot backfill these turns or receipts.
|
|
1929
1953
|
const rows = await sql<{ id: string }[]>`
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1954
|
+
WITH archived AS (
|
|
1955
|
+
UPDATE ai.conversations
|
|
1956
|
+
SET archived_at = now(), pinned_at = NULL
|
|
1957
|
+
WHERE id = ${input.conversationId}
|
|
1958
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1959
|
+
AND archived_at IS NULL
|
|
1960
|
+
AND NOT EXISTS (
|
|
1961
|
+
SELECT 1
|
|
1962
|
+
FROM ai.turns
|
|
1963
|
+
WHERE conversation_id = ai.conversations.id
|
|
1964
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1965
|
+
)
|
|
1966
|
+
AND NOT EXISTS (SELECT 1 FROM ai.queued_messages WHERE conversation_id = ai.conversations.id AND status IN ('pending','failed'))
|
|
1967
|
+
RETURNING id
|
|
1968
|
+
), considered_turns AS (
|
|
1969
|
+
UPDATE ai.turns
|
|
1970
|
+
SET memory_learned_at = completed_at,
|
|
1971
|
+
memory_learn_failed_at = NULL,
|
|
1972
|
+
memory_learn_fail_count = 0
|
|
1973
|
+
WHERE conversation_id IN (SELECT id FROM archived)
|
|
1974
|
+
AND status = 'completed'
|
|
1975
|
+
AND memory_learned_at IS NULL
|
|
1976
|
+
), reviewed_evidence AS (
|
|
1977
|
+
UPDATE ai.memory_workflow_evidence
|
|
1978
|
+
SET reviewed_at = now()
|
|
1979
|
+
WHERE conversation_id IN (SELECT id FROM archived)
|
|
1980
|
+
AND reviewed_at IS NULL
|
|
1981
|
+
)
|
|
1982
|
+
SELECT id FROM archived
|
|
1943
1983
|
`;
|
|
1944
1984
|
return Boolean(rows[0]);
|
|
1945
1985
|
},
|
|
1946
1986
|
|
|
1947
1987
|
restoreConversation: async (input) => {
|
|
1988
|
+
// Restore never backfills: everything completed beforehand counts as considered,
|
|
1989
|
+
// including chats archived before archiving retired pending learning work.
|
|
1948
1990
|
const rows = await sql<{ id: string }[]>`
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1991
|
+
WITH restored AS (
|
|
1992
|
+
UPDATE ai.conversations
|
|
1993
|
+
SET archived_at = NULL
|
|
1994
|
+
WHERE id = ${input.conversationId}
|
|
1995
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1996
|
+
AND archived_at IS NOT NULL
|
|
1997
|
+
RETURNING id
|
|
1998
|
+
), considered_turns AS (
|
|
1999
|
+
UPDATE ai.turns
|
|
2000
|
+
SET memory_learned_at = completed_at,
|
|
2001
|
+
memory_learn_failed_at = NULL,
|
|
2002
|
+
memory_learn_fail_count = 0
|
|
2003
|
+
WHERE conversation_id IN (SELECT id FROM restored)
|
|
2004
|
+
AND status = 'completed'
|
|
2005
|
+
AND memory_learned_at IS NULL
|
|
2006
|
+
), reviewed_evidence AS (
|
|
2007
|
+
UPDATE ai.memory_workflow_evidence
|
|
2008
|
+
SET reviewed_at = now()
|
|
2009
|
+
WHERE conversation_id IN (SELECT id FROM restored)
|
|
2010
|
+
AND reviewed_at IS NULL
|
|
2011
|
+
)
|
|
2012
|
+
SELECT id FROM restored
|
|
1955
2013
|
`;
|
|
1956
2014
|
return rows[0] ? loadConversationSummary(input) : null;
|
|
1957
2015
|
},
|
|
@@ -2957,6 +3015,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2957
3015
|
conversationId: input.conversationId,
|
|
2958
3016
|
turnId: input.turnId,
|
|
2959
3017
|
reason: input.status === "aborted" ? "aborted" : "error",
|
|
3018
|
+
turnError: input.status === "failed" ? (input.turnError ?? { code: "failed" }) : null,
|
|
2960
3019
|
});
|
|
2961
3020
|
await tx`
|
|
2962
3021
|
UPDATE ai.turn_steers
|
|
@@ -2977,11 +3036,12 @@ export const aiConversations: AiConversationService = {
|
|
|
2977
3036
|
// 1) Finalize turns that exhausted actual recovery attempts. Resuming a
|
|
2978
3037
|
// resolved user/frontend action is normal progress and does not consume
|
|
2979
3038
|
// this budget.
|
|
3039
|
+
// The sweep does not know a turn's language; the chat words the recorded reason in the reader's.
|
|
2980
3040
|
const exhaustedRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
|
|
2981
3041
|
UPDATE ai.turns
|
|
2982
3042
|
SET status = 'failed',
|
|
2983
3043
|
completed_at = now(),
|
|
2984
|
-
error =
|
|
3044
|
+
error = ${aiTurnErrorText({ code: "interrupted" }, "en")},
|
|
2985
3045
|
lease_owner = NULL,
|
|
2986
3046
|
lease_expires_at = NULL,
|
|
2987
3047
|
live_blocks = NULL
|
|
@@ -3029,11 +3089,13 @@ export const aiConversations: AiConversationService = {
|
|
|
3029
3089
|
`;
|
|
3030
3090
|
|
|
3031
3091
|
// 2) Finalize over-budget turns without a live lease.
|
|
3032
|
-
const budgetRows = await sql<
|
|
3092
|
+
const budgetRows = await sql<
|
|
3093
|
+
{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string; run_budget_ms: number | null }[]
|
|
3094
|
+
>`
|
|
3033
3095
|
UPDATE ai.turns
|
|
3034
3096
|
SET status = 'failed',
|
|
3035
3097
|
completed_at = now(),
|
|
3036
|
-
error =
|
|
3098
|
+
error = ${aiTurnErrorText({ code: "time_limit" }, "en")},
|
|
3037
3099
|
lease_owner = NULL,
|
|
3038
3100
|
lease_expires_at = NULL,
|
|
3039
3101
|
live_blocks = NULL
|
|
@@ -3047,8 +3109,12 @@ export const aiConversations: AiConversationService = {
|
|
|
3047
3109
|
AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
|
|
3048
3110
|
LIMIT ${limit}
|
|
3049
3111
|
)
|
|
3050
|
-
RETURNING id, conversation_id, error, attempt, live_seq
|
|
3112
|
+
RETURNING id, conversation_id, error, attempt, live_seq, run_budget_ms
|
|
3051
3113
|
`;
|
|
3114
|
+
const turnErrors = new Map<string, AiTurnError>([
|
|
3115
|
+
...exhaustedRows.map((row): [string, AiTurnError] => [row.id, { code: "interrupted" }]),
|
|
3116
|
+
...budgetRows.map((row): [string, AiTurnError] => [row.id, new AiRunTimeout(Number(row.run_budget_ms) || null).turnError()]),
|
|
3117
|
+
]);
|
|
3052
3118
|
result.failed = [...exhaustedRows, ...budgetRows].map((row) => ({
|
|
3053
3119
|
conversationId: row.conversation_id,
|
|
3054
3120
|
turnId: row.id,
|
|
@@ -3087,10 +3153,12 @@ export const aiConversations: AiConversationService = {
|
|
|
3087
3153
|
// History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
|
|
3088
3154
|
const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
|
|
3089
3155
|
for (const finalized of [...result.failed, ...result.aborted]) {
|
|
3156
|
+
const stop = stopped.has(finalized.turnId);
|
|
3090
3157
|
await recordTurnEnd(sql, {
|
|
3091
3158
|
conversationId: finalized.conversationId,
|
|
3092
3159
|
turnId: finalized.turnId,
|
|
3093
|
-
reason:
|
|
3160
|
+
reason: stop ? "aborted" : "error",
|
|
3161
|
+
turnError: stop ? null : (turnErrors.get(finalized.turnId) ?? { code: "wait_expired" }),
|
|
3094
3162
|
});
|
|
3095
3163
|
await sql`
|
|
3096
3164
|
UPDATE ai.pending_actions
|
package/src/ai/structured.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { AccessSubject } from "../server";
|
|
|
5
5
|
import { coreSettings } from "../services";
|
|
6
6
|
import type { TraceContext } from "../services/logging";
|
|
7
7
|
import { trace } from "../services/logging";
|
|
8
|
+
import { taskReasoningEffort } from "./provider";
|
|
8
9
|
import { inferenceProvider } from "./quota-provider";
|
|
9
10
|
import { resolveAiModel } from "./settings";
|
|
10
11
|
import { type AiUsageAttribution, safelyRecordStructuredRun } from "./structured-runs";
|
|
@@ -131,7 +132,7 @@ export const runAiStructured = async <TOutput extends z.ZodType>(
|
|
|
131
132
|
outputName: input.outputName,
|
|
132
133
|
temperature: input.temperature ?? 0,
|
|
133
134
|
maxOutputTokens: input.maxOutputTokens ?? resolved.profile.maxOutputTokens,
|
|
134
|
-
|
|
135
|
+
reasoningEffort: taskReasoningEffort(resolved.provider),
|
|
135
136
|
signal: input.signal,
|
|
136
137
|
});
|
|
137
138
|
const durationMs = Date.now() - startedAt;
|