@k2b/cloud 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +14 -4
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/chat/blocks.tsx +53 -32
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/messages.ts +18 -0
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +4 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +94 -25
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/executor.ts +170 -14
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +8 -0
- package/src/ai/migrate.ts +113 -2
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +39 -0
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/widgets.ts +36 -5
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
package/src/ai/migrate.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import { CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
3
|
+
import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
|
|
2
4
|
import { migrateAiMessageQueue } from "./message-queue";
|
|
3
5
|
import { migrateAiModelAccess } from "./model-access-migrate";
|
|
4
6
|
import { migrateAiQuotas } from "./quotas-migrate";
|
|
@@ -21,6 +23,8 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
21
23
|
if (!outbox?.ready) {
|
|
22
24
|
throw new Error("AI live updates write to events.outbox, which does not exist. Run Core's events migration before the AI migration.");
|
|
23
25
|
}
|
|
26
|
+
// Approval requests reference auth.users, so they follow the check that Core's own migrations ran.
|
|
27
|
+
await migrateApprovals();
|
|
24
28
|
await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
|
|
25
29
|
console.log(" ✓ ai schema");
|
|
26
30
|
|
|
@@ -640,7 +644,11 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
640
644
|
PRIMARY KEY (turn_id, request_id)
|
|
641
645
|
)`.simple();
|
|
642
646
|
|
|
647
|
+
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS approval_id UUID`.simple();
|
|
643
648
|
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
|
|
649
|
+
// A code run asks for its nested HTTP requests and Actions; their remembered approval names a website or Action, not the run.
|
|
650
|
+
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS remember_tool_name TEXT`.simple();
|
|
651
|
+
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS allow_chat BOOLEAN NOT NULL DEFAULT FALSE`.simple();
|
|
644
652
|
|
|
645
653
|
await sql`
|
|
646
654
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
|
|
@@ -700,13 +708,39 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
700
708
|
FROM ai.tool_approval_preferences
|
|
701
709
|
WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
|
|
702
710
|
AND NOT (length(tool_name) = 64 AND tool_name ~ '__[0-9a-f]{12}$')
|
|
703
|
-
ON CONFLICT
|
|
711
|
+
ON CONFLICT DO NOTHING
|
|
704
712
|
`.simple();
|
|
705
713
|
await sql`
|
|
706
714
|
DELETE FROM ai.tool_approval_preferences
|
|
707
715
|
WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
|
|
708
716
|
`.simple();
|
|
709
717
|
|
|
718
|
+
// An approval limited to one chat ends with that chat. NULL means it applies everywhere.
|
|
719
|
+
await sql`
|
|
720
|
+
ALTER TABLE ai.tool_approval_preferences
|
|
721
|
+
ADD COLUMN IF NOT EXISTS conversation_id UUID REFERENCES ai.conversations(id) ON DELETE CASCADE
|
|
722
|
+
`.simple();
|
|
723
|
+
await sql`
|
|
724
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ai_tool_approval_preferences_reach
|
|
725
|
+
ON ai.tool_approval_preferences(actor_user_id, tool_name, approval_scope, conversation_id) NULLS NOT DISTINCT
|
|
726
|
+
`.simple();
|
|
727
|
+
await sql`ALTER TABLE ai.tool_approval_preferences DROP CONSTRAINT IF EXISTS ai_tool_approval_preferences_unique`.simple();
|
|
728
|
+
await sql`
|
|
729
|
+
CREATE INDEX IF NOT EXISTS idx_ai_tool_approval_preferences_conversation
|
|
730
|
+
ON ai.tool_approval_preferences(conversation_id)
|
|
731
|
+
WHERE conversation_id IS NOT NULL
|
|
732
|
+
`.simple();
|
|
733
|
+
// Addresses a chat's web searches returned and the pages it read link to, normalized; web_extract and fetch_file
|
|
734
|
+
// read them without asking. Recorded when the tool runs, so a read never scans stored results.
|
|
735
|
+
await sql`
|
|
736
|
+
CREATE TABLE IF NOT EXISTS ai.web_addresses (
|
|
737
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
738
|
+
address TEXT NOT NULL,
|
|
739
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
740
|
+
PRIMARY KEY (conversation_id, address)
|
|
741
|
+
)
|
|
742
|
+
`.simple();
|
|
743
|
+
|
|
710
744
|
await sql`
|
|
711
745
|
CREATE TABLE IF NOT EXISTS ai.tool_calls (
|
|
712
746
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -724,9 +758,23 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
724
758
|
rejected_at TIMESTAMPTZ,
|
|
725
759
|
completed_at TIMESTAMPTZ,
|
|
726
760
|
CONSTRAINT ai_tool_calls_status_check CHECK (status IN ('pending', 'running', 'waiting_for_approval', 'waiting_for_frontend', 'completed', 'failed', 'rejected')),
|
|
727
|
-
CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_always', 'approved_by_preference', 'rejected'))
|
|
761
|
+
CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'))
|
|
728
762
|
)
|
|
729
763
|
`.simple();
|
|
764
|
+
await sql`
|
|
765
|
+
DO $$
|
|
766
|
+
BEGIN
|
|
767
|
+
IF NOT EXISTS (
|
|
768
|
+
SELECT 1 FROM pg_constraint
|
|
769
|
+
WHERE conname = 'ai_tool_calls_approval_state_check'
|
|
770
|
+
AND pg_get_constraintdef(oid) LIKE '%approved_for_chat%'
|
|
771
|
+
) THEN
|
|
772
|
+
ALTER TABLE ai.tool_calls DROP CONSTRAINT IF EXISTS ai_tool_calls_approval_state_check;
|
|
773
|
+
ALTER TABLE ai.tool_calls ADD CONSTRAINT ai_tool_calls_approval_state_check
|
|
774
|
+
CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'));
|
|
775
|
+
END IF;
|
|
776
|
+
END $$
|
|
777
|
+
`.simple();
|
|
730
778
|
|
|
731
779
|
await sql`
|
|
732
780
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_turn_call
|
|
@@ -738,6 +786,7 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
738
786
|
ON ai.tool_calls(conversation_id, created_at DESC)
|
|
739
787
|
`.simple();
|
|
740
788
|
|
|
789
|
+
await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS approval_decided_via TEXT`.simple();
|
|
741
790
|
await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
|
|
742
791
|
|
|
743
792
|
// Capability shape, outcome, and latency belong to capabilities.executions,
|
|
@@ -2525,5 +2574,67 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
2525
2574
|
` ! ${legacyPaths.files} conversation and ${legacyPaths.project_files} project file paths keep a non-NFC Unicode form because a normalized twin exists`,
|
|
2526
2575
|
);
|
|
2527
2576
|
}
|
|
2577
|
+
await backfillApprovalRequests();
|
|
2528
2578
|
console.log(" ✓ ai conversation tables");
|
|
2529
2579
|
};
|
|
2580
|
+
|
|
2581
|
+
const backfillApprovalRequests = async (): Promise<void> => {
|
|
2582
|
+
const decoded = (value: unknown): unknown => (typeof value === "string" ? JSON.parse(value) : value);
|
|
2583
|
+
while (true) {
|
|
2584
|
+
const count = await sql.begin(async (db) => {
|
|
2585
|
+
// Completed turns and ownerless chats cannot still ask a person; leave their legacy rows alone.
|
|
2586
|
+
const rows = await db<
|
|
2587
|
+
{
|
|
2588
|
+
id: string;
|
|
2589
|
+
turn_id: string;
|
|
2590
|
+
call_id: string;
|
|
2591
|
+
tool_name: string;
|
|
2592
|
+
approval_scope: string;
|
|
2593
|
+
message: string | null;
|
|
2594
|
+
review: unknown;
|
|
2595
|
+
args: unknown;
|
|
2596
|
+
allow_always: boolean;
|
|
2597
|
+
allow_chat: boolean;
|
|
2598
|
+
owner: string;
|
|
2599
|
+
title: string;
|
|
2600
|
+
expiry: Date;
|
|
2601
|
+
}[]
|
|
2602
|
+
>`SELECT a.*, c.created_by_user_id AS owner, c.title,
|
|
2603
|
+
COALESCE(t.deadline, a.created_at + interval '24 hours') AS expiry
|
|
2604
|
+
FROM ai.pending_actions a JOIN ai.turns t ON t.id=a.turn_id JOIN ai.conversations c ON c.id=a.conversation_id
|
|
2605
|
+
WHERE a.status='pending' AND a.kind IN ('approval','custom_approval') AND a.approval_id IS NULL
|
|
2606
|
+
AND c.created_by_user_id IS NOT NULL AND t.status='waiting_for_action'
|
|
2607
|
+
-- A running Assistant may already have asked again for this action; keep the request it saw.
|
|
2608
|
+
AND NOT EXISTS (SELECT 1 FROM approvals.requests r
|
|
2609
|
+
WHERE r.requester_kind='assistant_turn' AND r.requester_ref=a.turn_id::text || '/' || a.call_id)
|
|
2610
|
+
ORDER BY a.created_at, a.id LIMIT 200 FOR UPDATE OF a SKIP LOCKED`;
|
|
2611
|
+
for (const row of rows) {
|
|
2612
|
+
const review = CapabilityActionReviewSchema.safeParse(row.review == null ? null : decoded(row.review));
|
|
2613
|
+
// request computes both canonical digests; preserve the original wait deadline, including expiry.
|
|
2614
|
+
const record = await approvals.request(
|
|
2615
|
+
{
|
|
2616
|
+
...approvalScopeFromToolName(row.tool_name),
|
|
2617
|
+
approvalScope: row.approval_scope,
|
|
2618
|
+
requester: { kind: "assistant_turn", ref: `${row.turn_id}/${row.call_id}`, appId: "assistant", title: row.title },
|
|
2619
|
+
subjectId: row.owner,
|
|
2620
|
+
approverUserId: row.owner,
|
|
2621
|
+
// Every turn that can ask allows remembering, so these flags hold what the call offers.
|
|
2622
|
+
assurance: approvalAssurance(row.allow_always || row.allow_chat),
|
|
2623
|
+
sentence: row.message ?? row.tool_name,
|
|
2624
|
+
review: review.success ? review.data : null,
|
|
2625
|
+
input: decoded(row.args),
|
|
2626
|
+
expiresInMs: Math.min(APPROVAL_MAX_WAIT_MS, row.expiry.getTime() - Date.now()),
|
|
2627
|
+
},
|
|
2628
|
+
db,
|
|
2629
|
+
);
|
|
2630
|
+
if (!record.ok) continue;
|
|
2631
|
+
await db`UPDATE approvals.requests SET expires_at=${row.expiry},
|
|
2632
|
+
status=CASE WHEN ${row.expiry} <= now() THEN 'expired' ELSE status END
|
|
2633
|
+
WHERE id=${record.id}::uuid AND status='pending'`;
|
|
2634
|
+
await db`UPDATE ai.pending_actions SET approval_id=${record.id}::uuid WHERE id=${row.id}::uuid`;
|
|
2635
|
+
}
|
|
2636
|
+
return rows.length;
|
|
2637
|
+
});
|
|
2638
|
+
if (!count) return;
|
|
2639
|
+
}
|
|
2640
|
+
};
|
package/src/ai/protocol.ts
CHANGED
|
@@ -44,7 +44,15 @@ export type AiTurnBlock =
|
|
|
44
44
|
result?: unknown;
|
|
45
45
|
isError?: boolean;
|
|
46
46
|
/** Present while status is awaiting_approval. */
|
|
47
|
-
approval?: {
|
|
47
|
+
approval?: {
|
|
48
|
+
message?: string;
|
|
49
|
+
review?: CapabilityActionReview;
|
|
50
|
+
allowAlways: boolean;
|
|
51
|
+
/** Whether the approval can be remembered for this chat. */
|
|
52
|
+
allowChat?: boolean;
|
|
53
|
+
/** The website origin a chat approval would allow, for an HTTP request a code run makes. */
|
|
54
|
+
website?: string;
|
|
55
|
+
};
|
|
48
56
|
/**
|
|
49
57
|
* `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
|
|
50
58
|
* waited. Either way the approval stays visible as a receipt.
|
|
@@ -54,6 +62,8 @@ export type AiTurnBlock =
|
|
|
54
62
|
frontendMode?: AiFrontendToolMode;
|
|
55
63
|
/** Saved Cloud-owned display snapshot for capability calls. */
|
|
56
64
|
presentation?: AiToolPresentation;
|
|
65
|
+
/** Requests a website approval let through without asking, in order; shown whatever the call's outcome. */
|
|
66
|
+
receipts?: { method: string; url: string }[];
|
|
57
67
|
}
|
|
58
68
|
| { id: string; kind: "compaction"; status: "running" | "completed" | "skipped" | "failed"; result?: CompactResult };
|
|
59
69
|
|
|
@@ -215,6 +225,14 @@ const canonicalToolName = (name: string, presentation?: AiToolPresentation): str
|
|
|
215
225
|
return candidates.length === 1 ? candidates[0]! : name;
|
|
216
226
|
};
|
|
217
227
|
|
|
228
|
+
/** The receipts of one tool call, keyed by their index, in the order the requests went out. */
|
|
229
|
+
export const orderedWebsiteReceipts = (
|
|
230
|
+
receipts: Record<string, { method: string; url: string }> | undefined,
|
|
231
|
+
): { method: string; url: string }[] =>
|
|
232
|
+
Object.entries(receipts ?? {})
|
|
233
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
234
|
+
.map(([, receipt]) => receipt);
|
|
235
|
+
|
|
218
236
|
/**
|
|
219
237
|
* Convert persisted loop messages into the block model. Shared by the executor
|
|
220
238
|
* (baseline rebuild on claim) and the client (rendering finished turns), so a
|
|
@@ -228,6 +246,7 @@ export const buildBlocksFromMessages = (
|
|
|
228
246
|
steerId?: string;
|
|
229
247
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
230
248
|
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
249
|
+
websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
|
|
231
250
|
} | null;
|
|
232
251
|
}[],
|
|
233
252
|
): AiTurnBlock[] => {
|
|
@@ -243,6 +262,7 @@ export const buildBlocksFromMessages = (
|
|
|
243
262
|
blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
|
|
244
263
|
} else if (block.type === "tool_call") {
|
|
245
264
|
const outcome = meta?.toolOutcomes?.[block.id];
|
|
265
|
+
const receipts = orderedWebsiteReceipts(meta?.websiteReceipts?.[block.id]);
|
|
246
266
|
toolIndex.set(block.id, blocks.length);
|
|
247
267
|
blocks.push({
|
|
248
268
|
id: toolBlockId(block.id),
|
|
@@ -255,6 +275,7 @@ export const buildBlocksFromMessages = (
|
|
|
255
275
|
// A turn that ended before an approved call returned records the approval on the call's message, and an
|
|
256
276
|
// approval it left waiting as expired.
|
|
257
277
|
...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
|
|
278
|
+
...(receipts.length ? { receipts } : {}),
|
|
258
279
|
});
|
|
259
280
|
}
|
|
260
281
|
});
|
package/src/ai/routes.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
v,
|
|
19
19
|
} from "../server";
|
|
20
20
|
import { isRequestCredentialCurrent } from "../server/middleware/auth";
|
|
21
|
+
import { approvalDecisionChannel } from "../services/approvals";
|
|
21
22
|
import { ImageMetadataError } from "../services/image-metadata";
|
|
22
23
|
import { logger } from "../services/logging";
|
|
23
24
|
import { coreSettings } from "../services/settings/api";
|
|
@@ -93,6 +94,8 @@ type AiChatRequestContext = {
|
|
|
93
94
|
ownerUserId: string;
|
|
94
95
|
modelPolicy: AiModelPolicy;
|
|
95
96
|
toolApprovalContext: AiToolApprovalContext;
|
|
97
|
+
/** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
|
|
98
|
+
signedInSession: boolean;
|
|
96
99
|
};
|
|
97
100
|
|
|
98
101
|
const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
|
|
@@ -106,6 +109,7 @@ const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestCon
|
|
|
106
109
|
ownerUserId: user.id,
|
|
107
110
|
modelPolicy: personalAiModelPolicy,
|
|
108
111
|
toolApprovalContext: { actorUserId: user.id },
|
|
112
|
+
signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
|
|
109
113
|
};
|
|
110
114
|
};
|
|
111
115
|
|
|
@@ -890,6 +894,7 @@ export const aiRoutes = (() => {
|
|
|
890
894
|
try {
|
|
891
895
|
const submission = {
|
|
892
896
|
assistantChat: true as const,
|
|
897
|
+
...(ctx.signedInSession ? { signedInSession: true as const } : {}),
|
|
893
898
|
conversationId: conversation.id,
|
|
894
899
|
chatId: conversation.shortId,
|
|
895
900
|
input,
|
|
@@ -999,6 +1004,7 @@ export const aiRoutes = (() => {
|
|
|
999
1004
|
}
|
|
1000
1005
|
const result = await submitAiChatTurn({
|
|
1001
1006
|
assistantChat: true,
|
|
1007
|
+
...(ctx.signedInSession ? { signedInSession: true as const } : {}),
|
|
1002
1008
|
conversationId: conversation.id,
|
|
1003
1009
|
chatId: conversation.shortId,
|
|
1004
1010
|
input,
|
|
@@ -1121,7 +1127,12 @@ export const aiRoutes = (() => {
|
|
|
1121
1127
|
turnId: turn.id,
|
|
1122
1128
|
callId,
|
|
1123
1129
|
action: c.req.valid("json"),
|
|
1130
|
+
decidedBy: {
|
|
1131
|
+
userId: ctx.ownerUserId,
|
|
1132
|
+
via: approvalDecisionChannel({ credentialKind: c.get("credentialKind"), actor: ctx.actor }),
|
|
1133
|
+
},
|
|
1124
1134
|
toolApprovalContext: ctx.toolApprovalContext,
|
|
1135
|
+
signedInSession: ctx.signedInSession,
|
|
1125
1136
|
});
|
|
1126
1137
|
if (!result.ok) return toAiActionFailureResponse(c, result);
|
|
1127
1138
|
return respond(c, ok({ ok: true }));
|
package/src/ai/runtime.ts
CHANGED
|
@@ -4,10 +4,11 @@ import type { QueueMessage } from "@k2b/sync";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { lazySync } from "../_internal/process-sync";
|
|
6
6
|
import type { RequestActor } from "../server";
|
|
7
|
+
import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
|
|
7
8
|
import { logger } from "../services/logging";
|
|
8
9
|
import { superviseRuntimeTask } from "../services/runtime-lifecycle";
|
|
9
10
|
import { coreSettings } from "../services/settings/api";
|
|
10
|
-
import { type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
|
|
11
|
+
import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
|
|
11
12
|
import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
|
|
12
13
|
import { startAiDictationRuntime } from "./dictation-runtime";
|
|
13
14
|
import { AiTurnExecutor } from "./executor";
|
|
@@ -87,6 +88,8 @@ export const enqueueExistingAiTurn = (input: AiTurnJob): Promise<unknown> => enq
|
|
|
87
88
|
export type SubmitAiChatTurnInput = {
|
|
88
89
|
/** Only interactive Assistant HTTP handlers set this server-owned marker. */
|
|
89
90
|
assistantChat?: true;
|
|
91
|
+
/** Only a handler that saw a signed-in browser session sets this server-owned marker; see `AiChatTurnRunConfig`. */
|
|
92
|
+
signedInSession?: true;
|
|
90
93
|
conversationId: string;
|
|
91
94
|
/** Stable public ID exposed as runtime context, not instructions. */
|
|
92
95
|
chatId?: string;
|
|
@@ -153,6 +156,7 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
|
153
156
|
),
|
|
154
157
|
],
|
|
155
158
|
...(input.assistantChat ? { assistantChat: true } : {}),
|
|
159
|
+
...(input.signedInSession ? { signedInSession: true } : {}),
|
|
156
160
|
input: canonicalInput,
|
|
157
161
|
chatId: input.chatId,
|
|
158
162
|
actor: input.actor,
|
|
@@ -297,7 +301,7 @@ export const abortAiTurn = async (input: { conversationId: string; turnId: strin
|
|
|
297
301
|
// ---------------------------------------------------------------------------
|
|
298
302
|
|
|
299
303
|
export const AiTurnActionSchema = z.discriminatedUnion("type", [
|
|
300
|
-
z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.
|
|
304
|
+
z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.enum(["chat", "always"]).optional() }),
|
|
301
305
|
z.object({ type: z.literal("tool_result"), result: z.unknown() }),
|
|
302
306
|
]);
|
|
303
307
|
export type AiTurnActionInput = z.infer<typeof AiTurnActionSchema>;
|
|
@@ -312,7 +316,7 @@ export const listPendingAiTurnActions = async (input: { conversationId: string;
|
|
|
312
316
|
const [actions, config] = await Promise.all([aiConversations.listPendingTurnActions(input), aiConversations.getTurnRunConfig(input)]);
|
|
313
317
|
return aiTurnAllowsRememberedApprovals(config)
|
|
314
318
|
? actions
|
|
315
|
-
: actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false } : action));
|
|
319
|
+
: actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false, allowChat: false } : action));
|
|
316
320
|
};
|
|
317
321
|
|
|
318
322
|
export const submitAiTurnAction = async (input: {
|
|
@@ -320,7 +324,10 @@ export const submitAiTurnAction = async (input: {
|
|
|
320
324
|
turnId: string;
|
|
321
325
|
callId: string;
|
|
322
326
|
action: AiTurnActionInput;
|
|
327
|
+
decidedBy: { userId: string; via: ApprovalDecisionChannel };
|
|
323
328
|
toolApprovalContext?: AiToolApprovalContext;
|
|
329
|
+
/** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
|
|
330
|
+
signedInSession?: boolean;
|
|
324
331
|
}): Promise<{ ok: true } | { ok: false; status: 400 | 404 | 409; message: string }> => {
|
|
325
332
|
const pending = await aiConversations.getPendingTurnAction(input);
|
|
326
333
|
if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
|
|
@@ -338,18 +345,50 @@ export const submitAiTurnAction = async (input: {
|
|
|
338
345
|
|
|
339
346
|
if (input.action.type === "approval_response") {
|
|
340
347
|
if (pending.kind === "client_tool") return { ok: false, status: 400, message: "Frontend tool requests require a tool result." };
|
|
341
|
-
|
|
348
|
+
const remember = input.action.remember;
|
|
349
|
+
if (remember) {
|
|
342
350
|
const config = await aiConversations.getTurnRunConfig(input);
|
|
343
|
-
|
|
351
|
+
const toolName = pending.rememberToolName ?? pending.name;
|
|
352
|
+
const allowed = remember === "always" ? pending.allowAlways : (pending.allowChat ?? pending.allowAlways);
|
|
353
|
+
if (
|
|
354
|
+
!input.action.approved ||
|
|
355
|
+
!allowed ||
|
|
356
|
+
!input.toolApprovalContext ||
|
|
357
|
+
!aiTurnAllowsRememberedApprovals(config) ||
|
|
358
|
+
// A website is remembered only by a person in a signed-in session: `cld`, an API key, or another delegated
|
|
359
|
+
// credential can approve this one request, but cannot let the next ones through.
|
|
360
|
+
(toolName === AI_WEBSITE_APPROVAL_TOOL && (remember !== "chat" || !input.signedInSession))
|
|
361
|
+
) {
|
|
344
362
|
return { ok: false, status: 400, message: "This approval cannot be remembered." };
|
|
345
363
|
}
|
|
346
|
-
|
|
364
|
+
}
|
|
365
|
+
if (pending.approvalId) {
|
|
366
|
+
const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
|
|
367
|
+
if (!decision.ok)
|
|
368
|
+
return decision.code === "ALREADY_DECIDED"
|
|
369
|
+
? { ok: false, status: 409, message: "AI action was already resolved with a different response." }
|
|
370
|
+
: { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
|
|
371
|
+
}
|
|
372
|
+
if (remember && input.toolApprovalContext) {
|
|
373
|
+
const toolName = pending.rememberToolName ?? pending.name;
|
|
374
|
+
await rememberAiToolApproval(input.toolApprovalContext, {
|
|
375
|
+
toolName,
|
|
376
|
+
approvalScope: pending.approvalScope,
|
|
377
|
+
conversationId: remember === "chat" ? input.conversationId : null,
|
|
378
|
+
});
|
|
347
379
|
}
|
|
348
380
|
await aiToolAudit
|
|
349
381
|
.noteApprovalResolved({
|
|
350
382
|
turnId: input.turnId,
|
|
351
383
|
callId: input.callId,
|
|
352
|
-
|
|
384
|
+
decidedVia: input.decidedBy.via,
|
|
385
|
+
approvalState: input.action.approved
|
|
386
|
+
? remember === "always"
|
|
387
|
+
? "approved_always"
|
|
388
|
+
: remember === "chat"
|
|
389
|
+
? "approved_for_chat"
|
|
390
|
+
: "approved_once"
|
|
391
|
+
: "rejected",
|
|
353
392
|
})
|
|
354
393
|
.catch(() => undefined);
|
|
355
394
|
|
package/src/ai/store.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type SQL, sql } from "bun";
|
|
|
4
4
|
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
|
|
7
8
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
8
9
|
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
9
10
|
import type { AiTurnBlock } from "./protocol";
|
|
@@ -36,6 +37,7 @@ import type {
|
|
|
36
37
|
AiTurnStatus,
|
|
37
38
|
AiTurnSteer,
|
|
38
39
|
AiTurnSweepResult,
|
|
40
|
+
AiWebsiteReceipt,
|
|
39
41
|
} from "./types";
|
|
40
42
|
|
|
41
43
|
/** A queued turn this old without a claim is considered lost and re-enqueued by the sweep. */
|
|
@@ -191,6 +193,7 @@ type TurnRow = {
|
|
|
191
193
|
};
|
|
192
194
|
|
|
193
195
|
type PendingActionRow = {
|
|
196
|
+
approval_id: string | null;
|
|
194
197
|
turn_id: string;
|
|
195
198
|
conversation_id: string;
|
|
196
199
|
call_id: string;
|
|
@@ -202,6 +205,8 @@ type PendingActionRow = {
|
|
|
202
205
|
review: unknown | null;
|
|
203
206
|
approval_scope: string;
|
|
204
207
|
allow_always: boolean;
|
|
208
|
+
allow_chat: boolean | null;
|
|
209
|
+
remember_tool_name: string | null;
|
|
205
210
|
frontend_mode: AiFrontendToolMode | null;
|
|
206
211
|
resolved_event: unknown | null;
|
|
207
212
|
};
|
|
@@ -678,9 +683,12 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
|
|
|
678
683
|
message: row.message ?? undefined,
|
|
679
684
|
review: parseCapabilityActionReview(row.review),
|
|
680
685
|
allowAlways: row.allow_always,
|
|
686
|
+
allowChat: row.allow_always || row.allow_chat === true,
|
|
687
|
+
...(row.remember_tool_name === AI_WEBSITE_APPROVAL_TOOL ? { website: row.approval_scope } : {}),
|
|
681
688
|
};
|
|
682
689
|
|
|
683
690
|
const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRecord => ({
|
|
691
|
+
approvalId: row.approval_id ?? undefined,
|
|
684
692
|
turnId: row.turn_id,
|
|
685
693
|
conversationId: row.conversation_id,
|
|
686
694
|
callId: row.call_id,
|
|
@@ -692,6 +700,8 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
|
|
|
692
700
|
review: parseCapabilityActionReview(row.review),
|
|
693
701
|
approvalScope: row.approval_scope,
|
|
694
702
|
allowAlways: row.allow_always,
|
|
703
|
+
allowChat: row.allow_always || row.allow_chat === true,
|
|
704
|
+
...(row.remember_tool_name ? { rememberToolName: row.remember_tool_name } : {}),
|
|
695
705
|
frontendMode: row.frontend_mode ?? undefined,
|
|
696
706
|
resolvedEvent: row.resolved_event ? parseJsonValue<InboundEvent>(row.resolved_event) : null,
|
|
697
707
|
});
|
|
@@ -784,6 +794,18 @@ const messageSearchText = (message: Message): string => {
|
|
|
784
794
|
return text.slice(0, SEARCH_TEXT_MAX_CHARS);
|
|
785
795
|
};
|
|
786
796
|
|
|
797
|
+
/**
|
|
798
|
+
* An ended turn uses no further approval: cancel its open questions and the turn's own approvals that no call used.
|
|
799
|
+
* A Studio request linked from the turn stays as decided, because the code run uses it when the call runs there.
|
|
800
|
+
*/
|
|
801
|
+
const cancelTurnApprovals = async (db: typeof sql, turnId: string): Promise<void> => {
|
|
802
|
+
await db`
|
|
803
|
+
UPDATE approvals.requests SET status = 'cancelled'
|
|
804
|
+
WHERE id IN (SELECT approval_id FROM ai.pending_actions WHERE turn_id = ${turnId} AND approval_id IS NOT NULL)
|
|
805
|
+
AND (status = 'pending' OR (status = 'approved' AND requester_kind = 'assistant_turn'))
|
|
806
|
+
`;
|
|
807
|
+
};
|
|
808
|
+
|
|
787
809
|
/**
|
|
788
810
|
* Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
|
|
789
811
|
* time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
|
|
@@ -878,6 +900,35 @@ const recordTurnEnd = async (
|
|
|
878
900
|
}
|
|
879
901
|
};
|
|
880
902
|
|
|
903
|
+
/**
|
|
904
|
+
* Records requests a website approval let through on the assistant message that holds the tool call, as each one
|
|
905
|
+
* goes out, so the chat keeps every receipt whether the call succeeds, fails, or its turn stops. Website approvals
|
|
906
|
+
* apply only to interactive chats, whose messages live in `ai.messages`. A receipt with a known index replaces itself.
|
|
907
|
+
*/
|
|
908
|
+
export const recordAiWebsiteReceipts = async (input: {
|
|
909
|
+
conversationId: string;
|
|
910
|
+
turnId: string;
|
|
911
|
+
callId: string;
|
|
912
|
+
receipts: AiWebsiteReceipt[];
|
|
913
|
+
}): Promise<void> => {
|
|
914
|
+
if (!input.receipts.length) return;
|
|
915
|
+
const receipts = Object.fromEntries(input.receipts.map(({ index, method, url }) => [String(index), { method, url }]));
|
|
916
|
+
await sql`
|
|
917
|
+
UPDATE ai.messages
|
|
918
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
|
|
919
|
+
'websiteReceipts',
|
|
920
|
+
COALESCE(meta->'websiteReceipts', '{}'::jsonb) || jsonb_build_object(
|
|
921
|
+
${input.callId}::text,
|
|
922
|
+
COALESCE(meta->'websiteReceipts'->${input.callId}::text, '{}'::jsonb) || (${JSON.stringify(receipts)}::text)::jsonb
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
926
|
+
AND loop_id = ${input.turnId}::text
|
|
927
|
+
AND role = 'assistant'
|
|
928
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
929
|
+
`;
|
|
930
|
+
};
|
|
931
|
+
|
|
881
932
|
/** Insert a message inside an open conversation-lock transaction and bump the conversation. */
|
|
882
933
|
const insertMessageLocked = async (
|
|
883
934
|
input: {
|
|
@@ -3017,6 +3068,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3017
3068
|
WHERE turn_id = ${input.turnId}
|
|
3018
3069
|
AND status = 'pending'
|
|
3019
3070
|
`;
|
|
3071
|
+
await cancelTurnApprovals(tx, input.turnId);
|
|
3020
3072
|
if (input.status !== "completed") {
|
|
3021
3073
|
await recordTurnEnd(tx, {
|
|
3022
3074
|
conversationId: input.conversationId,
|
|
@@ -3173,6 +3225,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3173
3225
|
WHERE turn_id = ${finalized.turnId}
|
|
3174
3226
|
AND status = 'pending'
|
|
3175
3227
|
`;
|
|
3228
|
+
await cancelTurnApprovals(sql, finalized.turnId);
|
|
3176
3229
|
await sql`
|
|
3177
3230
|
UPDATE ai.turn_steers
|
|
3178
3231
|
SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
|
|
@@ -3261,6 +3314,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3261
3314
|
savePendingTurnAction: async (input) => {
|
|
3262
3315
|
await sql`
|
|
3263
3316
|
INSERT INTO ai.pending_actions (
|
|
3317
|
+
approval_id,
|
|
3264
3318
|
turn_id,
|
|
3265
3319
|
conversation_id,
|
|
3266
3320
|
call_id,
|
|
@@ -3271,12 +3325,15 @@ export const aiConversations: AiConversationService = {
|
|
|
3271
3325
|
review,
|
|
3272
3326
|
approval_scope,
|
|
3273
3327
|
allow_always,
|
|
3328
|
+
allow_chat,
|
|
3329
|
+
remember_tool_name,
|
|
3274
3330
|
frontend_mode,
|
|
3275
3331
|
status,
|
|
3276
3332
|
resolved_event,
|
|
3277
3333
|
resolved_at
|
|
3278
3334
|
)
|
|
3279
3335
|
VALUES (
|
|
3336
|
+
${input.approvalId ?? null}::uuid,
|
|
3280
3337
|
${input.turnId},
|
|
3281
3338
|
${input.conversationId},
|
|
3282
3339
|
${input.callId},
|
|
@@ -3287,6 +3344,8 @@ export const aiConversations: AiConversationService = {
|
|
|
3287
3344
|
${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
|
|
3288
3345
|
${input.approvalScope},
|
|
3289
3346
|
${input.allowAlways},
|
|
3347
|
+
${input.allowChat ?? input.allowAlways},
|
|
3348
|
+
${input.rememberToolName ?? null},
|
|
3290
3349
|
${input.frontendMode ?? null},
|
|
3291
3350
|
${input.resolvedEvent ? "resolved" : "pending"},
|
|
3292
3351
|
${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
|
|
@@ -3294,6 +3353,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3294
3353
|
)
|
|
3295
3354
|
ON CONFLICT (turn_id, call_id)
|
|
3296
3355
|
DO UPDATE SET
|
|
3356
|
+
approval_id = EXCLUDED.approval_id,
|
|
3297
3357
|
kind = EXCLUDED.kind,
|
|
3298
3358
|
tool_name = EXCLUDED.tool_name,
|
|
3299
3359
|
args = EXCLUDED.args,
|
|
@@ -3301,6 +3361,8 @@ export const aiConversations: AiConversationService = {
|
|
|
3301
3361
|
review = EXCLUDED.review,
|
|
3302
3362
|
approval_scope = EXCLUDED.approval_scope,
|
|
3303
3363
|
allow_always = EXCLUDED.allow_always,
|
|
3364
|
+
allow_chat = EXCLUDED.allow_chat,
|
|
3365
|
+
remember_tool_name = EXCLUDED.remember_tool_name,
|
|
3304
3366
|
frontend_mode = EXCLUDED.frontend_mode
|
|
3305
3367
|
`;
|
|
3306
3368
|
},
|
|
@@ -3370,12 +3432,15 @@ export const aiConversations: AiConversationService = {
|
|
|
3370
3432
|
|
|
3371
3433
|
clearPendingTurnActions: async (input) => {
|
|
3372
3434
|
await sql`
|
|
3373
|
-
UPDATE ai.pending_actions
|
|
3435
|
+
WITH aborted AS (UPDATE ai.pending_actions
|
|
3374
3436
|
SET status = 'aborted',
|
|
3375
3437
|
resolved_at = COALESCE(resolved_at, now())
|
|
3376
3438
|
WHERE conversation_id = ${input.conversationId}
|
|
3377
3439
|
AND turn_id = ${input.turnId}
|
|
3378
3440
|
AND status = 'pending'
|
|
3441
|
+
RETURNING approval_id)
|
|
3442
|
+
UPDATE approvals.requests SET status='cancelled'
|
|
3443
|
+
WHERE id IN (SELECT approval_id FROM aborted WHERE approval_id IS NOT NULL) AND status='pending'
|
|
3379
3444
|
`;
|
|
3380
3445
|
},
|
|
3381
3446
|
|
package/src/ai/tool-audit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import type { ApprovalDecisionChannel } from "../services/approvals";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* AI-specific tool-call state only: approval, turn linkage, and the request id
|
|
@@ -8,7 +9,14 @@ import { sql } from "bun";
|
|
|
8
9
|
*/
|
|
9
10
|
export type AiToolCallLocation = "server" | "client" | "client_view" | "client_interaction";
|
|
10
11
|
|
|
11
|
-
export type AiToolApprovalState =
|
|
12
|
+
export type AiToolApprovalState =
|
|
13
|
+
| "not_required"
|
|
14
|
+
| "waiting"
|
|
15
|
+
| "approved_once"
|
|
16
|
+
| "approved_for_chat"
|
|
17
|
+
| "approved_always"
|
|
18
|
+
| "approved_by_preference"
|
|
19
|
+
| "rejected";
|
|
12
20
|
|
|
13
21
|
export const aiToolAudit = {
|
|
14
22
|
/** Resolve the current server-recorded invocation, never a model-supplied chat id. */
|
|
@@ -140,12 +148,14 @@ export const aiToolAudit = {
|
|
|
140
148
|
turnId: string;
|
|
141
149
|
callId: string;
|
|
142
150
|
approvalState: Exclude<AiToolApprovalState, "not_required" | "waiting">;
|
|
151
|
+
decidedVia?: ApprovalDecisionChannel;
|
|
143
152
|
}): Promise<void> => {
|
|
144
153
|
const approved = input.approvalState !== "rejected";
|
|
145
154
|
await sql`
|
|
146
155
|
UPDATE ai.tool_calls
|
|
147
156
|
SET
|
|
148
157
|
approval_state = ${input.approvalState},
|
|
158
|
+
approval_decided_via = ${input.decidedVia ?? null},
|
|
149
159
|
status = CASE WHEN ${approved} THEN status ELSE 'rejected' END,
|
|
150
160
|
approved_at = CASE WHEN ${approved} THEN now() ELSE approved_at END,
|
|
151
161
|
rejected_at = CASE WHEN ${approved} THEN rejected_at ELSE now() END
|