Skip to content
Open
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
21 changes: 21 additions & 0 deletions packages/docs/plans/2026-07-29_glitter-style-card-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,27 @@ verifiable evidence`, an earlier-attempt-has-evidence case still completes, and
Next: land + deploy, then re-run Phase 1 (cached good chunks, incl.
`2024-10-0000`, reuse for free; `2023-03-0000` now sanitizes to its verifiable
subset).
- 2026-08-04 (sanitize merged + deployed → synthesis truncation): PR #1988 merged
(`417964dbe`, image `2.0.0-7956`, verified in-pod). A pinned dry run then got
cleanly through **all extraction** (zero citation errors — sanitize works) and
died in **synthesis** with `LengthFinishReasonError` (finish_reason=length →
unparseable). LLM traces (`llm-archive`) confirm: `gpt-5.6-sol` synthesis runs
**80–86k input**, 4.7–7.6k output, `reasoning_effort: "medium"` → hidden
reasoning + output crossed the 15k `max_completion_tokens` cap. Also observed
`glitter-style-synthesis-repair` calls (synthesis output failing
`finalizeStyleSynthesis` → repair loops). Same snapshot + code passed 2026-07-29
⇒ **gpt-5.6-sol model drift** since the cache was populated. Operator chose a
targeted truncation fix + one probe run.
- 2026-08-04 (truncation fix): `fix/glitter-synthesis-truncation` raises
`SYNTHESIS_MAX_OUTPUT_TOKENS` 15k→28k (comfortable headroom over the observed
~15k) and retries a synthesis call **once** at a 40k ceiling on
`LengthFinishReasonError` (imported from `openai/core/error`). The preflight
estimator + budget authorize now use the 40k ceiling as the honest worst case.
Test: a synthesis call that truncates at 28k succeeds on the 40k retry and the
Comment on lines +269 to +274

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the required August session summary

This adds the 2026-08-04 implementation work to the plan, but the document still proceeds directly into the old ## Session Log — 2026-07-29 section and never records an August Done / Remaining / Caveats summary. Append the required 2026-08-04 session log so the unfinished probe and handoff state are captured in the repository's mandated format.

AGENTS.md reference: AGENTS.md:L125-L142

Useful? React with 👍 / 👎.

run completes. This is a **probe**: if synthesis then converges within finalize
validation, Phase 1 completes; if it still fails the exact 20/30/18 contract
(the repair-loop symptom), that confirms a deeper `sol` re-tune is needed
(contract counts can't be sanitized by dropping).

## Session Log — 2026-07-29

Expand Down
2 changes: 2 additions & 0 deletions packages/pr-fleet-controller/packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"dev": "vite",
"typecheck": "PATH=node_modules/@typescript/native/bin:$PATH tsc --noEmit",
"test": "bun test",
"test:ci": "bun ../../../../scripts/run-ci-test.ts",
"test:report": "CI_TEST_COVERAGE=1 bun ../../../../scripts/run-ci-test.ts",
"lint": "bunx eslint --cache ."
},
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
StyleSynthesisSchema,
} from "./glitter-context-refresh-style-schemas.ts";
import * as glitterOpenai from "./glitter-context-refresh-openai.ts";
import { LengthFinishReasonError } from "openai/core/error";
import {
sanitizeChunkSummary,
validateChunkSummary,
Expand Down Expand Up @@ -310,10 +311,17 @@ const mockUsage = { prompt_tokens: 100, completion_tokens: 50 };
let chunkResponder: (call: number) => unknown = () => goodChunkSummary;
let chunkCallCount = 0;
let recordedSeeds: number[] = [];
// Synthesis calls whose max_completion_tokens is below this threshold reject with
// a length-truncation error (0 = never truncate).
let synthesisTruncateBelowTokens = 0;
let recordedSynthesisMaxTokens: number[] = [];

await mock.module("./glitter-context-refresh-openai.ts", () => ({
...glitterOpenai,
parseGlitterCompletion: (callSite: string, params: { seed: number }) => {
parseGlitterCompletion: (
callSite: string,
params: { seed: number; max_completion_tokens: number },
) => {
if (callSite.startsWith("glitter-style-chunk")) {
recordedSeeds.push(params.seed);
const parsed = chunkResponder(chunkCallCount);
Expand All @@ -323,6 +331,10 @@ await mock.module("./glitter-context-refresh-openai.ts", () => ({
usage: mockUsage,
});
}
recordedSynthesisMaxTokens.push(params.max_completion_tokens);
if (params.max_completion_tokens < synthesisTruncateBelowTokens) {
return Promise.reject(new LengthFinishReasonError());
}
return Promise.resolve({
choices: [{ message: { parsed: synthesis, content: null } }],
usage: mockUsage,
Expand Down Expand Up @@ -414,6 +426,24 @@ describe("Glitter extraction repair loop", () => {
});
});

describe("Glitter synthesis truncation retry", () => {
test("retries synthesis at a higher token cap on a length truncation", async () => {
recordedSynthesisMaxTokens = [];
// The base 28k call rejects with a length truncation; only the 40k retry
// ceiling succeeds.
synthesisTruncateBelowTokens = 40_000;
try {
const result = await generateWithStubbedModel(() => goodChunkSummary);
expect(result.schemaVersion).toBe(2);
// Base call at the 28k cap truncated, then the run retried at the ceiling.
expect(recordedSynthesisMaxTokens).toContain(28_000);
expect(recordedSynthesisMaxTokens).toContain(40_000);
} finally {
synthesisTruncateBelowTokens = 0;
}
});
});

describe("sanitizeChunkSummary", () => {
const chunk = {
key: "2026-07-0000",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { zodResponseFormat } from "openai/helpers/zod";
import { LengthFinishReasonError } from "openai/core/error";
import { z } from "zod/v4";
import {
type StyleCard,
Expand Down Expand Up @@ -45,7 +46,14 @@ import {
const EXTRACTION_MODEL = "gpt-5.6-luna";
const SYNTHESIS_MODEL = "gpt-5.6-sol";
const EXTRACTION_MAX_OUTPUT_TOKENS = 2000;
const SYNTHESIS_MAX_OUTPUT_TOKENS = 15_000;
// gpt-5.6-sol is a reasoning model at `reasoning_effort: "medium"`, so its
// hidden reasoning tokens share `max_completion_tokens` with the (large) style
// synthesis output. Observed live: reasoning + output crossed the former 15k cap
// and truncated (finish_reason=length → unparseable → LengthFinishReasonError).
// 28k gives comfortable headroom over the observed ~15k usage; if a call still
// truncates, it is retried once at the ceiling below.
const SYNTHESIS_MAX_OUTPUT_TOKENS = 28_000;
const SYNTHESIS_TRUNCATION_RETRY_MAX_OUTPUT_TOKENS = 40_000;
const DETERMINISTIC_SEED = 0;

// gpt-5.6 sometimes emits an observation citing a message ID outside its
Expand Down Expand Up @@ -379,10 +387,24 @@ async function runSynthesis(input: {
estimatedCallCostUsd({
model: SYNTHESIS_MODEL,
inputTokenUpperBound: inputTokenUpperBound(JSON.stringify(params)),
outputTokenUpperBound: SYNTHESIS_MAX_OUTPUT_TOKENS,
outputTokenUpperBound: SYNTHESIS_TRUNCATION_RETRY_MAX_OUTPUT_TOKENS,
}),
);
const completion = await parseGlitterCompletion(callSite, params);
// Reasoning + output can still truncate at the base cap; retry once with a
// higher `max_completion_tokens` on a length finish before giving up.
const completion = await (async () => {
try {
return await parseGlitterCompletion(callSite, params);
} catch (error: unknown) {
if (!(error instanceof LengthFinishReasonError)) {
throw error;
}
return await parseGlitterCompletion(callSite, {
...params,
max_completion_tokens: SYNTHESIS_TRUNCATION_RETRY_MAX_OUTPUT_TOKENS,
Comment on lines +395 to +404

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Account for the truncated attempt before retrying

When the 28k request ends with LengthFinishReasonError, that completed API request has already consumed billable input/reasoning/output tokens, but the catch immediately issues the 40k request without persisting or recording the first request's usage. If the retry succeeds, the artifact and GenerationBudget receive only the retry's usage; if it also fails, no spend receipt is created and the workflow's second activity attempt can pay for both calls again. The preflight estimate and authorization likewise reserve only one 40k call rather than the 28k call plus its retry, so the advertised hard run budget and reported actual spend can be exceeded precisely in the truncation scenario this change handles.

Useful? React with 👍 / 👎.

});
Comment on lines +402 to +405

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the retry ceiling in the artifact identity

When the 28k call truncates, this stores the 40k completion under a request hash that declares only maxCompletionTokens: 28_000. Consequently the reported artifact identity does not describe the request that produced it, and changing the retry ceiling later will continue reusing this stale artifact because the cache key will remain unchanged. Include the fallback policy/ceiling in the hashed request, or cache the retry as a distinct request.

Useful? React with 👍 / 👎.

}
})();
const message = completion.choices[0]?.message;
return glitterCompletionArtifact({
model: SYNTHESIS_MODEL,
Expand Down Expand Up @@ -431,18 +453,19 @@ export function estimateStyleGenerationCost(input: {
const synthesisInputUpperBound =
inputTokenUpperBound(synthesisBase) +
chunks.length * EXTRACTION_MAX_OUTPUT_TOKENS;
// Worst-case output is the truncation-retry ceiling, not the base cap.
const synthesisInitialCall = estimatedCallCostUsd({
model: SYNTHESIS_MODEL,
inputTokenUpperBound: synthesisInputUpperBound,
outputTokenUpperBound: SYNTHESIS_MAX_OUTPUT_TOKENS,
outputTokenUpperBound: SYNTHESIS_TRUNCATION_RETRY_MAX_OUTPUT_TOKENS,
});
// A synthesis repair likewise serializes the prior synthesis (bounded by the
// output cap) plus the error into its request.
// output ceiling) plus the error into its request.
const synthesisRepairCall = estimatedCallCostUsd({
model: SYNTHESIS_MODEL,
inputTokenUpperBound:
synthesisInputUpperBound + SYNTHESIS_MAX_OUTPUT_TOKENS,
outputTokenUpperBound: SYNTHESIS_MAX_OUTPUT_TOKENS,
synthesisInputUpperBound + SYNTHESIS_TRUNCATION_RETRY_MAX_OUTPUT_TOKENS,
outputTokenUpperBound: SYNTHESIS_TRUNCATION_RETRY_MAX_OUTPUT_TOKENS,
});
return (
extractionCost +
Expand Down
5 changes: 5 additions & 0 deletions scripts/ci-test-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
"directory": "packages/pr-fleet-controller",
"steps": [{ "runner": "bun" }]
},
{
"package": "@shepherdjerred/pr-fleet-web",
"directory": "packages/pr-fleet-controller/packages/web",
"steps": [{ "runner": "bun" }]
},
{
"package": "cooklang-for-obsidian",
"directory": "packages/cooklang-for-obsidian",
Expand Down