Skip to content

Continual harness memory is unreachable in practice: the prompt overview is a hardcoded 6-entry alphabetical head slice, and rlm.harness defaults to the session-local store, which is empty in a spawned child #819

Description

@etafund

Summary

Two defects in the continual harness compound, and each is the reason the other has no workaround. They are filed together because the mitigation for either one is the other one.

A. The system-prompt overview renders an alphabetical head slice of 6 entries per kind at a hardcoded cap. formatHarnessStateForPrompt() renders the continual harness overview into the system prompt using a default cap of 6 entries per kind and a 180-character body clip. The 6 that survive are chosen by an alphabetical sort on [path, title, id] — not recency, version, or relevance — so which lessons the model sees is decided by how their file paths happen to be spelled. The function's options object already accepts maxEntriesPerKind/maxContentLength, but neither production call site passes them, and grepping the published sources turns up no setting, env var, or config key that reaches them — so in practice the limits behave as hardcoded. (If there is a supported way to raise them that we missed, that would resolve most of A.)

B. rlm.harness resolves to the session-local store, which is empty for a spawned child. rlm.harness resolves to the session-local harness store by default, and for an rlm()-spawned child that store is new and empty at spawn. A child that does the natural thing — rlm.harness.overview() or rlm.harness.list("memory") — gets Harness state (local): ... with memory: 0, even though the same child's system prompt was built from the merged global + local state and lists global memory entries. Nothing in the local output mentions that a global store exists, how many entries it holds, or that global_=True exists.

The prompt text itself says the overview lines are "compact summaries, not full descriptions ... inspect ... the underlying continual harness entry only when detail matters". A is why the summaries are not sufficient; B is why the inspect path returns zero. Together, there is no obvious path by which a spawned child reads what the global store holds.

To be precise about what is not broken, since it bounds the ask:

  • Neither defect is silent in the strict sense. A emits an overflow line per kind; B correctly labels its output (local). But the model has no in-prompt way to reach the hidden entries and no documented way to raise the cap, and the local output names neither the global scope nor the flag that reads it.
  • Global reads work fine from a child when asked for explicitly. RLM_GLOBAL_HARNESS_STATE_DIR is exported to every kernel including children, so rlm.harness.list("memory", global_=True), rlm.harness.overview(global_=True), and rlm.harness.get("memory", "global:<id>") all resolve correctly (verified — see repro B). Defect B is purely that the no-argument default is local-only and silent about it — which is plausibly the call a child makes first.

A third, sharper gap falls out of B's design: parent-local entries do not reach children at all, while the system prompt explicitly recommends local scope for "session coordination".

Defect A looks like the same pattern as #799 (rlm.find_models() returning an alphabetical head slice): a head slice of an alphabetical ordering presented where a representative sample is expected.

Environment

  • prime-agent 0.7.0, installed from the published npm package prime-agent
  • Components: the coding-agent workspace package @earendil-works/pi-coding-agent at packages/coding-agent (continual harness / /refine overview injection into the system prompt) and prime-agent-runtime (the Python rlm package installed into the kernel venv)
  • Claims below were checked against main at b9a4461149419156599d60174dddf15458e2b9ee ("fix(coding-agent): show MCP login path for websearch (fix(coding-agent): show MCP login path for websearch #809)"). The installed prime-agent-runtime/src/rlm/harness.py and __init__.py are byte-identical to that ref.

Reproduction

A. Prompt overview cap

The behavior is fully determined by the exported function, so it reproduces without a live session. Save as packages/coding-agent/test/overview-cap.test.ts and run with npm test (vitest):

import { expect, it } from "vitest";
import { formatHarnessStateForPrompt, type HarnessEntry } from "../src/core/refinement/index.js";

const mk = (id: string, title: string, path: string, content: string, version = 1): HarnessEntry => ({
  id, kind: "memory", title, content, path, scope: "global", version,
  reference: {}, arguments: {}, metadata: {}, source: "test",
  created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
});

