@agent-native/core 0.172.3 → 0.172.4
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/calendar/actions/get-settings.ts +2 -5
- package/corpus/templates/calendar/actions/list-events.ts +21 -80
- package/corpus/templates/calendar/actions/update-settings.ts +11 -16
- package/corpus/templates/calendar/actions/view-screen.ts +25 -10
- package/corpus/templates/calendar/app/components/calendar/CommandPalette.tsx +7 -1
- package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +5 -2
- package/corpus/templates/calendar/app/lib/calendar-timezone.ts +3 -18
- package/corpus/templates/calendar/app/lib/event-form-utils.ts +3 -62
- package/corpus/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md +6 -0
- package/corpus/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md +6 -0
- package/corpus/templates/calendar/server/handlers/settings.ts +8 -24
- package/corpus/templates/calendar/server/lib/calendar-settings.ts +58 -0
- package/corpus/templates/calendar/server/lib/find-time.ts +15 -93
- package/corpus/templates/calendar/shared/settings.ts +15 -9
- package/corpus/templates/calendar/shared/timezone.ts +127 -0
- package/corpus/templates/slides/actions/list-decks.ts +79 -5
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +21 -1
- package/corpus/templates/slides/app/components/editor/ShareDialog.tsx +49 -21
- package/corpus/templates/slides/app/context/DeckContext.tsx +49 -21
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +2 -2
- package/corpus/templates/slides/app/pages/Index.tsx +11 -13
- package/corpus/templates/slides/changelog/2026-08-24-deck-previews-and-sharing-live-in-the-overflow-menu.md +5 -0
- package/dist/agent/production-agent.js +5 -1
- package/dist/agent/types.d.ts +5 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/posthog-ai.d.ts +36 -11
- package/dist/observability/posthog-ai.js +99 -30
- package/dist/observability/routes.d.ts +3 -3
- package/dist/observability/routes.js +9 -2
- package/dist/observability/traces.js +330 -142
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/tracking/providers.js +9 -3
- package/docs/content/observability.mdx +27 -10
- package/package.json +3 -3
|
@@ -147,6 +147,8 @@ export interface Deck {
|
|
|
147
147
|
starred?: boolean;
|
|
148
148
|
/** Slide aspect ratio (defaults to 16:9 when absent for backwards compat) */
|
|
149
149
|
aspectRatio?: AspectRatio;
|
|
150
|
+
/** First slide returned by the light deck listing for home-page previews. */
|
|
151
|
+
previewSlide?: Slide;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
export type DeckPersistenceResult =
|
|
@@ -199,19 +201,18 @@ interface DeckContextType {
|
|
|
199
201
|
) => Deck;
|
|
200
202
|
ensureDeckPersisted: (id: string) => Promise<DeckPersistenceResult>;
|
|
201
203
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* server, then fires the duplicate-deck action in the background. On error,
|
|
205
|
-
* the optimistic deck is rolled back.
|
|
204
|
+
* Duplicate a deck, hydrating a preview-only source before creating the
|
|
205
|
+
* optimistic copy. On error, the optimistic deck is rolled back.
|
|
206
206
|
*
|
|
207
|
-
* Returns the optimistic deck (or `null` if the source deck isn't found
|
|
207
|
+
* Returns the optimistic deck (or `null` if the source deck isn't found or
|
|
208
|
+
* could not be hydrated).
|
|
208
209
|
*/
|
|
209
210
|
duplicateDeck: (
|
|
210
211
|
sourceDeckId: string,
|
|
211
212
|
newId: string,
|
|
212
213
|
title?: string,
|
|
213
214
|
onFailure?: () => void,
|
|
214
|
-
) => Deck | null
|
|
215
|
+
) => Promise<Deck | null>;
|
|
215
216
|
deleteDeck: (id: string) => void;
|
|
216
217
|
updateDeck: (
|
|
217
218
|
id: string,
|
|
@@ -325,6 +326,8 @@ function normalizeActionDeck(value: unknown): Deck | null {
|
|
|
325
326
|
const deckRecord = deck as unknown as Record<string, unknown>;
|
|
326
327
|
const cleanedDeck = { ...deckRecord };
|
|
327
328
|
for (const field of GET_DECK_ONLY_DECK_FIELDS) delete cleanedDeck[field];
|
|
329
|
+
const previewSlide = deckRecord.previewSlide;
|
|
330
|
+
delete cleanedDeck.previewSlide;
|
|
328
331
|
|
|
329
332
|
// Strip the same decorative fields from every slide, so a deck fetched from
|
|
330
333
|
// `get-deck` is structurally identical to one built by local mutations —
|
|
@@ -356,9 +359,20 @@ function normalizeActionDeck(value: unknown): Deck | null {
|
|
|
356
359
|
? deck.updatedAt
|
|
357
360
|
: deck.createdAt || "",
|
|
358
361
|
slides,
|
|
362
|
+
...(previewSlide && typeof previewSlide === "object"
|
|
363
|
+
? { previewSlide: previewSlide as Slide }
|
|
364
|
+
: {}),
|
|
359
365
|
} as Deck;
|
|
360
366
|
}
|
|
361
367
|
|
|
368
|
+
export function getDuplicateSourceSlides(deck: Deck): Slide[] {
|
|
369
|
+
return deck.slides.length > 0
|
|
370
|
+
? deck.slides
|
|
371
|
+
: deck.previewSlide
|
|
372
|
+
? [deck.previewSlide]
|
|
373
|
+
: [];
|
|
374
|
+
}
|
|
375
|
+
|
|
362
376
|
// Debounced save to API + save-state listeners (so the toolbar indicator
|
|
363
377
|
// can show "Saving…" / "Saved"). The map tracks pending debounce timers;
|
|
364
378
|
// `inFlight` tracks active fetches. Combined, they answer "is anything
|
|
@@ -1040,7 +1054,7 @@ async function fetchDecksFromAPI(): Promise<Deck[] | null> {
|
|
|
1040
1054
|
try {
|
|
1041
1055
|
const result = await callAction<DeckListActionResult>(
|
|
1042
1056
|
"list-decks",
|
|
1043
|
-
{ light: "true" },
|
|
1057
|
+
{ light: "true", includePreview: "true" },
|
|
1044
1058
|
{ method: "GET" },
|
|
1045
1059
|
);
|
|
1046
1060
|
if (!Array.isArray(result?.decks)) {
|
|
@@ -1172,7 +1186,7 @@ async function fetchDecksForCurrentRoute(): Promise<Deck[] | null> {
|
|
|
1172
1186
|
}
|
|
1173
1187
|
if (!currentOpenDeckId) return loaded;
|
|
1174
1188
|
|
|
1175
|
-
// The list
|
|
1189
|
+
// The list has only first-slide previews. Hydrate just the deck the user
|
|
1176
1190
|
// opened so the editor gets full slide content without making the home page
|
|
1177
1191
|
// download every deck body.
|
|
1178
1192
|
const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
|
|
@@ -2143,15 +2157,27 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
2143
2157
|
);
|
|
2144
2158
|
|
|
2145
2159
|
const duplicateDeck = useCallback(
|
|
2146
|
-
(
|
|
2160
|
+
async (
|
|
2147
2161
|
sourceDeckId: string,
|
|
2148
2162
|
newId: string,
|
|
2149
2163
|
title?: string,
|
|
2150
2164
|
onFailure?: () => void,
|
|
2151
|
-
): Deck | null => {
|
|
2165
|
+
): Promise<Deck | null> => {
|
|
2152
2166
|
if (pendingDuplicateSourceIdsRef.current.has(sourceDeckId)) return null;
|
|
2153
|
-
|
|
2154
|
-
|
|
2167
|
+
pendingDuplicateSourceIdsRef.current.add(sourceDeckId);
|
|
2168
|
+
let source = decks.find((d) => d.id === sourceDeckId);
|
|
2169
|
+
if (!source) {
|
|
2170
|
+
pendingDuplicateSourceIdsRef.current.delete(sourceDeckId);
|
|
2171
|
+
return null;
|
|
2172
|
+
}
|
|
2173
|
+
if (source.slides.length === 0 && source.previewSlide) {
|
|
2174
|
+
const hydrated = await fetchDeckFromAPI(sourceDeckId);
|
|
2175
|
+
if (!hydrated) {
|
|
2176
|
+
pendingDuplicateSourceIdsRef.current.delete(sourceDeckId);
|
|
2177
|
+
return null;
|
|
2178
|
+
}
|
|
2179
|
+
source = hydrated;
|
|
2180
|
+
}
|
|
2155
2181
|
|
|
2156
2182
|
const now = new Date().toISOString();
|
|
2157
2183
|
const newTitle = title || `Copy of ${source.title}`;
|
|
@@ -2172,7 +2198,8 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
2172
2198
|
createdByMe: true,
|
|
2173
2199
|
shareToken: undefined,
|
|
2174
2200
|
};
|
|
2175
|
-
|
|
2201
|
+
delete optimistic.previewSlide;
|
|
2202
|
+
optimistic.slides = getDuplicateSourceSlides(source).map((s) => ({
|
|
2176
2203
|
...s,
|
|
2177
2204
|
id: `slide-${nanoid(8)}`,
|
|
2178
2205
|
}));
|
|
@@ -2181,7 +2208,6 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
2181
2208
|
// the duplicate-deck action's INSERT lands.
|
|
2182
2209
|
pendingCreateIdsRef.current.add(newId);
|
|
2183
2210
|
noteLocalCreate(newId);
|
|
2184
|
-
pendingDuplicateSourceIdsRef.current.add(sourceDeckId);
|
|
2185
2211
|
|
|
2186
2212
|
// Fire the action in the background. On error, roll back.
|
|
2187
2213
|
const duplicatePromise = callAction<DuplicateDeckActionResult>(
|
|
@@ -2190,10 +2216,12 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
2190
2216
|
deckId: sourceDeckId,
|
|
2191
2217
|
newId,
|
|
2192
2218
|
title,
|
|
2193
|
-
//
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
|
|
2219
|
+
// Preview-only sources were hydrated above. An empty array is not
|
|
2220
|
+
// a valid optimistic slide-id projection, so omit it for empty
|
|
2221
|
+
// decks and let the server generate ids for any uncovered slides.
|
|
2222
|
+
...(optimistic.slides.length > 0
|
|
2223
|
+
? { slideIds: optimistic.slides.map((s) => s.id) }
|
|
2224
|
+
: {}),
|
|
2197
2225
|
},
|
|
2198
2226
|
).then(() => undefined);
|
|
2199
2227
|
pendingCreatePromisesRef.current.set(newId, duplicatePromise);
|
|
@@ -2213,9 +2241,9 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
2213
2241
|
}
|
|
2214
2242
|
console.error("Duplicate failed:", err);
|
|
2215
2243
|
// Roll back: drop the optimistic deck from local state. The caller
|
|
2216
|
-
// (via onFailure) is responsible for navigating away if the user
|
|
2217
|
-
// still sitting on this now-gone deck's route — otherwise
|
|
2218
|
-
// stranded on a "Deck unavailable" screen with no way back.
|
|
2244
|
+
// (via onFailure) is responsible for navigating away if the user
|
|
2245
|
+
// is still sitting on this now-gone deck's route — otherwise
|
|
2246
|
+
// they're stranded on a "Deck unavailable" screen with no way back.
|
|
2219
2247
|
setDecks((prev) => prev.filter((d) => d.id !== newId));
|
|
2220
2248
|
onFailure?.();
|
|
2221
2249
|
})
|
|
@@ -1545,9 +1545,9 @@ export default function DeckEditor() {
|
|
|
1545
1545
|
onTogglePinMode={togglePinMode}
|
|
1546
1546
|
textBoxMode={textBoxMode}
|
|
1547
1547
|
onToggleTextBoxMode={toggleTextBoxMode}
|
|
1548
|
-
onDuplicateDeck={() => {
|
|
1548
|
+
onDuplicateDeck={async () => {
|
|
1549
1549
|
const newId = `deck-${nanoid()}`;
|
|
1550
|
-
const optimistic = duplicateDeck(id, newId, undefined, () => {
|
|
1550
|
+
const optimistic = await duplicateDeck(id, newId, undefined, () => {
|
|
1551
1551
|
// The background duplicate-deck action failed after we already
|
|
1552
1552
|
// navigated to the optimistic copy. If the user is still there,
|
|
1553
1553
|
// send them back instead of stranding them on a "Deck
|
|
@@ -1289,19 +1289,17 @@ export default function Index() {
|
|
|
1289
1289
|
// first (the same path the editor's own Duplicate uses) and navigate to
|
|
1290
1290
|
// that; the background action reconciles or rolls the copy back.
|
|
1291
1291
|
const handleDuplicate = useCallback(
|
|
1292
|
-
(id: string) => {
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
toast.error(t("home.duplicateFailed"));
|
|
1304
|
-
});
|
|
1292
|
+
async (id: string) => {
|
|
1293
|
+
const newId = `deck-${nanoid()}`;
|
|
1294
|
+
const copy = await duplicateDeck(id, newId, undefined, () => {
|
|
1295
|
+
// The background duplicate-deck action failed after we already
|
|
1296
|
+
// navigated to the optimistic copy's route. If the user is still
|
|
1297
|
+
// there, send them back to the deck list instead of stranding them
|
|
1298
|
+
// on a "Deck unavailable" screen for a deck that no longer exists.
|
|
1299
|
+
if (deckIdFromPathname(window.location.pathname) === newId) {
|
|
1300
|
+
navigate("/");
|
|
1301
|
+
}
|
|
1302
|
+
toast.error(t("home.duplicateFailed"));
|
|
1305
1303
|
});
|
|
1306
1304
|
// The context refuses a second copy of the same deck while the first
|
|
1307
1305
|
// one's action is still in flight.
|
|
@@ -3528,7 +3528,11 @@ export async function runAgentLoop(opts) {
|
|
|
3528
3528
|
if (!modelStreamBracketOpen)
|
|
3529
3529
|
return;
|
|
3530
3530
|
modelStreamBracketOpen = false;
|
|
3531
|
-
send({
|
|
3531
|
+
send({
|
|
3532
|
+
type: "model_stream",
|
|
3533
|
+
status: "end",
|
|
3534
|
+
...(terminalStopReason ? { reason: terminalStopReason } : {}),
|
|
3535
|
+
});
|
|
3532
3536
|
};
|
|
3533
3537
|
let lastModelStreamProgressAt = Date.now();
|
|
3534
3538
|
// FIX 2: true once a real (non-heartbeat) engine-stream event has been
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -305,6 +305,11 @@ export type AgentChatEvent = {
|
|
|
305
305
|
*/
|
|
306
306
|
type: "model_stream";
|
|
307
307
|
status: "start" | "end";
|
|
308
|
+
/** Why the model stopped, on the closing event: `end_turn`, `tool_use`,
|
|
309
|
+
* `max_tokens`, `stop_sequence`, `error`. Absent when the stream was cut
|
|
310
|
+
* before the engine reported one — a truncated call and a call that
|
|
311
|
+
* ended cleanly must stay distinguishable. */
|
|
312
|
+
reason?: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" | "error";
|
|
308
313
|
} | {
|
|
309
314
|
type: "tool_start";
|
|
310
315
|
tool: string;
|
|
@@ -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
|
}>>;
|
|
@@ -17,11 +17,11 @@ declare const _default: import("../../action.js").ActionDefinition<{
|
|
|
17
17
|
id?: undefined;
|
|
18
18
|
provider?: undefined;
|
|
19
19
|
} | {
|
|
20
|
+
error?: undefined;
|
|
20
21
|
configured?: undefined;
|
|
21
22
|
connectPath?: undefined;
|
|
22
23
|
url: string;
|
|
23
24
|
id: string;
|
|
24
25
|
provider: string;
|
|
25
|
-
error?: undefined;
|
|
26
26
|
}>;
|
|
27
27
|
export default _default;
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
* schema, and a name it does not define today it may define tomorrow with
|
|
25
25
|
* different meaning — `input_truncated` / `spans_dropped` cannot collide.
|
|
26
26
|
*
|
|
27
|
-
* Content (`$ai_input` / `$ai_output_choices
|
|
28
|
-
* `$ai_output_state`) is gated on config and always OMITTED
|
|
29
|
-
* Sending `[]` instead would be indistinguishable from a run
|
|
30
|
-
* no messages.
|
|
27
|
+
* Content (a generation's `$ai_input` / `$ai_output_choices`, a span's
|
|
28
|
+
* `$ai_input_state` / `$ai_output_state`) is gated on config and always OMITTED
|
|
29
|
+
* when disabled. Sending `[]` instead would be indistinguishable from a run
|
|
30
|
+
* that genuinely had no messages.
|
|
31
31
|
*
|
|
32
32
|
* @see https://posthog.com/docs/ai-observability/traces
|
|
33
33
|
* @see https://posthog.com/docs/ai-observability/spans
|
|
@@ -42,12 +42,18 @@ export interface AiErrorDetail {
|
|
|
42
42
|
terminal_state?: string;
|
|
43
43
|
retryable?: boolean;
|
|
44
44
|
}
|
|
45
|
+
export declare function resolveAiError(isError: boolean, error: AiErrorDetail | undefined): AiErrorDetail | undefined;
|
|
45
46
|
/**
|
|
46
47
|
* Serialize a content value under a byte ceiling.
|
|
47
48
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
49
|
+
* A message list keeps the last user message behind a marker entry naming what
|
|
50
|
+
* was dropped — what was asked is what a trace is opened for, and it stays
|
|
51
|
+
* small. Replacing the whole conversation with a placeholder, which is what
|
|
52
|
+
* this did, left nothing at all.
|
|
53
|
+
*
|
|
54
|
+
* Anything else (a string, an object) still becomes a placeholder: there is no
|
|
55
|
+
* message to keep. Either way `truncated` is true, so a cut payload can never
|
|
56
|
+
* be read as a complete one.
|
|
51
57
|
*/
|
|
52
58
|
export declare function boundAiContent(value: unknown): {
|
|
53
59
|
value: unknown;
|
|
@@ -66,6 +72,10 @@ export interface AiTraceEventInput {
|
|
|
66
72
|
durationMs: number;
|
|
67
73
|
isError: boolean;
|
|
68
74
|
error?: AiErrorDetail;
|
|
75
|
+
/** What KIND of failure this was — a terminal code, a cut-off reason, an
|
|
76
|
+
* engine error code. A classification, never content, so it ships even when
|
|
77
|
+
* the message is withheld. */
|
|
78
|
+
errorType?: string;
|
|
69
79
|
inputTokens?: number;
|
|
70
80
|
outputTokens?: number;
|
|
71
81
|
costUsd?: number;
|
|
@@ -73,11 +83,15 @@ export interface AiTraceEventInput {
|
|
|
73
83
|
/** Browser session id, when the run originated from a page. Links the trace
|
|
74
84
|
* to PostHog session replay. */
|
|
75
85
|
browserSessionId?: string;
|
|
76
|
-
/** Omitted unless `capturePrompts` is on. */
|
|
77
|
-
inputState?: unknown;
|
|
78
|
-
outputState?: unknown;
|
|
79
86
|
extraProperties?: Record<string, unknown>;
|
|
80
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The run's prompt and answer are NOT carried here. Every round-trip emits a
|
|
90
|
+
* generation with its own `$ai_input` / `$ai_output_choices`, so a trace-level
|
|
91
|
+
* copy repeated the first call's prompt and the last call's answer on a second
|
|
92
|
+
* event. The trace owns what only it knows: the run's name, failure, metadata
|
|
93
|
+
* and totals.
|
|
94
|
+
*/
|
|
81
95
|
export declare function emitAiTraceEvent(input: AiTraceEventInput): void;
|
|
82
96
|
export interface AiSpanEventInput {
|
|
83
97
|
runId: string;
|
|
@@ -90,6 +104,9 @@ export interface AiSpanEventInput {
|
|
|
90
104
|
latencySeconds: number;
|
|
91
105
|
isError: boolean;
|
|
92
106
|
error?: AiErrorDetail;
|
|
107
|
+
/** The tool failure's class — `tool_error`, `interrupted`. A classification,
|
|
108
|
+
* never the tool's output, so it ships with `captureToolResults` off. */
|
|
109
|
+
errorType?: string;
|
|
93
110
|
createdAt: number;
|
|
94
111
|
browserSessionId?: string;
|
|
95
112
|
/** Omitted unless `captureToolArgs` / `captureToolResults` are on. */
|
|
@@ -103,8 +120,12 @@ export interface AiFeedbackSurveyInput {
|
|
|
103
120
|
threadId: string | null;
|
|
104
121
|
userId: string | null;
|
|
105
122
|
feedbackType: "thumbs_up" | "thumbs_down" | "category" | "text";
|
|
106
|
-
/** The submitted value:
|
|
123
|
+
/** The submitted value: chosen category or free text. Ignored for thumbs,
|
|
124
|
+
* which answer a rating question and travel as PostHog's choice index. */
|
|
107
125
|
value: string;
|
|
126
|
+
/** Groups the events of one response. A thumbs-down and the follow-up text
|
|
127
|
+
* it opens are two `survey sent` events answering two questions of the SAME
|
|
128
|
+
* response, so they must share this id. */
|
|
108
129
|
submissionId: string;
|
|
109
130
|
model?: string;
|
|
110
131
|
browserSessionId?: string;
|
|
@@ -117,6 +138,10 @@ export interface AiFeedbackSurveyInput {
|
|
|
117
138
|
* nowhere. Returns `false` and emits nothing when no survey id is configured:
|
|
118
139
|
* inventing one would produce events attached to a survey that does not exist.
|
|
119
140
|
*
|
|
141
|
+
* A thumbs vote answers the survey's first question; a category or free-text
|
|
142
|
+
* answer answers the follow-up question after it. Both share the caller's
|
|
143
|
+
* `submissionId`, which is how PostHog joins them into one response.
|
|
144
|
+
*
|
|
120
145
|
* Sent to PostHog ONLY, not through `track()`. The survey response carries the
|
|
121
146
|
* user's free-text feedback verbatim, and configuring a PostHog survey id must
|
|
122
147
|
* not silently start shipping that text to Mixpanel, Amplitude, webhooks, or
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
* schema, and a name it does not define today it may define tomorrow with
|
|
25
25
|
* different meaning — `input_truncated` / `spans_dropped` cannot collide.
|
|
26
26
|
*
|
|
27
|
-
* Content (`$ai_input` / `$ai_output_choices
|
|
28
|
-
* `$ai_output_state`) is gated on config and always OMITTED
|
|
29
|
-
* Sending `[]` instead would be indistinguishable from a run
|
|
30
|
-
* no messages.
|
|
27
|
+
* Content (a generation's `$ai_input` / `$ai_output_choices`, a span's
|
|
28
|
+
* `$ai_input_state` / `$ai_output_state`) is gated on config and always OMITTED
|
|
29
|
+
* when disabled. Sending `[]` instead would be indistinguishable from a run
|
|
30
|
+
* that genuinely had no messages.
|
|
31
31
|
*
|
|
32
32
|
* @see https://posthog.com/docs/ai-observability/traces
|
|
33
33
|
* @see https://posthog.com/docs/ai-observability/spans
|
|
@@ -38,6 +38,21 @@ import { boundedText } from "../tracking/redaction.js";
|
|
|
38
38
|
export const MAX_AI_CONTENT_BYTES = 128 * 1024;
|
|
39
39
|
/** Hard ceiling on emitted `$ai_span` events per run. */
|
|
40
40
|
export const MAX_AI_SPANS_PER_RUN = 100;
|
|
41
|
+
/**
|
|
42
|
+
* `$ai_is_error: true` with no `$ai_error` is a dead end: the reader learns
|
|
43
|
+
* something broke and nothing else, which is how a failed tool span reached
|
|
44
|
+
* PostHog saying only "error". Emitters fall back to this rather than shipping
|
|
45
|
+
* the flag alone — a layer that failed without reporting a message is itself
|
|
46
|
+
* worth seeing, and is not the same as a message we chose to withhold.
|
|
47
|
+
*/
|
|
48
|
+
const UNREPORTED_ERROR = {
|
|
49
|
+
message: "failed without a reported error message",
|
|
50
|
+
};
|
|
51
|
+
export function resolveAiError(isError, error) {
|
|
52
|
+
if (!isError)
|
|
53
|
+
return undefined;
|
|
54
|
+
return error ?? UNREPORTED_ERROR;
|
|
55
|
+
}
|
|
41
56
|
function trackAiEvent(name, properties, userId, occurredAt) {
|
|
42
57
|
for (const key of Object.keys(properties)) {
|
|
43
58
|
if (properties[key] === undefined)
|
|
@@ -55,12 +70,24 @@ function trackAiEvent(name, properties, userId, occurredAt) {
|
|
|
55
70
|
// Tracking must never affect the agent run or trace persistence.
|
|
56
71
|
}
|
|
57
72
|
}
|
|
73
|
+
function utf8Bytes(text) {
|
|
74
|
+
return typeof Buffer !== "undefined"
|
|
75
|
+
? Buffer.byteLength(text, "utf8")
|
|
76
|
+
: new TextEncoder().encode(text).length;
|
|
77
|
+
}
|
|
78
|
+
/** Room reserved for the marker entry that reports what was dropped. */
|
|
79
|
+
const OMISSION_MARKER_BYTES = 512;
|
|
58
80
|
/**
|
|
59
81
|
* Serialize a content value under a byte ceiling.
|
|
60
82
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
83
|
+
* A message list keeps the last user message behind a marker entry naming what
|
|
84
|
+
* was dropped — what was asked is what a trace is opened for, and it stays
|
|
85
|
+
* small. Replacing the whole conversation with a placeholder, which is what
|
|
86
|
+
* this did, left nothing at all.
|
|
87
|
+
*
|
|
88
|
+
* Anything else (a string, an object) still becomes a placeholder: there is no
|
|
89
|
+
* message to keep. Either way `truncated` is true, so a cut payload can never
|
|
90
|
+
* be read as a complete one.
|
|
64
91
|
*/
|
|
65
92
|
export function boundAiContent(value) {
|
|
66
93
|
let serialized;
|
|
@@ -72,23 +99,45 @@ export function boundAiContent(value) {
|
|
|
72
99
|
}
|
|
73
100
|
if (serialized === undefined)
|
|
74
101
|
return { value: undefined, truncated: false };
|
|
75
|
-
const bytes =
|
|
76
|
-
? Buffer.byteLength(serialized, "utf8")
|
|
77
|
-
: new TextEncoder().encode(serialized).length;
|
|
102
|
+
const bytes = utf8Bytes(serialized);
|
|
78
103
|
if (bytes <= MAX_AI_CONTENT_BYTES)
|
|
79
104
|
return { value, truncated: false };
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
// What was ASKED is the one thing worth rescuing from an oversized
|
|
107
|
+
// transcript: the rest is context the thread itself still holds, and
|
|
108
|
+
// keeping as much of it as fits just ships the ceiling on every event.
|
|
109
|
+
const lastUser = value.findLast((entry) => !!entry &&
|
|
110
|
+
typeof entry === "object" &&
|
|
111
|
+
entry.role === "user");
|
|
112
|
+
const kept = lastUser !== undefined &&
|
|
113
|
+
utf8Bytes(JSON.stringify(lastUser) ?? "null") <=
|
|
114
|
+
MAX_AI_CONTENT_BYTES - OMISSION_MARKER_BYTES
|
|
115
|
+
? [lastUser]
|
|
116
|
+
: [];
|
|
117
|
+
return {
|
|
118
|
+
value: [
|
|
119
|
+
{
|
|
120
|
+
role: "system",
|
|
121
|
+
content: `[${value.length - kept.length} message(s) omitted: ${bytes} bytes exceeded the ${MAX_AI_CONTENT_BYTES}-byte trace content limit]`,
|
|
122
|
+
},
|
|
123
|
+
...kept,
|
|
124
|
+
],
|
|
125
|
+
truncated: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
80
128
|
return {
|
|
81
129
|
value: `[truncated: ${bytes} bytes exceeded the ${MAX_AI_CONTENT_BYTES}-byte trace content limit]`,
|
|
82
130
|
truncated: true,
|
|
83
131
|
};
|
|
84
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The run's prompt and answer are NOT carried here. Every round-trip emits a
|
|
135
|
+
* generation with its own `$ai_input` / `$ai_output_choices`, so a trace-level
|
|
136
|
+
* copy repeated the first call's prompt and the last call's answer on a second
|
|
137
|
+
* event. The trace owns what only it knows: the run's name, failure, metadata
|
|
138
|
+
* and totals.
|
|
139
|
+
*/
|
|
85
140
|
export function emitAiTraceEvent(input) {
|
|
86
|
-
const inputContent = input.inputState === undefined
|
|
87
|
-
? undefined
|
|
88
|
-
: boundAiContent(input.inputState);
|
|
89
|
-
const outputContent = input.outputState === undefined
|
|
90
|
-
? undefined
|
|
91
|
-
: boundAiContent(input.outputState);
|
|
92
141
|
trackAiEvent("$ai_trace", {
|
|
93
142
|
...input.extraProperties,
|
|
94
143
|
$ai_trace_id: input.runId,
|
|
@@ -97,15 +146,14 @@ export function emitAiTraceEvent(input) {
|
|
|
97
146
|
$ai_model: input.model,
|
|
98
147
|
$ai_provider: input.provider,
|
|
99
148
|
$ai_is_error: input.isError,
|
|
100
|
-
$ai_error: input.error,
|
|
149
|
+
$ai_error: resolveAiError(input.isError, input.error),
|
|
150
|
+
$ai_error_type: input.isError
|
|
151
|
+
? (input.errorType ?? "run_error")
|
|
152
|
+
: undefined,
|
|
101
153
|
duration_ms: Math.round(input.durationMs),
|
|
102
154
|
input_tokens: input.inputTokens,
|
|
103
155
|
output_tokens: input.outputTokens,
|
|
104
156
|
cost_usd: input.costUsd,
|
|
105
|
-
$ai_input_state: inputContent?.value,
|
|
106
|
-
$ai_output_state: outputContent?.value,
|
|
107
|
-
input_truncated: inputContent?.truncated || undefined,
|
|
108
|
-
output_truncated: outputContent?.truncated || undefined,
|
|
109
157
|
$session_id: input.browserSessionId,
|
|
110
158
|
created_at: new Date(input.createdAt).toISOString(),
|
|
111
159
|
}, input.userId, input.createdAt);
|
|
@@ -126,7 +174,10 @@ export function emitAiSpanEvent(input) {
|
|
|
126
174
|
$ai_span_name: input.spanName,
|
|
127
175
|
$ai_latency: input.latencySeconds,
|
|
128
176
|
$ai_is_error: input.isError,
|
|
129
|
-
$ai_error: input.error,
|
|
177
|
+
$ai_error: resolveAiError(input.isError, input.error),
|
|
178
|
+
$ai_error_type: input.isError
|
|
179
|
+
? (input.errorType ?? "tool_error")
|
|
180
|
+
: undefined,
|
|
130
181
|
$ai_input_state: inputContent?.value,
|
|
131
182
|
$ai_output_state: outputContent?.value,
|
|
132
183
|
input_truncated: inputContent?.truncated || undefined,
|
|
@@ -135,6 +186,14 @@ export function emitAiSpanEvent(input) {
|
|
|
135
186
|
created_at: new Date(input.createdAt).toISOString(),
|
|
136
187
|
}, input.userId, input.createdAt);
|
|
137
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* PostHog's thumbs survey is a choice question, and the response is the
|
|
191
|
+
* choice's 1-based index — not a label. Sending "thumbs_up" put a string where
|
|
192
|
+
* the rating belongs, so the vote counted as an unrecognized answer.
|
|
193
|
+
*
|
|
194
|
+
* @see https://posthog.com/docs/ai-observability/user-feedback/manual-event-capture
|
|
195
|
+
*/
|
|
196
|
+
const THUMB_RESPONSE_INDEX = { thumbs_up: 1, thumbs_down: 2 };
|
|
138
197
|
/**
|
|
139
198
|
* Emit PostHog's documented manual feedback event for an LLM trace.
|
|
140
199
|
*
|
|
@@ -143,6 +202,10 @@ export function emitAiSpanEvent(input) {
|
|
|
143
202
|
* nowhere. Returns `false` and emits nothing when no survey id is configured:
|
|
144
203
|
* inventing one would produce events attached to a survey that does not exist.
|
|
145
204
|
*
|
|
205
|
+
* A thumbs vote answers the survey's first question; a category or free-text
|
|
206
|
+
* answer answers the follow-up question after it. Both share the caller's
|
|
207
|
+
* `submissionId`, which is how PostHog joins them into one response.
|
|
208
|
+
*
|
|
146
209
|
* Sent to PostHog ONLY, not through `track()`. The survey response carries the
|
|
147
210
|
* user's free-text feedback verbatim, and configuring a PostHog survey id must
|
|
148
211
|
* not silently start shipping that text to Mixpanel, Amplitude, webhooks, or
|
|
@@ -155,17 +218,23 @@ export function emitAiFeedbackSurveyEvent(input) {
|
|
|
155
218
|
const surveyId = process.env.POSTHOG_AI_FEEDBACK_SURVEY_ID?.trim();
|
|
156
219
|
if (!surveyId)
|
|
157
220
|
return false;
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
221
|
+
const thumbResponse = input.feedbackType === "thumbs_up" || input.feedbackType === "thumbs_down"
|
|
222
|
+
? THUMB_RESPONSE_INDEX[input.feedbackType]
|
|
223
|
+
: undefined;
|
|
224
|
+
// `$survey_response` is the survey's first question — the thumbs — and
|
|
225
|
+
// `$survey_response_1` the follow-up asked after a thumbs-down. Putting the
|
|
226
|
+
// free text on the first key instead would file prose where the rating
|
|
227
|
+
// belongs and skew every thumbs metric in the survey.
|
|
228
|
+
const responses = thumbResponse !== undefined
|
|
229
|
+
? { $survey_response: thumbResponse }
|
|
230
|
+
: { $survey_response_1: input.value };
|
|
164
231
|
const properties = {
|
|
165
232
|
$survey_id: surveyId,
|
|
166
|
-
|
|
233
|
+
...responses,
|
|
167
234
|
$survey_submission_id: input.submissionId,
|
|
168
|
-
|
|
235
|
+
// A thumbs-down opens the follow-up question, so the response is not
|
|
236
|
+
// complete until that answer arrives (or the user closes it without one).
|
|
237
|
+
$survey_completed: input.feedbackType !== "thumbs_down",
|
|
169
238
|
$ai_trace_id: input.runId ?? undefined,
|
|
170
239
|
$ai_session_id: input.threadId ?? undefined,
|
|
171
240
|
$ai_model: input.model,
|
|
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
41
41
|
thumbsUpRate: number;
|
|
42
42
|
avgEvalScore: number;
|
|
43
43
|
} | {
|
|
44
|
+
error?: undefined;
|
|
44
45
|
summary: import("./types.js").TraceSummary;
|
|
45
46
|
spans: import("./types.js").TraceSpan[];
|
|
46
47
|
id?: undefined;
|
|
47
|
-
error?: undefined;
|
|
48
48
|
ok?: undefined;
|
|
49
49
|
} | {
|
|
50
|
+
error?: undefined;
|
|
50
51
|
summary?: undefined;
|
|
51
52
|
spans?: undefined;
|
|
52
53
|
id: string;
|
|
53
|
-
error?: undefined;
|
|
54
54
|
ok?: undefined;
|
|
55
55
|
} | {
|
|
56
56
|
summary?: undefined;
|
|
@@ -59,9 +59,9 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
59
59
|
error: any;
|
|
60
60
|
ok?: undefined;
|
|
61
61
|
} | {
|
|
62
|
+
error?: undefined;
|
|
62
63
|
summary?: undefined;
|
|
63
64
|
spans?: undefined;
|
|
64
65
|
id?: undefined;
|
|
65
|
-
error?: undefined;
|
|
66
66
|
ok: boolean;
|
|
67
67
|
}>>;
|
|
@@ -216,8 +216,15 @@ export function createObservabilityHandler() {
|
|
|
216
216
|
threadId,
|
|
217
217
|
userId: owner,
|
|
218
218
|
feedbackType,
|
|
219
|
-
value
|
|
220
|
-
|
|
219
|
+
value,
|
|
220
|
+
// One PostHog response per rated message, not per row: a thumbs-down
|
|
221
|
+
// and the free text it opens are two answers to the same survey, and
|
|
222
|
+
// a fresh id per row would file them as two unrelated responses.
|
|
223
|
+
// Falls back to the row id when there is no message to key on, which
|
|
224
|
+
// groups nothing — the honest outcome when nothing can be grouped.
|
|
225
|
+
submissionId: runId && typeof body.messageSeq === "number"
|
|
226
|
+
? `${runId}:${body.messageSeq}`
|
|
227
|
+
: id,
|
|
221
228
|
model,
|
|
222
229
|
browserSessionId: getRequestContext()?.browserSessionId,
|
|
223
230
|
});
|