Skip to content

Commit d75d5c4

Browse files
Cap prompt variable serialization for context budget
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent a08bab4 commit d75d5c4

3 files changed

Lines changed: 148 additions & 2 deletions

File tree

currentState.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ HyperAgent exposes a TypeScript SDK for browser automation with three primary pa
132132
- Normalized CLI per-step failure rendering in `cli/index.ts` so action error messages are sanitized/truncated via `formatCliError` before display.
133133
- Added Anthropic structured-output regression coverage confirming multi-action calls enforce deterministic `tool_choice: { type: "any", disable_parallel_tool_use: true }`.
134134
- Hardened CDP frame-filter host/path matching to avoid query-text false positives (e.g. unrelated URLs containing `https://yahoo.com/pixel` in query params) while preserving legitimate host-suffix + path rule matching.
135+
- Hardened prompt token budgeting for variables by capping serialized variable entries per step-message build and emitting omitted-count context instead of unbounded variable dumps.
135136
- Expanded top-level package exports for key workflow/config types at `@hyperbrowser/agent`.
136137
- Removed stale script entry (`build-dom-tree-script`) and improved README usage docs.
137138
- Added canonical single-action debug writer helper (`writePerformDebug`) while preserving deprecated alias compatibility.

src/agent/messages/builder.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,90 @@ describe("buildAgentStepMessages", () => {
710710
expect(joined).toContain("[variable value unavailable]");
711711
});
712712

713+
it("caps variable entries for prompt budget and reports omitted count", async () => {
714+
const page = createFakePage("https://example.com/current", [
715+
"https://example.com/current",
716+
]);
717+
const variables = Array.from({ length: 35 }, (_, index) => ({
718+
key: `var_${index}`,
719+
value: `value_${index}`,
720+
description: `description ${index}`,
721+
}));
722+
723+
const messages = await buildAgentStepMessages(
724+
[{ role: "system", content: "system" }],
725+
[],
726+
"task",
727+
page,
728+
{
729+
elements: new Map(),
730+
domState: "dom",
731+
xpathMap: {},
732+
backendNodeMap: {},
733+
},
734+
undefined,
735+
variables
736+
);
737+
738+
const joined = messages
739+
.map((message) =>
740+
typeof message.content === "string" ? message.content : ""
741+
)
742+
.join("\n");
743+
744+
expect(joined).toContain("<<var_0>>");
745+
expect(joined).toContain("<<var_24>>");
746+
expect(joined).not.toContain("<<var_25>>");
747+
expect(joined).toContain("... 10 more variables omitted for context budget");
748+
});
749+
750+
it("falls back to empty variable section when array length getter traps", async () => {
751+
const page = createFakePage("https://example.com/current", [
752+
"https://example.com/current",
753+
]);
754+
const trappedVariables = new Proxy(
755+
[
756+
{
757+
key: "token",
758+
value: "abc",
759+
description: "desc",
760+
},
761+
],
762+
{
763+
get: (target, prop, receiver) => {
764+
if (prop === "length") {
765+
throw new Error("length trap");
766+
}
767+
return Reflect.get(target, prop, receiver);
768+
},
769+
}
770+
);
771+
772+
const messages = await buildAgentStepMessages(
773+
[{ role: "system", content: "system" }],
774+
[],
775+
"task",
776+
page,
777+
{
778+
elements: new Map(),
779+
domState: "dom",
780+
xpathMap: {},
781+
backendNodeMap: {},
782+
},
783+
undefined,
784+
trappedVariables as unknown as Parameters<typeof buildAgentStepMessages>[6]
785+
);
786+
787+
const joined = messages
788+
.map((message) =>
789+
typeof message.content === "string" ? message.content : ""
790+
)
791+
.join("\n");
792+
793+
expect(joined).toContain("=== Variables ===");
794+
expect(joined).toContain("No variables set");
795+
});
796+
713797
it("truncates oversized DOM state payloads", async () => {
714798
const page = createFakePage("https://example.com/current", [
715799
"https://example.com/current",

src/agent/messages/builder.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const MAX_DOM_STATE_CHARS = 50_000;
1313
const MAX_OPEN_TAB_ENTRIES = 20;
1414
const MAX_TAB_URL_CHARS = 500;
1515
const MAX_VARIABLE_KEY_CHARS = 120;
16+
const MAX_VARIABLE_ITEMS = 25;
1617
const MAX_OMITTED_STEP_SUMMARY_STEPS = 5;
1718
const MAX_OMITTED_STEP_SUMMARY_CHARS = 1_500;
1819
const MAX_OMITTED_STEP_ACTION_CHARS = 120;
@@ -186,6 +187,59 @@ function safeReadRecordField(source: unknown, field: string): unknown {
186187
}
187188
}
188189

190+
function safeArrayLength(value: unknown): number {
191+
if (!Array.isArray(value)) {
192+
return 0;
193+
}
194+
try {
195+
const length = value.length;
196+
if (!Number.isFinite(length) || length < 0) {
197+
return 0;
198+
}
199+
return Math.floor(length);
200+
} catch {
201+
return 0;
202+
}
203+
}
204+
205+
function safeReadArrayItem<T>(value: unknown, index: number): T | undefined {
206+
if (!Array.isArray(value)) {
207+
return undefined;
208+
}
209+
try {
210+
return value[index] as T;
211+
} catch {
212+
return undefined;
213+
}
214+
}
215+
216+
function getBoundedVariables(variables: HyperVariable[]): {
217+
visibleVariables: HyperVariable[];
218+
omittedCount: number;
219+
} {
220+
const total = safeArrayLength(variables);
221+
if (total === 0) {
222+
return {
223+
visibleVariables: [],
224+
omittedCount: 0,
225+
};
226+
}
227+
228+
const visibleVariables: HyperVariable[] = [];
229+
const maxVisible = Math.min(total, MAX_VARIABLE_ITEMS);
230+
for (let index = 0; index < maxVisible; index += 1) {
231+
const variable = safeReadArrayItem<HyperVariable>(variables, index);
232+
if (typeof variable !== "undefined") {
233+
visibleVariables.push(variable);
234+
}
235+
}
236+
237+
return {
238+
visibleVariables,
239+
omittedCount: Math.max(0, total - visibleVariables.length),
240+
};
241+
}
242+
189243
function normalizeStepText(value: unknown, fallback: string): string {
190244
if (typeof value === "string") {
191245
return truncatePromptText(value);
@@ -197,11 +251,12 @@ function normalizeStepText(value: unknown, fallback: string): string {
197251
}
198252

199253
function buildVariablesContent(variables: HyperVariable[]): string {
200-
if (variables.length === 0) {
254+
const { visibleVariables, omittedCount } = getBoundedVariables(variables);
255+
if (visibleVariables.length === 0) {
201256
return "No variables set";
202257
}
203258

204-
return variables
259+
const variableLines = visibleVariables
205260
.map((variable, index) => {
206261
const key = normalizeVariableKey(safeReadVariableField(variable, "key"), index);
207262
const description = normalizeVariableDescription(
@@ -213,6 +268,12 @@ function buildVariablesContent(variables: HyperVariable[]): string {
213268
return `<<${key}>> - ${description} | current value: ${currentValue}`;
214269
})
215270
.join("\n");
271+
272+
if (omittedCount <= 0) {
273+
return variableLines;
274+
}
275+
const suffix = omittedCount === 1 ? "" : "s";
276+
return `${variableLines}\n... ${omittedCount} more variable${suffix} omitted for context budget`;
216277
}
217278

218279
function getStepPromptData(step: AgentStep): {

0 commit comments

Comments
 (0)