@agent-native/core 0.161.14 → 0.161.16
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/corpus/README.md +1 -1
- package/corpus/templates/clips/app/hooks/use-recording-leave-guard.ts +70 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
- package/corpus/templates/clips/app/routes/record.tsx +42 -0
- package/corpus/templates/slides/app/root.tsx +21 -0
- package/corpus/templates/slides/server/handlers/decks.ts +7 -3
- package/corpus/templates/slides/server/handlers/request-auth-context.ts +58 -1
- package/corpus/templates/slides/server/handlers/share.ts +7 -2
- package/corpus/templates/slides/server/handlers/uploads.ts +7 -2
- package/dist/agent/engine/translate-anthropic.js +19 -1
- package/dist/agent/engine/types.d.ts +7 -0
- package/dist/agent/production-agent.js +32 -0
- package/dist/agent/thread-data-builder.d.ts +16 -4
- package/dist/agent/thread-data-builder.js +159 -5
- package/dist/client/agent-chat-adapter.js +13 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-teams.js +3 -1
- package/dist/server/cors-origins.js +24 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/transcription/builder-transcription.js +12 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useBlocker } from "react-router";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Blocks in-app route navigation while `hasRecordingAtRisk()` is true, using
|
|
6
|
+
* React Router's `useBlocker` — the same check the recorder's `beforeunload`
|
|
7
|
+
* handler already uses for tab close/reload. Without this, an in-app link
|
|
8
|
+
* (e.g. Library) unmounts the recorder route with no warning at all, since
|
|
9
|
+
* the browser never fires `beforeunload` for client-side navigation.
|
|
10
|
+
*
|
|
11
|
+
* Exposes a tiny state machine for a confirm dialog rather than a boolean,
|
|
12
|
+
* because confirming has to navigate away only after the dialog has actually
|
|
13
|
+
* finished closing — see the `onCloseAutoFocus` deferral below.
|
|
14
|
+
*/
|
|
15
|
+
export function useRecordingLeaveGuard(hasRecordingAtRisk: () => boolean) {
|
|
16
|
+
const blocker = useBlocker(
|
|
17
|
+
useCallback(
|
|
18
|
+
({ currentLocation, nextLocation }) =>
|
|
19
|
+
currentLocation.pathname !== nextLocation.pathname &&
|
|
20
|
+
hasRecordingAtRisk(),
|
|
21
|
+
[hasRecordingAtRisk],
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const [leavePromptOpen, setLeavePromptOpen] = useState(false);
|
|
26
|
+
// See delete-recording-menu.tsx's `deletedWhileOpenRef`: navigating away
|
|
27
|
+
// while Radix is still tearing down the AlertDialog portal can leave its
|
|
28
|
+
// body pointer-events lock behind. Close the dialog first and defer
|
|
29
|
+
// `blocker.proceed()` to `onCloseAutoFocus`, once Radix has actually
|
|
30
|
+
// finished closing it, instead of calling it straight from the click
|
|
31
|
+
// handler.
|
|
32
|
+
const proceedBlockerRef = useRef<() => void>(() => {});
|
|
33
|
+
proceedBlockerRef.current =
|
|
34
|
+
blocker.state === "blocked" ? blocker.proceed : () => {};
|
|
35
|
+
const leaveConfirmedRef = useRef(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (blocker.state === "blocked") setLeavePromptOpen(true);
|
|
39
|
+
}, [blocker.state]);
|
|
40
|
+
|
|
41
|
+
const confirmLeave = useCallback(() => {
|
|
42
|
+
leaveConfirmedRef.current = true;
|
|
43
|
+
setLeavePromptOpen(false);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const onDialogOpenChange = useCallback(
|
|
47
|
+
(open: boolean) => {
|
|
48
|
+
if (open) return;
|
|
49
|
+
setLeavePromptOpen(false);
|
|
50
|
+
if (!leaveConfirmedRef.current && blocker.state === "blocked") {
|
|
51
|
+
blocker.reset();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[blocker],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const onCloseAutoFocus = useCallback((event: { preventDefault(): void }) => {
|
|
58
|
+
if (!leaveConfirmedRef.current) return;
|
|
59
|
+
leaveConfirmedRef.current = false;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
setTimeout(() => proceedBlockerRef.current(), 0);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
leavePromptOpen,
|
|
66
|
+
onDialogOpenChange,
|
|
67
|
+
onCloseAutoFocus,
|
|
68
|
+
confirmLeave,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1413,6 +1413,10 @@ All notable user-facing changes to Clips are documented here. Open it any time f
|
|
|
1413
1413
|
"Connect storage on the next screen: Builder.io (free tier storage + AI) or S3-compatible storage. Clips will finish saving it.",
|
|
1414
1414
|
connectStorageToRetryLoom:
|
|
1415
1415
|
"Connect storage on the next screen: Builder.io (free tier storage + AI) or S3-compatible storage. Clips will retry the import.",
|
|
1416
|
+
leaveConfirmTitle: "Leave and discard this recording?",
|
|
1417
|
+
leaveConfirmDescription:
|
|
1418
|
+
"Your in-progress recording hasn't finished saving. Leaving this page now will discard it.",
|
|
1419
|
+
leaveAndDiscard: "Leave and discard",
|
|
1416
1420
|
},
|
|
1417
1421
|
importRoute: {
|
|
1418
1422
|
pageTitle: "Import Loom — Clips",
|
|
@@ -35,6 +35,7 @@ import { Link, useLocation, useNavigate } from "react-router";
|
|
|
35
35
|
|
|
36
36
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
37
37
|
import { useDesktopPromo } from "@/hooks/use-desktop-promo";
|
|
38
|
+
import { useRecordingLeaveGuard } from "@/hooks/use-recording-leave-guard";
|
|
38
39
|
import {
|
|
39
40
|
fetchVideoStorageStatus,
|
|
40
41
|
useVideoStorageStatus,
|
|
@@ -2518,6 +2519,22 @@ export default function RecordRoute() {
|
|
|
2518
2519
|
};
|
|
2519
2520
|
}, [extensionCapture, stopLiveTranscription]);
|
|
2520
2521
|
|
|
2522
|
+
// In-app navigation (e.g. a Library link) unmounts this route the same way
|
|
2523
|
+
// a tab close does, but the browser never fires `beforeunload` for it — so
|
|
2524
|
+
// without this, the cleanup effect above ran `releaseCapture()`
|
|
2525
|
+
// unconditionally and silently killed an at-risk recording. Route every
|
|
2526
|
+
// in-app navigation attempt through the same `hasRecordingAtRisk()` check
|
|
2527
|
+
// `warnBeforeDiscard` uses, so both exits are gated by one check instead of
|
|
2528
|
+
// two divergent ones.
|
|
2529
|
+
const {
|
|
2530
|
+
leavePromptOpen,
|
|
2531
|
+
onDialogOpenChange,
|
|
2532
|
+
onCloseAutoFocus,
|
|
2533
|
+
confirmLeave,
|
|
2534
|
+
} = useRecordingLeaveGuard(
|
|
2535
|
+
useCallback(() => !!engineRef.current?.hasRecordingAtRisk(), []),
|
|
2536
|
+
);
|
|
2537
|
+
|
|
2521
2538
|
// -------------------------------------------------------------------------
|
|
2522
2539
|
// Render.
|
|
2523
2540
|
// -------------------------------------------------------------------------
|
|
@@ -2740,6 +2757,31 @@ export default function RecordRoute() {
|
|
|
2740
2757
|
</AlertDialogContent>
|
|
2741
2758
|
</AlertDialog>
|
|
2742
2759
|
|
|
2760
|
+
<AlertDialog open={leavePromptOpen} onOpenChange={onDialogOpenChange}>
|
|
2761
|
+
<AlertDialogContent onCloseAutoFocus={onCloseAutoFocus}>
|
|
2762
|
+
<AlertDialogHeader>
|
|
2763
|
+
<AlertDialogTitle>
|
|
2764
|
+
{t("recordRoute.leaveConfirmTitle")}
|
|
2765
|
+
</AlertDialogTitle>
|
|
2766
|
+
<AlertDialogDescription>
|
|
2767
|
+
{t("recordRoute.leaveConfirmDescription")}
|
|
2768
|
+
</AlertDialogDescription>
|
|
2769
|
+
</AlertDialogHeader>
|
|
2770
|
+
<AlertDialogFooter>
|
|
2771
|
+
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
|
2772
|
+
<AlertDialogAction
|
|
2773
|
+
onClick={(event) => {
|
|
2774
|
+
event.preventDefault();
|
|
2775
|
+
confirmLeave();
|
|
2776
|
+
}}
|
|
2777
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
2778
|
+
>
|
|
2779
|
+
{t("recordRoute.leaveAndDiscard")}
|
|
2780
|
+
</AlertDialogAction>
|
|
2781
|
+
</AlertDialogFooter>
|
|
2782
|
+
</AlertDialogContent>
|
|
2783
|
+
</AlertDialog>
|
|
2784
|
+
|
|
2743
2785
|
{/* Uploading overlay (also covers the compressing pass which can run
|
|
2744
2786
|
for several minutes on long recordings — without a distinct copy
|
|
2745
2787
|
users wonder if the app froze). */}
|
|
@@ -66,6 +66,26 @@ const BARE_ROUTES = new Set(["/slide"]);
|
|
|
66
66
|
/** Route prefixes that render without the app shell */
|
|
67
67
|
const BARE_PREFIXES = ["/share/", "/p/"];
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Routes that serve deck content to any visitor — owner, teammate, or an
|
|
71
|
+
* anonymous recipient of a shared link — rather than an app-management
|
|
72
|
+
* surface. `/deck/:id` renders the full editor shell (so it's not "bare"
|
|
73
|
+
* above: viewers still get chrome, just read-only), but it must never force
|
|
74
|
+
* sign-in or gate on first-run onboarding, or a shared link flashes the deck
|
|
75
|
+
* and then buries it under "create your first deck" (the onboarding gate has
|
|
76
|
+
* no route awareness of its own — see `sessionBypass` below). `/present` is
|
|
77
|
+
* the full-screen presentation view and `/slide` is the agent-embed preview;
|
|
78
|
+
* both are shown to viewers the same way.
|
|
79
|
+
*/
|
|
80
|
+
export function isShareableContentPath(pathname: string): boolean {
|
|
81
|
+
return (
|
|
82
|
+
BARE_ROUTES.has(pathname) ||
|
|
83
|
+
BARE_PREFIXES.some((p) => pathname.startsWith(p)) ||
|
|
84
|
+
pathname.startsWith("/deck/") ||
|
|
85
|
+
pathname.endsWith("/present")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
export const links: LinksFunction = () => [
|
|
70
90
|
{ rel: "stylesheet", href: stylesheet },
|
|
71
91
|
];
|
|
@@ -311,6 +331,7 @@ export default function Root() {
|
|
|
311
331
|
queryClient={queryClient}
|
|
312
332
|
defaultTheme="dark"
|
|
313
333
|
i18n={{ catalog: i18nCatalog }}
|
|
334
|
+
sessionBypass={isShareableContentPath(location.pathname)}
|
|
314
335
|
>
|
|
315
336
|
<AppContent />
|
|
316
337
|
</AppProviders>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { recordChange } from "@agent-native/core/server/poll";
|
|
2
2
|
import { defineEventHandler, setResponseStatus, createEventStream } from "h3";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { resolveSlidesRequestAuth } from "./request-auth-context.js";
|
|
5
5
|
|
|
6
6
|
// --- SSE for change notifications ---
|
|
7
7
|
type SSEPush = (data: string) => void;
|
|
@@ -91,8 +91,12 @@ export function notifyClients(
|
|
|
91
91
|
// callers can't tail the stream. (The agent path runs server-side and is
|
|
92
92
|
// not affected.)
|
|
93
93
|
export const deckEvents = defineEventHandler(async (event) => {
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
94
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
95
|
+
if (!auth.ok) {
|
|
96
|
+
setResponseStatus(event, auth.statusCode);
|
|
97
|
+
return { error: auth.error };
|
|
98
|
+
}
|
|
99
|
+
if (!auth.context.email) {
|
|
96
100
|
setResponseStatus(event, 401);
|
|
97
101
|
return { error: "Unauthorized" };
|
|
98
102
|
}
|
|
@@ -7,10 +7,36 @@ export interface SlidesRequestAuthContext {
|
|
|
7
7
|
orgId?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A session lookup that failed outright (DB blip, cookie race) — distinct
|
|
12
|
+
* from `getSession` resolving to `null`, which means "no session" and is a
|
|
13
|
+
* legitimate outcome for anonymous callers (public share/deck viewers).
|
|
14
|
+
* `getSession` only throws on a real backend failure, never merely because a
|
|
15
|
+
* request carries no session cookie, so this never fires for genuine
|
|
16
|
+
* anonymous access. Previously this was swallowed into the same
|
|
17
|
+
* `email: undefined` an anonymous visitor gets, so a transient failure
|
|
18
|
+
* presented as "unauthorized" on the upload path and as an untraceable error
|
|
19
|
+
* on deck creation — see `resolveSlidesRequestAuth` for the caller-facing
|
|
20
|
+
* shape.
|
|
21
|
+
*/
|
|
22
|
+
export class SlidesSessionLookupError extends Error {
|
|
23
|
+
readonly statusCode = 503;
|
|
24
|
+
constructor(cause: unknown) {
|
|
25
|
+
super("Could not verify your session. Please try again.");
|
|
26
|
+
this.name = "SlidesSessionLookupError";
|
|
27
|
+
this.cause = cause;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
export async function resolveSlidesRequestAuthContext(
|
|
11
32
|
event: H3Event,
|
|
12
33
|
): Promise<SlidesRequestAuthContext> {
|
|
13
|
-
|
|
34
|
+
let session: Awaited<ReturnType<typeof getSession>>;
|
|
35
|
+
try {
|
|
36
|
+
session = await getSession(event);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw new SlidesSessionLookupError(err);
|
|
39
|
+
}
|
|
14
40
|
|
|
15
41
|
// Prefer the live active org context over `session.orgId`. Better Auth's
|
|
16
42
|
// session.orgId is set at sign-in and not refreshed when the user switches
|
|
@@ -39,6 +65,37 @@ export async function resolveSlidesRequestAuthContext(
|
|
|
39
65
|
};
|
|
40
66
|
}
|
|
41
67
|
|
|
68
|
+
/** Discriminated result for route handlers that must fail closed on a
|
|
69
|
+
* session-lookup error rather than treat it as "logged out". */
|
|
70
|
+
export type SlidesRequestAuthResolution =
|
|
71
|
+
| { ok: true; context: SlidesRequestAuthContext }
|
|
72
|
+
| { ok: false; statusCode: number; error: string };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Like `resolveSlidesRequestAuthContext`, but turns a `SlidesSessionLookupError`
|
|
76
|
+
* into a response the caller can return directly instead of a 401 — so a
|
|
77
|
+
* route handler that gates on `!context.email` never mistakes "the session
|
|
78
|
+
* lookup failed" for "this visitor is signed out". A route with legitimate
|
|
79
|
+
* anonymous access (e.g. a public deck view) still gets `email: undefined`
|
|
80
|
+
* for real anonymous callers via `ok: true`; only a genuine lookup failure
|
|
81
|
+
* takes the `ok: false` branch.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveSlidesRequestAuth(
|
|
84
|
+
event: H3Event,
|
|
85
|
+
): Promise<SlidesRequestAuthResolution> {
|
|
86
|
+
try {
|
|
87
|
+
return { ok: true, context: await resolveSlidesRequestAuthContext(event) };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
statusCode:
|
|
92
|
+
err instanceof SlidesSessionLookupError ? err.statusCode : 500,
|
|
93
|
+
error:
|
|
94
|
+
err instanceof Error ? err.message : "Failed to verify your session.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
42
99
|
export async function withSlidesRequestContext<T>(
|
|
43
100
|
event: H3Event,
|
|
44
101
|
fn: (session: SlidesRequestAuthContext) => Promise<T>,
|
|
@@ -18,7 +18,7 @@ import { defineEventHandler, getRouterParam, setResponseStatus } from "h3";
|
|
|
18
18
|
|
|
19
19
|
import { getDb, schema } from "../db";
|
|
20
20
|
import {
|
|
21
|
-
|
|
21
|
+
resolveSlidesRequestAuth,
|
|
22
22
|
withSlidesRequestContext,
|
|
23
23
|
} from "./request-auth-context.js";
|
|
24
24
|
|
|
@@ -47,7 +47,12 @@ export const shareDeck = defineEventHandler(async (event) => {
|
|
|
47
47
|
// and pass the resolved context into `withSlidesRequestContext` so it
|
|
48
48
|
// doesn't re-resolve session + org on the same request (which would
|
|
49
49
|
// double the session/getOrgContext I/O per share).
|
|
50
|
-
const
|
|
50
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
51
|
+
if (!auth.ok) {
|
|
52
|
+
setResponseStatus(event, auth.statusCode);
|
|
53
|
+
return { error: auth.error };
|
|
54
|
+
}
|
|
55
|
+
const session = auth.context;
|
|
51
56
|
if (!session.email) {
|
|
52
57
|
setResponseStatus(event, 401);
|
|
53
58
|
return { error: "Unauthorized" };
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "../lib/uploaded-reference-storage.js";
|
|
22
22
|
import { canSaveAsUploadedAsset, uploadImageAsset } from "./assets.js";
|
|
23
23
|
import {
|
|
24
|
-
|
|
24
|
+
resolveSlidesRequestAuth,
|
|
25
25
|
withSlidesRequestContext,
|
|
26
26
|
} from "./request-auth-context.js";
|
|
27
27
|
|
|
@@ -211,7 +211,12 @@ export async function saveUploadedReferenceFile(args: {
|
|
|
211
211
|
|
|
212
212
|
// Upload one or more files
|
|
213
213
|
export const uploadFiles = defineEventHandler(async (event) => {
|
|
214
|
-
const
|
|
214
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
215
|
+
if (!auth.ok) {
|
|
216
|
+
setResponseStatus(event, auth.statusCode);
|
|
217
|
+
return { error: auth.error };
|
|
218
|
+
}
|
|
219
|
+
const authContext = auth.context;
|
|
215
220
|
const email = authContext.email;
|
|
216
221
|
if (!email) {
|
|
217
222
|
setResponseStatus(event, 401);
|
|
@@ -343,6 +343,11 @@ function enginePartToAnthropic(part, builderGateway) {
|
|
|
343
343
|
};
|
|
344
344
|
}
|
|
345
345
|
case "thinking":
|
|
346
|
+
// A redacted block has no readable thinking or signature — only its
|
|
347
|
+
// encrypted payload, which Anthropic requires back unmodified.
|
|
348
|
+
if (part.redactedData) {
|
|
349
|
+
return { type: "redacted_thinking", data: part.redactedData };
|
|
350
|
+
}
|
|
346
351
|
// Anthropic thinking blocks — pass through with signature for context window continuity
|
|
347
352
|
return {
|
|
348
353
|
type: "thinking",
|
|
@@ -409,7 +414,20 @@ export function anthropicContentToEngine(content) {
|
|
|
409
414
|
signature: b.signature,
|
|
410
415
|
};
|
|
411
416
|
}
|
|
412
|
-
|
|
417
|
+
if (block.type === "redacted_thinking") {
|
|
418
|
+
// Dropping this looked like "skip an unreadable block", but Anthropic
|
|
419
|
+
// requires the whole thinking sequence back verbatim within a tool-use
|
|
420
|
+
// turn: losing it makes the very next loop iteration send an assistant
|
|
421
|
+
// turn the API refuses, from a turn that streamed perfectly.
|
|
422
|
+
return {
|
|
423
|
+
type: "thinking",
|
|
424
|
+
text: "",
|
|
425
|
+
redactedData: block.data ?? "",
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// Unknown block type. Skipping is the only safe replay, but a silent skip
|
|
429
|
+
// is how redacted_thinking went missing — say which type was dropped.
|
|
430
|
+
console.warn(`[anthropic-engine] dropping unrecognized content block type "${block.type}" from the assistant turn; it will not be replayed`);
|
|
413
431
|
return { type: "text", text: "" };
|
|
414
432
|
})
|
|
415
433
|
.filter((p) => !(p.type === "text" && p.text === ""));
|
|
@@ -121,6 +121,13 @@ export interface EngineThinkingPart {
|
|
|
121
121
|
text: string;
|
|
122
122
|
/** Opaque signature for pass-through on next turn (Anthropic extended thinking) */
|
|
123
123
|
signature?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Encrypted payload of an Anthropic `redacted_thinking` block, which carries
|
|
126
|
+
* no readable `text` or `signature`. Anthropic requires the block back
|
|
127
|
+
* verbatim in the same tool-use turn, so it has to survive normalization —
|
|
128
|
+
* a part with this set replays as `redacted_thinking`, not as `thinking`.
|
|
129
|
+
*/
|
|
130
|
+
redactedData?: string;
|
|
124
131
|
}
|
|
125
132
|
export type EngineContentPart = EngineTextPart | EngineImagePart | EngineFilePart | EngineToolCallPart | EngineToolResultPart | EngineThinkingPart;
|
|
126
133
|
export type EngineMessage = {
|
|
@@ -772,6 +772,15 @@ const RUN_BUDGET_EXHAUSTED_ERROR_CODE = "run_budget_exhausted";
|
|
|
772
772
|
const RUN_BUDGET_EXHAUSTED_MESSAGE = "I ran out of time before finishing this step. " +
|
|
773
773
|
"I stopped rather than keep retrying silently. " +
|
|
774
774
|
"Check any completed tool cards above before retrying, ideally as one smaller follow-up.";
|
|
775
|
+
/**
|
|
776
|
+
* Text attachments have been capped since forever; binary ones never were, so
|
|
777
|
+
* a large PDF or screenshot went to the provider as unbounded inline base64.
|
|
778
|
+
* OpenAI rejects the whole request over 1,048,576 chars in one `file_url`
|
|
779
|
+
* ("string too long", measured at 4,149,128), which kills the turn — the cap is
|
|
780
|
+
* on the encoded string, so that is what this counts rather than decoded bytes.
|
|
781
|
+
* Held under the limit to leave room for the `data:<mediaType>;base64,` prefix.
|
|
782
|
+
*/
|
|
783
|
+
const MAX_INLINE_ATTACHMENT_BASE64_CHARS = 1_000_000;
|
|
775
784
|
const MAX_TEXT_ATTACHMENT_CHARS = 60_000;
|
|
776
785
|
const MAX_TEXT_ATTACHMENTS_TOTAL_CHARS = 80_000;
|
|
777
786
|
const MAX_SELECTION_CONTEXT_CHARS = 8_000;
|
|
@@ -1093,6 +1102,17 @@ export function buildUserContentWithAttachments(opts) {
|
|
|
1093
1102
|
continue;
|
|
1094
1103
|
}
|
|
1095
1104
|
const match = att.data.match(/^data:(image\/[^;]+);base64,(.+)$/);
|
|
1105
|
+
if (match &&
|
|
1106
|
+
isSupportedImageMediaType(match[1]) &&
|
|
1107
|
+
match[2].length > MAX_INLINE_ATTACHMENT_BASE64_CHARS) {
|
|
1108
|
+
// The upload already happened and `uploadedUrl` is the whole point of
|
|
1109
|
+
// it. Inlining the bytes anyway is what made the request unsendable.
|
|
1110
|
+
const label = att.name ? `"${att.name}"` : "An image";
|
|
1111
|
+
textAttachments.push(uploadedUrl
|
|
1112
|
+
? `[${label} was uploaded to ${uploadedUrl}. It was too large to send inline for vision analysis, so use the URL for embedding/reference.]`
|
|
1113
|
+
: `[${label} was too large to send inline for vision analysis and no upload URL is available. Ask the user to attach a smaller image.]`);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1096
1116
|
if (match && isSupportedImageMediaType(match[1])) {
|
|
1097
1117
|
userContent.push({
|
|
1098
1118
|
type: "image",
|
|
@@ -1125,6 +1145,13 @@ export function buildUserContentWithAttachments(opts) {
|
|
|
1125
1145
|
}
|
|
1126
1146
|
const filePart = dataUrlToFilePart(att);
|
|
1127
1147
|
if (filePart) {
|
|
1148
|
+
if (filePart.data.length > MAX_INLINE_ATTACHMENT_BASE64_CHARS) {
|
|
1149
|
+
const label = att.name ? `"${att.name}"` : "A file";
|
|
1150
|
+
textAttachments.push(uploadedUrl
|
|
1151
|
+
? `[${label} was uploaded to ${uploadedUrl}. It was too large to send inline, so read it from the URL if its contents are needed.]`
|
|
1152
|
+
: `[${label} was too large to send inline and no upload URL is available. Ask the user for a smaller file.]`);
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1128
1155
|
userContent.push(filePart);
|
|
1129
1156
|
continue;
|
|
1130
1157
|
}
|
|
@@ -7161,6 +7188,11 @@ export function createProductionAgentHandler(options) {
|
|
|
7161
7188
|
const { threadDataToEngineMessages } = await import("./thread-data-builder.js");
|
|
7162
7189
|
const priorThreadData = (await getThread(effectiveThreadId))
|
|
7163
7190
|
?.threadData;
|
|
7191
|
+
// `threadDataToEngineMessages` takes one argument and flattens tool
|
|
7192
|
+
// calls to text by design. The second argument never existed anywhere
|
|
7193
|
+
// in the repo, so this failed `tsc` and broke every production build
|
|
7194
|
+
// between 16:29 and now -- it reached main only because admin merges
|
|
7195
|
+
// do not wait for the typecheck that would have caught it.
|
|
7164
7196
|
const resumed = threadDataToEngineMessages(priorThreadData);
|
|
7165
7197
|
if (resumed.length > 0) {
|
|
7166
7198
|
const actionPreparationTool = typeof backgroundRunMarker?.actionPreparationTool === "string" &&
|
|
@@ -62,17 +62,29 @@ export declare function buildAssistantMessage(events: RunEvent[], runId?: string
|
|
|
62
62
|
* fail with "Parent message not found".
|
|
63
63
|
*/
|
|
64
64
|
export declare function normalizeThreadRepository(repo: any): any;
|
|
65
|
+
export interface ThreadDataToEngineMessagesOptions {
|
|
66
|
+
/**
|
|
67
|
+
* Replay the tool calls and results thread_data already stores instead of
|
|
68
|
+
* flattening each turn to its prose. Required by callers that RESUME a run
|
|
69
|
+
* (chained background continuation, agent-teams continue): a turn rebuilt as
|
|
70
|
+
* text alone tells the model what it said but not what it did, so it re-runs
|
|
71
|
+
* tools it already ran and cannot see their output. Callers that only need
|
|
72
|
+
* "what was said" — recovery floors, memory compaction — leave this off.
|
|
73
|
+
*/
|
|
74
|
+
includeToolCalls?: boolean;
|
|
75
|
+
}
|
|
65
76
|
/**
|
|
66
77
|
* Rebuild a flat `EngineMessage[]` from persisted thread_data (the
|
|
67
|
-
* assistant-ui ExportedMessageRepository shape).
|
|
68
|
-
*
|
|
69
|
-
*
|
|
78
|
+
* assistant-ui ExportedMessageRepository shape). Each turn collapses to its
|
|
79
|
+
* text by default, which is all a recovery floor or a memory compaction needs.
|
|
80
|
+
* Callers resuming a run pass `includeToolCalls` to replay what the turn
|
|
81
|
+
* actually DID as well as what it said.
|
|
70
82
|
*
|
|
71
83
|
* Used to resume a background sub-agent in a fresh function invocation (the
|
|
72
84
|
* server-side analog of the browser re-POSTing history for the main chat).
|
|
73
85
|
* Originally inlined in `integrations/webhook-handler.ts`.
|
|
74
86
|
*/
|
|
75
|
-
export declare function threadDataToEngineMessages(threadData: string | Record<string, unknown> | null | undefined): EngineMessage[];
|
|
87
|
+
export declare function threadDataToEngineMessages(threadData: string | Record<string, unknown> | null | undefined, options?: ThreadDataToEngineMessagesOptions): EngineMessage[];
|
|
76
88
|
/**
|
|
77
89
|
* The trailing window of what was actually said in a thread, for a request that
|
|
78
90
|
* arrived carrying no history of its own. The client trims history against a
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { formatChatErrorText, normalizeChatError, } from "../client/error-format.js";
|
|
2
2
|
import { isCredentialGapCodeAgentEvent, normalizeCodeAgentTranscript, } from "../code-agents/transcript-normalizer.js";
|
|
3
3
|
import { BUILDER_GATEWAY_INTERNAL_ERROR_CODE } from "./engine/error-detail.js";
|
|
4
|
+
import { stringifyToolUseInputForGateway } from "./engine/translate-anthropic.js";
|
|
4
5
|
const INTERRUPTED_TOOL_RESULT = "Interrupted before this tool returned a result.";
|
|
5
6
|
export const ASSISTANT_RUN_DURATION_METADATA_KEY = "agentNativeRunDurationMs";
|
|
6
7
|
const MAX_STORED_ATTACHMENT_CHARS = 60_000;
|
|
@@ -653,17 +654,121 @@ export function normalizeThreadRepository(repo) {
|
|
|
653
654
|
headId && seenIds.has(headId) ? headId : (previousId ?? null);
|
|
654
655
|
return normalized;
|
|
655
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* A replayed tool result is evidence, not the source of truth — the resumed
|
|
659
|
+
* turn can always re-read current state. Bound each one so restoring fidelity
|
|
660
|
+
* cannot itself overflow the context window, and say so in-band when it bites.
|
|
661
|
+
*/
|
|
662
|
+
const MAX_REPLAYED_TOOL_RESULT_CHARS = 12_000;
|
|
663
|
+
/**
|
|
664
|
+
* Total budget for replayed tool payloads across the whole thread. Per-result
|
|
665
|
+
* capping alone is not a bound: a long run has hundreds of calls, so replaying
|
|
666
|
+
* every one of them would overflow the context window and break the very
|
|
667
|
+
* continuation this replay exists to serve. The newest turns keep their tool
|
|
668
|
+
* detail; older turns fall back to prose and say so.
|
|
669
|
+
*/
|
|
670
|
+
const MAX_REPLAYED_TOOL_PAYLOAD_CHARS = 64_000;
|
|
671
|
+
const ELIDED_TOOL_DETAIL_NOTE = "[Tool calls from this turn were elided from replayed history to fit the context. Re-read the current state with tools if their detail matters.]";
|
|
672
|
+
function replayedToolResultContent(result) {
|
|
673
|
+
const body = typeof result === "string"
|
|
674
|
+
? result
|
|
675
|
+
: result === undefined || result === null
|
|
676
|
+
? ""
|
|
677
|
+
: (() => {
|
|
678
|
+
try {
|
|
679
|
+
return JSON.stringify(result) ?? "";
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return String(result);
|
|
683
|
+
}
|
|
684
|
+
})();
|
|
685
|
+
if (body.length <= MAX_REPLAYED_TOOL_RESULT_CHARS)
|
|
686
|
+
return body;
|
|
687
|
+
const omitted = body.length - MAX_REPLAYED_TOOL_RESULT_CHARS;
|
|
688
|
+
return `${body.slice(0, MAX_REPLAYED_TOOL_RESULT_CHARS)}\n\n[Tool result truncated after ${MAX_REPLAYED_TOOL_RESULT_CHARS.toLocaleString()} characters; ${omitted.toLocaleString()} omitted from replayed history. Re-read the current state with tools if the exact content matters.]`;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Integration turns (Slack and friends) deliberately replay only what the
|
|
692
|
+
* participant saw plus a compact artifact ledger — never the raw tool results.
|
|
693
|
+
* `threadMessageTextForEngine` owns that policy, so a structured replay has to
|
|
694
|
+
* defer to it rather than reach past it into `content`.
|
|
695
|
+
*/
|
|
696
|
+
function hasIntegrationReplayPolicy(message) {
|
|
697
|
+
const metadata = message?.metadata;
|
|
698
|
+
if (!metadata || typeof metadata !== "object")
|
|
699
|
+
return false;
|
|
700
|
+
return (metadata.integrationDelivery !== undefined ||
|
|
701
|
+
metadata.integrationDeliveryAttempted === true ||
|
|
702
|
+
Array.isArray(metadata.integrationArtifacts));
|
|
703
|
+
}
|
|
704
|
+
function replayableToolCalls(message) {
|
|
705
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
706
|
+
return content.filter((part) => part?.type === "tool-call" &&
|
|
707
|
+
typeof part.toolCallId === "string" &&
|
|
708
|
+
part.toolCallId.trim() &&
|
|
709
|
+
typeof part.toolName === "string" &&
|
|
710
|
+
part.toolName.trim());
|
|
711
|
+
}
|
|
712
|
+
/** What replaying this turn's tool calls with their results would actually cost. */
|
|
713
|
+
function replayedToolPayloadCost(message) {
|
|
714
|
+
let cost = 0;
|
|
715
|
+
for (const part of replayableToolCalls(message)) {
|
|
716
|
+
cost += stringifyToolUseInputForGateway(part.args ?? {}).length;
|
|
717
|
+
if (part.result !== undefined) {
|
|
718
|
+
cost += replayedToolResultContent(part.result).length;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return cost;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* One persisted assistant turn spans many tool rounds. Replay it as the
|
|
725
|
+
* provider protocol wants it — one assistant message carrying every `tool-call`,
|
|
726
|
+
* then one user message carrying every matching `tool-result`. The exact
|
|
727
|
+
* round-by-round interleaving is not recoverable from thread_data and does not
|
|
728
|
+
* matter; what matters is that the calls and their outputs survive at all.
|
|
729
|
+
*/
|
|
730
|
+
function assistantReplayContent(message, text) {
|
|
731
|
+
const assistant = [];
|
|
732
|
+
const results = [];
|
|
733
|
+
if (text.trim())
|
|
734
|
+
assistant.push({ type: "text", text });
|
|
735
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
736
|
+
for (const part of content) {
|
|
737
|
+
if (part?.type !== "tool-call")
|
|
738
|
+
continue;
|
|
739
|
+
const id = typeof part.toolCallId === "string" ? part.toolCallId.trim() : "";
|
|
740
|
+
const name = typeof part.toolName === "string" ? part.toolName.trim() : "";
|
|
741
|
+
if (!id || !name)
|
|
742
|
+
continue;
|
|
743
|
+
const input = part.args && typeof part.args === "object" && !Array.isArray(part.args)
|
|
744
|
+
? part.args
|
|
745
|
+
: {};
|
|
746
|
+
assistant.push({ type: "tool-call", id, name, input });
|
|
747
|
+
if (part.result === undefined)
|
|
748
|
+
continue;
|
|
749
|
+
results.push({
|
|
750
|
+
type: "tool-result",
|
|
751
|
+
toolCallId: id,
|
|
752
|
+
toolName: name,
|
|
753
|
+
toolInput: stringifyToolUseInputForGateway(input),
|
|
754
|
+
content: replayedToolResultContent(part.result),
|
|
755
|
+
...(part.isError === true ? { isError: true } : {}),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return { assistant, results };
|
|
759
|
+
}
|
|
656
760
|
/**
|
|
657
761
|
* Rebuild a flat `EngineMessage[]` from persisted thread_data (the
|
|
658
|
-
* assistant-ui ExportedMessageRepository shape).
|
|
659
|
-
*
|
|
660
|
-
*
|
|
762
|
+
* assistant-ui ExportedMessageRepository shape). Each turn collapses to its
|
|
763
|
+
* text by default, which is all a recovery floor or a memory compaction needs.
|
|
764
|
+
* Callers resuming a run pass `includeToolCalls` to replay what the turn
|
|
765
|
+
* actually DID as well as what it said.
|
|
661
766
|
*
|
|
662
767
|
* Used to resume a background sub-agent in a fresh function invocation (the
|
|
663
768
|
* server-side analog of the browser re-POSTing history for the main chat).
|
|
664
769
|
* Originally inlined in `integrations/webhook-handler.ts`.
|
|
665
770
|
*/
|
|
666
|
-
export function threadDataToEngineMessages(threadData) {
|
|
771
|
+
export function threadDataToEngineMessages(threadData, options = {}) {
|
|
667
772
|
const messages = [];
|
|
668
773
|
if (!threadData)
|
|
669
774
|
return messages;
|
|
@@ -676,11 +781,60 @@ export function threadDataToEngineMessages(threadData) {
|
|
|
676
781
|
}
|
|
677
782
|
if (!Array.isArray(data?.messages))
|
|
678
783
|
return messages;
|
|
679
|
-
|
|
784
|
+
const entries = data.messages;
|
|
785
|
+
const replaysTools = (m) => options.includeToolCalls === true &&
|
|
786
|
+
m?.role === "assistant" &&
|
|
787
|
+
!hasIntegrationReplayPolicy(m);
|
|
788
|
+
// Spend the tool-payload budget on the most recent turns: those are the ones
|
|
789
|
+
// a resumed run is about to build on. Decided up front so the walk below can
|
|
790
|
+
// stay in conversation order.
|
|
791
|
+
const toolPayloadAllowed = new Set();
|
|
792
|
+
if (options.includeToolCalls) {
|
|
793
|
+
let spent = 0;
|
|
794
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
795
|
+
const m = entries[i]?.message ?? entries[i];
|
|
796
|
+
if (!replaysTools(m))
|
|
797
|
+
continue;
|
|
798
|
+
const cost = replayedToolPayloadCost(m);
|
|
799
|
+
if (cost === 0)
|
|
800
|
+
continue;
|
|
801
|
+
if (spent + cost > MAX_REPLAYED_TOOL_PAYLOAD_CHARS)
|
|
802
|
+
continue;
|
|
803
|
+
spent += cost;
|
|
804
|
+
toolPayloadAllowed.add(i);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
for (const [index, entry] of entries.entries()) {
|
|
680
808
|
const m = entry?.message ?? entry;
|
|
681
809
|
if (!m || (m.role !== "user" && m.role !== "assistant"))
|
|
682
810
|
continue;
|
|
683
811
|
const text = threadMessageTextForEngine(m);
|
|
812
|
+
if (replaysTools(m)) {
|
|
813
|
+
if (toolPayloadAllowed.has(index)) {
|
|
814
|
+
const { assistant, results } = assistantReplayContent(m, text);
|
|
815
|
+
if (assistant.length === 0)
|
|
816
|
+
continue;
|
|
817
|
+
messages.push({ role: "assistant", content: assistant });
|
|
818
|
+
if (results.length > 0) {
|
|
819
|
+
messages.push({ role: "user", content: results });
|
|
820
|
+
}
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
// Over budget, or nothing to replay. Prose still travels — but a turn
|
|
824
|
+
// whose tool detail was dropped must not read like a turn that never
|
|
825
|
+
// called a tool.
|
|
826
|
+
const elided = replayableToolCalls(m).length > 0;
|
|
827
|
+
const prose = elided
|
|
828
|
+
? `${text.trim() ? `${text.trim()}\n\n` : ""}${ELIDED_TOOL_DETAIL_NOTE}`
|
|
829
|
+
: text;
|
|
830
|
+
if (!prose.trim())
|
|
831
|
+
continue;
|
|
832
|
+
messages.push({
|
|
833
|
+
role: "assistant",
|
|
834
|
+
content: [{ type: "text", text: prose }],
|
|
835
|
+
});
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
684
838
|
if (!text.trim())
|
|
685
839
|
continue;
|
|
686
840
|
messages.push({ role: m.role, content: [{ type: "text", text }] });
|
|
@@ -66,6 +66,17 @@ const MAX_HISTORY_ATTACHMENT_CHARS = 60_000;
|
|
|
66
66
|
// visibly (not silently) capped.
|
|
67
67
|
const MAX_OUTBOUND_ATTACHMENT_CHARS = 200_000;
|
|
68
68
|
const MAX_HISTORY_MESSAGES = 24;
|
|
69
|
+
// Every provider prompt cache matches a byte-identical PREFIX. A window that
|
|
70
|
+
// ends at the newest message and starts MAX_HISTORY_MESSAGES back moves its
|
|
71
|
+
// START by one message per turn, so from the first turn past the cap onward no
|
|
72
|
+
// cached prefix ever matches again and the whole conversation is re-billed at
|
|
73
|
+
// full write price on every turn — the cache breakpoints the engines place
|
|
74
|
+
// (system prefix, last tool, last user message) cannot save a prefix whose
|
|
75
|
+
// first bytes changed. Quantizing where the window starts holds those bytes
|
|
76
|
+
// identical for a stride of turns, so one turn in STRIDE pays the write and the
|
|
77
|
+
// rest read. The window then holds up to MAX + STRIDE - 1 messages; the char
|
|
78
|
+
// budgets below, not the message count, are what bound the payload.
|
|
79
|
+
const HISTORY_WINDOW_STRIDE = 8;
|
|
69
80
|
const MAX_HISTORY_TOTAL_CHARS = 64_000;
|
|
70
81
|
// Budget for everything actually said in the thread — every user ask and every
|
|
71
82
|
// assistant conclusion. Separate from MAX_HISTORY_TOTAL_CHARS, which bounds the
|
|
@@ -788,7 +799,8 @@ function estimateHistoryMessageCost(message) {
|
|
|
788
799
|
return Math.max(1, cost);
|
|
789
800
|
}
|
|
790
801
|
function limitPriorMessagesForRequest(messages) {
|
|
791
|
-
const
|
|
802
|
+
const overflow = Math.max(0, messages.length - MAX_HISTORY_MESSAGES);
|
|
803
|
+
const recent = messages.slice(Math.floor(overflow / HISTORY_WINDOW_STRIDE) * HISTORY_WINDOW_STRIDE);
|
|
792
804
|
const kept = [];
|
|
793
805
|
let words = 0;
|
|
794
806
|
let payload = 0;
|
|
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
|
|
|
62
62
|
error: string;
|
|
63
63
|
states?: undefined;
|
|
64
64
|
} | {
|
|
65
|
+
error?: undefined;
|
|
65
66
|
states: {
|
|
66
67
|
clientId: number;
|
|
67
68
|
state: string;
|
|
68
69
|
}[];
|
|
69
|
-
error?: undefined;
|
|
70
70
|
}>>;
|
|
71
71
|
/**
|
|
72
72
|
* GET /_agent-native/collab/:docId/users
|
|
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
|
|
|
77
77
|
error: string;
|
|
78
78
|
users?: undefined;
|
|
79
79
|
} | {
|
|
80
|
+
error?: undefined;
|
|
80
81
|
users: {
|
|
81
82
|
clientId: number;
|
|
82
83
|
lastSeen: number;
|
|
83
84
|
}[];
|
|
84
|
-
error?: undefined;
|
|
85
85
|
}>>;
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
error: string;
|
|
17
16
|
ok?: undefined;
|
|
17
|
+
error: string;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -41,23 +41,23 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
41
41
|
thumbsUpRate: number;
|
|
42
42
|
avgEvalScore: number;
|
|
43
43
|
} | {
|
|
44
|
+
ok?: undefined;
|
|
44
45
|
error?: undefined;
|
|
45
46
|
summary: import("./types.js").TraceSummary;
|
|
46
47
|
spans: import("./types.js").TraceSpan[];
|
|
47
48
|
id?: undefined;
|
|
48
|
-
ok?: undefined;
|
|
49
49
|
} | {
|
|
50
|
+
ok?: undefined;
|
|
50
51
|
error?: undefined;
|
|
51
52
|
summary?: undefined;
|
|
52
53
|
spans?: undefined;
|
|
53
54
|
id: string;
|
|
54
|
-
ok?: undefined;
|
|
55
55
|
} | {
|
|
56
|
+
ok?: undefined;
|
|
56
57
|
summary?: undefined;
|
|
57
58
|
spans?: undefined;
|
|
58
59
|
id?: undefined;
|
|
59
60
|
error: any;
|
|
60
|
-
ok?: undefined;
|
|
61
61
|
} | {
|
|
62
62
|
error?: undefined;
|
|
63
63
|
summary?: undefined;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -34,16 +34,16 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
|
|
|
34
34
|
/** POST /_agent-native/secrets/:key — write a secret. */
|
|
35
35
|
export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
36
36
|
error: string;
|
|
37
|
-
status?: undefined;
|
|
38
37
|
ok?: undefined;
|
|
38
|
+
status?: undefined;
|
|
39
39
|
} | {
|
|
40
40
|
ok: boolean;
|
|
41
41
|
status: string;
|
|
42
42
|
error?: undefined;
|
|
43
43
|
} | {
|
|
44
|
+
ok?: undefined;
|
|
44
45
|
error: string;
|
|
45
46
|
removed?: undefined;
|
|
46
|
-
ok?: undefined;
|
|
47
47
|
} | {
|
|
48
48
|
ok: boolean;
|
|
49
49
|
removed: boolean;
|
|
@@ -54,9 +54,9 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
|
|
|
54
54
|
* or the current stored value without changing anything.
|
|
55
55
|
*/
|
|
56
56
|
export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
57
|
+
ok?: undefined;
|
|
57
58
|
error: string;
|
|
58
59
|
note?: undefined;
|
|
59
|
-
ok?: undefined;
|
|
60
60
|
} | {
|
|
61
61
|
ok: boolean;
|
|
62
62
|
note?: undefined;
|
|
@@ -1236,7 +1236,9 @@ export async function processAgentTeamRun(opts) {
|
|
|
1236
1236
|
catch {
|
|
1237
1237
|
priorThreadData = undefined;
|
|
1238
1238
|
}
|
|
1239
|
-
messages = threadDataToEngineMessages(priorThreadData
|
|
1239
|
+
messages = threadDataToEngineMessages(priorThreadData, {
|
|
1240
|
+
includeToolCalls: true,
|
|
1241
|
+
});
|
|
1240
1242
|
if (messages.length === 0) {
|
|
1241
1243
|
messages = [
|
|
1242
1244
|
{
|
|
@@ -12,6 +12,26 @@ export function isTrustedNativeAppOrigin(origin) {
|
|
|
12
12
|
export function isLocalhostOrigin(origin) {
|
|
13
13
|
return LOCALHOST_ORIGIN_RE.test(origin);
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Normalize an allowlist entry or an incoming `Origin` header for exact-match
|
|
17
|
+
* comparison. This does not widen what is admitted — it is still an exact
|
|
18
|
+
* scheme+host+port match — it only tolerates formatting slop that carries no
|
|
19
|
+
* security meaning:
|
|
20
|
+
* - scheme/host casing (RFC 6454 origins are case-insensitive; an operator
|
|
21
|
+
* pasting a domain from a UI that preserves entered case can end up with
|
|
22
|
+
* e.g. "HTTPS://App.Example.com")
|
|
23
|
+
* - a trailing slash (browsers never send one in `Origin`, but it's an easy
|
|
24
|
+
* copy/paste mistake when configuring the allowlist)
|
|
25
|
+
* - a bare domain with no scheme (`CORS_ALLOWED_ORIGINS` is operationally a
|
|
26
|
+
* "domain allowlist" — operators reasonably enter "app.example.com"
|
|
27
|
+
* instead of "https://app.example.com", which then never matches a real
|
|
28
|
+
* Origin header and silently locks out that legitimate origin)
|
|
29
|
+
*/
|
|
30
|
+
function normalizeCorsOrigin(value) {
|
|
31
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
32
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
|
|
33
|
+
return (hasScheme ? trimmed : `https://${trimmed}`).toLowerCase();
|
|
34
|
+
}
|
|
15
35
|
export function getAllowedCorsOrigin(origin, options = {}) {
|
|
16
36
|
if (!origin)
|
|
17
37
|
return null;
|
|
@@ -22,7 +42,10 @@ export function getAllowedCorsOrigin(origin, options = {}) {
|
|
|
22
42
|
return origin;
|
|
23
43
|
const allowedOrigins = options.allowedOrigins ?? readCorsAllowedOrigins();
|
|
24
44
|
if (allowedOrigins.length > 0) {
|
|
25
|
-
|
|
45
|
+
const normalizedOrigin = normalizeCorsOrigin(origin);
|
|
46
|
+
return allowedOrigins.some((allowed) => normalizeCorsOrigin(allowed) === normalizedOrigin)
|
|
47
|
+
? origin
|
|
48
|
+
: null;
|
|
26
49
|
}
|
|
27
50
|
if (options.allowAnyOriginWhenNoAllowlist)
|
|
28
51
|
return origin;
|
|
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
|
|
|
26
26
|
expiresAt?: undefined;
|
|
27
27
|
ttlSeconds?: undefined;
|
|
28
28
|
} | {
|
|
29
|
-
error?: undefined;
|
|
30
29
|
token: string;
|
|
31
30
|
expiresAt: string;
|
|
32
31
|
ttlSeconds: number;
|
|
32
|
+
error?: undefined;
|
|
33
33
|
}>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveBuilderGatewayAuth, getBuilderProxyOrigin, } from "../server/credential-provider.js";
|
|
1
|
+
import { resolveBuilderGatewayAuth, getBuilderProxyOrigin, recordBuilderGatewayAuthFailure, } from "../server/credential-provider.js";
|
|
2
2
|
function describeError(err) {
|
|
3
3
|
if (!(err instanceof Error))
|
|
4
4
|
return String(err);
|
|
@@ -64,6 +64,17 @@ export async function transcribeWithBuilder(opts) {
|
|
|
64
64
|
}
|
|
65
65
|
if (!res.ok) {
|
|
66
66
|
const text = await res.text().catch(() => "");
|
|
67
|
+
// The chat path records this so a rejected credential is not retried for
|
|
68
|
+
// BUILDER_AUTH_FAILURE_TTL_MS; transcription never did, so one bad
|
|
69
|
+
// credential re-sent the same doomed request on every attempt -- prod
|
|
70
|
+
// logged 24 identical `Missing Authentication header` 401s in a day off a
|
|
71
|
+
// single unusable credential, with nothing to stop the next one.
|
|
72
|
+
if (res.status === 401 || res.status === 403) {
|
|
73
|
+
await recordBuilderGatewayAuthFailure({
|
|
74
|
+
status: res.status,
|
|
75
|
+
message: text,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
67
78
|
throw new Error(`Builder transcription failed (${res.status} ${res.statusText}): ${text}`);
|
|
68
79
|
}
|
|
69
80
|
return (await res.json());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.161.
|
|
3
|
+
"version": "0.161.16",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|