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.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The Pi plugin records successful Engram responses, distinguishes reachable empty responses from unreachable failures, renders null mem_search results as empty lists, and adds contract tests for response variants.

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. The test helper exposes the recorded state.
mem_search response handling and validation
plugin/pi/index.ts, plugin/pi/test/native-tool-contract.test.mjs
Reachable null search responses render as empty lists. Tests cover 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 clearly describes the primary change: treating empty Pi mem_search results as no results instead of an outage.
Linked Issues check ✅ Passed The changes address issue #473 by distinguishing reachable empty responses from unreachable servers and preserving errors for non-2xx responses.
Out of Scope Changes check ✅ Passed The implementation and tests remain within the linked issue scope and directly support the requested mem_search behavior.
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 +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.

@felipe3dfx felipe3dfx 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.

Verdict: LGTM — the fix is correct and well tested. Two minor observations below.

Correctness — verified

  • The lastFetchReachedServer flag is reset at the start of every engramFetch and only set after the status check passes, so it can never carry a stale value from a previous fetch within the same tool call.
  • The mem_save two-leg case (ensureSession + POST) is intact: if ensureSession succeeds but the POST fails, the POST already reset the flag, and timeouts still surface the "do not blindly retry" message. The null return contract of engramFetch is unchanged for all call sites.
  • The self-consuming takeLastFetch...() accessor mirrors the existing lastFetchTimeoutMethod pattern, so it's consistent with the file's style.

Observations

  1. The behavior change is broader than the title suggests. The data === null && !takeLastFetchReachedServer() guard applies to all tools, not just mem_search. A 2xx with a null body on mem_context, mem_delete, etc. was previously reported as an outage and now renders as success with "{}" text (via textResult). That's probably the right call (a 2xx is a success), but it deserves a mention in the PR description — only mem_search gets the special [] rendering.

  2. Unused reachedServer getter in the test fixture. index-source.test.mjs exposes reachedServer: () => lastFetchReachedServer but no test asserts on it (the let declaration itself is needed so the extracted engramFetch body compiles). Either add an assertion exercising the flag directly, or drop the getter.

  3. Note, non-blocking: the flag is module-level mutable state; two concurrent tool calls could clobber each other. Same pre-existing trade-off as lastFetchTimeoutMethod, and Pi tool calls are sequential in practice, so no change requested here.

Test coverage

Good: the four new cases in native-tool-contract.test.mjs cover 200 null, 200 [], populated results, and a 500 with a server error. The "unreachable server → outage" case was already covered by the pre-existing test in the same file, so the matrix is complete.

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
@jacargentina
jacargentina force-pushed the fix/pi-mem-search-null-empty-results branch from aff951a to 1a6dc8d Compare August 1, 2026 20:50

@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: 1

🤖 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 193-211: Refactor engramFetch and its callers to return an
explicit fetch result envelope that distinguishes successful 2xx responses,
reachable null/empty bodies, transport failures, and timeouts. Remove the
module-level lastFetchReachedServer state and takeLastFetchReachedServer helper,
propagate the result through the relevant tool flow, and keep the adapter
focused on parsing inputs, invoking the core API, and returning the explicit
result.
🪄 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: 790373f9-000f-4d50-995c-4c1bc9fa1228

📥 Commits

Reviewing files that changed from the base of the PR and between aff951a and 1a6dc8d.

📒 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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 3 'engramFetch|lastFetchReachedServer|executeMemoryTool' plugin/pi/index.ts
fd -t f -e go . -0 | xargs -0 -r rg -n -C 3 'mem_search|/search|Engram|unreachable|reachab' || true

Repository: Gentleman-Programming/engram

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- plugin/pi/index.ts relevant sections ---\n'
sed -n '1,290p' plugin/pi/index.ts
sed -n '820,905p' plugin/pi/index.ts

printf '\n--- tool dispatch and result handling mentions ---\n'
rg -n -C 4 'executeMemoryTool|resultToString|lastFetch|takeLastFetch|out-of-band|HTTP|outage|server unreachable|null JSON' plugin/pi/index.ts

Repository: Gentleman-Programming/engram

Length of output: 257


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- plugin/pi/index.ts relevant sections ---'
sed -n '1,290p' plugin/pi/index.ts
sed -n '820,905p' plugin/pi/index.ts

printf '%s\n' ''
printf '%s\n' '--- tool dispatch and result handling mentions ---'
rg -n -C 4 'executeMemoryTool|resultToString|lastFetch|takeLastFetch|out-of-band|HTTP|outage|server unreachable|null JSON' plugin/pi/index.ts

Repository: Gentleman-Programming/engram

Length of output: 22162


Move fetch result envelopes out of the adapter.

engramFetch hides reachable empty responses and transport failures behind null while storing the last-server-reached/timeout state in module globals. Return explicit result states from the core API or tool instead; then this adapter can stay limited to input parsing, calling core, and returning the 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, Refactor engramFetch and its
callers to return an explicit fetch result envelope that distinguishes
successful 2xx responses, reachable null/empty bodies, transport failures, and
timeouts. Remove the module-level lastFetchReachedServer state and
takeLastFetchReachedServer helper, propagate the result through the relevant
tool flow, and keep the adapter focused on parsing inputs, invoking the core
API, and returning the explicit result.

Source: Path instructions

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

2 participants