it("renders only the 6 alphabetically-first memory entries", () => {
  const entries: Record<string, HarnessEntry> = {};

  // 48 pre-existing entries spread across the alphabet.
  for (let i = 0; i < 48; i++) {
    const letter = String.fromCharCode(97 + (i % 26));
    const id = `mem-${String(i).padStart(3, "0")}`;
    entries[id] = mk(id, `${letter}-lesson-${i}`, `memory/${letter}/${letter}-lesson-${i}.md`,
      `Older lesson ${i}. `.repeat(12));
  }

  // The newest entry: highest version, written last, but its path starts with "w".
  entries["mem-new"] = mk("mem-new", "workspace-resolver-fix", "memory/w/workspace-resolver-fix.md",
    "CRITICAL, just learned: the resolver error is caused by a stale lockfile. Re-resolve from a clean cache.", 7);

  const state = { entries: { prompt: {}, memory: entries, skill: {}, subagent: {} }, refinements: [] };
  const out = formatHarnessStateForPrompt(state as never);

  const shown = out.split("\n").filter((l) => l.startsWith("- [global:"))
    .map((l) => l.match(/\[global:([\w-]+)\]/)![1]);

  console.log("rendered:", shown.join(", "));
  expect(shown).toHaveLength(6);            // 6 of 49
  expect(shown).toContain("mem-new");       // FAILS — newest entry is not rendered
});

B. A child reads an empty store

The child kernel environment is fully determined by _rlmKernelEnv(), so this reproduces standalone without spawning — set RLM_HARNESS_STATE_DIR to an empty directory (what a fresh child gets) and RLM_GLOBAL_HARNESS_STATE_DIR to a populated one (what every kernel gets):

  1. Create two harness dirs, one empty and one holding 40 memory entries:
mkdir -p /tmp/child-harness /tmp/global-harness
python - <<'PY'
import json
empty = {"entries": {"prompt": {}, "memory": {}, "skill": {}, "subagent": {}}, "refinements": []}
json.dump(empty, open("/tmp/child-harness/harness_state.json", "w"))

full = {"entries": {"prompt": {}, "memory": {}, "skill": {}, "subagent": {}}, "refinements": []}
for i in range(40):
    eid = f"mem-{i:03d}"
    full["entries"]["memory"][eid] = {
        "id": eid, "kind": "memory", "title": f"Memory {i}",
        "content": f"Durable fact number {i}. " * 20,
        "path": "global/memory", "version": 1, "scope": "global",
        "metadata": {}, "arguments": {}, "reference": {},
        "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
    }
json.dump(full, open("/tmp/global-harness/harness_state.json", "w"))
PY
  1. Run the calls a child would make, with the kernel env a child receives:
