@gram-ai/elements 1.28.0 → 1.29.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.
Files changed (33) hide show
  1. package/dist/elements.cjs +1 -1
  2. package/dist/elements.js +1 -1
  3. package/dist/{index-CtZz13Cf.js → index-BzA55RRF.js} +11741 -11557
  4. package/dist/index-BzA55RRF.js.map +1 -0
  5. package/dist/{index-BmTGnEaV.cjs → index-CgO7wXs-.cjs} +52 -48
  6. package/dist/index-CgO7wXs-.cjs.map +1 -0
  7. package/dist/lib/contextCompaction.d.ts +58 -0
  8. package/dist/lib/contextCompaction.test.d.ts +1 -0
  9. package/dist/lib/errorTracking.config.d.ts +2 -0
  10. package/dist/lib/tools.byte-cap.test.d.ts +1 -0
  11. package/dist/lib/tools.d.ts +19 -0
  12. package/dist/lib/tools.test.d.ts +1 -0
  13. package/dist/{profiler-Ccma0l1p.js → profiler-BPCxiY-X.js} +2 -2
  14. package/dist/{profiler-Ccma0l1p.js.map → profiler-BPCxiY-X.js.map} +1 -1
  15. package/dist/{profiler-CjNa3A1d.cjs → profiler-BmAwBXpj.cjs} +2 -2
  16. package/dist/{profiler-CjNa3A1d.cjs.map → profiler-BmAwBXpj.cjs.map} +1 -1
  17. package/dist/{startRecording-jSovclaq.cjs → startRecording-B0Xe2DOI.cjs} +2 -2
  18. package/dist/{startRecording-jSovclaq.cjs.map → startRecording-B0Xe2DOI.cjs.map} +1 -1
  19. package/dist/{startRecording-DAURU74n.js → startRecording-DXGt4fON.js} +2 -2
  20. package/dist/{startRecording-DAURU74n.js.map → startRecording-DXGt4fON.js.map} +1 -1
  21. package/dist/types/index.d.ts +49 -0
  22. package/package.json +1 -1
  23. package/src/contexts/ElementsProvider.tsx +50 -5
  24. package/src/lib/contextCompaction.test.ts +201 -0
  25. package/src/lib/contextCompaction.ts +211 -0
  26. package/src/lib/errorTracking.config.ts +2 -0
  27. package/src/lib/errorTracking.ts +1 -1
  28. package/src/lib/tools.byte-cap.test.ts +132 -0
  29. package/src/lib/tools.test.ts +259 -0
  30. package/src/lib/tools.ts +122 -0
  31. package/src/types/index.ts +55 -0
  32. package/dist/index-BmTGnEaV.cjs.map +0 -1
  33. package/dist/index-CtZz13Cf.js.map +0 -1
