d6de1c369a
* learnings: edit-in-place tmpfile (drop update_learnings tool)
learnings now follow the PR-summary file pattern: server seeds
`pullfrog-learnings.md` from `Repo.learnings` at startup, agent reads
it as part of context, may edit in place during the post-run reflection
turn, server reads back at end-of-run and PATCHes if changed.
motivation: `update_learnings` required the agent to pass the FULL
merged list as a string parameter — an output-token tax that grew
linearly with the learnings size, and a constant prompt-context
expansion since the contents were also inlined into the LEARNINGS
section. for repos with mature learnings the prompt was getting
visibly noisy in CI logs.
key changes:
- new `action/utils/learnings.ts` (seed/read helpers + 10k cap)
- `main.ts`: always seed; `persistLearnings` mirrors `persistSummary`
(success path, error path, exit-signal handler, idempotent guard,
byte-trim equality skip); forwards `model` for `LearningsRevision.model`
- `LEARNINGS` prompt section now contains only the file path + a
one-line "read it" instruction (no contents inlined)
- `update_learnings` MCP tool deleted; `action/mcp/learnings.ts` removed
- reflection turn (`buildLearningsReflectionPrompt`) reframed around
file editing with explicit prune-stale + leave-alone-if-nothing-new
framing
- `learningsStep` removed from every mode checklist — surface lives only
in the LEARNINGS prompt section + the reflection turn now
* learnings: harden seed step + refresh stale docs (review feedback)
Three findings from PR review, all implemented:
1. wrap learnings seed in best-effort try/catch (action/main.ts) —
the always-on seed block ran unconditionally and an unwrapped
`seedLearningsFile` (mkdir + writeFile) failure (ENOSPC, EACCES,
hostile sandbox) would unwind into the outer main() catch and flip
an otherwise-successful run to "❌ Pullfrog failed" before the
agent even started. asymmetric with `persistLearnings`'s own
best-effort contract. wrap and log on failure; downstream
consumers (`persistLearnings`, agent harnesses, `resolveInstructions`)
already handle `learningsFilePath: undefined` cleanly.
2. refresh wiki/main.md — `resolveInstructions` parameter renamed
from `learnings` to `learningsFilePath` in this PR; the data-flow
diagram and the resolver dependency table both still showed the
pre-refactor signature.
3. drop deleted `learnings.ts` from ROADMAP.md + RESEARCH.md
"missing MCP tool tests" bullets — `action/mcp/learnings.ts` was
removed in this PR; the bullets are otherwise still accurate.
65 lines
2.7 KiB
TypeScript
65 lines
2.7 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` from `Repo.learnings` (or empty
|
|
* when the repo has none yet)
|
|
* 2. the agent reads the file at startup as part of its context, and may
|
|
* edit it in place at end-of-run when prompted by the reflection turn
|
|
* 3. main.ts reads the file back at end-of-run and PATCHes
|
|
* `/api/repo/[owner]/[repo]/learnings` if it changed (byte-trim equality
|
|
* against the seed determines change detection)
|
|
*
|
|
* 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).
|
|
*/
|
|
|
|
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. */
|
|
const MAX_LEARNINGS_LENGTH = 10_000;
|
|
|
|
export function learningsFilePath(tmpdir: string): string {
|
|
return join(tmpdir, LEARNINGS_FILE_NAME);
|
|
}
|
|
|
|
/** seed the learnings file with the repo's current learnings, or an empty
|
|
* file when the repo has none yet. returns the absolute path. */
|
|
export async function seedLearningsFile(params: {
|
|
tmpdir: string;
|
|
current: string | null;
|
|
}): Promise<string> {
|
|
const path = learningsFilePath(params.tmpdir);
|
|
await mkdir(dirname(path), { recursive: true });
|
|
// empty file when no learnings exist yet — the agent reads it, sees
|
|
// nothing, and the LEARNINGS prompt section explains what the file is for.
|
|
// a header comment would risk being persisted as part of the first real
|
|
// edit, polluting the DB row with placeholder text.
|
|
await writeFile(path, params.current ?? "", "utf8");
|
|
return path;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length > MAX_LEARNINGS_LENGTH) return trimmed.slice(0, MAX_LEARNINGS_LENGTH);
|
|
return trimmed;
|
|
}
|