Skip to content

fix(pi): treat an empty mem_search as no results, not an outage#655

Open
jacargentina wants to merge 1 commit into
Gentleman-Programming:mainfrom
jacargentina:fix/pi-mem-search-null-empty-results
Open

fix(pi): treat an empty mem_search as no results, not an outage#655
jacargentina wants to merge 1 commit into
Gentleman-Programming:mainfrom
jacargentina:fix/pi-mem-search-null-empty-results

Conversation

@jacargentina

@jacargentina jacargentina commented Jul 22, 2026

Copy link
Copy Markdown

🔗 Linked Issue

Closes #473


🏷️ PR Type

  • type:bug — Bug fix
  • type:feature — New feature
  • type:docs — Documentation only
  • type:refactor — Code refactoring (no behavior change)
  • type:chore — Maintenance, dependencies, tooling
  • type:breaking-change — Breaking change

📝 Summary

  • Distinguishes unreachable Engram servers from reachable servers returning a 2xx response with a JSON null body.
  • Makes empty mem_search responses render as an empty list instead of reporting an Engram outage.

📂 Changes

File Change
plugin/pi/index.ts Adds lastFetchReachedServer as an out-of-band signal and handles reachable empty search results
correctly.
plugin/pi/test/index-source.test.mjs Extends fetch test fixtures to expose server-reachability state.
plugin/pi/test/native-tool-contract.test.mjs Adds coverage for null, empty, populated, and non-2xx search responses.

🧪 Test Plan

  • Unit tests pass locally: go test ./...
  • E2E tests pass locally: go test -tags e2e ./internal/server/...
  • Pi plugin tests pass locally: npm test --prefix plugin/pi
  • Affected behavior is covered by automated contract tests.

🤖 Automated Checks

Check What it verifies Status
Check Issue Reference PR body contains Closes #N / Fixes #N / Resolves #N Pending CI
Check Issue Has status:approved Linked issue has status:approved label Pending label
Check PR Has type:* Label PR has exactly one type:* label Pending label
Unit Tests go test ./... passes Passed locally
E2E Tests go test -tags e2e ./internal/server/... passes Passed locally

✅ Contributor Checklist

  • I linked issue bug(pi): direct mem_search reports Engram unavailable while CLI and MCP work #473.
  • I added exactly one type label: type:bug.
  • I ran unit tests locally: go test ./....
  • I ran E2E tests locally: go test -tags e2e ./internal/server/....
  • Docs reviewed; no user-facing documentation changes are required for this internal transport/result-handling fix.
  • Commits follow conventional commit format.
  • No Co-Authored-By trailers in commits.

💬 Notes for Reviewers

engramFetch retains its existing null return contract, so existing call sites and the mem_save two-leg
no-duplicate-write behavior remain unchanged.

The new lastFetchReachedServer signal distinguishes a reachable 2xx response with a JSON null body from a genuine
transport failure. Empty mem_search results now render as [], while unreachable servers and non-2xx responses continue to
surface errors.

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for memory searches when the server is reachable but returns no results.
    • Empty memory searches now consistently display as an empty list instead of an error or null.
    • Unreachable-server errors are now distinguished from valid empty responses.
    • Server-provided errors from failed searches are surfaced more clearly.
  • Tests

    • Added coverage for empty, populated, and failed memory search responses.

engramFetch returns null both for an unreachable server and for a
reachable server that answers a request with a 2xx and a null JSON body.
The Engram HTTP API returns exactly such a null for a no-hit /search, so
executeMemoryTool misclassified every empty search as "could not reach
the Engram HTTP server", forcing harnesses to fall back from
Engram-backed to file-backed artifacts even though the store was healthy.

Record whether the last engramFetch actually got a 2xx back
(lastFetchReachedServer) as an out-of-band signal, mirroring the existing
lastFetchTimeoutMethod pattern. The tool layer now only reports an outage
when a null came with no response behind it; a reachable-but-empty
response falls through and mem_search renders it as an empty list.

engramFetch keeps its null return contract, so the ~20 call sites and the
mem_save two-leg no-duplicate-write guarantee are unaffected.

Refs Gentleman-Programming#473
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The Pi plugin now records whether Engram fetches reached the server, distinguishes unreachable failures from reachable empty responses, renders null mem_search results as empty lists, and adds contract tests for null, empty, populated, and error responses.

Changes

Pi mem_search reachability and result handling

Layer / File(s) Summary
Fetch reachability tracking
plugin/pi/index.ts, plugin/pi/test/index-source.test.mjs
engramFetch records successful 2xx reachability for later null handling, and the test helper exposes the corresponding state.
mem_search null and error handling
plugin/pi/index.ts, plugin/pi/test/native-tool-contract.test.mjs
Reachable null search responses render as empty lists, while tests verify null, empty, populated, and non-2xx responses.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: type:bug