@@ -0,0 +1,259 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ convertToModelMessages,
4
+ isToolUIPart,
5
+ jsonSchema,
6
+ lastAssistantMessageIsCompleteWithToolCalls,
7
+ readUIMessageStream,
8
+ stepCountIs,
9
+ streamText,
10
+ type ToolSet,
11
+ type UIMessage,
12
+ type UIMessagePart,
13
+ } from "ai";
14
+ import { MockLanguageModelV2 } from "ai/test";
15
+
16
+ type MockStream = Extract<
17
+ NonNullable<
18
+ NonNullable<
19
+ ConstructorParameters<typeof MockLanguageModelV2>[0]
20
+ >["doStream"]
21
+ >,
22
+ (...a: never[]) => PromiseLike<{ stream: ReadableStream<unknown> }>
23
+ >;
24
+ type StreamPart =
25
+ Awaited<ReturnType<MockStream>>["stream"] extends ReadableStream<infer T>
26
+ ? T
27
+ : never;
28
+
29
+ /**
30
+ * Repro for the assistants-onboarding "Skip bugged state":
31
+ *
32
+ * 1. Assistant calls a frontend tool (e.g. `request_environment_secrets`) that
33
+ * renders a form with a Skip button.
34
+ * 2. User clicks Skip. The form calls `draft.resolvePending(toolCallId, { cancelled: true })`.
35
+ * 3. Expected: tool-result is patched onto the message, the agent continues,
36
+ * chat returns to a ready state.
37
+ * 4. Observed (pre-fix): the chat stayed stuck — the next user message landed
38
+ * with an invalid tool sequence and the provider rejected it with a
39
+ * "message needing to be sent with role: assistant"-shaped error.
40
+ *
41
+ * `streamText` runs without an `execute` for frontend tools: AI-SDK's
42
+ * `frontendTools()` helper strips execute so client-side logic can take over.
43
+ * The missing link on main was that the runtime patched in the tool result
44
+ * but nothing resumed the turn. The fix wires `sendAutomaticallyWhen:
45
+ * lastAssistantMessageIsCompleteWithToolCalls` into `useChatRuntime`, which
46
+ * flips that resume on.
47
+ */
48
+
49
+ function toolCallChunks(opts: {
50
+ toolCallId: string;
51
+ toolName: string;
52
+ input: string;
53
+ }): StreamPart[] {
54
+ return [
55
+ { type: "stream-start", warnings: [] },
56
+ {
57
+ type: "response-metadata",
58
+ id: "resp-1",
59
+ modelId: "m",
60
+ timestamp: new Date(0),
61
+ },
62
+ { type: "tool-input-start", id: opts.toolCallId, toolName: opts.toolName },
63
+ { type: "tool-input-delta", id: opts.toolCallId, delta: opts.input },
64
+ { type: "tool-input-end", id: opts.toolCallId },
65
+ {
66
+ type: "tool-call",
67
+ toolCallId: opts.toolCallId,
68
+ toolName: opts.toolName,
69
+ input: opts.input,
70
+ },
71
+ {
72
+ type: "finish",
73
+ finishReason: "tool-calls",
74
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
75
+ },
76
+ ];
77
+ }
78
+
79
+ function makeStream<T>(chunks: T[]): ReadableStream<T> {
80
+ return new ReadableStream({
81
+ start(controller) {
82
+ for (const c of chunks) controller.enqueue(c);
83
+ controller.close();
84
+ },
85
+ });
86
+ }
87
+
88
+ async function collectUIMessages(
89
+ stream: AsyncIterable<UIMessage>,
90
+ ): Promise<UIMessage[]> {
91
+ const out: UIMessage[] = [];
92
+ for await (const msg of stream) {
93
+ const idx = out.findIndex((m) => m.id === msg.id);
94
+ if (idx >= 0) out[idx] = msg;
95
+ else out.push(msg);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ async function streamToolCallOnly(toolCallId: string): Promise<UIMessage[]> {
101
+ const toolsNoExecute = {
102
+ request_environment_secrets: {
103
+ description: "Ask the user to enter secrets for an env.",
104
+ inputSchema: jsonSchema({
105
+ type: "object",
106
+ properties: {
107
+ keys: {
108
+ type: "array",
109
+ items: {
110
+ type: "object",
111
+ properties: { name: { type: "string" } },
112
+ required: ["name"],
113
+ },
114
+ },
115
+ },
116
+ required: ["keys"],
117
+ }),
118
+ },
119
+ } as unknown as ToolSet;
120
+
121
+ const model = new MockLanguageModelV2({
122
+ doStream: async () => ({
123
+ stream: makeStream([
124
+ ...toolCallChunks({
125
+ toolCallId,
126
+ toolName: "request_environment_secrets",
127
+ input: JSON.stringify({ keys: [{ name: "SLACK_BOT_TOKEN" }] }),
128
+ }),
129
+ ]),
130
+ }),
131
+ });
132
+
133
+ const result = streamText({
134
+ model,
135
+ messages: [{ role: "user", content: "Set up Slack" }],
136
+ tools: toolsNoExecute,
137
+ stopWhen: stepCountIs(5),
138
+ });
139
+ return collectUIMessages(
140
+ readUIMessageStream({ stream: result.toUIMessageStream() }),
141
+ );
142
+ }
143
+
144
+ describe("frontend tool Skip flow (sendAutomaticallyWhen fix)", () => {
145
+ it("without a tool-result, the message sequence is invalid — this is the bug we are fixing", async () => {
146
+ // Mirrors the elements flow on main: frontend tool has no execute inside
147
+ // streamText (it's run client-side by useToolInvocations). If nothing
148
+ // patches a tool-result onto the message, sending a follow-up user
149
+ // message produces an invalid sequence.
150
+ const toolCallId = "call_unresolved";
151
+ const messages = await streamToolCallOnly(toolCallId);
152
+
153
+ const assistant = messages.find((m) => m.role === "assistant")!;
154
+ const toolParts = (assistant.parts as UIMessagePart<never, never>[]).filter(
155
+ (p) => isToolUIPart(p),
156
+ );
157
+ expect(toolParts).toHaveLength(1);
158
+ expect((toolParts[0] as unknown as { state: string }).state).toBe(
159
+ "input-available",
160
+ );
161
+
162
+ const follow: UIMessage[] = [
163
+ ...messages,
164
+ {
165
+ id: "u2",
166
+ role: "user",
167
+ parts: [{ type: "text", text: "skip" }],
168
+ } as unknown as UIMessage,
169
+ ];
170
+
171
+ // Also: `lastAssistantMessageIsCompleteWithToolCalls` must return `false`
172
+ // here — there is no tool result, so the runtime should NOT auto-resume.
173
+ expect(lastAssistantMessageIsCompleteWithToolCalls({ messages })).toBe(
174
+ false,
175
+ );
176
+
177
+ // And the resulting model-message sequence contains a bogus `role: "tool"`
178
+ // with empty content — the provider will reject this as an invalid tool
179
+ // message, surfacing to the user as the "needs role: assistant" error.
180
+ const modelMsgs = convertToModelMessages(follow);
181
+ const assistantIdx = modelMsgs.findIndex(
182
+ (m) =>
183
+ m.role === "assistant" &&
184
+ Array.isArray(m.content) &&
185
+ (m.content as Array<{ type: string }>).some(
186
+ (c) => c.type === "tool-call",
187
+ ),
188
+ );
189
+ expect(assistantIdx).toBeGreaterThanOrEqual(0);
190
+ expect(modelMsgs[assistantIdx + 1]?.role).toBe("tool");
191
+ expect(modelMsgs[assistantIdx + 1]?.content).toEqual([]);
192
+ });
193
+
194
+ it("once the tool-result is patched onto the message, sendAutomaticallyWhen fires and the sequence is valid", async () => {
195
+ // Simulates the full post-fix behaviour: `useToolInvocations` ran execute
196
+ // client-side and called `addToolResult`, which flips the tool part to
197
+ // `output-available`. With the result in place:
198
+ // - `lastAssistantMessageIsCompleteWithToolCalls` returns true, so the
199
+ // runtime re-issues the model turn (this is the 1-line fix).
200
+ // - `convertToModelMessages` produces a real `role: "tool"` message
201
+ // with the result, which the provider accepts.
202
+ const toolCallId = "call_resolved";
203
+ const rawMessages = await streamToolCallOnly(toolCallId);
204
+
205
+ // Patch in the tool-output-available state — this is the shape
206
+ // `chatHelpers.addToolResult` produces under the hood.
207
+ const patched: UIMessage[] = rawMessages.map((m) => {
208
+ if (m.role !== "assistant") return m;
209
+ return {
210
+ ...m,
211
+ parts: (m.parts as Array<Record<string, unknown>>).map((p) =>
212
+ isToolUIPart(p as UIMessagePart<never, never>)
213
+ ? {
214
+ ...p,
215
+ state: "output-available",
216
+ output: { ok: true, cancelled: true },
217
+ }
218
+ : p,
219
+ ),
220
+ } as UIMessage;
221
+ });
222
+
223
+ const toolPart = (
224
+ (
225
+ patched.find((m) => m.role === "assistant")!.parts as UIMessagePart<
226
+ never,
227
+ never
228
+ >[]
229
+ ).filter((p) => isToolUIPart(p))[0] as unknown as { state: string }
230
+ ).state;
231
+ expect(toolPart).toBe("output-available");
232
+
233
+ // Pre-condition for the fix: the runtime auto-resumes the turn.
234
+ expect(
235
+ lastAssistantMessageIsCompleteWithToolCalls({ messages: patched }),
236
+ ).toBe(true);
237
+
238
+ // And the sequence handed to the model is well-formed (assistant
239
+ // tool-call is followed by a real role:"tool" message with a result).
240
+ const modelMsgs = convertToModelMessages(patched);
241
+ const assistantIdx = modelMsgs.findIndex(
242
+ (m) =>
243
+ m.role === "assistant" &&
244
+ Array.isArray(m.content) &&
245
+ (m.content as Array<{ type: string }>).some(
246
+ (c) => c.type === "tool-call",
247
+ ),
248
+ );
249
+ const next = modelMsgs[assistantIdx + 1];
250
+ expect(next?.role).toBe("tool");
251
+ expect(Array.isArray(next?.content) && next.content.length).toBeGreaterThan(
252
+ 0,
253
+ );
254
+ const toolResult = (
255
+ next?.content as Array<{ type: string; output?: { type?: string } }>
256
+ )[0];
257
+ expect(toolResult?.type).toBe("tool-result");
258
+ });
259
+ });
package/src/lib/tools.ts CHANGED
@@ -156,6 +156,128 @@ export interface ApprovalHelpers {
156
156
  whitelistTool: (toolName: string) => void;
157
157
  }
158
158
 
159
+ /**
160
+ * Default head/tail split (bytes) when a tool result exceeds the cap. Head keeps
161
+ * early context (e.g. the preamble of a log query); tail keeps the most recent
162
+ * lines, which are usually the most relevant.
163
+ */
164
+ const BYTE_CAP_HEAD_FRACTION = 0.9;
165
+
166
+ /**
167
+ * Truncates a single string to maxBytes using a head + tail preserving strategy
168
+ * when it exceeds the cap. Returns the original string when under the cap.
169
+ */
170
+ export function truncateTextToByteCap(text: string, maxBytes: number): string {
171
+ if (maxBytes <= 0) return text;
172
+ const original = text;
173
+ // Work in UTF-8 bytes to match what OpenRouter counts.
174
+ const encoded = new TextEncoder().encode(original);
175
+ if (encoded.byteLength <= maxBytes) return original;
176
+
177
+ // Reserve room for the notice up-front so final output stays under maxBytes.
178
+ // Without this deduction, output would be head + notice + tail ≈ maxBytes
179
+ // + ~100 bytes, which silently overshoots the cap.
180
+ const notice = `\n\n[…tool output truncated from ${encoded.byteLength} bytes to ${maxBytes}; ask a narrower question to see more…]\n\n`;
181
+ const noticeBytes = new TextEncoder().encode(notice).byteLength;
182
+ const availableBytes = Math.max(0, maxBytes - noticeBytes);
183
+
184
+ const headBytes = Math.max(
185
+ 0,
186
+ Math.floor(availableBytes * BYTE_CAP_HEAD_FRACTION),
187
+ );
188
+ const tailBytes = Math.max(0, availableBytes - headBytes);
189
+ const decoder = new TextDecoder("utf-8", { fatal: false });
190
+ const head = decoder.decode(encoded.slice(0, headBytes));
191
+ const tail =
192
+ tailBytes > 0
193
+ ? decoder.decode(encoded.slice(encoded.byteLength - tailBytes))
194
+ : "";
195
+
196
+ return tail ? `${head}${notice}${tail}` : `${head}${notice}`;
197
+ }
198
+
199
+ /**
200
+ * Walks the shape returned by MCP/AI-SDK tool executors and truncates any
201
+ * over-sized text payload in place. Handles:
202
+ * - plain strings
203
+ * - { content: Array<{ type, text?, ... }>, isError? }
204
+ * Other shapes pass through untouched.
205
+ */
206
+ export function capToolResultBytes(result: unknown, maxBytes: number): unknown {
207
+ if (maxBytes <= 0) return result;
208
+
209
+ if (typeof result === "string") {
210
+ return truncateTextToByteCap(result, maxBytes);
211
+ }
212
+
213
+ if (result && typeof result === "object" && "content" in result) {
214
+ const r = result as {
215
+ content?: unknown;
216
+ isError?: boolean;
217
+ [k: string]: unknown;
218
+ };
219
+ if (Array.isArray(r.content)) {
220
+ const cappedContent = r.content.map((chunk) => {
221
+ if (
222
+ chunk &&
223
+ typeof chunk === "object" &&
224
+ (chunk as { type?: unknown }).type === "text" &&
225
+ typeof (chunk as { text?: unknown }).text === "string"
226
+ ) {
227
+ return {
228
+ ...(chunk as Record<string, unknown>),
229
+ text: truncateTextToByteCap(
230
+ (chunk as { text: string }).text,
231
+ maxBytes,
232
+ ),
233
+ };
234
+ }
235
+ return chunk;
236
+ });
237
+ return { ...r, content: cappedContent };
238
+ }
239
+ }
240
+
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * Wraps tools so that oversized results are truncated before they reach the
246
+ * conversation history. Tools whose result fits under the cap pass through
247
+ * untouched. Composes cleanly before or after wrapToolsWithApproval.
248
+ */
249
+ export function wrapToolsWithByteCap(
250
+ tools: ToolSet,
251
+ maxBytes: number | undefined,
252
+ ): ToolSet {
253
+ if (!maxBytes || maxBytes <= 0) {
254
+ return tools;
255
+ }
256
+
257
+ return Object.fromEntries(
258
+ Object.entries(tools).map(([name, tool]) => {
259
+ const originalExecute = tool.execute;
260
+ if (!originalExecute) {
261
+ return [name, tool];
262
+ }
263
+
264
+ return [
265
+ name,
266
+ {
267
+ ...tool,
268
+ execute: async (args: unknown, options?: ToolCallOptions) => {
269
+ const result = await originalExecute(
270
+ args,
271
+ options as Parameters<typeof originalExecute>[1],
272
+ );
273
+ return capToolResultBytes(result, maxBytes);
274
+ },
275
+ },
276
+ ];
277
+ }),
278
+ ) as ToolSet;
279
+ }
280
+
159
281
  /**
160
282
  * Wraps tools with approval logic based on the approval config.
161
283
  */
@@ -268,6 +268,13 @@ export interface ElementsConfig {
268
268
  */
269
269
  tools?: ToolsConfig;
270
270
 
271
+ /**
272
+ * Configuration for automatic conversation compaction when the estimated
273
+ * input size approaches the model's context window. Defaults are safe for
274
+ * all models; override per-page to tighten or disable.
275
+ */
276
+ contextCompaction?: ContextCompactionConfig;
277
+
271
278
  /**
272
279
  * Configuration for chat history and thread persistence.
273
280
  * When enabled, conversations are saved and the thread list is shown.
@@ -690,6 +697,54 @@ export interface ToolsConfig {
690
697
  * }
691
698
  */
692
699
  toolsToInclude?: ToolsFilter;
700
+
701
+ /**
702
+ * Maximum UTF-8 byte size for any single tool call's result. Results larger
703
+ * than this are truncated with a head+tail preserving strategy and a notice
704
+ * suffix before being added to the conversation. Prevents one greedy tool
705
+ * call (e.g. a wide log search) from filling the model's context window.
706
+ *
707
+ * Omit or set to 0 to disable.
708
+ *
709
+ * @example
710
+ * tools: {
711
+ * maxOutputBytes: 50_000, // ~12.5K tokens per tool call
712
+ * }
713
+ */
714
+ maxOutputBytes?: number;
715
+ }
716
+
717
+ /**
718
+ * Configuration for automatic compaction of older conversation turns when the
719
+ * estimated input size approaches the model's context window. Prevents
720
+ * upstream 400 "prompt too long" errors without losing the system prompt or
721
+ * the most recent turns.
722
+ */
723
+ export interface ContextCompactionConfig {
724
+ /**
725
+ * Hard ceiling (in tokens) for the outbound request. Overrides the built-in
726
+ * per-model map. Use this when you know your upstream provider enforces a
727
+ * smaller limit than the model's nominal maximum.
728
+ */
729
+ maxTokens?: number;
730
+
731
+ /**
732
+ * Fraction of the model ceiling at which compaction kicks in. Defaults to
733
+ * 0.7 — leaves room for the assistant's response and some slack for the
734
+ * chars/4 token heuristic's error.
735
+ */
736
+ compactAtFraction?: number;
737
+
738
+ /**
739
+ * Number of most-recent messages preserved verbatim during compaction.
740
+ * Defaults to 4 (covers the current turn + its immediate predecessor).
741
+ */
742
+ keepRecent?: number;
743
+
744
+ /**
745
+ * Disable compaction entirely. Useful in tests and for opting out per-page.
746
+ */
747
+ disabled?: boolean;
693
748
  }
694
749
 
695
750
  export interface WelcomeConfig {