From 12230999af779ed53ea1f89a3818c29e394c8643 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Fri, 8 May 2026 14:29:56 -0700 Subject: [PATCH] feat(context): make payload-summary char cap configurable per profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded 4000-char cap inside `summarizeJson` truncated task payloads regardless of the profile's `context_policy.max_prompt_chars`, which meant evidence-heavy review tasks (curated PR diffs, structured audits) lost most of their payload before reaching the model — even on profiles whose prompt budget had room to spare. Add an optional `max_payload_chars` field to `context_policy` and thread it through `buildPromptText` → `summarizeJson`. Default behavior is unchanged: profiles that don't set the field keep the 4000-char cap (now exported as `DEFAULT_MAX_PAYLOAD_CHARS`). Tests cover three cases: default (no override, 4000 cap applies), higher override (12000 lets a 8000-char payload through), and lower override (1000 cap truncates a 2000-char payload). --- src/context.ts | 15 ++++++++++---- src/runtime.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 7 +++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/context.ts b/src/context.ts index 55f4c39..b9e3655 100644 --- a/src/context.ts +++ b/src/context.ts @@ -7,7 +7,14 @@ import type { Database } from "bun:sqlite"; import { getWorkflowById } from "./db"; import type { AdapterConfig, BundleArtifactRecord, Profile, ReplayGrade, TaskRecord, RuntimeConfig } from "./types"; -const MAX_PAYLOAD_CHARS = 4000; +/** + * Default cap on characters used when summarizing the task payload for the + * prompt. Profiles can override via `context_policy.max_payload_chars` when + * they need to ship larger evidence payloads (e.g., curated PR diffs for + * review tasks). Kept conservative to preserve prior behavior for profiles + * that do not opt in. + */ +export const DEFAULT_MAX_PAYLOAD_CHARS = 4000; function sanitizeRelativeArtifactPath(value: string): string { return value @@ -40,8 +47,8 @@ function truncateText(value: string, maxChars: number): string { return `${value.slice(0, Math.max(0, maxChars - 16))}\n[truncated]\n`; } -function summarizeJson(value: Record): string { - return truncateText(JSON.stringify(value, null, 2), MAX_PAYLOAD_CHARS); +function summarizeJson(value: Record, maxChars: number = DEFAULT_MAX_PAYLOAD_CHARS): string { + return truncateText(JSON.stringify(value, null, 2), maxChars); } function buildBundleTaskSnapshot(task: TaskRecord): Record { @@ -200,7 +207,7 @@ function buildGoalLoopContextLines(config: RuntimeConfig, task: TaskRecord): str } function buildPromptText(config: RuntimeConfig, profile: Profile, task: TaskRecord): string { - const payloadBlock = summarizeJson(task.payload); + const payloadBlock = summarizeJson(task.payload, profile.context_policy.max_payload_chars); const phaseSkills = Array.isArray(task.payload.phase_skills) ? task.payload.phase_skills.filter((value): value is string => typeof value === "string" && value.trim().length > 0) : []; diff --git a/src/runtime.test.ts b/src/runtime.test.ts index 69cb432..a4719c5 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -792,6 +792,56 @@ test("goal loop context no longer inlines oversized artifact content", async () expect(context).not.toContain("tail"); }); +test("payload truncates to default 4000 chars when context_policy does not override max_payload_chars", () => { + const config = testConfig(); + const longString = "x".repeat(8000); + const profile = testProfile(); + const context = assembleContext(config, profile, testTaskRecord({ + payload: { evidence: longString } + })); + + expect(context).toContain("[truncated]"); + // Default cap is 4000; the payload block should not contain the full 8000-char value. + expect(context).not.toContain("x".repeat(8000)); +}); + +test("payload respects context_policy.max_payload_chars override when profile sets it higher", () => { + const config = testConfig(); + const longString = "y".repeat(8000); + const profile = testProfile({ + context_policy: { + include_recent_task_memory: true, + max_prompt_chars: 32000, + max_payload_chars: 12000 + } + }); + const context = assembleContext(config, profile, testTaskRecord({ + payload: { evidence: longString } + })); + + // With a 12000-char payload cap, the full 8000-char value fits without truncation. + expect(context).toContain("y".repeat(8000)); + expect(context).not.toContain("[truncated]"); +}); + +test("payload respects context_policy.max_payload_chars override when profile sets it lower", () => { + const config = testConfig(); + const longString = "z".repeat(2000); + const profile = testProfile({ + context_policy: { + include_recent_task_memory: true, + max_prompt_chars: 16000, + max_payload_chars: 1000 + } + }); + const context = assembleContext(config, profile, testTaskRecord({ + payload: { evidence: longString } + })); + + expect(context).toContain("[truncated]"); + expect(context).not.toContain("z".repeat(2000)); +}); + test("artifact-writing context requires declared managed artifact paths in artifact_paths", () => { const config = testConfig(); const context = assembleContext(config, testProfile(), testTaskRecord({ diff --git a/src/types.ts b/src/types.ts index 7b8785d..ab47176 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,13 @@ export type Profile = { context_policy: { include_recent_task_memory: boolean; max_prompt_chars: number; + /** + * Maximum characters used when summarizing the task payload for the prompt. + * Optional; defaults to the runtime-internal `DEFAULT_MAX_PAYLOAD_CHARS` + * (currently 4000) when unset. Profiles that need to ship larger evidence + * payloads (curated diffs, structured review excerpts) can raise this. + */ + max_payload_chars?: number; }; result_schema: Record; integration_policies: Record;