-
Notifications
You must be signed in to change notification settings - Fork 611
fix(pi): treat an empty mem_search as no results, not an outage #655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -190,13 +190,25 @@ function takeLastFetchTimeoutMethod(): string | undefined { | |
| return method; | ||
| } | ||
|
|
||
| // 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; | ||
| let res: Response | undefined; | ||
| let timedOut = false; | ||
| for (let attempt = 0; attempt < ENGRAM_FETCH_MAX_ATTEMPTS; attempt += 1) { | ||
|
|
@@ -241,6 +253,10 @@ async function engramFetch<TResponse = unknown>(path: string, opts: FetchOptions | |
| throw new EngramHttpError(message, res.status, data); | ||
| } | ||
|
|
||
| // The server answered with a 2xx. Record that out-of-band so the tool layer can tell a genuine | ||
| // empty response (a JSON null body, e.g. a no-hit search) apart from an unreachable server — | ||
| // engramFetch returns null for both, and only the latter should surface as an outage. | ||
| lastFetchReachedServer = true; | ||
| return data as TResponse; | ||
| } | ||
|
|
||
|
|
@@ -821,10 +837,14 @@ async function executeMemoryTool(toolName: string, params: Record<string, unknow | |
|
|
||
| try { | ||
| const data = await callMemoryTool(toolName, params, ctx); | ||
| if (data === null) { | ||
| 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 } }; | ||
|
Comment on lines
+840
to
+847
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.tsRepository: 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}")
PYRepository: Gentleman-Programming/engram Length of output: 7168 Preserve JSON parse failures from search results.
🤖 Prompt for AI Agents |
||
| if (toolName === "mem_doctor" && data && typeof data === "object" && "status" in data && data.status === "error") { | ||
| const errorResult = { ...result, isError: true }; | ||
| ctx.ui?.setStatus?.("engram", `🧠 ${project} · ${compactResultStatus(toolName, errorResult)}`); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,3 +78,87 @@ test("registered Pi-native mem_search reports native provider transport failure" | |
| await rm(NODE_MODULES, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| function jsonResponse(status, body) { | ||
| return { ok: status >= 200 && status < 300, status, json: async () => body }; | ||
| } | ||
|
|
||
| // Runs mem_search against a stubbed Engram HTTP server. `searchResponder` returns the Response-like | ||
| // object for the /search request; every other endpoint the extension touches during init/project | ||
| // detection gets a permissive 200 so the search path is what the assertions actually exercise. | ||
| async function runMemSearch(searchResponder) { | ||
| const originalFetch = globalThis.fetch; | ||
| const originalUrl = process.env.ENGRAM_URL; | ||
| process.env.ENGRAM_URL = "http://127.0.0.1:17437"; | ||
| globalThis.fetch = async (url) => { | ||
| const target = String(url); | ||
| if (target.includes("/search")) return searchResponder(); | ||
| if (target.includes("/project/current")) return jsonResponse(200, { project: "gentle-agent-state" }); | ||
| return jsonResponse(200, {}); | ||
| }; | ||
|
|
||
| try { | ||
| 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 }); | ||
|
Comment on lines
+101
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Do not delete the repository’s This helper overwrites stub package paths and then recursively removes 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| test("mem_search treats a JSON null (no-hit) response as an empty result, not an outage", async () => { | ||
| const result = await runMemSearch(() => jsonResponse(200, null)); | ||
|
|
||
| assert.notEqual(result.isError, true); | ||
| assert.doesNotMatch(result.content[0].text, /could not reach the Engram HTTP server/); | ||
| assert.deepEqual(result.details.data, []); | ||
| }); | ||
|
|
||
| test("mem_search preserves an existing empty array response", async () => { | ||
| const result = await runMemSearch(() => jsonResponse(200, [])); | ||
|
|
||
| assert.notEqual(result.isError, true); | ||
| assert.doesNotMatch(result.content[0].text, /could not reach the Engram HTTP server/); | ||
| assert.deepEqual(result.details.data, []); | ||
| }); | ||
|
|
||
| test("mem_search returns populated results unchanged", async () => { | ||
| const hits = [{ id: 1, title: "state markers", type: "discovery" }]; | ||
| const result = await runMemSearch(() => jsonResponse(200, hits)); | ||
|
|
||
| assert.notEqual(result.isError, true); | ||
| assert.deepEqual(result.details.data, hits); | ||
| }); | ||
|
|
||
| test("mem_search surfaces an HTTP non-2xx response as an error", async () => { | ||
| const result = await runMemSearch(() => jsonResponse(500, { error: "search index offline" })); | ||
|
|
||
| assert.equal(result.isError, true); | ||
| assert.match(result.content[0].text, /search index offline/); | ||
| }); | ||
There was a problem hiding this comment.
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:
Repository: Gentleman-Programming/engram
Length of output: 13551
🏁 Script executed:
Repository: Gentleman-Programming/engram
Length of output: 6686
Keep fetch status out of module-global state.
lastFetchReachedServerandlastFetchTimeoutMethodare shared across all tool/lifecycle requests, so a concurrent successful fetch can clear or overwrite the marker for another request beforeexecuteMemoryToolreads them. Scope this reachability/timeout metadata to the same call ascallMemoryTool, e.g. by returning it through the per-operation result.🤖 Prompt for AI Agents
Source: Path instructions