RLM_HARNESS_STATE_DIR=/tmp/child-harness \
RLM_GLOBAL_HARNESS_STATE_DIR=/tmp/global-harness \
python -c "
from rlm.harness import get_harness_state
s = get_harness_state()                       # what rlm.harness resolves to
print(s.overview())
print('default list:', len(s.list(\"memory\")))
print('global  list:', len(get_harness_state(global_=True).list(\"memory\")))
"

Equivalently, in a session whose global store holds 40+ memory entries: spawn handle = await rlm("Check harness memory for what we already know about X, then report") and have the child run print(rlm.harness.overview()) as its first step.

Expected

A. The overview that reaches the system prompt should either (a) include recently written or high-priority entries with a guaranteed slot, so a lesson recorded by /refine is visible to the next session, or (b) let the operator raise the cap when the store outgrows it. Ideally both.

B. The default local overview should not read as "the harness is empty" when a populated global store is one keyword argument away. A child asking the harness what it knows should either be shown the global store or be told, in the same output, that a global store exists with N entries and how to read it.

Actual

A

Selection is an alphabetical head slice at a fixed depth of 6, with bodies hard-cut at 180 characters. Measured output of the repro above (49-entry store):

rendered: mem-000, mem-026, mem-001, mem-027, mem-002, mem-028
- +43 more memory entries

The render order is strictly alphabetical by path (memory/a/a-lesson-0.md, memory/a/a-lesson-26.md, memory/b/b-lesson-1.md, …). The entry written most recently, at version 7, is absent. A newly recorded lesson is included only if its path sorts into the first 6 for its kind — a property of the filename, unrelated to how recently or how urgently it was learned.

Measured on that same fixture, comparing the default render against the same call with maxEntriesPerKind/maxContentLength raised past the store size:

render chars
default (6 entries / 180 chars) 3,951
uncapped 15,290

So the prompt carried 25.8% of the stored harness text for this fixture. That percentage is fixture-dependent: bodies here are only ~191-203 characters, barely over the 180-char clip, so nearly all of the loss is the 43 dropped entries rather than truncation. A store with longer entries retains proportionally less, since the surviving entry count stays fixed at 6 and each surviving body is still cut at 180.

B

Measured output of step 2, verbatim:

Harness state (local): /tmp/child-harness/harness_state.json
Call contract: installed Python skills use await <skill_import>(...) or a matching shell CLI; ...
prompt: 0
memory: 0
skill: 0
subagent: 0
refinements: 0
default list: 0
global  list: 40

memory: 0 against a global store holding 40 entries. There is no global_=True anywhere in the output, no global path, and no count. Meanwhile that same child's system prompt was rendered from global + local merged state and does list global memory entries — so the child is presented with two contradictory views of its own memory, one of which it obtained by direct measurement.

Aggravating factor: parent-local entries do not reach children. Each session's local store lives under its own session-artifact dir, and neither _rlmKernelEnv() nor _createInlineRlmSubagentRuntime() passes or exports a parent harness dir — yet the prompt instructs agents to "Default to local continual harness refinement for ... session coordination" (refinement.ts L450). Coordination notes written at the recommended scope are structurally invisible to the children they are meant to coordinate.

Code pointers

Line numbers are against main at the ref above.

A. Overview render cap

packages/coding-agent/src/core/refinement/refinement.tsformatHarnessStateForPrompt (L429), constants at L26-28:

const DEFAULT_OVERVIEW_ENTRY_LIMIT = 6;
const DEFAULT_OVERVIEW_REFINEMENT_LIMIT = 5;
const DEFAULT_OVERVIEW_CONTENT_LIMIT = 180;

The sort key (L467-469) is purely lexicographic — no recency, version, or relevance term:

const entries = Object.values(state.entries[kind]).sort((a, b) =>
    [a.path, a.title, a.id].join("\0").localeCompare([b.path, b.title, b.id].join("\0")),
);

followed by the head slice at L481 and the overflow notice at L497-500:

for (const entry of entries.slice(0, maxEntriesPerKind)) {
const overflow = entries.length - Math.min(entries.length, maxEntriesPerKind);
if (overflow > 0) {
    lines.push(`- +${overflow} more ${kind} entries`);
}

Bodies pass through compactText (L421), which collapses whitespace and hard-cuts with an ellipsis:

function compactText(text: string, maxLength: number): string {
	const normalized = text.replace(/\s+/g, " ").trim();
	if (normalized.length <= maxLength) {
		return normalized;
	}
	return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
}

Why the limits are unreachable in practice. formatHarnessStateForPrompt does expose the knobs (L432-434), defaulting at L440-442:

const maxEntriesPerKind = options.maxEntriesPerKind ?? DEFAULT_OVERVIEW_ENTRY_LIMIT;
const maxContentLength = options.maxContentLength ?? DEFAULT_OVERVIEW_CONTENT_LIMIT;

But both production call sites in packages/coding-agent/src/core/system-prompt.ts (L106 and L141) pass only the example-inclusion flags:

prompt += `\n\n${formatHarnessStateForPrompt(harnessState, { includeIpythonExamples: hasIpython, includeShellExamples: hasBash, includeRefineExamples: hasIpython && hasRefineSkill })}`;

Grepping maxEntriesPerKind|maxContentLength|DEFAULT_OVERVIEW_ across the published package sources returns hits only inside refinement.ts itself (plus its bundled copies) — no settings key, no env var, no docs reference. Is there a supported override we are missing?

Recency data is already present and unused by the sort. HarnessEntry (L34-48) carries both timestamps and a version, none of which participate in the ordering:

export interface HarnessEntry {
	id: string;
	kind: RefinementKind;
	title: string;
	content: string;
	path: string;
	...
	created_at: string;
	updated_at: string;
	version: number;
}

Limits disagree with the Python side. prime-agent-runtime/src/rlm/harness.py, HarnessState.overview() (L721, class defined at L141) defaults to max_entries_per_kind: int = 20 and clips bodies at 120 characters:

summary = entry.content.strip().replace("\n", " ")
if len(summary) > 120:
    summary = f"{summary[:117]}..."

So the in-kernel view an agent gets from harness.overview() and the system-prompt view disagree on both dimensions (20/120 vs 6/180). An agent that inspects its own store in IPython sees a different, larger set than the one it was primed with — which makes the prompt view hard to reason about from inside the session, and which interacts directly with defect B: the in-kernel view that disagrees is also the one that defaults to the empty local store. Is the divergence intentional?

Existing test coverage does not exercise the cap. packages/coding-agent/test/refinement.test.ts L607 calls formatHarnessStateForPrompt(merged) against a 2-entry merged state, so it never crosses either limit.

B. Local-by-default resolution

prime-agent-runtime/src/rlm/__init__.py_HarnessProxy._resolve() (L250-252). rlm.harness is this proxy; every attribute access lands here, and the resolving call takes no scope argument:

def _resolve(self) -> HarnessState:
    try:
        return get_harness_state()

prime-agent-runtime/src/rlm/harness.pyget_harness_state() (L785-791) defaults to local:

def get_harness_state(
    state_dir: str | Path | None = None, *, global_: bool = False, **kwargs: Any
) -> HarnessState:
    """Return the cached local harness state, or global when requested."""
    global_ = _resolve_global_flag(global_, kwargs)
    file_path = _state_file(state_dir, global_=global_)
    scope: HarnessScope = "global" if global_ else "local"

prime-agent-runtime/src/rlm/harness.pyHarnessState.overview() (L721-738). The header names the scope but never the other scope, and the counts come from self.entries only:

def overview(self, *, max_entries_per_kind: int = 20, global_: bool = False, **kwargs: Any) -> str:
    if target := self._global_target(global_, kwargs):
        return target.overview(max_entries_per_kind=max_entries_per_kind)
    self._sync_from_disk()
    lines = [
        f"Harness state ({self.scope}): {self.file_path}",
        ...
    ]
    for kind in _KINDS:
        records = self.list(kind)[:max_entries_per_kind]
        lines.append(f"{kind}: {len(self.entries[kind])}")

HarnessState.list() (L425) and HarnessState.snapshot() (L770) have the same shape: global_ defaults to False, and the local result is returned with no indication that a global store was skipped.

packages/coding-agent/src/core/agent-session.ts_rlmKernelEnv() (L8799-8818) exports both dirs to every kernel, which is why global_=True works and why the local dir is the child's own (comments elided):

const env: Record<string, string> = {
    RLM_DEPTH: String(this._rlmDepth),
    RLM_MAX_DEPTH: String(this._rlmMaxDepth),
    RLM_GLOBAL_HARNESS_STATE_DIR: getGlobalHarnessStateDir(),
};
const rlmSessionDir = this._ensureRlmSessionDir();
if (rlmSessionDir) {
    env.RLM_SESSION_DIR = rlmSessionDir;
    env.RLM_HARNESS_STATE_DIR = this._localHarnessStateDir() ?? getLocalHarnessStateDir(rlmSessionDir)!;
}

packages/coding-agent/src/core/agent-session.ts_localHarnessStateDir() (L7165-7170) resolves to the session's own artifact dir, which for a freshly spawned child is new and empty:

private _localHarnessStateDir(): string | undefined {
    return (
        getLocalHarnessStateDir(this.sessionManager.getSessionArtifactDir()) ??
        (this._rlmSessionDir ? getLocalHarnessStateDir(this._rlmSessionDir) : undefined)
    );
}

packages/coding-agent/src/core/agent-session.ts_loadMergedHarnessState() (L7547-7554), fed to buildSystemPrompt at L4288 as harnessState: this._loadMergedHarnessState(). This is the path that gives the child a prompt view including global entries, and the reason the two views disagree:

/** Global harness state overlaid with this session's local state, when persisted. */
private _loadMergedHarnessState(): HarnessState {
    const localHarnessStateDir = this._localHarnessStateDir();
    return mergeHarnessStates(
        loadHarnessState(getGlobalHarnessStateDir(), "global"),
        localHarnessStateDir ? loadHarnessState(localHarnessStateDir, "local") : undefined,
    );
}

packages/coding-agent/src/core/agent-session.ts_createInlineRlmSubagentRuntime() (L8948) builds the child with SessionManager.create(this._cwd, options.sessionDir) (L8949) and passes rlmSessionDir: options.sessionDir (L8999). No parent harness dir is passed, so the child's local store is unrelated to the parent's.

packages/coding-agent/src/core/refinement/refinement.ts — guidance line at L450:

"Default to local continual harness refinement for current task progress, temporary blockers, and session coordination. Use global continual harness refinement only for stable cross-session lessons, durable user preferences, reusable skills/subagents, or explicitly project-qualified facts."

packages/coding-agent/docs/rlm.md (145 lines) — grep -ci harness and grep -c global_ both return 0. The local/global scope model and the global_=True keyword are not documented on the page an agent is most likely to read.

Suggested fix

A. Overview render cap

  1. Plumb the existing options through. The parameters already exist; add an overview settings block read by the settings manager and forward it at both system-prompt.ts call sites. This alone unblocks operators with large stores and is a small change.
  2. Make selection recency- or priority-aware. updated_at and version are already on every HarnessEntry. Sorting by updated_at descending before the slice — or reserving a portion of the budget for the N most recently updated entries and filling the rest alphabetically — gives every newly refined entry a slot without changing the schema. Keeping a stable alphabetical order within the selected set preserves prompt-cache friendliness.
  3. Reconcile with the Python defaults in HarnessState.overview() so the in-kernel and system-prompt views agree, or document why they differ.
  4. Consider raising the shipped defaults. 6 entries per kind is low relative to what a store accumulates after even moderate /refine use.
  5. Optionally, make the overflow actionable by naming the mechanism to reach hidden entries in the overflow line itself (for example pointing at harness.overview() or a lookup by kind), so the model has a next step rather than just a count. Note that this is only useful once B is fixed — today that pointer leads to an empty local store.

B. Local-by-default resolution

  1. Make the local view self-describing. In HarnessState.overview(), when self.scope == "local" and a global store resolves, append a line naming the global path, its per-kind counts, and the exact call to read it — e.g. after the Harness state (local): ... header:
NOTE: this is the LOCAL store. The GLOBAL store (<global path>) holds prompt: N, memory: N, skill: N, subagent: N.
Read it with rlm.harness.overview(global_=True) / rlm.harness.list("memory", global_=True) / rlm.harness.get(kind, "global:<id>").

Local overviews currently hide the (usually larger) global store, and this is the one output an agent is most likely to read first. The same hint is worth attaching to an empty-result list() when the global store is non-empty.

  1. Document the scope model in packages/coding-agent/docs/rlm.md, including global_=True and the "global:<id>" prefix form that _strip_scope_prefix (harness.py L59) already accepts — right now the flag appears only in the Python source.

  2. Close the parent-local gap. Either export a RLM_PARENT_HARNESS_STATE_DIR from _rlmKernelEnv() and merge it read-only in _loadMergedHarnessState() (with scope rendered as parent: so children do not try to write it), or amend the refinement.ts L450 guidance to stop recommending local scope for "session coordination" — as written, following the recommendation makes those notes unreadable by the children being coordinated.

  3. Optionally, have rlm() inline a compact global-memory digest into the child's spawn brief for children whose task mentions prior knowledge. The spawn brief is the one channel that can carry full entry bodies to a child at spawn time, and the harness inlines nothing into it today.

Impact

The continual harness is the mechanism by which /refine is supposed to make lessons persist across sessions and across agents. Both defects work against that, and each blocks the other's mitigation.

From A: with an alphabetical cap of 6, a store that has accumulated a few dozen memories surfaces a near-fixed subset determined by filename spelling, and newly written lessons are the least likely to appear — they are added to a store that is already past the cap, and nothing about being new helps them rank. In the fixture above the prompt carried 25.8% of the stored harness text, and a real store with longer bodies would carry less. Every spawned child is primed from the same overview, so all children inherit the identical alphabetical head slice.

From B: the failure mode is a confident empty answer. A child asked to "check what we already know before starting" gets memory: 0 from the default call, and unless it independently knows to pass global_=True it has no signal that a populated global store exists — while the parent, reading its own populated overview, has no signal that the child read an empty one. The parent-local gap compounds it: the system prompt steers coordination notes into a scope that children cannot read, so following the documented guidance reduces what children can see.

Combined: the prompt view is a small fixed slice and tells the agent to inspect the store for detail; the default inspect path returns zero. For long-running multi-agent operation the accumulated global store is a large part of what makes delegation cheaper than doing the work inline, and neither default read path delivers it.

Related: #799 (same alphabetical-head-slice pattern in rlm.find_models()), #769 (server-backed long-term memory — different storage backend, same "children cannot get at what the fleet knows" surface), #762 (skill API surface not discoverable — adjacent to the documentation half of this).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions