5518890b18
* audit learnings: reshape reflection prompt + bake tool quirks into descriptions (#619) Cross-repo audit of the 48 repos with non-null learnings turned up two recurring failure modes: 1. ~25-30% of bullets across the most-active repos are pullfrog-tool quirks ("shell timeout is in milliseconds", "git args must be a JSON array", "create_pull_request_review drops out-of-hunk comments", "push_branch may report timeout when push succeeded", "checkout_pr shallow.lock retries", "commit_id needs full 40-char SHA"). These are universal across repos and should live in tool descriptions, not be rediscovered and stored 48 times. Tool descriptions now surface them. 2. Bullets are routinely 200-1000 chars (paragraph-length), and 12 of 48 repos are at the 10k cap. The reflection prompt now: caps bullets at ~240 chars (one specific fact), bans PR/review/commit/date-anchored facts that decay within weeks, bans tool-quirk learnings, and tells the agent that cap pressure means compress+prune existing bullets, not skip new findings. Co-authored-by: Cursor <cursoragent@cursor.com> * learnings: add server-generated TOC, fixed section taxonomy, raise cap to 100k (#707) Cap goes 10k → 100k. Reads stay bounded because the seeded file now opens with a server-generated table of contents listing every `## ` section's line range — agents read the TOC, then `read_file offset/limit` just the sections relevant to the current task instead of slurping the whole file. ## Section taxonomy (fixed) `## Build & test`, `## CI`, `## Conventions`, `## Architecture`, `## Gotchas`. Free-form `### ` sub-headings inside a section are fine. Pre-taxonomy free-text rows get wrapped in a `## Legacy` carve-out on first seed so they remain visible while the agent gradually re-curates them during reflection turns. ## Storage shape unchanged `Repo.learnings` still holds raw markdown (no schema migration). The TOC is a pure read-side affordance: prepended at seed time, stripped from the agent-edited file before persist. Markers `<!-- pullfrog-learnings-toc:* -->` delimit the strip region. Agent edits inside the markers are discarded. ## Round-trip semantics `seedLearningsFile` now returns `{ path, canonicalSeed }` where `canonicalSeed` is the post-TOC body — same shape `readLearningsFile` returns at end-of-run, so `persistLearnings` byte-compares them directly to skip the no-op PATCH. Empty-repo first runs end up with the section scaffold both as seed and as read-back, so untouched runs still short-circuit cleanly. ## Reflection prompt Adds explicit section-placement guidance (place each new bullet under the most relevant `## `; do NOT add new top-level headings; do NOT edit anything between the TOC markers). Carries forward the bullet hygiene from the previous commit: ≤240 chars per bullet, no pullfrog-tool quirks (those belong in tool descriptions), no PR/review/commit/date references. The "near cap" framing is replaced with "compress and prune within a section when it grows noisy" since the cap pressure that drove cramming is gone. Co-authored-by: Cursor <cursoragent@cursor.com> * anneal round 1: line-anchored taxonomy detect, partial-merge, line-boundary truncation, scaffold-empty UI Multi-lens review of the TOC + taxonomy diff surfaced a cluster of correctness and operational bugs. Fixes: - `hasAnyTaxonomyHeading` used `String.includes("## X")` which false-positives on `### X` (the `## ` substring sits inside `### `), prose containing `## CI`, fenced code documenting markdown, etc. Replaced with a line-anchored predicate that reuses `parseHeadings` so detection and TOC construction stay consistent. - The "any heading present → pass through verbatim" rule meant a body with one taxonomy heading would seed without the other four. Worse, requiring all five would flip a body back into Legacy when the agent legitimately pruned a section to empty. New `partial` kind: keep existing content in place, append missing sections in canonical order so the agent always has the full scaffold without losing pruning intent. - `stripLearningsToc` collapsed `\n{3,}` globally; `canonicalSeed` doesn't, so an untouched body with intentional triple-newline spacing would compare unequal and burn a spurious LearningsRevision row each run. Drop the global collapse — only the leading newlines that the strip itself introduces are normalized. - 100k truncation via `slice(0, 100_000)` could cut mid-line, breaking `parseHeadings` (whole-line `^## `) on the next seed and flipping a cut body back into Legacy. New `truncateAtLineBoundary` cuts at the last newline before the cap. - `LearningsSection.tsx` rendered a scaffold-only body as "has learnings" instead of the empty placeholder. Added a `hasOnlyEmptyScaffold` guard so the console behaves the same as pre-PR for the empty case. - Seed log line distinguishes `kind=structured/partial/legacy-wrapped/ empty` instead of `existing=yes/no`, so operators can spot legacy migration activity in logs. - New tests cover: substring false-positive (`### Build & test`, in-prose mentions), partial-taxonomy merge (no Legacy wrap), full-taxonomy structured pass-through, last-newline truncation, triple-newline preservation. Deferred (documented in PR body): deploy-ordering footgun (action before API), rollback for rows >10k, Gemini sanitizer dropping `description` on `anyOf` branches, reflection-on-failed-runs. Co-authored-by: Cursor <cursoragent@cursor.com> * anneal r2: hard-truncate fallback when line boundary discards >4k Round-2 review caught a regression in `truncateAtLineBoundary`: when the only newline within the first 100k chars sits near the start (e.g. one heading + 100k+ char single line — pathological pasted log dumps), the line-boundary cut discards almost all of the body. losing one partial line is preferable to losing kilobytes; threshold the fallback at 4k. Co-authored-by: Cursor <cursoragent@cursor.com> * move TOC out of file: prompt-side rendering, server-parsed headings drops the in-file TOC + fixed taxonomy in favor of: - file on disk = verbatim Repo.learnings (no markers, no scaffold) - server parses headings (mdast-util-from-markdown) at run-context time and returns them as RepoSettings.learningsHeadings - action renders heading TOC into the LEARNINGS prompt section as parenthesized line ranges like `Build & test (L1-L42)` with hierarchy via 2-space indent off the shallowest depth - reflection prompt teaches agent-curated structure with a soft 300-line per-section cap and explicit guidance to restructure flat legacy lists cuts 8 helpers (ensureSections, stripLearningsToc, assembleFile, buildTocBlock, parseHeadings, buildSectionScaffold, hasAnyTaxonomyHeading, LEARNINGS_SECTIONS) and the canonicalSeed round-trip dance. action seedLearningsFile is now { path } only; main.ts byte-compares the trimmed read-back against (current ?? "").trim() to gate the persist PATCH. truncateAtLineBoundary kept for safety. new tests: - test/learningsToc.test.ts (11 parser cases incl. fenced-code, blockquote, arbitrary h1-h6 nesting, startLine-points-at-heading invariant) - action/utils/learningsTocRender.test.ts (7 renderer cases) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
91 lines
4.2 KiB
TypeScript
91 lines
4.2 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
/**
|
|
* Repo-level learnings — operational facts about a repo (setup steps, test
|
|
* commands, conventions, gotchas) that accumulate across agent runs and feed
|
|
* back into future runs as durable context. Modeled on the PR-summary tmpfile
|
|
* pattern (see action/utils/prSummary.ts):
|
|
*
|
|
* 1. server seeds `pullfrog-learnings.md` with the verbatim body of
|
|
* `Repo.learnings` (or empty for fresh repos), and parses headings
|
|
* server-side (`utils/learningsToc.ts`) — the parsed TOC is rendered
|
|
* into the LEARNINGS prompt section, not into the file
|
|
* 2. the agent reads the TOC in the prompt and uses listed line ranges
|
|
* to read just the sections relevant to the current task — file can
|
|
* grow large, but only targeted ranges hit the agent's context
|
|
* 3. agent edits the file in place at end-of-run during the reflection
|
|
* turn (see action/agents/postRun.ts buildLearningsReflectionPrompt)
|
|
* 4. main.ts reads the file back at end-of-run and PATCHes
|
|
* `/api/repo/[owner]/[repo]/learnings` if the body changed
|
|
*
|
|
* Edit-in-place avoids stuffing the entire learnings list into both the
|
|
* prompt context and an `update_learnings` MCP tool call (which previously
|
|
* required passing the FULL merged list as a string parameter — an
|
|
* output-token tax that grew linearly with the learnings size).
|
|
*
|
|
* Section structure is agent-curated. The reflection prompt teaches
|
|
* hierarchy + a soft 300-line-per-section cap to keep TOC ranges
|
|
* agent-targetable on long-lived repos; there is no fixed taxonomy.
|
|
*/
|
|
|
|
export const LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
|
|
|
|
/** server-side cap mirrors `MAX_LEARNINGS_LENGTH` in
|
|
* `app/api/repo/[owner]/[repo]/learnings/route.ts`. truncating client-side
|
|
* keeps the PATCH from being rejected with a 400. raised from 10k → 100k
|
|
* once the TOC affordance landed: with line-range reads via the
|
|
* server-parsed TOC the agent doesn't ingest the whole file, so the cap
|
|
* can grow to whatever curation discipline allows. 100k holds ~400-500
|
|
* short bullets. */
|
|
const MAX_LEARNINGS_LENGTH = 100_000;
|
|
|
|
export function learningsFilePath(tmpdir: string): string {
|
|
return join(tmpdir, LEARNINGS_FILE_NAME);
|
|
}
|
|
|
|
/** seed the rolling learnings tmpfile with the verbatim DB body (or empty
|
|
* string for fresh repos). returns the absolute path. the parsed TOC is
|
|
* carried separately via `RepoSettings.learningsHeadings` and rendered
|
|
* into the prompt by `resolveInstructions`, so the file on disk is just
|
|
* the body — no markers, no scaffold, no in-file TOC. */
|
|
export async function seedLearningsFile(params: {
|
|
tmpdir: string;
|
|
current: string | null;
|
|
}): Promise<string> {
|
|
const path = learningsFilePath(params.tmpdir);
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, params.current ?? "", "utf8");
|
|
return path;
|
|
}
|
|
|
|
/** truncate at the last newline boundary before `cap` so we don't leave
|
|
* a partial line at the tail (a half-truncated `## Headi` confuses the
|
|
* server's next-seed TOC parse and shrinks visible structure). falls
|
|
* back to a hard `slice` when the line boundary would discard a large
|
|
* run of content — i.e. when the tail of `head` is one giant line (rare:
|
|
* minified pastes, fenced log dumps). losing a partial last line is
|
|
* preferable to losing kilobytes of body. */
|
|
const TRUNCATION_LINE_BOUNDARY_TOLERANCE = 4096;
|
|
function truncateAtLineBoundary(body: string, cap: number): string {
|
|
if (body.length <= cap) return body;
|
|
const head = body.slice(0, cap);
|
|
const lastNewline = head.lastIndexOf("\n");
|
|
if (lastNewline <= 0) return head;
|
|
if (cap - lastNewline > TRUNCATION_LINE_BOUNDARY_TOLERANCE) return head;
|
|
return head.slice(0, lastNewline);
|
|
}
|
|
|
|
/** read the agent-edited learnings file. returns null when the file is
|
|
* missing or unreadable (treated as "no change"). caps content at the
|
|
* server's max length to avoid a 400 round-trip. */
|
|
export async function readLearningsFile(path: string): Promise<string | null> {
|
|
let raw: string;
|
|
try {
|
|
raw = await readFile(path, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
return truncateAtLineBoundary(raw.trim(), MAX_LEARNINGS_LENGTH);
|
|
}
|