@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/executor.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
|
|
1
|
+
import type { AssistantMessage, 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";
|
|
5
5
|
import type { AccessSubject, RequestActor } from "../server";
|
|
6
6
|
import { createHelpReader } from "../services/help";
|
|
7
7
|
import { logger } from "../services/logging";
|
|
8
|
+
import { getMandate } from "../services/mandates";
|
|
9
|
+
import type { MandatePolicyV1 } from "../services/mandates/policy";
|
|
8
10
|
import { coreSettings } from "../services/settings/api";
|
|
9
11
|
import { normalizeLocale } from "../shared/locale";
|
|
10
12
|
import {
|
|
@@ -14,11 +16,13 @@ import {
|
|
|
14
16
|
aiTurnAllowsRememberedApprovals,
|
|
15
17
|
hasRememberedAiToolApproval,
|
|
16
18
|
} from "./approvals";
|
|
17
|
-
import { isAssistantChatTurn } from "./assistant-models";
|
|
19
|
+
import { aiChatAccessSubject, isAssistantChatTurn, resolveAssistantAudioModel } from "./assistant-models";
|
|
18
20
|
import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
|
|
19
|
-
import { createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
21
|
+
import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
20
22
|
import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
|
|
21
|
-
import {
|
|
23
|
+
import { aiChatMessages } from "./chat/messages";
|
|
24
|
+
import { aiTurnErrorText } from "./chat/turn-error";
|
|
25
|
+
import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
|
|
22
26
|
import { createCloudCompactFn } from "./compaction";
|
|
23
27
|
import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
24
28
|
import { aiFileStore } from "./files-store";
|
|
@@ -26,6 +30,7 @@ import { aiMemories } from "./memories";
|
|
|
26
30
|
import { createCloudAiMemoryTool } from "./memory-tool";
|
|
27
31
|
import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
|
|
28
32
|
import { aiModelAccess } from "./model-access";
|
|
33
|
+
import { answerOpenToolCalls } from "./open-tool-calls";
|
|
29
34
|
import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
|
|
30
35
|
import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
|
|
31
36
|
import { aiProjects } from "./projects";
|
|
@@ -57,6 +62,16 @@ import { aiToolAudit } from "./tool-audit";
|
|
|
57
62
|
import { acceptCanonicalToolNames } from "./tool-call-names";
|
|
58
63
|
import { resolveAiToolResultMaxChars } from "./tool-result-budget";
|
|
59
64
|
import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
65
|
+
import { AiTranscriptionError } from "./transcription";
|
|
66
|
+
import {
|
|
67
|
+
AiTurnFailure,
|
|
68
|
+
type AiTurnFailureInfo,
|
|
69
|
+
type AiTurnFailureReason,
|
|
70
|
+
aiTurnFailureFromThrown,
|
|
71
|
+
aiTurnReasonFromThrown,
|
|
72
|
+
rememberProviderErrors,
|
|
73
|
+
} from "./turn-failure";
|
|
74
|
+
import { type AiTurnGuidance, loadAiTurnGuidance } from "./turn-guidance";
|
|
60
75
|
import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
|
|
61
76
|
import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
|
|
62
77
|
import type {
|
|
@@ -80,10 +95,17 @@ const AI_COALESCE_MS = 25;
|
|
|
80
95
|
const AI_COALESCE_MAX_CHARS = 512;
|
|
81
96
|
const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
|
|
82
97
|
|
|
98
|
+
// Failed generation can contain calls that nessi deliberately never executes or resumes.
|
|
99
|
+
const isToolRound = (message: AssistantMessage): boolean =>
|
|
100
|
+
message.stopReason !== "error" &&
|
|
101
|
+
message.stopReason !== "interrupted" &&
|
|
102
|
+
message.stopReason !== "aborted" &&
|
|
103
|
+
message.content.some((block) => block.type === "tool_call");
|
|
104
|
+
|
|
83
105
|
const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
|
|
84
106
|
const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
|
|
85
107
|
const rounds = messages.flatMap(({ message }) => {
|
|
86
|
-
if (message.role !== "assistant") return [];
|
|
108
|
+
if (message.role !== "assistant" || !isToolRound(message)) return [];
|
|
87
109
|
const callIds = message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : []));
|
|
88
110
|
return callIds.length > 0 ? [callIds] : [];
|
|
89
111
|
});
|
|
@@ -546,7 +568,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
546
568
|
tools:
|
|
547
569
|
source.kind === "default"
|
|
548
570
|
? [
|
|
549
|
-
...(await createConfiguredDefaultCloudAiTools(
|
|
571
|
+
...(await createConfiguredDefaultCloudAiTools({
|
|
572
|
+
accessSubject: aiChatAccessSubject(config.actor),
|
|
573
|
+
allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
|
|
574
|
+
})),
|
|
550
575
|
// Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
|
|
551
576
|
...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
|
|
552
577
|
(tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
|
|
@@ -564,7 +589,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
564
589
|
// ---------------------------------------------------------------------------
|
|
565
590
|
|
|
566
591
|
type AttemptOutcome =
|
|
567
|
-
| { kind: "finished"; status: "completed" | "failed" | "aborted";
|
|
592
|
+
| { kind: "finished"; status: "completed" | "failed" | "aborted"; failure: AiTurnFailureInfo | null; timing?: LoopAggregate["timing"] }
|
|
568
593
|
| { kind: "suspended" };
|
|
569
594
|
|
|
570
595
|
export class AiTurnExecutor {
|
|
@@ -587,7 +612,14 @@ export class AiTurnExecutor {
|
|
|
587
612
|
pipeline.seedBaseline(claim.liveBlocks ?? []);
|
|
588
613
|
await pipeline.emitTurnStarted(claim.turn.modelProfileId ?? "");
|
|
589
614
|
await pipeline.emitBaseline();
|
|
590
|
-
await this.finalize(
|
|
615
|
+
await this.finalize(
|
|
616
|
+
conversationId,
|
|
617
|
+
turnId,
|
|
618
|
+
pipeline,
|
|
619
|
+
"failed",
|
|
620
|
+
{ error: { code: "failed" }, detail: "AI turn is missing its run configuration." },
|
|
621
|
+
null,
|
|
622
|
+
);
|
|
591
623
|
return;
|
|
592
624
|
}
|
|
593
625
|
|
|
@@ -611,8 +643,9 @@ export class AiTurnExecutor {
|
|
|
611
643
|
turnId,
|
|
612
644
|
pipeline,
|
|
613
645
|
"failed",
|
|
614
|
-
error
|
|
646
|
+
aiTurnFailureFromThrown(error, "AI turn state could not be loaded."),
|
|
615
647
|
"chat",
|
|
648
|
+
claim.runConfig?.kind === "chat" ? claim.runConfig.locale : undefined,
|
|
616
649
|
);
|
|
617
650
|
return;
|
|
618
651
|
}
|
|
@@ -622,16 +655,39 @@ export class AiTurnExecutor {
|
|
|
622
655
|
await this.runChat(conversationId, turnId, claim, runConfig, pipeline, signal, false, attemptState);
|
|
623
656
|
}
|
|
624
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Ends the turn. A failure stores its reason twice: as a code on the turn's last message, which the chat words in
|
|
660
|
+
* the reader's language, and as text in the turn's language for readers without the chat view. The raw cause, such
|
|
661
|
+
* as a provider's own message, goes only to the log. A failed compaction left the chat as it was, so its text names
|
|
662
|
+
* only the reason.
|
|
663
|
+
*/
|
|
625
664
|
private async finalize(
|
|
626
665
|
conversationId: string,
|
|
627
666
|
turnId: string,
|
|
628
667
|
pipeline: StreamPipeline,
|
|
629
668
|
status: "completed" | "failed" | "aborted",
|
|
630
|
-
|
|
669
|
+
failure: AiTurnFailureInfo | null,
|
|
631
670
|
kind: AiTurnRunConfig["kind"] | null,
|
|
671
|
+
locale?: string,
|
|
632
672
|
) {
|
|
633
|
-
|
|
634
|
-
|
|
673
|
+
const failed = status === "failed" ? (failure ?? { error: { code: "failed" as const }, detail: "AI turn failed" }) : null;
|
|
674
|
+
let error: string | null = null;
|
|
675
|
+
if (failed) {
|
|
676
|
+
log.error("AI turn failed", { conversationId, turnId, code: failed.error.code, error: failed.detail });
|
|
677
|
+
error = failed.message ?? null;
|
|
678
|
+
if (!error) {
|
|
679
|
+
const textLocale = normalizeLocale(locale ?? (await coreSettings.get<string>("app.locale")));
|
|
680
|
+
error = kind === "compact" ? aiChatMessages(textLocale).turnErrorReason(failed.error) : aiTurnErrorText(failed.error, textLocale);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const finalized = await aiConversations.completeTurn({
|
|
684
|
+
conversationId,
|
|
685
|
+
turnId,
|
|
686
|
+
status,
|
|
687
|
+
error,
|
|
688
|
+
turnError: failed?.error ?? null,
|
|
689
|
+
leaseOwner: this.config.leaseOwner,
|
|
690
|
+
});
|
|
635
691
|
if (finalized === "completed") await pipeline.emitTurnFinished(status, error);
|
|
636
692
|
await pipeline.flush().catch(() => undefined);
|
|
637
693
|
if (finalized === "completed" && this.config.onTurnFinalized) {
|
|
@@ -686,7 +742,7 @@ export class AiTurnExecutor {
|
|
|
686
742
|
const subject = accessSubjectForActor(material.actor);
|
|
687
743
|
const project = subject ? await aiProjects.getByShortId(config.project.id, subject, "read") : null;
|
|
688
744
|
if (!project) {
|
|
689
|
-
throw new
|
|
745
|
+
throw new AiTurnFailure("not_allowed", "Project access is no longer available.");
|
|
690
746
|
}
|
|
691
747
|
resolvedProjectId = project.id;
|
|
692
748
|
}
|
|
@@ -709,7 +765,15 @@ export class AiTurnExecutor {
|
|
|
709
765
|
}
|
|
710
766
|
} catch (error) {
|
|
711
767
|
signal.removeEventListener("abort", onSignal);
|
|
712
|
-
await this.finalize(
|
|
768
|
+
await this.finalize(
|
|
769
|
+
conversationId,
|
|
770
|
+
turnId,
|
|
771
|
+
pipeline,
|
|
772
|
+
"failed",
|
|
773
|
+
aiTurnFailureFromThrown(error, "AI turn failed"),
|
|
774
|
+
"chat",
|
|
775
|
+
config.locale,
|
|
776
|
+
);
|
|
713
777
|
return;
|
|
714
778
|
}
|
|
715
779
|
const { settings, resolved } = validated;
|
|
@@ -732,8 +796,12 @@ export class AiTurnExecutor {
|
|
|
732
796
|
turnId,
|
|
733
797
|
pipeline,
|
|
734
798
|
"failed",
|
|
735
|
-
|
|
799
|
+
{
|
|
800
|
+
error: { code: "not_allowed" },
|
|
801
|
+
detail: error instanceof Error ? error.message : "Cloud capability actor resolution failed",
|
|
802
|
+
},
|
|
736
803
|
"chat",
|
|
804
|
+
config.locale,
|
|
737
805
|
);
|
|
738
806
|
return;
|
|
739
807
|
}
|
|
@@ -801,6 +869,20 @@ export class AiTurnExecutor {
|
|
|
801
869
|
(tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
|
|
802
870
|
)
|
|
803
871
|
: [];
|
|
872
|
+
let audioUnavailableInstruction: string | undefined;
|
|
873
|
+
if (
|
|
874
|
+
defaultToolSource &&
|
|
875
|
+
toolsSupported &&
|
|
876
|
+
[...(config.files?.attached ?? []), ...(config.files?.available ?? [])].some((file) => file.mediaType.startsWith("audio/")) &&
|
|
877
|
+
!sourceToolNames.includes("transcribe_audio")
|
|
878
|
+
) {
|
|
879
|
+
try {
|
|
880
|
+
await resolveAssistantAudioModel(aiChatAccessSubject(config.actor), material.modelPolicy?.allowedDataBoundaries);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
const reason = error instanceof AiTranscriptionError ? error.message : "Audio transcription is not available right now.";
|
|
883
|
+
audioUnavailableInstruction = `Audio files in this conversation cannot be transcribed: ${reason} Tell the user this reason when they ask about audio content.`;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
804
886
|
const offeredToolNames = new Set(activeTools.map((tool) => tool.def.name));
|
|
805
887
|
// Built-ins that exist but this turn does not offer: client tools without their client, tools a
|
|
806
888
|
// task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
|
|
@@ -865,8 +947,9 @@ export class AiTurnExecutor {
|
|
|
865
947
|
turnId,
|
|
866
948
|
pipeline,
|
|
867
949
|
"failed",
|
|
868
|
-
error
|
|
950
|
+
aiTurnFailureFromThrown(error, "Image preparation failed"),
|
|
869
951
|
"chat",
|
|
952
|
+
promptLocale,
|
|
870
953
|
);
|
|
871
954
|
return;
|
|
872
955
|
}
|
|
@@ -908,6 +991,52 @@ export class AiTurnExecutor {
|
|
|
908
991
|
|
|
909
992
|
const appliedSteers: AiTurnSteer[] = [];
|
|
910
993
|
|
|
994
|
+
let mandatePolicy: MandatePolicyV1 | null | undefined;
|
|
995
|
+
if (config.background && config.mandate) {
|
|
996
|
+
mandatePolicy = null;
|
|
997
|
+
try {
|
|
998
|
+
const mandate = await getMandate(config.mandate.id);
|
|
999
|
+
if (mandate && mandate.state === "active" && mandate.revision === config.mandate.revision) {
|
|
1000
|
+
mandatePolicy = mandate.policy;
|
|
1001
|
+
} else {
|
|
1002
|
+
backgroundError = "Scheduled task mandate is unavailable or changed; recreate or update the task.";
|
|
1003
|
+
}
|
|
1004
|
+
} catch {
|
|
1005
|
+
backgroundError = "Scheduled task mandate could not be loaded; retry or update the task.";
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (backgroundError) {
|
|
1009
|
+
signal.removeEventListener("abort", onSignal);
|
|
1010
|
+
await this.finalize(
|
|
1011
|
+
conversationId,
|
|
1012
|
+
turnId,
|
|
1013
|
+
pipeline,
|
|
1014
|
+
"failed",
|
|
1015
|
+
{ error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError },
|
|
1016
|
+
"chat",
|
|
1017
|
+
promptLocale,
|
|
1018
|
+
);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const authorizeBackground =
|
|
1022
|
+
config.background && config.mandate
|
|
1023
|
+
? async (entry: AiCapabilityCatalogEntry, args: unknown) => {
|
|
1024
|
+
try {
|
|
1025
|
+
await aiChatTasks.authorizeCapability({
|
|
1026
|
+
mandate: config.mandate!,
|
|
1027
|
+
appId: entry.appId,
|
|
1028
|
+
capabilityId: entry.operation.localId,
|
|
1029
|
+
kind: entry.kind,
|
|
1030
|
+
input: args,
|
|
1031
|
+
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
1032
|
+
});
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
if (!(error instanceof AiChatTaskAuthorityError && error.code === "MANDATE_POLICY_DENIED"))
|
|
1035
|
+
backgroundError = error instanceof Error ? error.message : "Background capability access denied";
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
: undefined;
|
|
911
1040
|
const toolStore = config.background ? createRunToolStore(await aiConversations.getLoadedTools({ conversationId })) : aiConversations;
|
|
912
1041
|
const tools = toolActor
|
|
913
1042
|
? createAiToolResolver({
|
|
@@ -915,6 +1044,7 @@ export class AiTurnExecutor {
|
|
|
915
1044
|
actor: capabilityAuthority?.actor ?? toolActor,
|
|
916
1045
|
staticTools: activeTools,
|
|
917
1046
|
allowedTools,
|
|
1047
|
+
mandatePolicy,
|
|
918
1048
|
unofferedTools,
|
|
919
1049
|
runtimeContext: dynamicToolRuntimeContext,
|
|
920
1050
|
store: toolStore,
|
|
@@ -940,25 +1070,7 @@ export class AiTurnExecutor {
|
|
|
940
1070
|
}),
|
|
941
1071
|
}
|
|
942
1072
|
: {}),
|
|
943
|
-
|
|
944
|
-
? {
|
|
945
|
-
authorizeBackground: async (entry, args) => {
|
|
946
|
-
try {
|
|
947
|
-
await aiChatTasks.authorizeCapability({
|
|
948
|
-
mandate: config.mandate!,
|
|
949
|
-
appId: entry.appId,
|
|
950
|
-
capabilityId: entry.operation.localId,
|
|
951
|
-
kind: entry.kind,
|
|
952
|
-
input: args,
|
|
953
|
-
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
954
|
-
});
|
|
955
|
-
} catch (error) {
|
|
956
|
-
backgroundError = error instanceof Error ? error.message : "Background capability access denied";
|
|
957
|
-
throw error;
|
|
958
|
-
}
|
|
959
|
-
},
|
|
960
|
-
}
|
|
961
|
-
: {}),
|
|
1073
|
+
authorizeBackground,
|
|
962
1074
|
onReview: (callId, review) => {
|
|
963
1075
|
capabilityActionReviews.set(callId, review);
|
|
964
1076
|
if (review.approvalScope) rememberableCapabilityApprovals.set(callId, review.approvalScope);
|
|
@@ -967,16 +1079,7 @@ export class AiTurnExecutor {
|
|
|
967
1079
|
? {
|
|
968
1080
|
execute: async (entry, args, context) => {
|
|
969
1081
|
try {
|
|
970
|
-
|
|
971
|
-
await aiChatTasks.authorizeCapability({
|
|
972
|
-
mandate: config.mandate,
|
|
973
|
-
appId: entry.appId,
|
|
974
|
-
capabilityId: entry.operation.localId,
|
|
975
|
-
kind: entry.kind,
|
|
976
|
-
input: args,
|
|
977
|
-
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
978
|
-
});
|
|
979
|
-
}
|
|
1082
|
+
await authorizeBackground?.(entry, args);
|
|
980
1083
|
const result = await executeAiCapability({
|
|
981
1084
|
conversationId,
|
|
982
1085
|
turnId,
|
|
@@ -1046,12 +1149,43 @@ export class AiTurnExecutor {
|
|
|
1046
1149
|
skillSubject,
|
|
1047
1150
|
activeTools.some((tool) => tool.def.name === "load_skill"),
|
|
1048
1151
|
);
|
|
1049
|
-
const
|
|
1152
|
+
const chat = await aiConversations.getConversation({ conversationId });
|
|
1153
|
+
const workingPlan = chat?.todoPlan;
|
|
1154
|
+
const skillCatalogOffered = activeTools.some((tool) => tool.def.name === "load_skill");
|
|
1155
|
+
const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator");
|
|
1156
|
+
// Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
|
|
1157
|
+
// attempt after an approval or a scheduled run gets neither.
|
|
1158
|
+
let guidance: AiTurnGuidance = {};
|
|
1159
|
+
if (isFresh && !config.background && defaultToolSource && user && chat) {
|
|
1160
|
+
try {
|
|
1161
|
+
guidance = await loadAiTurnGuidance(
|
|
1162
|
+
{
|
|
1163
|
+
chat,
|
|
1164
|
+
turnId,
|
|
1165
|
+
ownerUserId: user.id,
|
|
1166
|
+
input: config.input,
|
|
1167
|
+
instructions: [settings.globalInstructions, config.project?.instructions, memory?.text],
|
|
1168
|
+
skillOffers: skillCatalogOffered && skillCreatorAvailable && loadedSkills.length === 0,
|
|
1169
|
+
memoryOffers: memoryToolEnabled,
|
|
1170
|
+
hasAttachments: Boolean(config.files?.attached.length),
|
|
1171
|
+
},
|
|
1172
|
+
aiConversations,
|
|
1173
|
+
);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
log.warn("AI turn guidance unavailable; continuing without it", {
|
|
1176
|
+
conversationId,
|
|
1177
|
+
turnId,
|
|
1178
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1050
1182
|
const systemPrompt = composeAiSystemPrompt({
|
|
1051
1183
|
globalInstructions: settings.globalInstructions,
|
|
1052
1184
|
loadedSkills,
|
|
1053
1185
|
turnInstructions: [
|
|
1054
1186
|
material.systemPrompt,
|
|
1187
|
+
audioUnavailableInstruction,
|
|
1188
|
+
guidance.offerHint,
|
|
1055
1189
|
workingPlan
|
|
1056
1190
|
? `Current working plan (new todo_write results supersede this):\n${JSON.stringify({ todos: workingPlan.todos })}`
|
|
1057
1191
|
: undefined,
|
|
@@ -1067,7 +1201,7 @@ export class AiTurnExecutor {
|
|
|
1067
1201
|
project: config.project,
|
|
1068
1202
|
files: config.files,
|
|
1069
1203
|
projectToolEnabled,
|
|
1070
|
-
skills:
|
|
1204
|
+
skills: skillCatalogOffered ? skillCatalog.skills : undefined,
|
|
1071
1205
|
omittedSkillCount: activeTools.some((tool) => tool.def.name === "search_skills") ? skillCatalog.omitted : 0,
|
|
1072
1206
|
user,
|
|
1073
1207
|
memoryEnabled: memoryActive,
|
|
@@ -1080,18 +1214,34 @@ export class AiTurnExecutor {
|
|
|
1080
1214
|
timeZone,
|
|
1081
1215
|
locale: promptLocale,
|
|
1082
1216
|
interactive: !config.background,
|
|
1083
|
-
skillCreatorAvailable
|
|
1217
|
+
skillCreatorAvailable,
|
|
1218
|
+
recentWork: guidance.recentWork,
|
|
1084
1219
|
});
|
|
1085
1220
|
// The turn policy counts the whole turn, including rounds that compaction archived.
|
|
1086
1221
|
const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
|
|
1087
1222
|
const turnBlocks = buildBlocksFromMessages(turnMessages);
|
|
1223
|
+
const providerNamesByCallId = new Map(
|
|
1224
|
+
turnMessages.flatMap(({ message }) =>
|
|
1225
|
+
message.role === "assistant"
|
|
1226
|
+
? message.content.flatMap((part) => (part.type === "tool_call" ? [[part.id, part.name] as const] : []))
|
|
1227
|
+
: [],
|
|
1228
|
+
),
|
|
1229
|
+
);
|
|
1088
1230
|
const priorToolRounds = toolRoundState(turnMessages);
|
|
1089
1231
|
const quotaSubject = accessSubjectForActor(material.actor);
|
|
1090
1232
|
const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
|
|
1233
|
+
// The reason a failed turn names: the one the current model call or the turn's own checks gave.
|
|
1234
|
+
const failureReason: { current: AiTurnFailureReason | null } = { current: null };
|
|
1235
|
+
const remember = (reason: AiTurnFailureReason | null) => {
|
|
1236
|
+
failureReason.current = reason;
|
|
1237
|
+
};
|
|
1091
1238
|
const turnPolicy = applyAiTurnPolicy({
|
|
1092
1239
|
provider: acceptCanonicalToolNames(
|
|
1093
1240
|
retryTransientProviderErrors(
|
|
1094
|
-
|
|
1241
|
+
rememberProviderErrors(
|
|
1242
|
+
answerOpenToolCalls(assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId)),
|
|
1243
|
+
remember,
|
|
1244
|
+
),
|
|
1095
1245
|
{
|
|
1096
1246
|
deadline,
|
|
1097
1247
|
delaysMs: this.config.providerRetryDelaysMs,
|
|
@@ -1111,7 +1261,7 @@ export class AiTurnExecutor {
|
|
|
1111
1261
|
runBudgetMs: claim.turn.runBudgetMs ?? null,
|
|
1112
1262
|
finishedToolCalls: turnBlocks
|
|
1113
1263
|
.slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
|
|
1114
|
-
.flatMap((block) => (block.kind === "tool" ? [block] : [])),
|
|
1264
|
+
.flatMap((block) => (block.kind === "tool" ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }] : [])),
|
|
1115
1265
|
onDecision: (decision) =>
|
|
1116
1266
|
decision.kind === "hint"
|
|
1117
1267
|
? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
|
|
@@ -1136,7 +1286,14 @@ export class AiTurnExecutor {
|
|
|
1136
1286
|
turnPolicy.noteSteering();
|
|
1137
1287
|
return steers.map((steer) => steer.text);
|
|
1138
1288
|
},
|
|
1139
|
-
tools:
|
|
1289
|
+
tools: async () => {
|
|
1290
|
+
try {
|
|
1291
|
+
return await turnPolicy.tools();
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
remember(aiTurnReasonFromThrown(error));
|
|
1294
|
+
throw error;
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1140
1297
|
...(turnPolicy.maxTurns === undefined ? {} : { maxTurns: turnPolicy.maxTurns }),
|
|
1141
1298
|
temperature: resolved.profile.temperature,
|
|
1142
1299
|
maxOutputTokens: resolved.profile.maxOutputTokens,
|
|
@@ -1178,6 +1335,7 @@ export class AiTurnExecutor {
|
|
|
1178
1335
|
appliedSteers,
|
|
1179
1336
|
noteToolRound: turnPolicy.noteToolRound,
|
|
1180
1337
|
noteToolCall: turnPolicy.noteToolCall,
|
|
1338
|
+
failureReason,
|
|
1181
1339
|
onBackgroundBlocked: (message) => {
|
|
1182
1340
|
backgroundError = message;
|
|
1183
1341
|
},
|
|
@@ -1202,15 +1360,17 @@ export class AiTurnExecutor {
|
|
|
1202
1360
|
: null;
|
|
1203
1361
|
if (timeout) {
|
|
1204
1362
|
outcome.status = "failed";
|
|
1205
|
-
outcome.
|
|
1363
|
+
outcome.failure = { error: timeout.turnError(), detail: "Run time limit reached." };
|
|
1206
1364
|
}
|
|
1207
1365
|
const finalized = await this.finalize(
|
|
1208
1366
|
conversationId,
|
|
1209
1367
|
turnId,
|
|
1210
1368
|
pipeline,
|
|
1211
1369
|
backgroundError ? "failed" : outcome.status,
|
|
1212
|
-
|
|
1370
|
+
// A background run keeps Cloud's own words for what its mandate blocked.
|
|
1371
|
+
backgroundError ? { error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError } : outcome.failure,
|
|
1213
1372
|
"chat",
|
|
1373
|
+
promptLocale,
|
|
1214
1374
|
);
|
|
1215
1375
|
if (finalized === "pending_steering" && outcome.status === "completed" && !signal.aborted) {
|
|
1216
1376
|
await this.runChat(conversationId, turnId, claim, config, pipeline, signal, true);
|
|
@@ -1244,6 +1404,12 @@ export class AiTurnExecutor {
|
|
|
1244
1404
|
appliedSteers: AiTurnSteer[];
|
|
1245
1405
|
noteToolRound: () => void;
|
|
1246
1406
|
noteToolCall: (call: AiTurnPolicyToolCall) => void;
|
|
1407
|
+
/**
|
|
1408
|
+
* Why the turn would fail now, as a model call or the turn's own checks said. A new model call starts without one,
|
|
1409
|
+
* so a context overflow that compaction resolved never names a later failure. Only the model calls and checks set
|
|
1410
|
+
* it, in the order they run; the events here arrive later than that.
|
|
1411
|
+
*/
|
|
1412
|
+
failureReason: { current: AiTurnFailureReason | null };
|
|
1247
1413
|
onBackgroundBlocked?: (message: string) => void;
|
|
1248
1414
|
}): Promise<AttemptOutcome> {
|
|
1249
1415
|
const {
|
|
@@ -1260,6 +1426,7 @@ export class AiTurnExecutor {
|
|
|
1260
1426
|
appliedSteers,
|
|
1261
1427
|
noteToolRound,
|
|
1262
1428
|
noteToolCall,
|
|
1429
|
+
failureReason,
|
|
1263
1430
|
} = input;
|
|
1264
1431
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1265
1432
|
let lastIssueMessage: string | null = null;
|
|
@@ -1333,7 +1500,7 @@ export class AiTurnExecutor {
|
|
|
1333
1500
|
result: event.result,
|
|
1334
1501
|
isError: event.isError === true,
|
|
1335
1502
|
});
|
|
1336
|
-
} else if (event.type === "turn_end" &&
|
|
1503
|
+
} else if (event.type === "turn_end" && !abortController.signal.aborted && isToolRound(event.message)) {
|
|
1337
1504
|
noteToolRound();
|
|
1338
1505
|
} else if (event.type === "issue") {
|
|
1339
1506
|
lastIssueMessage = event.issue.message;
|
|
@@ -1346,25 +1513,24 @@ export class AiTurnExecutor {
|
|
|
1346
1513
|
.catch(() => undefined);
|
|
1347
1514
|
}
|
|
1348
1515
|
const timing = aggregate.timing;
|
|
1349
|
-
if (event.reason === "aborted") return { kind: "finished", status: "aborted",
|
|
1350
|
-
if (event.reason === "stop") return { kind: "finished", status: "completed",
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1516
|
+
if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
|
|
1517
|
+
if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
|
|
1518
|
+
const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
|
|
1519
|
+
const reason: AiTurnFailureReason =
|
|
1520
|
+
event.reason === "max_turns"
|
|
1521
|
+
? { error: { code: "step_limit" } }
|
|
1522
|
+
: event.reason === "context_overflow"
|
|
1523
|
+
? { error: { code: "context_full" } }
|
|
1524
|
+
: event.reason === "no_credits"
|
|
1525
|
+
? { error: { code: "quota_exhausted" } }
|
|
1526
|
+
: (failureReason.current ?? { error: { code: "failed" } });
|
|
1527
|
+
return { kind: "finished", status: "failed", failure: { ...reason, detail }, timing };
|
|
1360
1528
|
}
|
|
1361
1529
|
}
|
|
1362
|
-
return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed",
|
|
1530
|
+
return { kind: "finished", status: abortController.signal.aborted ? "aborted" : "completed", failure: null };
|
|
1363
1531
|
} catch (error) {
|
|
1364
|
-
if (abortController.signal.aborted) return { kind: "finished", status: "aborted",
|
|
1365
|
-
|
|
1366
|
-
await pipeline.emitError(message).catch(() => undefined);
|
|
1367
|
-
return { kind: "finished", status: "failed", error: message };
|
|
1532
|
+
if (abortController.signal.aborted) return { kind: "finished", status: "aborted", failure: null };
|
|
1533
|
+
return { kind: "finished", status: "failed", failure: aiTurnFailureFromThrown(error, "AI turn failed") };
|
|
1368
1534
|
} finally {
|
|
1369
1535
|
stopHeartbeat();
|
|
1370
1536
|
await pipeline.flush().catch(() => undefined);
|
|
@@ -1556,14 +1722,7 @@ export class AiTurnExecutor {
|
|
|
1556
1722
|
});
|
|
1557
1723
|
} catch (error) {
|
|
1558
1724
|
signal.removeEventListener("abort", onSignal);
|
|
1559
|
-
await this.finalize(
|
|
1560
|
-
conversationId,
|
|
1561
|
-
turnId,
|
|
1562
|
-
pipeline,
|
|
1563
|
-
"failed",
|
|
1564
|
-
error instanceof Error ? error.message : "AI compaction failed",
|
|
1565
|
-
"compact",
|
|
1566
|
-
);
|
|
1725
|
+
await this.finalize(conversationId, turnId, pipeline, "failed", aiTurnFailureFromThrown(error, "AI compaction failed"), "compact");
|
|
1567
1726
|
return;
|
|
1568
1727
|
}
|
|
1569
1728
|
const { settings, resolved } = validated;
|
|
@@ -1574,17 +1733,23 @@ export class AiTurnExecutor {
|
|
|
1574
1733
|
turnId,
|
|
1575
1734
|
leaseOwner: this.config.leaseOwner,
|
|
1576
1735
|
});
|
|
1736
|
+
const reason: { current: AiTurnFailureReason | null } = { current: null };
|
|
1577
1737
|
const loop = compact({
|
|
1578
1738
|
agentId: "cloud",
|
|
1579
1739
|
loopId: turnId,
|
|
1580
1740
|
store,
|
|
1581
|
-
provider:
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1741
|
+
provider: rememberProviderErrors(
|
|
1742
|
+
inferenceProvider(resolved.provider, resolved.profile, {
|
|
1743
|
+
kind: "background",
|
|
1744
|
+
task: "chat-compaction",
|
|
1745
|
+
conversationId,
|
|
1746
|
+
turnId,
|
|
1747
|
+
appId: "core",
|
|
1748
|
+
}),
|
|
1749
|
+
(remembered) => {
|
|
1750
|
+
reason.current = remembered;
|
|
1751
|
+
},
|
|
1752
|
+
),
|
|
1588
1753
|
force: true,
|
|
1589
1754
|
signal: abortController.signal,
|
|
1590
1755
|
compact: createCloudCompactFn({
|
|
@@ -1603,15 +1768,21 @@ export class AiTurnExecutor {
|
|
|
1603
1768
|
|
|
1604
1769
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1605
1770
|
let status: "completed" | "failed" | "aborted" = "failed";
|
|
1606
|
-
let
|
|
1771
|
+
let failure: AiTurnFailureInfo | null = null;
|
|
1772
|
+
let issueMessage: string | null = null;
|
|
1607
1773
|
try {
|
|
1608
1774
|
for await (const event of loop as AsyncIterable<CompactEvent>) {
|
|
1609
1775
|
if (event.type === "compaction_start") await pipeline.applyCompaction("running");
|
|
1610
1776
|
else if (event.type === "compaction_end") await pipeline.applyCompaction("completed");
|
|
1611
|
-
else if (event.type === "issue")
|
|
1777
|
+
else if (event.type === "issue") issueMessage = event.issue.message;
|
|
1612
1778
|
else if (event.type === "loop_end") {
|
|
1613
1779
|
status = event.reason === "stop" ? "completed" : event.reason === "aborted" ? "aborted" : "failed";
|
|
1614
1780
|
await pipeline.applyCompaction(status === "failed" ? "failed" : "completed", event.result);
|
|
1781
|
+
if (status === "failed")
|
|
1782
|
+
failure = {
|
|
1783
|
+
...(reason.current ?? { error: { code: "failed" } }),
|
|
1784
|
+
detail: issueMessage ?? `AI compaction ended: ${event.reason}`,
|
|
1785
|
+
};
|
|
1615
1786
|
}
|
|
1616
1787
|
}
|
|
1617
1788
|
} catch (err) {
|
|
@@ -1619,8 +1790,8 @@ export class AiTurnExecutor {
|
|
|
1619
1790
|
status = "aborted";
|
|
1620
1791
|
} else {
|
|
1621
1792
|
status = "failed";
|
|
1622
|
-
|
|
1623
|
-
|
|
1793
|
+
const thrown = aiTurnFailureFromThrown(err, "AI compaction failed");
|
|
1794
|
+
failure = reason.current ? { ...reason.current, detail: thrown.detail } : thrown;
|
|
1624
1795
|
}
|
|
1625
1796
|
} finally {
|
|
1626
1797
|
stopHeartbeat();
|
|
@@ -1629,9 +1800,9 @@ export class AiTurnExecutor {
|
|
|
1629
1800
|
|
|
1630
1801
|
if (abortController.signal.reason instanceof AiRunTimeout) {
|
|
1631
1802
|
status = "failed";
|
|
1632
|
-
|
|
1803
|
+
failure = { error: abortController.signal.reason.turnError(), detail: "Run time limit reached." };
|
|
1633
1804
|
}
|
|
1634
|
-
await this.finalize(conversationId, turnId, pipeline, status,
|
|
1805
|
+
await this.finalize(conversationId, turnId, pipeline, status, failure, "compact");
|
|
1635
1806
|
}
|
|
1636
1807
|
}
|
|
1637
1808
|
|
|
@@ -1838,14 +2009,6 @@ class StreamPipeline {
|
|
|
1838
2009
|
await this.saving;
|
|
1839
2010
|
}
|
|
1840
2011
|
|
|
1841
|
-
async emitError(message: string): Promise<void> {
|
|
1842
|
-
const seq = this.nextSeq();
|
|
1843
|
-
const block: AiTurnBlock = { id: `error-${this.attempt}-${seq}`, kind: "text", text: `⚠️ ${message}` };
|
|
1844
|
-
const event = this.envelope({ type: "block_set" as const, seq, block }) as AiWireEvent;
|
|
1845
|
-
this.blocks = applyWireEventToBlocks(this.blocks, event);
|
|
1846
|
-
await this.publish(event);
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
2012
|
/** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
|
|
1850
2013
|
async emitProviderRetry(): Promise<void> {
|
|
1851
2014
|
const seq = this.nextSeq();
|
|
@@ -38,3 +38,7 @@ export const guessAiMediaType = (path: string): string => {
|
|
|
38
38
|
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
39
39
|
return MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
40
40
|
};
|
|
41
|
+
|
|
42
|
+
/** Image classification includes filenames when clients send a generic media type. */
|
|
43
|
+
export const isAiImage = (path: string, mediaType: string): boolean =>
|
|
44
|
+
mediaType.startsWith("image/") || guessAiMediaType(path).startsWith("image/");
|