From 348df40d4f355e1b1774dcb5c64c6eb05c8040ec Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Thu, 17 Sep 2026 13:19:59 +0300 Subject: [PATCH] feat(coding-agent): curate restored state, frame heartbeats, and add violation counters --- .../coding-agent/.changes/harness-hygiene.md | 3 ++ .../coding-agent/src/core/agent-session.ts | 4 +- packages/coding-agent/src/core/messages.ts | 3 +- .../src/core/refinement/refinement.ts | 36 ++++++++++++++-- .../coding-agent/src/package-manager-cli.ts | 29 +++++++++++-- packages/coding-agent/test/refinement.test.ts | 23 ++++++++++ .../test/session-command-messages.test.ts | 43 ++++++++++++++++++- .../agent-session-model-extension.test.ts | 2 +- .../test/suite/agent-session-queue.test.ts | 4 +- prime-agent-runtime/src/rlm/harness.py | 31 ++++++++++++- prime-agent-runtime/test/test_harness.py | 12 ++++++ 11 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 packages/coding-agent/.changes/harness-hygiene.md diff --git a/packages/coding-agent/.changes/harness-hygiene.md b/packages/coding-agent/.changes/harness-hygiene.md new file mode 100644 index 000000000..a9f2fb7f1 --- /dev/null +++ b/packages/coding-agent/.changes/harness-hygiene.md @@ -0,0 +1,3 @@ +- Changed restored-state notices and continuation messages to summarize kernel state and carry last verified state instead of dumping variable names. +- Changed heartbeat prompts to format as harness heartbeats with instructions and labels instead of plain user messages. +- Added violation counters on continual harness notes with escalation to automated checks on repeated violations. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ad0c8eab7..4b5a2acab 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -8616,7 +8616,7 @@ export class AgentSession { names === null ? "" : names.length > 0 - ? ` These names are still defined: ${names.join(", ")}.` + ? ` ${names.length} top-level name(s) remain defined in the kernel namespace.` : " You have not defined any names yet."; const prunedDetail = pruned && pruned.length > 0 @@ -8651,7 +8651,7 @@ export class AgentSession { const lines = ["[python-state-restored]", ""]; if (result.restored.length > 0) { lines.push( - `Your Python kernel state was revived from your previous session. These names are available again: ${result.restored.join(", ")}.`, + `Your Python kernel state was revived from your previous session (${result.restored.length} top-level name(s) restored from ${result.path}).`, ); } else { lines.push( diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 5c091e46e..0ecc5e63c 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -934,10 +934,11 @@ export interface HeartbeatPromptMessage extends CustomMessage; metadata?: Record; reason?: string; + violations?: number; } export interface RefinementProposal { @@ -146,6 +148,7 @@ Scope and persistence policy: - Use memory for declarative facts and preferences, skill for repeatable procedures exposed as Python calls, prompt for narrow behavioral policy addendums, and subagent for reusable delegation roles. - Create or update the smallest relevant component: repeated delegation roles should become subagent specs, repeated procedures should become skills, durable facts/preferences should become memories, and narrow behavioral policies should become prompt addendums. - When an edit is persisted, include metadata such as \`{"scope":"local"}\` or \`{"scope":"global"}\` when that helps future review understand the intended blast radius. +- Violation escalation: When an entry displays repeated violations (\`N violations\` in the overview), do NOT re-word the note again. Repeated violations MUST escalate to an automated guard, validator, or check (such as a runtime guard, test, or check script) instead of another prompt re-wording. Once an automated check or guard is established, prune or delete the redundant prompt note. Use the trajectory, current continual harness state, and prior refinement history. Prefer small evidence-backed edits. If prior refinements caused issues, rollback or @@ -342,12 +345,18 @@ export function loadHarnessState( for (const [id, rawEntry] of Object.entries(records)) { const entry = objectRecord(rawEntry); if (!entry) continue; + const violationsRaw = entry.violations; + const violations = + typeof violationsRaw === "number" && Number.isInteger(violationsRaw) && violationsRaw > 0 + ? violationsRaw + : undefined; state.entries[kind][id] = { ...(entry as unknown as HarnessEntry), scope: normalizeHarnessScope(entry.scope, scope), reference: objectRecord(entry.reference) ?? {}, arguments: objectRecord(entry.arguments) ?? {}, metadata: objectRecord(entry.metadata) ?? {}, + ...(violations !== undefined ? { violations } : {}), }; } } @@ -386,6 +395,19 @@ export function saveHarnessState(harnessStateDir: string, state: HarnessState): return statePath; } +export function recordHarnessViolation(state: HarnessState, id: string, count = 1): HarnessEntry | undefined { + const bareId = id.replace(/^(local|global):/, ""); + for (const kind of Object.keys(state.entries) as RefinementKind[]) { + const entry = state.entries[kind][bareId]; + if (entry) { + entry.violations = Math.max(0, (entry.violations ?? 0) + count); + entry.updated_at = new Date().toISOString(); + return entry; + } + } + return undefined; +} + export function getRefinementHistoryPath(harnessStateDir: string = getGlobalHarnessStateDir()): string { return join(harnessStateDir, REFINEMENT_HISTORY_FILE_NAME); } @@ -618,7 +640,9 @@ function compareOverviewEntries(a: HarnessEntry, b: HarnessEntry): number { } function overviewHeadline(entry: HarnessEntry): string { - return `- [${entry.scope ?? "global"}:${entry.id}] ${collapseWhitespace(`${entry.title} (${entry.path}, v${entry.version})`)}`; + const violationCount = typeof entry.violations === "number" && entry.violations > 0 ? entry.violations : 0; + const violationText = violationCount > 0 ? `, ${violationCount} violation${violationCount === 1 ? "" : "s"}` : ""; + return `- [${entry.scope ?? "global"}:${entry.id}] ${collapseWhitespace(`${entry.title} (${entry.path}, v${entry.version}${violationText})`)}`; } function overviewStubLine(entry: HarnessEntry): string { @@ -627,7 +651,9 @@ function overviewStubLine(entry: HarnessEntry): string { if (room < OVERVIEW_STUB_MIN_REMAINDER) { return address; } - return `${address} ${compactText(`${entry.title} (${entry.path}, v${entry.version})`, room)}`; + const violationCount = typeof entry.violations === "number" && entry.violations > 0 ? entry.violations : 0; + const violationText = violationCount > 0 ? `, ${violationCount} violation${violationCount === 1 ? "" : "s"}` : ""; + return `${address} ${compactText(`${entry.title} (${entry.path}, v${entry.version}${violationText})`, room)}`; } function overviewDetailLine(entry: HarnessEntry, maxContentLength: number): string { @@ -730,8 +756,8 @@ export function formatHarnessStateForPrompt( "Use these continual harness prompt notes, memories, skills, and subagent specs when they are relevant. The base system prompt is immutable; prompt entries below are supplemental notes only.", "", includeRefineExamples - ? "When to call `await refine.run()`: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist locally or globally, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Keep `await refine.run()` continual harness edits small and evidence-backed." - : "When to refine the continual harness: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist locally or globally, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Keep continual harness edits small and evidence-backed.", + ? "When to call `await refine.run()`: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist locally or globally, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Repeated violations on a note must escalate to a check or guard instead of another re-wording. Keep `await refine.run()` continual harness edits small and evidence-backed." + : "When to refine the continual harness: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist locally or globally, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Repeated violations on a note must escalate to a check or guard instead of another re-wording. Keep continual harness edits small and evidence-backed.", "", includeIpythonExamples ? "Call contract: read each installed Python skill's SKILL.md and call its documented module function in the Python REPL; do not assume a `.run` entrypoint. Use ` ...` in shell when a CLI exists. Continual harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Spawn a continual harness subagent spec by composing a concise task prompt and calling `handle = await rlm.spawn('sub-task', name='worker', model='')`; call `await rlm.models()` first to inspect available models; `name` and `model` are required (`thinking=` is optional); admission returns immediately with `rlm_child_id`, `name`, `session_dir`, and `model`, never the child's answer. Results arrive only through explicit `agent_message` replies or files; children reply with `await agent_message.send(message, receiver_role='parent')`. Use `await rlm.list_subagents()` to recover direct child handles and `await agent_message.send(..., receiver_role='child', receiver_name=handle.name)` for follow-ups. Do not invent wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries." @@ -1065,6 +1091,7 @@ export function applyRefinementProposal( const createdAt = before?.created_at ?? now(); const version = before ? before.version + 1 : 1; + const violations = edit.violations !== undefined ? edit.violations : before?.violations; const after: HarnessEntry = { id, kind: edit.kind, @@ -1079,6 +1106,7 @@ export function applyRefinementProposal( created_at: createdAt, updated_at: now(), version, + ...(violations !== undefined && violations > 0 ? { violations } : {}), }; records[id] = after; proposalModifiedKeys.add(entryKey); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 575b9c279..8a1b523da 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -592,8 +592,31 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise { } } -const UPDATE_RESTART_CONTINUATION_PROMPT = - "Prime Agent restarted after an update. Continue the interrupted task from the saved transcript and restored tool/kernel state. Inspect current state before retrying commands when needed."; +export function buildUpdateRestartContinuationPrompt(session: DaemonUpdateRestartSession): string { + const interruptedActivities: string[] = []; + if (session.wasStreaming) interruptedActivities.push("streaming a turn"); + if (session.wasCompacting) interruptedActivities.push("compacting context"); + if (session.wasBashRunning) interruptedActivities.push("running background bash execution"); + if (session.hadRunningRlmChildren) interruptedActivities.push("running subagents"); + if (session.wasRetrying) interruptedActivities.push("retrying an operation"); + if (session.hadAcceptedPromptInFlight) interruptedActivities.push("processing an in-flight prompt"); + + const activitySummary = + interruptedActivities.length > 0 + ? `Interrupted activity: ${interruptedActivities.join(", ")}.` + : "Interrupted while waiting for next turn."; + + const queueDetails = + session.queue.actions.actions.length > 0 + ? ` Restored queued actions: ${session.queue.actions.actions.length}.` + : ""; + + return [ + "Prime Agent restarted after an update.", + `Last verified state: working directory is ${session.cwd}. ${activitySummary}${queueDetails}`, + "Continue the interrupted task from the saved transcript and verified state instead of starting over.", + ].join("\n\n"); +} const UPDATE_SESSION_LOSS_COPY: DaemonSessionLossCopy = { busyDetail(count) { @@ -1179,7 +1202,7 @@ async function restoreDaemonUpdateRestartSession( { type: "prompt", activeSessionId, - message: UPDATE_RESTART_CONTINUATION_PROMPT, + message: buildUpdateRestartContinuationPrompt(session), expandPromptTemplates: false, }, 120000, diff --git a/packages/coding-agent/test/refinement.test.ts b/packages/coding-agent/test/refinement.test.ts index bef3227f7..dd7283453 100644 --- a/packages/coding-agent/test/refinement.test.ts +++ b/packages/coding-agent/test/refinement.test.ts @@ -28,6 +28,7 @@ import { type RefinementKind, type RefinementProposal, type RefinementResult, + recordHarnessViolation, refineHarness, saveHarnessState, scoreHarnessEntryForQuery, @@ -975,6 +976,28 @@ describe("harness digest relevance ranking", () => { expect(() => formatHarnessStateForPrompt(state, { queryTerms: new Map([["worktree", 1]]) })).not.toThrow(); }); + it("records violation counts and surfaces them in the harness digest", () => { + const state = loadHarnessState(join(makeTempDir(), "h"), "local"); + state.entries.prompt.note = makeEntry( + "note", + "Negative control check", + "Verify before claiming pass.", + "2026-08-01T00:00:00.000Z", + ); + + const initialOverview = formatHarnessStateForPrompt(state); + expect(initialOverview).toContain("[global:note] Negative control check (memory, v1):"); + expect(initialOverview).not.toContain("v1,"); + + recordHarnessViolation(state, "note", 1); + const oneViolationOverview = formatHarnessStateForPrompt(state); + expect(oneViolationOverview).toContain("[global:note] Negative control check (memory, v1, 1 violation):"); + + recordHarnessViolation(state, "note", 1); + const twoViolationsOverview = formatHarnessStateForPrompt(state); + expect(twoViolationsOverview).toContain("[global:note] Negative control check (memory, v1, 2 violations):"); + }); + it.each<[string, string[]]>([ ["Worktree?", ["worktree"]], ["path/to/skill", ["path", "skill"]], diff --git a/packages/coding-agent/test/session-command-messages.test.ts b/packages/coding-agent/test/session-command-messages.test.ts index d4fe8d36a..dd5b69878 100644 --- a/packages/coding-agent/test/session-command-messages.test.ts +++ b/packages/coding-agent/test/session-command-messages.test.ts @@ -33,6 +33,7 @@ import { buildConversationComponents } from "../src/modes/interactive/components import { SlashCommandMessageComponent } from "../src/modes/interactive/components/slash-command-message.js"; import { SlashCommandResultMessageComponent } from "../src/modes/interactive/components/slash-command-result-message.js"; import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { buildUpdateRestartContinuationPrompt } from "../src/package-manager-cli.js"; const componentOptions = { ui: { requestRender: vi.fn() } as unknown as TUI, @@ -291,7 +292,47 @@ describe("session command messages", () => { updatedAt: "2026-01-01T00:00:00.000Z", runCount: 1, }).content, - ).toBe("[heartbeat: every 10 minutes run#1]\n\ncheck in"); + ).toBe("[heartbeat: every 10 minutes run#1]\n\nHarness heartbeat.\nInstruction: check in"); + + expect( + createHeartbeatPromptMessage({ + id: "hb-labeled", + status: "active", + source: "heartbeat", + label: "periodic check", + activeSessionId: "a", + sessionId: "s", + sessionFile: "/tmp/s.jsonl", + cwd: "/tmp", + prompt: "check in", + schedule: { kind: "interval", expression: "every 5m", intervalMs: 300_000 }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + runCount: 2, + }).content, + ).toBe("[heartbeat: every 5m run#2]\n\nHarness heartbeat: periodic check.\nInstruction: check in"); + }); + + test("restart continuation prompt carries last verified state instead of generic inspection", () => { + const prompt = buildUpdateRestartContinuationPrompt({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/s.jsonl", + cwd: "/work/project", + config: {} as never, + queue: { actions: { formatVersion: 1, actions: [{ payload: { kind: "turn" } } as never] }, nextTurn: [] }, + shouldResume: true, + wasStreaming: true, + wasCompacting: false, + wasBashRunning: false, + hadRunningRlmChildren: true, + wasRetrying: false, + hadAcceptedPromptInFlight: false, + }); + expect(prompt).toContain( + "Last verified state: working directory is /work/project. Interrupted activity: streaming a turn, running subagents. Restored queued actions: 1.", + ); + expect(prompt).not.toContain("Inspect current state"); }); test("every constructor-backed synthetic kind opens with a grammar-conforming bracket line", () => { diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index 57907eca4..512728fd1 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -245,7 +245,7 @@ describe("AgentSession model and extension characterization", () => { (harness: Harness) => harness.session.promptHeartbeat(createHeartbeat()), { role: "custom", - text: "[heartbeat: every 5m run#2]\n\nCheck whether the long-running task needs another step.", + text: "[heartbeat: every 5m run#2]\n\nHarness heartbeat.\nInstruction: Check whether the long-running task needs another step.", }, ], ])( diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 03850e5ec..e7cd6b9ce 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1850,7 +1850,7 @@ type StateRestoreHost = { }): void; }; -const HEARTBEAT_MARKED_PROMPT = "[heartbeat: every 5m run#0]\n\ncheck progress"; +const HEARTBEAT_MARKED_PROMPT = "[heartbeat: every 5m run#0]\n\nHarness heartbeat.\nInstruction: check progress"; const shellCompletion = { pid: 42, command: "npm test", exitCode: 0 }; @@ -2177,7 +2177,7 @@ describe("AgentSession queue regressions", () => { fauxAssistantMessage("original turn complete"), (context) => { providerSawRestoreContext = context.messages.some((message) => - getMessageText(message).includes("These names are available again: alpha, beta."), + getMessageText(message).includes("2 top-level name(s) restored from /tmp/kernel-state.dill"), ); return fauxAssistantMessage("queued turn complete"); }, diff --git a/prime-agent-runtime/src/rlm/harness.py b/prime-agent-runtime/src/rlm/harness.py index a9e2a521c..c1838b4c3 100644 --- a/prime-agent-runtime/src/rlm/harness.py +++ b/prime-agent-runtime/src/rlm/harness.py @@ -182,6 +182,7 @@ class HarnessEntry: created_at: str = field(default_factory=_now) updated_at: str = field(default_factory=_now) version: int = 1 + violations: int = 0 @dataclass @@ -764,6 +765,29 @@ class HarnessState: def delete_subagent(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: return self.delete("subagent", id, global_=global_, **kwargs) + def record_violation( + self, + id: str, + count: int = 1, + *, + global_: bool = False, + **kwargs: Any, + ) -> HarnessEntry: + """Increment the violation counter for a harness note.""" + if target := self._global_target(global_, kwargs): + return target.record_violation(id, count=count) + self._ensure_local_writable() + self._sync_from_disk() + cleaned_id, global_ = _strip_scope_prefix(id, global_) + for kind in _KINDS: + if cleaned_id in self.entries[kind]: + entry = self.entries[kind][cleaned_id] + entry.violations = max(0, entry.violations + count) + entry.updated_at = _now() + self.save() + return entry + raise KeyError(f"Harness entry not found: {id}") + def record_refinement( self, trigger: str, @@ -846,8 +870,13 @@ class HarnessState: if len(reference_text) > 120: reference_text = f"{reference_text[:117]}..." reference_summary = f" ref={reference_text}" + violation_text = ( + f", {entry.violations} violation{'s' if entry.violations != 1 else ''}" + if entry.violations > 0 + else "" + ) lines.append( - f" - [{entry.scope}:{entry.id}] {entry.title} ({entry.path}, v{entry.version})" + f" - [{entry.scope}:{entry.id}] {entry.title} ({entry.path}, v{entry.version}{violation_text})" f"{reference_summary}{argument_summary}: {summary}" ) overflow = len(self.entries[kind]) - len(records) diff --git a/prime-agent-runtime/test/test_harness.py b/prime-agent-runtime/test/test_harness.py index c571678b4..380b8bfb4 100644 --- a/prime-agent-runtime/test/test_harness.py +++ b/prime-agent-runtime/test/test_harness.py @@ -1146,3 +1146,15 @@ class HarnessSearchTest(unittest.TestCase): with self.assertRaises(TypeError): state.search("worktree", limit=0) + def test_record_violation_and_overview(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + state = HarnessState(Path(temp_dir) / "harness_state.json") + state.create_prompt_note("Test rule", "Follow negative control.", id="rule") + self.assertNotIn("violation", state.overview()) + entry = state.record_violation("rule", count=1) + self.assertEqual(entry.violations, 1) + self.assertIn("v1, 1 violation", state.overview()) + entry = state.record_violation("rule", count=2) + self.assertEqual(entry.violations, 3) + self.assertIn("v1, 3 violations", state.overview()) + -- 2.51.2