@k2b/cloud 0.38.0 → 0.40.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/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
package/src/ai/protocol.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CompactResult, InboundEvent, Message } from "@k2b/nessi";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CapabilityActionReviewData } from "../contracts/capabilities";
|
|
3
|
+
import type { AiActionCheck, AiStoredActionReview } from "./action-review";
|
|
3
4
|
import type { AiConversation, AiFrontendToolMode, AiStoredMessage, AiToolPresentation, AiTurnStatus } from "./types";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -48,7 +49,7 @@ export type AiTurnBlock =
|
|
|
48
49
|
/** Present while status is awaiting_approval. */
|
|
49
50
|
approval?: {
|
|
50
51
|
message?: string;
|
|
51
|
-
review?:
|
|
52
|
+
review?: CapabilityActionReviewData;
|
|
52
53
|
allowAlways: boolean;
|
|
53
54
|
/** Whether the approval can be remembered for this chat. */
|
|
54
55
|
allowChat?: boolean;
|
|
@@ -66,6 +67,13 @@ export type AiTurnBlock =
|
|
|
66
67
|
presentation?: AiToolPresentation;
|
|
67
68
|
/** Requests a website approval let through without asking, in order; shown whatever the call's outcome. */
|
|
68
69
|
receipts?: { method: string; url: string }[];
|
|
70
|
+
/**
|
|
71
|
+
* The call's review as Cloud stored it when the app reviewed it. It stays after the decision, so the question,
|
|
72
|
+
* its receipt, and a reload all read the same sentence.
|
|
73
|
+
*/
|
|
74
|
+
review?: AiStoredActionReview;
|
|
75
|
+
/** The app's `check` after an outcome Cloud could not see. */
|
|
76
|
+
check?: AiActionCheck;
|
|
69
77
|
}
|
|
70
78
|
| { id: string; kind: "compaction"; status: "running" | "completed" | "skipped" | "failed"; result?: CompactResult };
|
|
71
79
|
|
|
@@ -248,6 +256,8 @@ export const buildBlocksFromMessages = (
|
|
|
248
256
|
meta?: {
|
|
249
257
|
steerId?: string;
|
|
250
258
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
259
|
+
toolReviews?: Record<string, AiStoredActionReview>;
|
|
260
|
+
toolChecks?: Record<string, AiActionCheck>;
|
|
251
261
|
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
252
262
|
websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
|
|
253
263
|
} | null;
|
|
@@ -275,6 +285,8 @@ export const buildBlocksFromMessages = (
|
|
|
275
285
|
args: block.args,
|
|
276
286
|
status: "running",
|
|
277
287
|
presentation: meta?.toolPresentations?.[block.id],
|
|
288
|
+
review: meta?.toolReviews?.[block.id],
|
|
289
|
+
check: meta?.toolChecks?.[block.id],
|
|
278
290
|
// A turn that ended before an approved call returned records the approval on the call's message, and an
|
|
279
291
|
// approval it left waiting as expired.
|
|
280
292
|
...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
3
|
+
import { listCapabilities } from "../_internal/registry";
|
|
4
|
+
import { type CapabilityDispatchParams, dispatchCapability, QUERY_TIMEOUT_MS } from "../api/capabilities";
|
|
5
|
+
import {
|
|
6
|
+
CloudResourceRefSchema,
|
|
7
|
+
capabilityResultSchema,
|
|
8
|
+
cloudResourceRefAppId,
|
|
9
|
+
LinkedCloudResourceReferenceSchema,
|
|
10
|
+
resolveCapabilityResourceReader,
|
|
11
|
+
} from "../contracts/capabilities";
|
|
12
|
+
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
13
|
+
import type { RequestAuthority } from "../server";
|
|
14
|
+
import { resolveAppIdentityPresentation } from "../shared/app-presentation";
|
|
15
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
16
|
+
import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
|
|
17
|
+
import { createCodeSourceTool } from "./code-source-tools";
|
|
18
|
+
import { RECENT_SENT_REF_LIMIT } from "./recent-sent-refs";
|
|
19
|
+
import { addCloudResourceHrefs } from "./resource-refs";
|
|
20
|
+
import { aiSkills } from "./skills";
|
|
21
|
+
import { aiConversations } from "./store";
|
|
22
|
+
import type { AiConversationService, AiRecentResource } from "./types";
|
|
23
|
+
|
|
24
|
+
export const AiRecentResourceSchema: z.ZodType<AiRecentResource> = z
|
|
25
|
+
.object({
|
|
26
|
+
ref: CloudResourceRefSchema,
|
|
27
|
+
title: LinkedCloudResourceReferenceSchema.shape.title.unwrap(),
|
|
28
|
+
icon: LinkedCloudResourceReferenceSchema.shape.icon,
|
|
29
|
+
href: LinkedCloudResourceReferenceSchema.shape.href,
|
|
30
|
+
kind: z.string().min(1).max(120),
|
|
31
|
+
preview: z.string().max(160).optional(),
|
|
32
|
+
appName: z.string().min(1).max(120),
|
|
33
|
+
count: z.number().int().positive(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
|
|
37
|
+
const ReaderResult = capabilityResultSchema(z.unknown(), { consumer: true });
|
|
38
|
+
const StudioData = z.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
title: LinkedCloudResourceReferenceSchema.shape.title.unwrap(),
|
|
41
|
+
icon: LinkedCloudResourceReferenceSchema.shape.icon,
|
|
42
|
+
description: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
const studioReader = async (input: {
|
|
45
|
+
id: string;
|
|
46
|
+
conversationId: string;
|
|
47
|
+
authority: RequestAuthority;
|
|
48
|
+
locale: string;
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
}): Promise<unknown> => {
|
|
51
|
+
const tool = createCodeSourceTool("code_read");
|
|
52
|
+
if (tool.location !== "server") throw new Error("Studio reader must run on the server.");
|
|
53
|
+
return tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: input.id }), {
|
|
54
|
+
actor: input.authority.actor,
|
|
55
|
+
conversationId: input.conversationId,
|
|
56
|
+
locale: input.locale,
|
|
57
|
+
signal: input.signal,
|
|
58
|
+
requestApproval: async () => false,
|
|
59
|
+
requestClientTool: async () => {
|
|
60
|
+
throw new Error("Recent resources cannot call client tools.");
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type AiRecentResourceDependencies = {
|
|
66
|
+
store?: Pick<AiConversationService, "listRecentSentResourceRefs">;
|
|
67
|
+
listRegistry?: () => Promise<CapabilityRegistryEntry[]>;
|
|
68
|
+
dispatch?: (params: CapabilityDispatchParams) => Promise<Response>;
|
|
69
|
+
skills?: Pick<typeof aiSkills, "list">;
|
|
70
|
+
studioReader?: typeof studioReader;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const german = (locale: string) => locale.toLowerCase().split("-")[0] === "de";
|
|
75
|
+
/** Skills and Studio apps belong to the Assistant, which has no capability presentation to read its name from. */
|
|
76
|
+
const assistantName = (locale: string) => (german(locale) ? "Assistent" : "Assistant");
|
|
77
|
+
|
|
78
|
+
/** First line only; reserve one character for the ellipsis and avoid splitting words. */
|
|
79
|
+
export const recentResourcePreview = (value: string | undefined): string | undefined => {
|
|
80
|
+
const line = value?.split(/\r\n?|\n/, 1)[0]?.trim();
|
|
81
|
+
if (!line) return undefined;
|
|
82
|
+
if (line.length <= 160) return line;
|
|
83
|
+
const clipped = line.slice(0, 159);
|
|
84
|
+
const boundary = clipped.search(/\s+\S*$/);
|
|
85
|
+
return `${(boundary > 0 ? clipped.slice(0, boundary) : clipped).trimEnd()}…`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Enforce the shared budget even when a dependency ignores cancellation. */
|
|
89
|
+
const withinBudget = async <T>(signal: AbortSignal, run: () => Promise<T>): Promise<T> => {
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
let abort: () => void = () => {};
|
|
92
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
93
|
+
abort = () => reject(signal.reason);
|
|
94
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
return await Promise.race([run(), aborted]);
|
|
98
|
+
} finally {
|
|
99
|
+
signal.removeEventListener("abort", abort);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const listAiRecentResources = async (input: {
|
|
104
|
+
ownerUserId: string;
|
|
105
|
+
authority: RequestAuthority;
|
|
106
|
+
locale: string;
|
|
107
|
+
signal: AbortSignal;
|
|
108
|
+
dependencies?: AiRecentResourceDependencies;
|
|
109
|
+
}): Promise<AiRecentResource[]> => {
|
|
110
|
+
const deps = input.dependencies ?? {};
|
|
111
|
+
const signal = AbortSignal.any([input.signal, AbortSignal.timeout(deps.timeoutMs ?? QUERY_TIMEOUT_MS)]);
|
|
112
|
+
// Unreadable history is no answer, not an empty one: the request fails, so the browser asks again at its next opening.
|
|
113
|
+
const sent = await withinBudget(signal, () =>
|
|
114
|
+
(deps.store ?? aiConversations).listRecentSentResourceRefs({ ownerUserId: input.ownerUserId }),
|
|
115
|
+
);
|
|
116
|
+
if (!sent.length || signal.aborted) return [];
|
|
117
|
+
const candidates = sent.slice(0, RECENT_SENT_REF_LIMIT);
|
|
118
|
+
const skillIds = candidates.flatMap(({ ref }) => (ref.type === "core.ai.skill" ? [ref.id] : []));
|
|
119
|
+
// Exactly the sent Skills, so a large Skill catalog never pushes one out; failing, they are left out like a reader.
|
|
120
|
+
const skills = skillIds.length
|
|
121
|
+
? withinBudget(signal, () => (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds })).catch(() => [])
|
|
122
|
+
: Promise.resolve([]);
|
|
123
|
+
const registry = candidates.some(({ ref }) => ref.type !== "core.ai.skill" && ref.type !== "assistant.artifact")
|
|
124
|
+
? withinBudget(signal, deps.listRegistry ?? listCapabilities).catch(() => [])
|
|
125
|
+
: Promise.resolve([]);
|
|
126
|
+
const resources = await Promise.all(
|
|
127
|
+
candidates.map(async (candidate): Promise<AiRecentResource | null> => {
|
|
128
|
+
try {
|
|
129
|
+
signal.throwIfAborted();
|
|
130
|
+
const { ref, count } = candidate;
|
|
131
|
+
if (ref.type === "core.ai.skill") {
|
|
132
|
+
const skill = (await skills).find((skill) => skill.shortId === ref.id && skill.enabled !== false);
|
|
133
|
+
if (!skill) return null;
|
|
134
|
+
return {
|
|
135
|
+
ref,
|
|
136
|
+
count,
|
|
137
|
+
title: skill.name.slice(0, 500),
|
|
138
|
+
icon: "ti ti-sparkles",
|
|
139
|
+
kind: "Skill",
|
|
140
|
+
preview: recentResourcePreview(skill.description),
|
|
141
|
+
appName: assistantName(input.locale),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (ref.type === "assistant.artifact") {
|
|
145
|
+
const { actor, accessSubject, credentialKind, scopes } = input.authority;
|
|
146
|
+
// code_read reconstructs a user session authority. Only use it when that is
|
|
147
|
+
// exactly this request's authority; never widen scoped/delegated credentials.
|
|
148
|
+
if (
|
|
149
|
+
credentialKind !== "session" ||
|
|
150
|
+
scopes.length ||
|
|
151
|
+
actor.kind !== "user" ||
|
|
152
|
+
actor.delegation ||
|
|
153
|
+
actor.sessionKind === "app" ||
|
|
154
|
+
accessSubject.type !== "user" ||
|
|
155
|
+
accessSubject.userId !== actor.user.id
|
|
156
|
+
)
|
|
157
|
+
return null;
|
|
158
|
+
const answer = ReaderResult.parse(
|
|
159
|
+
await withinBudget(signal, () =>
|
|
160
|
+
(deps.studioReader ?? studioReader)({
|
|
161
|
+
id: ref.id,
|
|
162
|
+
conversationId: candidate.conversationId,
|
|
163
|
+
authority: input.authority,
|
|
164
|
+
locale: input.locale,
|
|
165
|
+
signal,
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
signal.throwIfAborted();
|
|
170
|
+
const data = StudioData.parse(answer.data);
|
|
171
|
+
if (data.id !== ref.id || !data.title.trim()) return null;
|
|
172
|
+
const matched = answer.refs?.find((entry) => entry.type === ref.type && entry.id === ref.id);
|
|
173
|
+
const ownRef = matched ? LinkedCloudResourceReferenceSchema.parse(matched) : undefined;
|
|
174
|
+
const href = ownRef?.href ?? answer.links?.find((link) => link.rel === "open")?.href;
|
|
175
|
+
return {
|
|
176
|
+
ref,
|
|
177
|
+
count,
|
|
178
|
+
title: data.title,
|
|
179
|
+
icon: ownRef?.icon ?? data.icon,
|
|
180
|
+
href,
|
|
181
|
+
preview: recentResourcePreview(ownRef?.preview ?? data.description),
|
|
182
|
+
kind: german(input.locale) ? "Studio-App" : "Studio app",
|
|
183
|
+
appName: assistantName(input.locale),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const apps = await registry;
|
|
187
|
+
signal.throwIfAborted();
|
|
188
|
+
const appId = cloudResourceRefAppId(ref);
|
|
189
|
+
const app = apps.find((app) => app.appId === appId);
|
|
190
|
+
const reader = app && resolveCapabilityResourceReader(app.manifest, ref);
|
|
191
|
+
if (!app || !reader) return null;
|
|
192
|
+
const response = await withinBudget(signal, () =>
|
|
193
|
+
(deps.dispatch ?? dispatchCapability)({
|
|
194
|
+
request: new Request("http://localhost/api/ai/resources/recent", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { [LOCALE_HEADER]: input.locale, "x-request-id": crypto.randomUUID() },
|
|
197
|
+
signal,
|
|
198
|
+
}),
|
|
199
|
+
kind: "queries",
|
|
200
|
+
origin: "assistant",
|
|
201
|
+
appId: app.appId,
|
|
202
|
+
capabilityId: reader.localId,
|
|
203
|
+
input: { id: ref.id },
|
|
204
|
+
authority: input.authority,
|
|
205
|
+
locale: input.locale,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
if (!response.ok) return null;
|
|
209
|
+
const answer = ReaderResult.parse(addCloudResourceHrefs(await withinBudget(signal, () => response.json()), apps));
|
|
210
|
+
const matched = answer.refs?.find((entry) => entry.type === ref.type && entry.id === ref.id);
|
|
211
|
+
const ownRef = matched ? LinkedCloudResourceReferenceSchema.parse(matched) : undefined;
|
|
212
|
+
if (!ownRef?.title?.trim()) return null;
|
|
213
|
+
signal.throwIfAborted();
|
|
214
|
+
const manifest = resolveCapabilityManifestPresentation(app.manifest, app.presentation, input.locale);
|
|
215
|
+
const type = manifest.types.find((type) => `${app.appId}.${type.localId}` === ref.type);
|
|
216
|
+
if (!type) return null;
|
|
217
|
+
const presentation = resolveAppIdentityPresentation(
|
|
218
|
+
{ name: app.appName, description: app.appDescription, presentation: app.appPresentation },
|
|
219
|
+
input.locale,
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
ref,
|
|
223
|
+
count,
|
|
224
|
+
title: ownRef.title,
|
|
225
|
+
icon: (ownRef.icon ?? type.icon ?? app.appIcon)?.slice(0, 120),
|
|
226
|
+
href: ownRef.href,
|
|
227
|
+
kind: type.title.slice(0, 120),
|
|
228
|
+
preview: recentResourcePreview(ownRef.preview),
|
|
229
|
+
appName: presentation.name.slice(0, 120),
|
|
230
|
+
};
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
return resources.flatMap((resource) => {
|
|
237
|
+
const parsed = AiRecentResourceSchema.safeParse(resource);
|
|
238
|
+
return parsed.success ? [parsed.data] : [];
|
|
239
|
+
});
|
|
240
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseAiResourceMarker } from "./resource-markers";
|
|
2
|
+
import type { AiRecentSentResourceRef } from "./types";
|
|
3
|
+
|
|
4
|
+
export const RECENT_SENT_REF_LIMIT = 8;
|
|
5
|
+
// Look back 25 messages/conversations per displayed ref: useful sparse history without
|
|
6
|
+
// scanning the owner's lifetime. insertMessageLocked (used by submitChatTurn and steering)
|
|
7
|
+
// bumps conversation.updated_at, so newly sent turns enter this indexed window.
|
|
8
|
+
export const RECENT_SENT_MESSAGE_SCAN_LIMIT = RECENT_SENT_REF_LIMIT * 25;
|
|
9
|
+
export const RECENT_SENT_CONVERSATION_SCAN_LIMIT = RECENT_SENT_MESSAGE_SCAN_LIMIT;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Rows arrive newest first and contain only marker text, never whole message bodies. Forking or copying a chat copies
|
|
13
|
+
* its messages with their short IDs, so a message ID counts once, however many chats hold a copy of it.
|
|
14
|
+
*/
|
|
15
|
+
export const collectRecentSentResourceRefs = (
|
|
16
|
+
messages: readonly { conversationId: string; messageId: string; lastSentAt: string; markers: readonly string[] }[],
|
|
17
|
+
): AiRecentSentResourceRef[] => {
|
|
18
|
+
const refs = new Map<string, AiRecentSentResourceRef>();
|
|
19
|
+
const counted = new Set<string>();
|
|
20
|
+
for (const message of messages) {
|
|
21
|
+
if (counted.has(message.messageId)) continue;
|
|
22
|
+
counted.add(message.messageId);
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
for (const text of message.markers) {
|
|
25
|
+
const marker = parseAiResourceMarker(text);
|
|
26
|
+
if (!marker) continue;
|
|
27
|
+
const key = JSON.stringify([marker.ref.type, marker.ref.id]);
|
|
28
|
+
if (seen.has(key)) continue;
|
|
29
|
+
seen.add(key);
|
|
30
|
+
const existing = refs.get(key);
|
|
31
|
+
if (existing) existing.count++;
|
|
32
|
+
else if (refs.size < RECENT_SENT_REF_LIMIT) {
|
|
33
|
+
refs.set(key, { ref: marker.ref, count: 1, lastSentAt: message.lastSentAt, conversationId: message.conversationId });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...refs.values()];
|
|
38
|
+
};
|
|
@@ -3,7 +3,7 @@ import { userFromActor } from "../server/actor";
|
|
|
3
3
|
import { type ApiErrorResponse, respond } from "../server/api";
|
|
4
4
|
import type { AuthContext, RequestActor } from "../server/middleware/auth";
|
|
5
5
|
import { err, fail } from "../server/services";
|
|
6
|
-
import type { AiToolApprovalContext } from "./approvals";
|
|
6
|
+
import type { AiToolApprovalChangeContext, AiToolApprovalContext } from "./approvals";
|
|
7
7
|
import { personalAiModelPolicy } from "./personal-agent";
|
|
8
8
|
import type { AiModelPolicy } from "./types";
|
|
9
9
|
|
|
@@ -13,6 +13,7 @@ export type AiChatRequestContext = {
|
|
|
13
13
|
ownerUserId: string;
|
|
14
14
|
modelPolicy: AiModelPolicy;
|
|
15
15
|
toolApprovalContext: AiToolApprovalContext;
|
|
16
|
+
toolApprovalChangeContext: AiToolApprovalChangeContext;
|
|
16
17
|
/** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
|
|
17
18
|
signedInSession: boolean;
|
|
18
19
|
};
|
|
@@ -28,6 +29,11 @@ export const resolveAiChatRequestContext = async <T extends AuthContext>(
|
|
|
28
29
|
ownerUserId: user.id,
|
|
29
30
|
modelPolicy: personalAiModelPolicy,
|
|
30
31
|
toolApprovalContext: { actorUserId: user.id },
|
|
32
|
+
toolApprovalChangeContext: {
|
|
33
|
+
actorUserId: user.id,
|
|
34
|
+
actor,
|
|
35
|
+
requestId: c.req.header("x-request-id") ?? null,
|
|
36
|
+
},
|
|
31
37
|
signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
|
|
32
38
|
};
|
|
33
39
|
};
|
package/src/ai/routes.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
2
|
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { describeRoute } from "hono-openapi";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { listCapabilities } from "../_internal/registry";
|
|
6
|
+
import { QUERY_TIMEOUT_MS } from "../api/capabilities";
|
|
7
|
+
import { ErrorResponseSchema } from "../contracts";
|
|
5
8
|
import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
|
|
6
9
|
import {
|
|
7
10
|
type AccessSubject,
|
|
@@ -11,23 +14,31 @@ import {
|
|
|
11
14
|
fail,
|
|
12
15
|
getLocale,
|
|
13
16
|
getTimeZone,
|
|
17
|
+
jsonResponse,
|
|
14
18
|
ok,
|
|
15
19
|
type RequestActor,
|
|
16
20
|
rateLimit,
|
|
21
|
+
requiresAuth,
|
|
17
22
|
respond,
|
|
18
23
|
v,
|
|
19
24
|
} from "../server";
|
|
25
|
+
import { isAppSessionActor } from "../server/actor";
|
|
20
26
|
import { isRequestCredentialCurrent } from "../server/middleware/auth";
|
|
27
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
28
|
+
import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
|
|
21
29
|
import { approvalDecisionChannel } from "../services/approvals";
|
|
22
30
|
import { ImageMetadataError } from "../services/image-metadata";
|
|
23
31
|
import { logger } from "../services/logging";
|
|
24
32
|
import { coreSettings } from "../services/settings/api";
|
|
25
33
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
34
|
+
import { isUnknownActionOutcome } from "./action-review";
|
|
26
35
|
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
27
36
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
28
37
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
38
|
+
import { checkAiCapability, resolveAiCapabilityActor } from "./capability-execution";
|
|
29
39
|
import { aiChatMessages } from "./chat/messages";
|
|
30
40
|
import { getAiChatQuotas } from "./chat-quotas";
|
|
41
|
+
import { aiChatTasks } from "./chat-tasks";
|
|
31
42
|
import { AI_CLIENT_TOOL_ACTION_MAX_BYTES, aiClientToolOutputSchemas } from "./client-tool-results";
|
|
32
43
|
import { createCloudAiLocalBashTool, createCloudAiLocalTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
33
44
|
import { enqueueAiDictation } from "./dictation-runtime";
|
|
@@ -65,6 +76,8 @@ import {
|
|
|
65
76
|
import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
|
|
66
77
|
import { aiProjects } from "./projects";
|
|
67
78
|
import { projectPublicAiStoredMessages, publicAiStoredMessages } from "./public-projection";
|
|
79
|
+
import { AiRecentResourceSchema, listAiRecentResources } from "./recent-resources";
|
|
80
|
+
import { RECENT_SENT_REF_LIMIT } from "./recent-sent-refs";
|
|
68
81
|
import { type AiChatRequestContext, resolveAiChatRequestContext as resolveContext } from "./request-context";
|
|
69
82
|
import { aiResourceMarker } from "./resource-markers";
|
|
70
83
|
import { isConversationResourceCursor } from "./resource-refs";
|
|
@@ -83,9 +96,10 @@ import { AI_SHORT_ID_PATTERN } from "./short-id";
|
|
|
83
96
|
import { selectAiSkillCatalog } from "./skill-catalog";
|
|
84
97
|
import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool } from "./skill-tool";
|
|
85
98
|
import { aiSkills } from "./skills";
|
|
86
|
-
import { aiConversations } from "./store";
|
|
99
|
+
import { AI_TOOL_CHECK_STALE_MS, aiConversations, claimAiToolCheck, readAiToolCheck, settleAiToolCheck } from "./store";
|
|
87
100
|
import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
|
|
88
101
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
102
|
+
import { aiToolAudit } from "./tool-audit";
|
|
89
103
|
import { aiToolPromptHints } from "./tools";
|
|
90
104
|
import type { AiConversation, AiTurn } from "./types";
|
|
91
105
|
|
|
@@ -303,6 +317,7 @@ export const aiRoutes = (() => {
|
|
|
303
317
|
|
|
304
318
|
return (
|
|
305
319
|
new Hono<AuthContext>()
|
|
320
|
+
.use("*", noTransform())
|
|
306
321
|
.use(rateLimit())
|
|
307
322
|
.use("*", auth.requireRole("authenticated"))
|
|
308
323
|
.get("/status", async (c) => {
|
|
@@ -457,6 +472,42 @@ export const aiRoutes = (() => {
|
|
|
457
472
|
});
|
|
458
473
|
return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
|
|
459
474
|
})
|
|
475
|
+
.get(
|
|
476
|
+
"/resources/recent",
|
|
477
|
+
describeRoute({
|
|
478
|
+
tags: ["AI"],
|
|
479
|
+
summary: "List recently sent resources",
|
|
480
|
+
description: "Up to eight recently sent refs, with current reader-authorized metadata. Unreadable resources are omitted.",
|
|
481
|
+
...requiresAuth,
|
|
482
|
+
responses: {
|
|
483
|
+
200: jsonResponse(
|
|
484
|
+
z.object({ resources: z.array(AiRecentResourceSchema).max(RECENT_SENT_REF_LIMIT) }),
|
|
485
|
+
"Current recent resources",
|
|
486
|
+
),
|
|
487
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
488
|
+
403: jsonResponse(ErrorResponseSchema, "AI context unavailable"),
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
491
|
+
// Titles come from app readers, so a scoped token needs the read scope the capability routes ask for.
|
|
492
|
+
auth.requireOAuthScope("read", "admin"),
|
|
493
|
+
async (c) => {
|
|
494
|
+
c.header("Cache-Control", "no-store");
|
|
495
|
+
const ctx = await resolveContext(c);
|
|
496
|
+
if (ctx instanceof Response) return ctx;
|
|
497
|
+
setRequestTimeout(c, Math.ceil(QUERY_TIMEOUT_MS / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
498
|
+
try {
|
|
499
|
+
const resources = await listAiRecentResources({
|
|
500
|
+
ownerUserId: ctx.ownerUserId,
|
|
501
|
+
authority: auth.getAuthority(c),
|
|
502
|
+
locale: getLocale(c),
|
|
503
|
+
signal: c.req.raw.signal,
|
|
504
|
+
});
|
|
505
|
+
return respond(c, ok({ resources }));
|
|
506
|
+
} finally {
|
|
507
|
+
setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
)
|
|
460
511
|
.get("/resources", v("query", UserResourcesQuerySchema), async (c) => {
|
|
461
512
|
const ctx = await resolveContext(c);
|
|
462
513
|
if (ctx instanceof Response) return ctx;
|
|
@@ -784,6 +835,13 @@ export const aiRoutes = (() => {
|
|
|
784
835
|
ownerUserId: ctx.ownerUserId,
|
|
785
836
|
});
|
|
786
837
|
if (!archived) return notFound(c);
|
|
838
|
+
// The chat is archived either way; admission pauses a routine this step missed before its next run.
|
|
839
|
+
await aiChatTasks.pauseForArchivedChat({ conversationId: conversation.id, userId: ctx.ownerUserId }).catch((error) =>
|
|
840
|
+
logger("ai:chat-tasks").warn("Could not pause the routines of an archived chat", {
|
|
841
|
+
conversationId: conversation.id,
|
|
842
|
+
error: error instanceof Error ? error.message : String(error),
|
|
843
|
+
}),
|
|
844
|
+
);
|
|
787
845
|
return respond(c, ok({ ok: true }));
|
|
788
846
|
})
|
|
789
847
|
.post("/conversations/:conversationId/restore", async (c) => {
|
|
@@ -791,10 +849,27 @@ export const aiRoutes = (() => {
|
|
|
791
849
|
if (ctx instanceof Response) return ctx;
|
|
792
850
|
const conversation = await loadConversation(c, ctx, true);
|
|
793
851
|
if (!conversation) return notFound(c);
|
|
852
|
+
await aiChatTasks.markRestoreStarted({ conversationId: conversation.id, userId: ctx.ownerUserId });
|
|
794
853
|
const restored = await aiConversations.restoreConversation({
|
|
795
854
|
conversationId: conversation.id,
|
|
796
855
|
ownerUserId: ctx.ownerUserId,
|
|
797
856
|
});
|
|
857
|
+
// A restored chat must not leave archive reasons on routines that stay paused.
|
|
858
|
+
// The phone app may restore the chat and relabel pauses, but may not resume background authority.
|
|
859
|
+
// If this step fails, the routine recovery relabels them after a grace period; it never resumes.
|
|
860
|
+
if (restored)
|
|
861
|
+
await aiChatTasks
|
|
862
|
+
.resumeForRestoredChat({
|
|
863
|
+
conversationId: conversation.id,
|
|
864
|
+
userId: ctx.ownerUserId,
|
|
865
|
+
resume: !isAppSessionActor(c.get("actor")),
|
|
866
|
+
})
|
|
867
|
+
.catch((error) =>
|
|
868
|
+
logger("ai:chat-tasks").warn("Could not resume the routines of a restored chat", {
|
|
869
|
+
conversationId: conversation.id,
|
|
870
|
+
error: error instanceof Error ? error.message : String(error),
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
798
873
|
return restored ? respond(c, ok(await publicConversationFor(restored, c.get("accessSubject")))) : notFound(c);
|
|
799
874
|
})
|
|
800
875
|
.post("/conversations/:conversationId/viewed", async (c) => {
|
|
@@ -1132,6 +1207,101 @@ export const aiRoutes = (() => {
|
|
|
1132
1207
|
await abortAiTurn({ conversationId: conversation.id, turnId: turn.id });
|
|
1133
1208
|
return respond(c, ok({ ok: true }));
|
|
1134
1209
|
})
|
|
1210
|
+
.post("/conversations/:conversationId/turns/:turnId/calls/:callId/check", async (c) => {
|
|
1211
|
+
const ctx = await resolveContext(c);
|
|
1212
|
+
if (ctx instanceof Response) return ctx;
|
|
1213
|
+
// The check runs with the person's session authority, so a scoped token or another client cannot borrow it.
|
|
1214
|
+
if (ctx.actor.kind !== "user" || !ctx.signedInSession)
|
|
1215
|
+
return respond(c, fail(err.forbidden("Action outcome checks require a signed-in browser session.")));
|
|
1216
|
+
const conversation = await loadConversation(c, ctx);
|
|
1217
|
+
if (!conversation) return notFound(c);
|
|
1218
|
+
const turn = await loadTurn(conversation, c.req.param("turnId"));
|
|
1219
|
+
const callId = c.req.param("callId");
|
|
1220
|
+
if (!turn || !callId) return notFound(c);
|
|
1221
|
+
const target = { conversationId: conversation.id, turnId: turn.id, callId };
|
|
1222
|
+
// The app's Query may take its whole budget; the request must outlast it.
|
|
1223
|
+
setRequestTimeout(c, Math.ceil(QUERY_TIMEOUT_MS / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
1224
|
+
try {
|
|
1225
|
+
const messages = await aiConversations.listTurnMessages({
|
|
1226
|
+
conversationId: conversation.id,
|
|
1227
|
+
loopId: turn.id,
|
|
1228
|
+
includeCompacted: true,
|
|
1229
|
+
});
|
|
1230
|
+
const message = messages.find(
|
|
1231
|
+
({ message }) =>
|
|
1232
|
+
message.role === "assistant" && message.content.some((block) => block.type === "tool_call" && block.id === callId),
|
|
1233
|
+
);
|
|
1234
|
+
if (!message) return notFound(c);
|
|
1235
|
+
const review = message.meta?.toolReviews?.[callId];
|
|
1236
|
+
const previous = message.meta?.toolChecks?.[callId];
|
|
1237
|
+
if (!review || !("sentence" in review) || !review.check) return respond(c, fail(err.conflict("This call has no outcome check.")));
|
|
1238
|
+
if (!previous) {
|
|
1239
|
+
// An unknown tool result ends executor recovery for this call, even while its turn is live. Without
|
|
1240
|
+
// a result, only a terminal cut-off call may be checked. Both paths require evidence of dispatch.
|
|
1241
|
+
const terminal = turn.status === "completed" || turn.status === "failed" || turn.status === "aborted";
|
|
1242
|
+
const result = messages.find(({ message }) => message.role === "tool_result" && message.callId === callId)?.message;
|
|
1243
|
+
const eligible = result
|
|
1244
|
+
? result.role === "tool_result" && result.isError === true && isUnknownActionOutcome(result.result)
|
|
1245
|
+
: terminal;
|
|
1246
|
+
if (!eligible || !(await aiToolAudit.hasCapabilityDispatch(target)))
|
|
1247
|
+
return respond(c, fail(err.conflict("This call has no outcome check.")));
|
|
1248
|
+
}
|
|
1249
|
+
// A settled check answers again as it was stored, so a client whose last response got lost can recover
|
|
1250
|
+
// without running the Query again.
|
|
1251
|
+
if (previous?.state === "done" || previous?.state === "not_done") return respond(c, ok(previous));
|
|
1252
|
+
const block =
|
|
1253
|
+
message.message.role === "assistant"
|
|
1254
|
+
? message.message.content.find((block) => block.type === "tool_call" && block.id === callId)
|
|
1255
|
+
: undefined;
|
|
1256
|
+
const catalog = buildAiCapabilityCatalog(await listCapabilities());
|
|
1257
|
+
const presentation = message.meta?.toolPresentations?.[callId];
|
|
1258
|
+
const entry = catalog.find(
|
|
1259
|
+
(entry) =>
|
|
1260
|
+
entry.kind === "action" &&
|
|
1261
|
+
(entry.name === (block?.type === "tool_call" ? block.name : undefined) ||
|
|
1262
|
+
entry.providerName === (block?.type === "tool_call" ? block.name : undefined)) &&
|
|
1263
|
+
(!presentation || presentation.appId === entry.appId),
|
|
1264
|
+
);
|
|
1265
|
+
if (!entry) return respond(c, fail(err.conflict("The Action's app is unavailable. Try the check again when it returns.")));
|
|
1266
|
+
const authority = await resolveAiCapabilityActor({
|
|
1267
|
+
conversationId: conversation.id,
|
|
1268
|
+
persistedActor: ctx.actor,
|
|
1269
|
+
store: aiConversations,
|
|
1270
|
+
});
|
|
1271
|
+
// One check at a time per call, across Core replicas and the executor: the claim is one conditional write,
|
|
1272
|
+
// and no transaction stays open while the app's Query runs.
|
|
1273
|
+
const claimedAt = new Date().toISOString();
|
|
1274
|
+
const claimed = await claimAiToolCheck({
|
|
1275
|
+
...target,
|
|
1276
|
+
first: !previous,
|
|
1277
|
+
at: claimedAt,
|
|
1278
|
+
staleBefore: new Date(Date.now() - AI_TOOL_CHECK_STALE_MS).toISOString(),
|
|
1279
|
+
});
|
|
1280
|
+
if (!claimed) {
|
|
1281
|
+
const stored = await readAiToolCheck(target);
|
|
1282
|
+
if (stored?.state === "done" || stored?.state === "not_done") return respond(c, ok(stored));
|
|
1283
|
+
return respond(c, fail(err.conflict("An outcome check is already running for this call.")));
|
|
1284
|
+
}
|
|
1285
|
+
const answered = await checkAiCapability({
|
|
1286
|
+
...target,
|
|
1287
|
+
entry,
|
|
1288
|
+
check: review.check,
|
|
1289
|
+
catalog,
|
|
1290
|
+
authority,
|
|
1291
|
+
// The receipt joins the stored review's sentence, so it is asked for in that language.
|
|
1292
|
+
locale: review.locale,
|
|
1293
|
+
context: { callId, signal: c.req.raw.signal },
|
|
1294
|
+
});
|
|
1295
|
+
if (await settleAiToolCheck({ ...target, claimedAt, check: answered })) return respond(c, ok(answered));
|
|
1296
|
+
// The claim went stale and a newer check took over; its stored result is the answer.
|
|
1297
|
+
const stored = await readAiToolCheck(target);
|
|
1298
|
+
return stored && stored.state !== "checking"
|
|
1299
|
+
? respond(c, ok(stored))
|
|
1300
|
+
: respond(c, fail(err.conflict("An outcome check is already running for this call.")));
|
|
1301
|
+
} finally {
|
|
1302
|
+
setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
1303
|
+
}
|
|
1304
|
+
})
|
|
1135
1305
|
.post(
|
|
1136
1306
|
"/conversations/:conversationId/turns/:turnId/actions/:callId",
|
|
1137
1307
|
bodyLimit({ maxSize: AI_CLIENT_TOOL_ACTION_MAX_BYTES }),
|
|
@@ -1168,7 +1338,7 @@ export const aiRoutes = (() => {
|
|
|
1168
1338
|
client: c.req.header(APPROVAL_CLIENT_HEADER),
|
|
1169
1339
|
}),
|
|
1170
1340
|
},
|
|
1171
|
-
toolApprovalContext: ctx.
|
|
1341
|
+
toolApprovalContext: ctx.toolApprovalChangeContext,
|
|
1172
1342
|
signedInSession: ctx.signedInSession,
|
|
1173
1343
|
});
|
|
1174
1344
|
if (!result.ok) return toAiActionFailureResponse(c, result);
|