Suggested reviewers: gentleman-programming, alan-thegentleman

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise, specific, and matches the main mem_search outage-vs-empty-results fix.
Linked Issues check ✅ Passed The changes address #473 by treating reachable null search responses as empty results while preserving real outage handling.
Out of Scope Changes check ✅ Passed The PR stays focused on mem_search reachability handling and its contract tests, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugin/pi/index.ts`:
- Around line 840-847: Update the mem_search handling around engramFetch so a
null result is converted to [] only when the 2xx response contained valid parsed
JSON representing a no-hit result. Preserve and surface JSON parse failures
instead of allowing them to pass through the data === null branch as an empty
search result; keep the existing transport-failure check using
takeLastFetchReachedServer and unreachableMessage unchanged.
- Around line 193-211: Remove the module-global fetch status variables and make
reachability and timeout metadata per-operation. Update engramFetch and the
callMemoryTool/executeMemoryTool flow to return and consume this metadata
through the current operation’s result, ensuring concurrent requests cannot
overwrite each other’s status.

In `@plugin/pi/test/native-tool-contract.test.mjs`:
- Around line 101-131: Update the test helper around installRuntimeStubs and its
finally cleanup so it never recursively removes the repository’s
plugin/pi/node_modules directory. Use an isolated temporary fixture for stub
packages, or track and restore only the package paths created by this test,
while preserving cleanup of the test-owned resources and restoring environment
state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 470692e1-e8b8-48ca-a7d0-60d741652341

📥 Commits

Reviewing files that changed from the base of the PR and between 763a6ba and aff951a.

📒 Files selected for processing (3)
  • plugin/pi/index.ts
  • plugin/pi/test/index-source.test.mjs
  • plugin/pi/test/native-tool-contract.test.mjs

Comment thread plugin/pi/index.ts
Comment on lines +193 to +211
// Whether the most recent engramFetch actually got a 2xx back from the server. engramFetch returns
// null both for an unreachable server and for a reachable server that answered with a null JSON body
// (an empty result set); this out-of-band flag lets the tool layer tell those two apart.
let lastFetchReachedServer = false;

function takeLastFetchReachedServer(): boolean {
const reached = lastFetchReachedServer;
lastFetchReachedServer = false;
return reached;
}

async function engramFetch<TResponse = unknown>(path: string, opts: FetchOptions = {}): Promise<TResponse | null> {
const method = opts.method ?? "GET";
// This call's outcome supersedes any earlier one. A tool call can issue several fetches
// (mem_save creates the session, then writes the observation); without this reset a timeout
// on the first leg would mislabel an unrelated failure on the second as "may already have
// been applied", telling the agent not to retry a write that never left the machine.
lastFetchTimeoutMethod = undefined;
lastFetchReachedServer = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)plugin/pi/index\.ts$|plugin/pi/'

echo "Outline:"
ast-grep outline plugin/pi/index.ts --view expanded || true

echo "Relevant lines 180-280:"
sed -n '180,280p' plugin/pi/index.ts

echo "Search for shared state/readers:"
rg -n "lastFetchReachedServer|takeLastFetchReachedServer|lastFetchTimeoutMethod|engramFetch|executeMemoryTool" plugin/pi/index.ts

Repository: Gentleman-Programming/engram

Length of output: 13551


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant executeMemoryTool lines 820-890:"
sed -n '820,890p' plugin/pi/index.ts

echo "All occurrences of reachability/timeout markers:"
rg -n "takeLastFetchReachedServer|takeLastFetchTimeoutMethod|lastFetchReachedServer|lastFetchTimeoutMethod|reach.*server|reachable|outage|timeout" plugin/pi/index.ts

echo "Read-only JavaScript probe for interleaving semantics:"
node - <<'JS'
async function interleavingDemo() {
  let lastFetchTimeoutMethod;
  let lastFetchReachedServer = false;

  function takeLastFetchTimeoutMethod() {
    const method = lastFetchTimeoutMethod;
    lastFetchTimeoutMethod = undefined;
    return method;
  }
  function takeLastFetchReachedServer() {
    const reached = lastFetchReachedServer;
    lastFetchReachedServer = false;
    return reached;
  }
  async function engramFetch({ name, timedOut, ok, reached }) {
    lastFetchTimeoutMethod = undefined;
    lastFetchReachedServer = false;
    if (timedOut) lastFetchTimeoutMethod = name;
    if (!ok) return null;
    if (reached) lastFetchReachedServer = true;
    return null;
  }

  // Original async function semantics:
  // request A is unreachable/timeout before it has set A's marker;
  // request B completes a reachable no-hit response after A sets module-wide marker to false.
  const reqALeftOffWithNull = await engramFetch({ name: "A", timedOut: false, ok: false, reached: false });
  const beforeRequestBCompletesReached = takeLastFetchReachedServer();
  await engramFetch({ name: "B", timedOut: false, ok: true, reached: true });
  const afterRequestBCompletesReached = takeLastFetchReachedServer();

  console.log(JSON.stringify({
    reqALeftOffWithNull,
    lastReachedAfterRequestAFailedAndBeforeRequestBCompletes: beforeRequestBCompletesReached,
    lastReachedAfterRequestBCompletes: afterRequestBCompletesReached,
  }));
}
interleavingDemo().catch(console.error);
JS

Repository: Gentleman-Programming/engram

Length of output: 6686


Keep fetch status out of module-global state.

lastFetchReachedServer and lastFetchTimeoutMethod are shared across all tool/lifecycle requests, so a concurrent successful fetch can clear or overwrite the marker for another request before executeMemoryTool reads them. Scope this reachability/timeout metadata to the same call as callMemoryTool, e.g. by returning it through the per-operation result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/pi/index.ts` around lines 193 - 211, Remove the module-global fetch
status variables and make reachability and timeout metadata per-operation.
Update engramFetch and the callMemoryTool/executeMemoryTool flow to return and
consume this metadata through the current operation’s result, ensuring
concurrent requests cannot overwrite each other’s status.

