Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions autoresearch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$repo_root"

if [[ "$(node -p 'process.versions.node.split(".")[0]')" != "24" ]]; then
echo "autoresearch requires Node 24; found $(node --version)" >&2
exit 1
fi
for command_name in pnpm tee timeout; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "missing required command: $command_name" >&2
exit 1
fi
done
if [[ ! -d node_modules ]]; then
echo "dependencies are missing; run pnpm install --frozen-lockfile before autoresearch" >&2
exit 1
fi

entry_count="${T4_PERF_ENTRY_COUNT:-10000}"
event_count="${T4_PERF_EVENT_COUNT:-100000}"
repetitions="${T4_PERF_REPETITIONS:-7}"
warmups="${T4_PERF_WARMUPS:-1}"
timeout_seconds="${T4_AUTORESEARCH_TIMEOUT_SECONDS:-120}"
run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$"
output_dir="test-results/perf/autoresearch/$run_id"
mkdir -p "$output_dir"
log_path="$output_dir/run.log"

runner=()
cpu_affinity="unbound"
if command -v taskset >/dev/null 2>&1; then
requested_cpu="${T4_AUTORESEARCH_CPU:-}"
if [[ -z "$requested_cpu" ]]; then
allowed_affinity="$(taskset -pc "$$" 2>/dev/null || true)"
allowed_affinity="${allowed_affinity##*: }"
first_affinity_range="${allowed_affinity%%,*}"
requested_cpu="${first_affinity_range%%-*}"
fi
if [[ -n "$requested_cpu" ]] && taskset -c "$requested_cpu" true 2>/dev/null; then
cpu_affinity="$requested_cpu"
runner=(taskset -c "$cpu_affinity")
elif [[ -n "${T4_AUTORESEARCH_CPU:-}" ]]; then
echo "T4_AUTORESEARCH_CPU is outside this process's allowed CPU set" >&2
exit 1
fi
fi

export CI=1
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--expose-gc"
export T4_PERF_ENTRY_COUNT="$entry_count"
export T4_PERF_EVENT_COUNT="$event_count"
export T4_PERF_REPETITIONS="$repetitions"
export T4_PERF_WARMUPS="$warmups"
export T4_PERF_MACHINE_LABEL="${T4_PERF_MACHINE_LABEL:-linux-vps-x64}"
export T4_PERF_OUTPUT_DIR="$output_dir"
export T4_AUTORESEARCH_TIMEOUT_SECONDS_RESOLVED="$timeout_seconds"
export T4_AUTORESEARCH_CPU_AFFINITY_RESOLVED="$cpu_affinity"

{
"${runner[@]}" timeout "$timeout_seconds" pnpm exec vp test run packages/client/test/projection.test.ts
"${runner[@]}" timeout "$timeout_seconds" pnpm exec vp test run scripts/perf/core.test.ts
node scripts/perf/autoresearch-report.mjs "$output_dir/latest-core.json"
printf 'ASI timeout_seconds=%s\n' "$timeout_seconds"
printf 'ASI cpu_affinity=%s\n' "$cpu_affinity"
printf 'ASI log=%s\n' "$log_path"
} 2>&1 | tee "$log_path"
8 changes: 8 additions & 0 deletions docs/PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ settle before replacing the warm copy. The tail paint sample is taken after the
that copy and two more browser animation frames have elapsed. These numbers show where a slowdown
occurs; they are not a claim about a physical display's pixel response time.

Session-click phases have two boundaries. Metrics named `browser.session-dom-click-to-*` start at
the actual DOM click received by the application and are the primary product-performance signal.
The older `browser.session-click-to-*` metrics start immediately before Playwright asks Chromium to
click, so they also include Playwright's actionability checks, pointer movement, and input delivery.
The separate `browser.playwright-session-click-command-to-dom-click` metric makes that
automation-only time visible. Do not attribute it to application rendering or use it alone to judge
a product optimization.

`ui.mount-bounded-10k` stops after the original mount assertions, before the paint-only wait and
phase-file write. `ui.playwright-scenario-instrumented` records the full instrumented test duration
under a new name so paint instrumentation cannot silently change an existing comparison boundary.
Expand Down
14 changes: 14 additions & 0 deletions e2e/remote-app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,11 @@ test("@soak mounts the bounded tail of a 10k history on a phone viewport", async
};
requestAnimationFrame(inspect);
});
await rail.locator('[data-session-row="host-history/session-history"]').evaluate((element) => {
element.addEventListener("click", () => {
Object.assign(window, { __t4SessionDomClickAt: performance.now() });
}, { capture: true, once: true });
});
const sessionClickStartedAt = await page.evaluate(() => performance.now());
await rail.locator('[data-session-row="host-history/session-history"]').click();

Expand Down Expand Up @@ -587,6 +592,10 @@ test("@soak mounts the bounded tail of a 10k history on a phone viewport", async
) {
throw new Error("browser paint observer returned incomplete phases");
}
const sessionDomClickAt = await page.evaluate(() => (
window as typeof window & { __t4SessionDomClickAt?: number }
).__t4SessionDomClickAt);
if (sessionDomClickAt === undefined) throw new Error("session DOM click timestamp was not captured");
const phaseOutput = process.env.T4_PERF_PHASE_OUTPUT;
if (phaseOutput !== undefined) {
await writeFile(
Expand All @@ -595,6 +604,11 @@ test("@soak mounts the bounded tail of a 10k history on a phone viewport", async
mountDuration,
navigationDomContentLoaded: navigationTiming.domContentLoaded,
connectedAfterDomContentLoaded: connectedAt - navigationTiming.domContentLoaded,
sessionClickCommandToDomClick: sessionDomClickAt - sessionClickStartedAt,
sessionDomClickToTranscriptVisible: phases.transcriptVisibleAt - sessionDomClickAt,
sessionDomClickToTailAligned: phases.tailAlignedAt - sessionDomClickAt,
sessionDomClickToRealListVisible: phases.realListVisibleAt - sessionDomClickAt,
sessionDomClickToTailPainted: phases.tailPaintedAt - sessionDomClickAt,
sessionClickToTranscriptVisible: phases.transcriptVisibleAt - sessionClickStartedAt,
sessionClickToTailAligned: phases.tailAlignedAt - sessionClickStartedAt,
tailAlignedToRealListVisible: phases.realListVisibleAt - phases.tailAlignedAt,
Expand Down
28 changes: 20 additions & 8 deletions packages/client/src/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,15 @@ function touch(
options: Required<ProjectionOptions>,
): ProjectionSnapshot {
const existing = snapshot.sessions.get(sessionKey);
if (
existing !== undefined &&
snapshot.lru.at(-1) === sessionKey &&
snapshot.lru.length <= options.maxWarmSessions &&
snapshot.activeSessionKey !== undefined &&
snapshot.lru.includes(snapshot.activeSessionKey)
) {
return snapshot;
}
const lru = [...snapshot.lru.filter((item) => item !== sessionKey), sessionKey];
let sessions = snapshot.sessions;
if (existing === undefined)
Expand Down Expand Up @@ -619,15 +628,19 @@ function withSession(
sessionKey: string,
update: (session: SessionProjection) => SessionProjection,
options: Required<ProjectionOptions>,
arrivalOrdinal?: number,
): ProjectionSnapshot {
const warmed = touch(snapshot, sessionKey, options);
const current = warmed.sessions.get(sessionKey)!;
const updated = update(current);
if (updated === current) return warmed;
return Object.freeze({
...warmed,
sessions: mapWith(warmed.sessions, sessionKey, Object.freeze(updated)),
});
if (updated === current) {
if (arrivalOrdinal === undefined || warmed.arrivalOrdinal === arrivalOrdinal) return warmed;
return Object.freeze({ ...warmed, arrivalOrdinal });
}
const sessions = mapWith(warmed.sessions, sessionKey, Object.freeze(updated));
return arrivalOrdinal === undefined
? Object.freeze({ ...warmed, sessions })
: Object.freeze({ ...warmed, sessions, arrivalOrdinal });
}
function updateRoot(
snapshot: ProjectionSnapshot,
Expand Down Expand Up @@ -1103,10 +1116,9 @@ function applyProjectionInput(
});
},
config,
frame.type === "event" ? eventArrivalOrdinal : undefined,
);
return frame.type === "event"
? Object.freeze({ ...next, arrivalOrdinal: eventArrivalOrdinal })
: next;
return next;
}
case "gap": {
const sessionKey = key(String(frame.hostId), String(frame.sessionId));
Expand Down
127 changes: 90 additions & 37 deletions packages/client/src/transcript-retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,29 @@ const SECRET_KEY = /token|secret|password|credential|authorization|cookie|privat

/** Exact byte totals for immutable values created by this module. */
const retainedJsonByteCache = new WeakMap<object, number>();

const IMPORTANT_KEYS = [
"type",
"images",
"role",
"text",
"reasoning",
"tool",
"title",
"args",
"result",
"details",
"customType",
"customDetails",
"output",
"stdout",
"stderr",
"content",
] as const;
const UTF8_ENCODER = new TextEncoder();

const IMPORTANT_KEY_PRIORITY: Readonly<Record<string, number>> = Object.freeze(
Object.assign(Object.create(null) as Record<string, number>, {
type: 0,
images: 1,
role: 2,
text: 3,
reasoning: 4,
tool: 5,
title: 6,
args: 7,
result: 8,
details: 9,
customType: 10,
customDetails: 11,
output: 12,
stdout: 13,
stderr: 14,
content: 15,
}),
);
const IMPORTANT_KEY_COUNT = Math.max(...Object.values(IMPORTANT_KEY_PRIORITY)) + 1;

interface SanitizedNode {
readonly value: unknown;
Expand Down Expand Up @@ -97,14 +101,53 @@ export type RetainedTranscriptEvent = Extract<
{ kind: "snapshot" | "entry" | "event" | "agent.transcript" | "gap" }
>;

function jsonStringBytes(value: string): number {
let bytes = 2;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (
code === 0x22 ||
code === 0x5c ||
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d
) {
bytes += 2;
} else if (code < 0x20) {
bytes += 6;
} else if (code < 0x80) {
bytes += 1;
} else if (code < 0x800) {
bytes += 2;
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
index += 1;
} else {
bytes += 6;
}
} else {
bytes += code >= 0xdc00 && code <= 0xdfff ? 6 : 3;
}
}
return bytes;
}

export function retainedJsonBytes(value: unknown): number {
if (value !== null && (typeof value === "object" || typeof value === "function")) {
if (typeof value === "string") return jsonStringBytes(value);
if (typeof value === "number") return Number.isFinite(value) ? String(value).length : 4;
if (typeof value === "boolean") return value ? 4 : 5;
if (value === null) return 4;
if (typeof value === "object" || typeof value === "function") {
const cached = retainedJsonByteCache.get(value);
if (cached !== undefined) return cached;
}
const serialized = JSON.stringify(value);
if (serialized === undefined) return 0;
return new TextEncoder().encode(serialized).byteLength;
return UTF8_ENCODER.encode(serialized).byteLength;
}

function rememberRetainedJsonBytes<T>(value: T, bytes: number): T {
Expand All @@ -131,9 +174,8 @@ function boundedInteger(value: number | undefined, fallback: number, ceiling: nu
*/
export function retainedText(value: string, maxJsonBytes: number): string {
const budget = Math.max(2, Math.floor(maxJsonBytes));
if (retainedJsonBytes(value) <= budget && retainedJsonBytes(value) <= MAX_RETAINED_VALUE_STRING_BYTES + 2) {
return value;
}
const valueBytes = retainedJsonBytes(value);
if (valueBytes <= budget && valueBytes <= MAX_RETAINED_VALUE_STRING_BYTES + 2) return value;

const available = Math.max(0, Math.min(value.length, MAX_RETAINED_VALUE_STRING_BYTES));
let low = 0;
Expand All @@ -156,19 +198,26 @@ export function retainedText(value: string, maxJsonBytes: number): string {
}

function orderedEntries(value: Record<string, unknown>): Array<[string, unknown]> {
const priority = new Map<string, number>(IMPORTANT_KEYS.map((key, index) => [key, index]));
return Object.entries(value).sort(([left], [right]) => {
const leftPriority = priority.get(left) ?? IMPORTANT_KEYS.length;
const rightPriority = priority.get(right) ?? IMPORTANT_KEYS.length;
return leftPriority - rightPriority;
});
const entries = Object.entries(value);
for (let index = 1; index < entries.length; index += 1) {
const previous = IMPORTANT_KEY_PRIORITY[entries[index - 1]![0]] ?? IMPORTANT_KEY_COUNT;
const current = IMPORTANT_KEY_PRIORITY[entries[index]![0]] ?? IMPORTANT_KEY_COUNT;
if (previous <= current) continue;
entries.sort(([left], [right]) => {
const leftPriority = IMPORTANT_KEY_PRIORITY[left] ?? IMPORTANT_KEY_COUNT;
const rightPriority = IMPORTANT_KEY_PRIORITY[right] ?? IMPORTANT_KEY_COUNT;
return leftPriority - rightPriority;
});
break;
}
return entries;
}

function sanitizeNode(
value: unknown,
budget: number,
depth: number,
ancestors: ReadonlySet<object>,
ancestors: Set<object>,
imageBlock = false,
): SanitizedNode | undefined {
if (budget < 1 || value === undefined) return undefined;
Expand All @@ -190,19 +239,19 @@ function sanitizeNode(
return undefined;
}

const nextAncestors = new Set(ancestors);
nextAncestors.add(value);
ancestors.add(value);
if (Array.isArray(value)) {
let bytes = 2;
const output: unknown[] = [];
for (const item of value.slice(0, MAX_RETAINED_VALUE_ARRAY_ITEMS)) {
const separator = output.length === 0 ? 0 : 1;
const child = sanitizeNode(item, budget - bytes - separator, depth + 1, nextAncestors);
const child = sanitizeNode(item, budget - bytes - separator, depth + 1, ancestors);
if (child === undefined) continue;
output.push(child.value);
bytes += separator + child.bytes;
if (bytes >= budget) break;
}
ancestors.delete(value);
return bytes <= budget ? { value: Object.freeze(output), bytes } : undefined;
}

Expand Down Expand Up @@ -231,7 +280,7 @@ function sanitizeNode(
item,
budget - bytes - fixedBytes,
depth + 1,
nextAncestors,
ancestors,
sourceIsImageBlock,
);
if (child === undefined) continue;
Expand All @@ -240,6 +289,7 @@ function sanitizeNode(
bytes += fixedBytes + child.bytes;
if (bytes >= budget) break;
}
ancestors.delete(value);
return bytes <= budget ? { value: Object.freeze(output), bytes } : undefined;
}

Expand Down Expand Up @@ -378,7 +428,8 @@ export function appendRetainedValue<T>(
retainedCount -= 1;
}

const candidates = [...values.slice(firstPriorIndex), value];
const candidates = values.slice(firstPriorIndex) as T[];
candidates.push(value);
bytes += retainedArrayItemBytes(value) + (retainedCount === 0 ? 0 : 1);
retainedCount += 1;

Expand All @@ -390,7 +441,9 @@ export function appendRetainedValue<T>(
firstRetainedIndex += 1;
}

const retained = Object.freeze(candidates.slice(firstRetainedIndex));
const retained = Object.freeze(
firstRetainedIndex === 0 ? candidates : candidates.slice(firstRetainedIndex),
);
return rememberRetainedJsonBytes(retained, bytes);
}

Expand Down
Loading
Loading