@k2b/cloud 0.39.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 +8 -4
- package/src/_internal/app-favicon.ts +4 -2
- 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 +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- 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.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -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/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 +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- 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 +2 -0
- package/src/ai/migrate.ts +11 -2
- 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/routes.ts +145 -1
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- 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-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 +2 -0
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- 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 +6 -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/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -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 +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- 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 +120 -13
- 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/seen-hints.ts +35 -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/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -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 +11 -0
- 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/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/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/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -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 +10 -2
- package/src/ssr/Layout.tsx +25 -2
- 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/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- 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 +2 -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 +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/effects.css +198 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- 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 +17 -22
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CapabilityActionCallReview,
|
|
3
|
+
CapabilityActionPolicy,
|
|
4
|
+
CapabilityActionReceipt,
|
|
5
|
+
CapabilityActionReview,
|
|
6
|
+
CapabilityActionReviewData,
|
|
7
|
+
CapabilitySemanticLink,
|
|
8
|
+
} from "../contracts/capabilities";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What the chat stores about one Action call so that a reload shows the same question and receipt even after the app
|
|
12
|
+
* changed: the app's per-call review as it was when Cloud asked, or, for an app that still returns the old review shape,
|
|
13
|
+
* that review as literal text. Pure and browser-safe: the server stores it, the chat renders it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One old review detail: a label and its value, kept complete. */
|
|
17
|
+
export type AiLegacyReviewRow = { label: string; value: string; format?: "date" | "date-time" };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An old review (`message`, `details`, `links`) kept as literal text, never as a template: the message is the question,
|
|
21
|
+
* every inline or plain-text detail is a row without the 12-row bound, Markdown details are one Markdown block, and
|
|
22
|
+
* `summary` is the old success summary used as the receipt text.
|
|
23
|
+
*/
|
|
24
|
+
export type AiLegacyActionReview = {
|
|
25
|
+
legacy: {
|
|
26
|
+
question: string;
|
|
27
|
+
rows: AiLegacyReviewRow[];
|
|
28
|
+
markdown?: string;
|
|
29
|
+
summary?: string;
|
|
30
|
+
links?: CapabilitySemanticLink[];
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A new review, with `ask` filled from the declared policy when the call itself names no reason. */
|
|
35
|
+
export type AiStoredActionReview = CapabilityActionCallReview | AiLegacyActionReview;
|
|
36
|
+
|
|
37
|
+
export const isLegacyActionReview = (review: AiStoredActionReview): review is AiLegacyActionReview => "legacy" in review;
|
|
38
|
+
|
|
39
|
+
/** The old review shape as stored literal text. */
|
|
40
|
+
export const legacyActionReview = (review: CapabilityActionReview): AiLegacyActionReview => {
|
|
41
|
+
const details = review.details ?? [];
|
|
42
|
+
// One Markdown block, each detail under its own label, so a reader keeps knowing which text belongs where.
|
|
43
|
+
const markdown = details
|
|
44
|
+
.filter((detail) => detail.format === "markdown" && detail.value.trim())
|
|
45
|
+
.map((detail) => `**${detail.label.replace(/([\\*_`[\]])/g, "\\$1")}**\n\n${detail.value}`)
|
|
46
|
+
.join("\n\n");
|
|
47
|
+
return {
|
|
48
|
+
legacy: {
|
|
49
|
+
question: review.message,
|
|
50
|
+
rows: details
|
|
51
|
+
.filter((detail) => detail.format !== "markdown")
|
|
52
|
+
.map((detail) => ({
|
|
53
|
+
label: detail.label,
|
|
54
|
+
value: detail.value,
|
|
55
|
+
...(detail.format === "date" || detail.format === "date-time" ? { format: detail.format } : {}),
|
|
56
|
+
})),
|
|
57
|
+
...(markdown ? { markdown } : {}),
|
|
58
|
+
...(review.links?.length ? { links: review.links } : {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The review Cloud stores and shows for one call. */
|
|
64
|
+
export const storedActionReview = (review: CapabilityActionReviewData, policy?: CapabilityActionPolicy | null): AiStoredActionReview => {
|
|
65
|
+
if (!("sentence" in review)) return legacyActionReview(review);
|
|
66
|
+
if (review.ask || !policy || !("ask" in policy)) return review;
|
|
67
|
+
return { ...review, ask: policy.ask };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The app's `check` Query after an outcome Cloud could not see. `checking` while it runs; `done` and `not_done` settle
|
|
72
|
+
* the call; `pending` means the app does not know yet; `failed` means the check itself gave no usable answer.
|
|
73
|
+
*/
|
|
74
|
+
export type AiActionCheckState = "checking" | "done" | "not_done" | "pending" | "failed";
|
|
75
|
+
export type AiActionCheck = { state: AiActionCheckState; receipt?: CapabilityActionReceipt; at?: string };
|
|
76
|
+
|
|
77
|
+
/** A settled outcome survives an open response; otherwise prefer the later timestamp, or incoming without both. */
|
|
78
|
+
export const preferredActionCheck = (current: AiActionCheck | undefined, incoming: AiActionCheck): AiActionCheck => {
|
|
79
|
+
if (!current) return incoming;
|
|
80
|
+
const currentSettled = current.state === "done" || current.state === "not_done";
|
|
81
|
+
const incomingSettled = incoming.state === "done" || incoming.state === "not_done";
|
|
82
|
+
if (currentSettled !== incomingSettled) return currentSettled ? current : incoming;
|
|
83
|
+
return current.at && incoming.at && Date.parse(current.at) > Date.parse(incoming.at) ? current : incoming;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The framework's unknown-outcome codes, in persisted `CODE: message` or structured error results. */
|
|
87
|
+
export const isUnknownActionOutcome = (result: unknown): boolean => {
|
|
88
|
+
const code =
|
|
89
|
+
typeof result === "string"
|
|
90
|
+
? /^([A-Z][A-Z0-9_]{2,}):/.exec(result.trim())?.[1]
|
|
91
|
+
: result !== null && typeof result === "object" && "code" in result && typeof result.code === "string"
|
|
92
|
+
? result.code
|
|
93
|
+
: undefined;
|
|
94
|
+
return code === "ACTION_OUTCOME_UNKNOWN" || code === "IDEMPOTENCY_UNCERTAIN" || code === "IDEMPOTENCY_IN_PROGRESS";
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A `checking` claim older than this belonged to a worker or request that stopped and is open again: twice a Query's
|
|
99
|
+
* 15-second budget, plus a margin. Core takes such a claim over, and the card offers to check again.
|
|
100
|
+
*/
|
|
101
|
+
export const AI_TOOL_CHECK_STALE_MS = 2 * 15_000 + 30_000;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CapabilityActionPolicy, CapabilityActionReviewData } from "../contracts/capabilities";
|
|
2
|
+
import { aiChatTasks } from "./chat-tasks";
|
|
3
|
+
|
|
4
|
+
export class AiBackgroundApprovalRequiredError extends Error {
|
|
5
|
+
readonly code = "NEEDS_APPROVAL";
|
|
6
|
+
|
|
7
|
+
constructor(readonly action: string) {
|
|
8
|
+
super(
|
|
9
|
+
`NEEDS_APPROVAL: ${action} needs the user's approval every time, and routines never ask. Stop and say in one sentence what was missing.`,
|
|
10
|
+
);
|
|
11
|
+
this.name = "AiBackgroundApprovalRequiredError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type BackgroundActionInput = {
|
|
16
|
+
turnId: string;
|
|
17
|
+
mandate: { id: string; revision: number };
|
|
18
|
+
appId: string;
|
|
19
|
+
capabilityId: string;
|
|
20
|
+
kind: "query" | "action";
|
|
21
|
+
args: unknown;
|
|
22
|
+
policy?: CapabilityActionPolicy;
|
|
23
|
+
approval?: "none" | "rememberable" | "always";
|
|
24
|
+
hasReview: boolean;
|
|
25
|
+
action: string;
|
|
26
|
+
review: () => Promise<CapabilityActionReviewData | null>;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Core routine gate. A review can narrow a mandate, never grant authority. */
|
|
31
|
+
export const authorizeBackgroundActionWith = async (
|
|
32
|
+
input: BackgroundActionInput,
|
|
33
|
+
authorizeCapability: typeof aiChatTasks.authorizeCapability,
|
|
34
|
+
): Promise<CapabilityActionReviewData | null> => {
|
|
35
|
+
// These declarations can never be granted to a routine. Stop with the approval state even if a stale grant exists.
|
|
36
|
+
if (input.kind === "action" && input.policy && (!("silent" in input.policy) || input.policy.routines === false)) {
|
|
37
|
+
await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
|
|
38
|
+
throw new AiBackgroundApprovalRequiredError(input.action);
|
|
39
|
+
}
|
|
40
|
+
await authorizeCapability({
|
|
41
|
+
mandate: input.mandate,
|
|
42
|
+
appId: input.appId,
|
|
43
|
+
capabilityId: input.capabilityId,
|
|
44
|
+
kind: input.kind,
|
|
45
|
+
input: input.args,
|
|
46
|
+
approval: input.approval,
|
|
47
|
+
...(input.policy ? { policy: input.policy } : {}),
|
|
48
|
+
});
|
|
49
|
+
if (input.kind === "query" || (!input.policy && input.approval === "none")) return null;
|
|
50
|
+
input.signal?.throwIfAborted();
|
|
51
|
+
if (!input.policy && input.approval !== "rememberable") {
|
|
52
|
+
await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
|
|
53
|
+
throw new AiBackgroundApprovalRequiredError(input.action);
|
|
54
|
+
}
|
|
55
|
+
let review: CapabilityActionReviewData | null = null;
|
|
56
|
+
if (input.hasReview && !input.signal?.aborted) {
|
|
57
|
+
try {
|
|
58
|
+
review = await input.review();
|
|
59
|
+
} catch {
|
|
60
|
+
// An unavailable review cannot authorize an unattended effect.
|
|
61
|
+
input.signal?.throwIfAborted();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
input.signal?.throwIfAborted();
|
|
65
|
+
if (review && (input.policy ? "sentence" in review && review.ask === undefined : !("sentence" in review) && review.approvalScope))
|
|
66
|
+
return review;
|
|
67
|
+
await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
|
|
68
|
+
throw new AiBackgroundApprovalRequiredError(input.action);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const authorizeBackgroundAction = (input: BackgroundActionInput): Promise<CapabilityActionReviewData | null> =>
|
|
72
|
+
authorizeBackgroundActionWith(input, aiChatTasks.authorizeCapability);
|
package/src/ai/capabilities.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import type { Tool, ToolContext, ToolResolver } from "@k2b/nessi";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveCapabilityActionWording, resolveCapabilityOperationTitle } from "../_internal/capabilities";
|
|
5
|
+
import { capabilityReviewView } from "../_internal/capability-reviews";
|
|
5
6
|
import {
|
|
6
7
|
CAPABILITY_APPROVAL_REASON_FIELD,
|
|
7
8
|
CAPABILITY_APPROVAL_REASON_MAX_CHARS,
|
|
@@ -13,8 +14,9 @@ import {
|
|
|
13
14
|
import { HELP_READ_MAX_CHARS, HELP_SEARCH_MAX_LIMIT, readHelpArticle } from "../_internal/help-catalog";
|
|
14
15
|
import {
|
|
15
16
|
type CapabilityActionManifest,
|
|
16
|
-
type
|
|
17
|
+
type CapabilityActionReviewData,
|
|
17
18
|
type CapabilityQueryManifest,
|
|
19
|
+
capabilityActionPolicy,
|
|
18
20
|
cloudResourceRefAppId,
|
|
19
21
|
LinkedCloudResourceReferenceSchema,
|
|
20
22
|
resolveCapabilityResourceReader,
|
|
@@ -756,9 +758,14 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
756
758
|
actor: RequestActor;
|
|
757
759
|
/** Locale of the turn; the approval text a person reads is worded in it. */
|
|
758
760
|
locale?: string;
|
|
759
|
-
review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<
|
|
760
|
-
authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown) => Promise<void>;
|
|
761
|
-
onReview?: (
|
|
761
|
+
review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReviewData | null>;
|
|
762
|
+
authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<void>;
|
|
763
|
+
onReview?: (
|
|
764
|
+
callId: string,
|
|
765
|
+
review: CapabilityActionReviewData,
|
|
766
|
+
approval: CapabilityActionManifest["approval"],
|
|
767
|
+
entry: AiCapabilityCatalogEntry,
|
|
768
|
+
) => void | Promise<void>;
|
|
762
769
|
execute: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<unknown>;
|
|
763
770
|
}): AiRuntimeTool[] => {
|
|
764
771
|
const byName = new Map(input.catalog.map((entry) => [entry.name, entry]));
|
|
@@ -769,7 +776,7 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
769
776
|
defineAiTool({
|
|
770
777
|
name: entry.providerName,
|
|
771
778
|
canonicalName: entry.name,
|
|
772
|
-
description: `${entry.title}. ${entry.description} Never retry ACTION_OUTCOME_UNKNOWN. Do not retry unchanged after INTERNAL or INVALID_APP_RESPONSE; report the provider error.`,
|
|
779
|
+
description: `${entry.title}. ${entry.description} Never retry ACTION_OUTCOME_UNKNOWN or NEEDS_APPROVAL. Never retry IDEMPOTENCY_UNCERTAIN or IDEMPOTENCY_IN_PROGRESS. After ACTION_NOT_DONE, ask the person before trying again. Do not retry unchanged after INTERNAL or INVALID_APP_RESPONSE; report the provider error.`,
|
|
773
780
|
inputSchema: aiCapabilityInputSchema(
|
|
774
781
|
offersApprovalReason(entry) ? withApprovalReason(entry.operation.inputSchema) : entry.operation.inputSchema,
|
|
775
782
|
),
|
|
@@ -779,16 +786,21 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
779
786
|
approval: "never",
|
|
780
787
|
}).server(async (modelArgs, context) => {
|
|
781
788
|
const args = withoutApprovalReason(entry, modelArgs);
|
|
782
|
-
await input.authorizeBackground?.(entry, args);
|
|
789
|
+
await input.authorizeBackground?.(entry, args, context);
|
|
783
790
|
if (!input.authorizeBackground && entry.kind === "action" && (entry.operation as CapabilityActionManifest).approval !== "none") {
|
|
784
791
|
const review = (await input.review?.(entry, args, context)) ?? null;
|
|
785
|
-
if (review && context.callId)
|
|
792
|
+
if (review && context.callId)
|
|
793
|
+
await input.onReview?.(context.callId, review, "approval" in entry.operation ? entry.operation.approval : undefined, entry);
|
|
786
794
|
// Text-only readers such as the CLI read what the card shows: the app's sentence, its review, and the model's reason.
|
|
787
795
|
const locale = input.locale ?? DEFAULT_LOCALE;
|
|
788
796
|
const message = capabilityApprovalText({
|
|
789
797
|
appName: entry.display.appName,
|
|
790
798
|
subject: capabilityActionSubject(aiCapabilityActionWording(entry, locale), args, { locale, timeZone: context.timeZone }),
|
|
791
|
-
review: review
|
|
799
|
+
review: review
|
|
800
|
+
? capabilityReviewView(review, {
|
|
801
|
+
timeZone: context.timeZone ?? "UTC",
|
|
802
|
+
}).message
|
|
803
|
+
: undefined,
|
|
792
804
|
reason: offersApprovalReason(entry) ? capabilityApprovalReason(modelArgs) : null,
|
|
793
805
|
locale,
|
|
794
806
|
});
|
|
@@ -873,9 +885,14 @@ export const createAiToolResolver =
|
|
|
873
885
|
locale?: string;
|
|
874
886
|
maxLoadedTools?: number;
|
|
875
887
|
execute?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<unknown>;
|
|
876
|
-
review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<
|
|
877
|
-
authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown) => Promise<void>;
|
|
878
|
-
onReview?: (
|
|
888
|
+
review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReviewData | null>;
|
|
889
|
+
authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<void>;
|
|
890
|
+
onReview?: (
|
|
891
|
+
callId: string,
|
|
892
|
+
review: CapabilityActionReviewData,
|
|
893
|
+
approval: CapabilityActionManifest["approval"],
|
|
894
|
+
entry: AiCapabilityCatalogEntry,
|
|
895
|
+
) => void | Promise<void>;
|
|
879
896
|
onPrepared?: (snapshot: {
|
|
880
897
|
prepared: PreparedAiTools;
|
|
881
898
|
presentations: Map<string, AiToolPresentation>;
|
|
@@ -907,6 +924,7 @@ export const createAiToolResolver =
|
|
|
907
924
|
capabilityId: entry.operation.localId,
|
|
908
925
|
kind: entry.kind,
|
|
909
926
|
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
927
|
+
capabilityPolicy: capabilityActionPolicy(entry.app.manifest, entry.operation.localId),
|
|
910
928
|
}));
|
|
911
929
|
const mandateExcluded = new Set(
|
|
912
930
|
fullCapabilityCatalog.filter((entry) => !mandateAllows(entry)).flatMap((entry) => [entry.name, entry.providerName]),
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { ToolContext } from "@k2b/nessi";
|
|
3
|
+
import { capabilityActionReceiptIssues, capabilityQueryChecksActionOutcome } from "../_internal/capability-reviews";
|
|
3
4
|
import { type CapabilityDispatchDependencies, dispatchCapability } from "../api/capabilities";
|
|
4
5
|
import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type CapabilityActionCallReview,
|
|
8
|
+
CapabilityActionCheckSchema,
|
|
9
|
+
type CapabilityActionManifest,
|
|
10
|
+
type CapabilityActionReviewData,
|
|
11
|
+
CapabilityActionReviewDataSchema,
|
|
12
|
+
} from "../contracts/capabilities";
|
|
6
13
|
import type { RequestActor } from "../server";
|
|
7
14
|
import type { AccessSubject } from "../server/services/access";
|
|
8
15
|
import { isAccountCategoryAllowed } from "../services/account-category-policy";
|
|
@@ -10,10 +17,14 @@ import { isAccountExpired } from "../services/account-model";
|
|
|
10
17
|
import { accounts } from "../services/accounts";
|
|
11
18
|
import { withoutAdminRole } from "../services/session/user";
|
|
12
19
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
20
|
+
import type { AiActionCheck } from "./action-review";
|
|
13
21
|
import type { AiCapabilityCatalogEntry } from "./capabilities";
|
|
22
|
+
import { AiCapabilityExecutionError } from "./capability-result";
|
|
14
23
|
import { aiToolAudit } from "./tool-audit";
|
|
15
24
|
import type { AiConversationService } from "./types";
|
|
16
25
|
|
|
26
|
+
export { AiCapabilityExecutionError, aiCapabilityCheckResult } from "./capability-result";
|
|
27
|
+
|
|
17
28
|
type CapabilityActor = Extract<RequestActor, { kind: "user" }>;
|
|
18
29
|
|
|
19
30
|
export const resolveAiCapabilityActor = async (input: {
|
|
@@ -38,17 +49,6 @@ export const resolveAiCapabilityActor = async (input: {
|
|
|
38
49
|
return { actor: { kind: "user", user }, accessSubject: { type: "user", userId: user.id } };
|
|
39
50
|
};
|
|
40
51
|
|
|
41
|
-
export class AiCapabilityExecutionError extends Error {
|
|
42
|
-
constructor(
|
|
43
|
-
readonly code: string,
|
|
44
|
-
readonly status: number,
|
|
45
|
-
message: string,
|
|
46
|
-
) {
|
|
47
|
-
super(`${code}: ${message}`);
|
|
48
|
-
this.name = "AiCapabilityExecutionError";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
52
|
const idempotencyKey = (conversationId: string, callId: string): string =>
|
|
53
53
|
`ai-${createHash("sha256").update(`${conversationId}\0${callId}`).digest("hex")}`;
|
|
54
54
|
|
|
@@ -71,13 +71,13 @@ const validationIssueText = (details: unknown): string => {
|
|
|
71
71
|
return issues.length > 0 ? ` Input issues: ${issues.join("; ")}` : "";
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
-
const parseCapabilityResponse = async (response: Response): Promise<unknown> => {
|
|
74
|
+
const parseCapabilityResponse = async (response: Response, dispatched: boolean): Promise<unknown> => {
|
|
75
75
|
const body = (await response.json().catch(() => null)) as { code?: unknown; message?: unknown; details?: unknown } | null;
|
|
76
76
|
if (response.ok) return body;
|
|
77
77
|
const code = typeof body?.code === "string" ? body.code : "CAPABILITY_FAILED";
|
|
78
78
|
const message = typeof body?.message === "string" ? body.message : "Capability execution failed";
|
|
79
79
|
const issueText = code === "VALIDATION_FAILED" ? validationIssueText(body?.details) : "";
|
|
80
|
-
throw new AiCapabilityExecutionError(code, response.status, `${message}${issueText}
|
|
80
|
+
throw new AiCapabilityExecutionError(code, response.status, `${message}${issueText}`, dispatched);
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
type AiCapabilityCall = {
|
|
@@ -96,7 +96,10 @@ type AiCapabilityCall = {
|
|
|
96
96
|
};
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
const dispatchAiCapability = async (
|
|
99
|
+
const dispatchAiCapability = async (
|
|
100
|
+
input: Omit<AiCapabilityCall, "context"> & { context: Pick<ToolContext, "callId" | "signal"> },
|
|
101
|
+
review: boolean,
|
|
102
|
+
): Promise<unknown> => {
|
|
100
103
|
if (input.authority.accessSubject.type !== "user" || input.authority.accessSubject.userId !== input.authority.actor.user.id) {
|
|
101
104
|
throw new Error("Cloud capability authority is inconsistent.");
|
|
102
105
|
}
|
|
@@ -128,41 +131,71 @@ const dispatchAiCapability = async (input: AiCapabilityCall, review: boolean): P
|
|
|
128
131
|
headers,
|
|
129
132
|
signal: input.context.signal,
|
|
130
133
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
134
|
+
let dispatched = false;
|
|
135
|
+
const response = await dispatch({
|
|
136
|
+
request,
|
|
137
|
+
kind: input.entry.kind === "query" ? "queries" : "actions",
|
|
138
|
+
origin: "assistant",
|
|
139
|
+
review,
|
|
140
|
+
appId: input.entry.appId,
|
|
141
|
+
capabilityId: input.entry.operation.localId,
|
|
142
|
+
input: input.args,
|
|
143
|
+
authority: {
|
|
144
|
+
actor: input.authority.actor,
|
|
145
|
+
accessSubject: input.authority.accessSubject,
|
|
146
|
+
credentialKind: "session",
|
|
147
|
+
scopes: [],
|
|
148
|
+
},
|
|
149
|
+
mandate: input.mandate
|
|
150
|
+
? {
|
|
151
|
+
mandateId: input.mandate.id,
|
|
152
|
+
mandateRevision: input.mandate.revision,
|
|
153
|
+
ownerAppId: "core",
|
|
154
|
+
...(!review && input.actionApproval ? { actionApproval: input.actionApproval } : {}),
|
|
155
|
+
}
|
|
156
|
+
: undefined,
|
|
157
|
+
dependencies: input.dependencies,
|
|
158
|
+
onUpstreamAttempt: () => {
|
|
159
|
+
dispatched = true;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
return await parseCapabilityResponse(response, dispatched);
|
|
157
163
|
};
|
|
158
164
|
|
|
159
|
-
export const reviewAiCapability = async (input: AiCapabilityCall): Promise<
|
|
165
|
+
export const reviewAiCapability = async (input: AiCapabilityCall): Promise<CapabilityActionReviewData | null> => {
|
|
160
166
|
if (input.entry.kind !== "action" || !(input.entry.operation as CapabilityActionManifest).review) return null;
|
|
161
|
-
return
|
|
167
|
+
return CapabilityActionReviewDataSchema.parse(await dispatchAiCapability(input, true));
|
|
162
168
|
};
|
|
163
169
|
|
|
164
170
|
export const executeAiCapability = (input: AiCapabilityCall): Promise<unknown> => dispatchAiCapability(input, false);
|
|
165
171
|
|
|
172
|
+
/** Run only the app-owned read-only outcome Query, once. Invalid answers stay unknown and remain recoverable. */
|
|
173
|
+
export const checkAiCapability = async (
|
|
174
|
+
input: Omit<AiCapabilityCall, "args" | "actionApproval" | "context"> & {
|
|
175
|
+
context: Pick<ToolContext, "callId" | "signal">;
|
|
176
|
+
check: NonNullable<CapabilityActionCallReview["check"]>;
|
|
177
|
+
/** The app's catalog, in which the check Query is looked up; the caller builds it (no import cycle). */
|
|
178
|
+
catalog: readonly AiCapabilityCatalogEntry[];
|
|
179
|
+
},
|
|
180
|
+
): Promise<AiActionCheck> => {
|
|
181
|
+
try {
|
|
182
|
+
const query = input.catalog.find(
|
|
183
|
+
(entry) => entry.appId === input.entry.appId && entry.kind === "query" && entry.operation.localId === input.check.query,
|
|
184
|
+
);
|
|
185
|
+
if (!query || "idempotency" in query.operation || !capabilityQueryChecksActionOutcome(query.operation))
|
|
186
|
+
throw new Error("Action outcome Query is unavailable.");
|
|
187
|
+
// The Action's audit keeps its own dispatch identity; the Query has its separate platform execution record.
|
|
188
|
+
const result = await dispatchAiCapability({ ...input, turnId: undefined, entry: query, args: input.check.input }, false);
|
|
189
|
+
if (!result || typeof result !== "object" || !("data" in result)) throw new Error("Invalid outcome check envelope.");
|
|
190
|
+
const checked = CapabilityActionCheckSchema.parse(result.data);
|
|
191
|
+
if (checked.receipt && capabilityActionReceiptIssues(checked.receipt, "links" in result ? result.links : undefined).length)
|
|
192
|
+
throw new Error("Invalid outcome check receipt.");
|
|
193
|
+
return { state: checked.outcome, ...(checked.receipt ? { receipt: checked.receipt } : {}), at: new Date().toISOString() };
|
|
194
|
+
} catch {
|
|
195
|
+
return { state: "failed", at: new Date().toISOString() };
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
166
199
|
/**
|
|
167
200
|
* A rejected approval never reaches the dispatcher, so the assistant records
|
|
168
201
|
* the same execution row the dispatcher would have written for the attempt.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { StoreEntry } from "@k2b/nessi";
|
|
2
|
+
import type { AiActionCheck } from "./action-review";
|
|
3
|
+
|
|
4
|
+
export class AiCapabilityExecutionError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
readonly code: string,
|
|
7
|
+
readonly status: number,
|
|
8
|
+
message: string,
|
|
9
|
+
readonly dispatched: boolean = false,
|
|
10
|
+
) {
|
|
11
|
+
super(`${code}: ${message}`);
|
|
12
|
+
this.name = "AiCapabilityExecutionError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A check settles only what the app knows; it never authorizes another Action attempt. */
|
|
17
|
+
export const aiCapabilityCheckResult = (check: AiActionCheck): unknown => {
|
|
18
|
+
if (check.state === "done")
|
|
19
|
+
return {
|
|
20
|
+
data: null,
|
|
21
|
+
...(check.receipt ? { receipt: check.receipt } : {}),
|
|
22
|
+
outcomeCheck: "done",
|
|
23
|
+
message: "The app's check confirmed the Action happened. Do not run it again.",
|
|
24
|
+
};
|
|
25
|
+
if (check.state === "not_done")
|
|
26
|
+
throw new AiCapabilityExecutionError(
|
|
27
|
+
"ACTION_NOT_DONE",
|
|
28
|
+
409,
|
|
29
|
+
"The app's check confirmed nothing happened. Ask the person before trying again.",
|
|
30
|
+
);
|
|
31
|
+
throw new AiCapabilityExecutionError(
|
|
32
|
+
"ACTION_OUTCOME_UNKNOWN",
|
|
33
|
+
409,
|
|
34
|
+
check.state === "pending"
|
|
35
|
+
? "The app does not know the outcome yet. Do not retry the Action."
|
|
36
|
+
: "The outcome check failed. Do not retry the Action.",
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Project durable, settled checks into model history without changing the stored messages or retrying an Action. */
|
|
41
|
+
export const projectSettledAiToolChecks = (
|
|
42
|
+
rows: readonly (StoreEntry & { meta?: { toolChecks?: Record<string, AiActionCheck> } | null })[],
|
|
43
|
+
): StoreEntry[] => {
|
|
44
|
+
const entries = rows.map(({ seq, kind, message }) => ({ seq, kind, message }));
|
|
45
|
+
const missing = new Map<number, StoreEntry[]>();
|
|
46
|
+
for (const [index, row] of rows.entries()) {
|
|
47
|
+
if (row.message.role !== "assistant" || !row.meta?.toolChecks) continue;
|
|
48
|
+
for (const call of row.message.content) {
|
|
49
|
+
if (call.type !== "tool_call") continue;
|
|
50
|
+
const check = row.meta.toolChecks[call.id];
|
|
51
|
+
if (!check || (check.state !== "done" && check.state !== "not_done")) continue;
|
|
52
|
+
let resultIndex: number | undefined;
|
|
53
|
+
// Call IDs can be reused in another exchange; a check belongs only to this call's result.
|
|
54
|
+
for (let next = index + 1; next < rows.length; next++) {
|
|
55
|
+
const message = rows[next]!.message;
|
|
56
|
+
if (message.role === "user") break;
|
|
57
|
+
if (message.role === "assistant" && message.content.some((part) => part.type === "tool_call" && part.id === call.id)) break;
|
|
58
|
+
if (message.role === "tool_result" && message.callId === call.id) {
|
|
59
|
+
resultIndex = next;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const existing = resultIndex !== undefined ? rows[resultIndex]!.message : undefined;
|
|
64
|
+
// A result that already agrees keeps its original data and receipt.
|
|
65
|
+
if (
|
|
66
|
+
existing?.role === "tool_result" &&
|
|
67
|
+
((check.state === "done" && !existing.isError) ||
|
|
68
|
+
(check.state === "not_done" &&
|
|
69
|
+
existing.isError &&
|
|
70
|
+
typeof existing.result === "string" &&
|
|
71
|
+
existing.result.startsWith("ACTION_NOT_DONE:")))
|
|
72
|
+
)
|
|
73
|
+
continue;
|
|
74
|
+
let result: unknown;
|
|
75
|
+
let isError = false;
|
|
76
|
+
try {
|
|
77
|
+
result = aiCapabilityCheckResult(check);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!(error instanceof AiCapabilityExecutionError)) throw error;
|
|
80
|
+
result = error.message;
|
|
81
|
+
isError = true;
|
|
82
|
+
}
|
|
83
|
+
const message = { role: "tool_result" as const, callId: call.id, name: call.name, result, isError };
|
|
84
|
+
if (resultIndex !== undefined) {
|
|
85
|
+
entries[resultIndex] = { ...entries[resultIndex]!, message };
|
|
86
|
+
} else {
|
|
87
|
+
// A cut-off call has no result row. Share its sequence so compaction still checkpoints durable rows.
|
|
88
|
+
const answers = missing.get(index) ?? [];
|
|
89
|
+
answers.push({ seq: row.seq, kind: "message", message });
|
|
90
|
+
missing.set(index, answers);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entries.flatMap((entry, index) => [entry, ...(missing.get(index) ?? [])]);
|
|
95
|
+
};
|