Source: Path instructions

Comment thread plugin/pi/index.ts
Comment on lines +840 to +847
if (data === null && !takeLastFetchReachedServer()) {
// A null with no 2xx behind it is a genuine transport failure (unreachable/timeout), not an
// empty result. Surface it as an outage; a reachable-but-empty response falls through.
throw new Error(unreachableMessage(takeLastFetchTimeoutMethod()));
}
const result = { content: [{ type: "text" as const, text: textResult(data) }], details: { data } };
// A no-hit search comes back as a null JSON body; render it as an empty list, not "{}".
const rendered = data === null && toolName === "mem_search" ? [] : data;
const result = { content: [{ type: "text" as const, text: textResult(rendered) }], details: { data: rendered } };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)plugin/pi/index\.ts$|plugin/.*pi.*' || true

echo "== outline =="
ast-grep outline plugin/pi/index.ts --view expanded | head -200 || true

echo "== relevant sections =="
sed -n '220,260p' plugin/pi/index.ts
echo "---"
sed -n '820,860p' plugin/pi/index.ts

echo "== json-related search =="
rg -n "res\.json|json\(|no-hit|mem_search|unreachable|timeout|Content-Type|parsed|parse" plugin/pi/index.ts

Repository: Gentleman-Programming/engram

Length of output: 12461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== callMemoryTool section =="
sed -n '667,830p' plugin/pi/index.ts

echo "== parse failure shape probe =="
python3 - <<'PY'
values = [
    ("valid null", None),
    ("invalid JSON", "abc123"),  # would raise on res.json() and be coerced to null in current code
]
for name, data in values:
    rendered = data if data is not None else []
    print(f"{name}: input={name} -> rendered={rendered}")
PY

Repository: Gentleman-Programming/engram

Length of output: 7168


Preserve JSON parse failures from search results.

engramFetch maps any res.json() failure for a 2xx response to null, so mem_search can render malformed or empty JSON as [] instead of showing the protocol error. Only treat data === null as an empty no-hit result when it comes from valid parsed JSON.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/pi/index.ts` around lines 840 - 847, Update the mem_search handling
around engramFetch so a null result is converted to [] only when the 2xx
response contained valid parsed JSON representing a no-hit result. Preserve and
surface JSON parse failures instead of allowing them to pass through the data
=== null branch as an empty search result; keep the existing transport-failure
check using takeLastFetchReachedServer and unreachableMessage unchanged.

Comment on lines +101 to +131
await installRuntimeStubs();
const registeredTools = new Map();
const pluginUrl = pathToFileURL(join(ROOT, "index.ts"));
pluginUrl.search = `?contract=${Date.now()}-${Math.random()}`;
const { default: registerEngram } = await import(pluginUrl.href);
registerEngram({
registerTool(tool) {
registeredTools.set(tool.name, tool);
},
on() {},
});

const memSearch = registeredTools.get("mem_search");
assert.ok(memSearch, "mem_search tool should be registered");

return await memSearch.execute(
"tool-call-search",
{ query: "state markers", project: "gentle-agent-state" },
undefined,
undefined,
{
cwd: ROOT,
sessionManager: { getSessionId: () => "test-session" },
ui: { setStatus() {} },
},
);
} finally {
globalThis.fetch = originalFetch;
if (originalUrl === undefined) delete process.env.ENGRAM_URL;
else process.env.ENGRAM_URL = originalUrl;
await rm(NODE_MODULES, { recursive: true, force: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not delete the repository’s node_modules.

This helper overwrites stub package paths and then recursively removes plugin/pi/node_modules at Line 131. Running the test in an installed workspace can destroy unrelated dependencies and interfere with concurrent tests. Use an isolated fixture or back up and restore only the package paths created by this test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/pi/test/native-tool-contract.test.mjs` around lines 101 - 131, Update
the test helper around installRuntimeStubs and its finally cleanup so it never
recursively removes the repository’s plugin/pi/node_modules directory. Use an
isolated temporary fixture for stub packages, or track and restore only the
package paths created by this test, while preserving cleanup of the test-owned
resources and restoring environment state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(pi): direct mem_search reports Engram unavailable while CLI and MCP work

1 participant