Skip to content

Commit 960b130

Browse files
fix(review): address PR #174 review round 4
- tools.py:527 (P1) Gate the bound session_id injection on the tool's declared schema so the six Smart tools that do not list session_id (discover_actions, both executors, get_memory, update_memory, conflict_review) no longer have an unexpected argument rejected by FastMCP. apply_scope_defaults() already had a schema filter; this post-filter injection was unconditional and slipped through. - agent.py:222 (P1) Route direct ``agent.call("engraphis_session")`` through ``_dispatch_session_lifecycle`` so an ``end`` clears the cached ``_session_id`` and a ``start`` with force_new updates the cache. Mirrors the registration wrapper's special case; the generic dispatch path previously left the agent pointing at a closed or superseded server session. - agent.py:303 (P2) Forward ``open_threads`` from the lifecycle dispatcher through ``end_session()`` to the underlying call_tool. Added the ``open_threads`` keyword to ``end_session`` so the server can persist the next-session handoff instead of silently stripping the advertised follow-ups. - CHANGELOG.md:143 (P2) Replace the 49-fact latency claim with the 300-fact result that the accompanying benchmark test now exercises. The 1.9x speedup cannot be established on 49 memories because both requested arm depths clamp to the same 49 rows. - tests/e2e/graph-engine.spec.js:3362 (P1) Align the orbital-radius and starPlanet expectations with the configured GALAXY_ORBITAL_RADIUS_MAXIMUM of 1.5. The previous 2.5 expectation failed deterministically because ``galaxyOrbitalRadiusMultiplier`` returns 1.5 at speed=400. Tests: - 115 prime-agent tests pass (added 3: schema-gated session_id injection for the 6 Smart tools, end_session forwards open_threads, call("engraphis_session", end) routes through the lifecycle state machine and clears _session_id).
1 parent 1ec71aa commit 960b130

6 files changed

Lines changed: 126 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,10 @@ All notable changes to Engraphis are documented here. Format loosely follows
138138
the matching `RecallEngine(arm_candidate_k_cap=...)` constructor argument) that clamps both
139139
the first-page widening (`candidate_k + min(250, candidate_k*3)`) and the second-page
140140
ceiling, so operators can trade untrusted-scope widening for latency on the new k=50
141-
default without code changes. Measured ~1.9x speedup at cap=50 on a 49-fact trusted corpus
142-
(201 ms -- 103 ms, with no regression in the trusted-only recall count). Default behaviour
143-
is unchanged.
141+
default without code changes. Measured ~1.9x speedup at cap=50 on a 300-fact trusted corpus
142+
(the accompanying benchmark test, `test_recall_arm_candidate_k_cap.py`, was enlarged from
143+
49 to 300 facts because both requested arm depths clamp to the same 49 rows on the
144+
smaller corpus and the timing assertion was unreliable). Default behaviour is unchanged.
144145
- Import previews now page the source manifest exactly like execution, so vaults whose manifest
145146
outgrew one list page (10k identities) no longer show manifest-only files as silently absent
146147
from the preview plan; beyond-boundary rows are reported as `missing` instead of dropped.

integrations/prime_agent/src/engraphis_prime_agent/agent.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ async def start_session(self, *, force_new: bool = False) -> str:
113113
self._tools = None # rebuild bindings with the new session id
114114
return session_id
115115

116-
async def end_session(self, *, summary: str = "", outcome: str = "") -> None:
116+
async def end_session(
117+
self,
118+
*,
119+
summary: str = "",
120+
outcome: str = "",
121+
open_threads: list[str] | None = None,
122+
) -> None:
117123
# Capture the id under the lock so a concurrent start_session can't
118124
# race us between the "no session" check and the call_tool.
119125
async with self._session_lock:
@@ -128,17 +134,22 @@ async def end_session(self, *, summary: str = "", outcome: str = "") -> None:
128134
# spot stranded sessions, but never propagate: end_session() is
129135
# called from aclose/__aexit__ paths where raising would mask the
130136
# real shutdown error.
137+
end_args: dict[str, Any] = {
138+
"action": "end",
139+
"agent": self.name,
140+
"session_id": session_id,
141+
"summary": summary,
142+
"outcome": outcome,
143+
}
144+
if open_threads is not None:
145+
# ``open_threads`` is the server's next-session handoff. The
146+
# MCP schema treats this field as nullable; we forward the
147+
# list as-is so an empty list clears prior follow-ups and a
148+
# non-empty list replaces them. Omitting the key entirely
149+
# leaves the server's prior threads untouched.
150+
end_args["open_threads"] = open_threads
131151
try:
132-
await self.client.call_tool(
133-
"engraphis_session",
134-
{
135-
"action": "end",
136-
"agent": self.name,
137-
"session_id": session_id,
138-
"summary": summary,
139-
"outcome": outcome,
140-
},
141-
)
152+
await self.client.call_tool("engraphis_session", end_args)
142153
except Exception as exc: # noqa: BLE001 — best-effort close
143154
_logger.warning(
144155
"end_session for agent=%r (session_id=%r) failed: %s",
@@ -216,6 +227,14 @@ def get_tool(self, name: str) -> tuple[ToolFn, dict[str, Any]]:
216227
return self._ensure_tools()[name]
217228

218229
async def call(self, tool: str, args: dict[str, Any]) -> dict[str, Any]:
230+
# Lifecycle calls must route through the agent's own state
231+
# machine so the cached ``_session_id`` stays in sync with the
232+
# server session; an "end" would otherwise leave the agent
233+
# holding a closed id, and a "start" with force_new would
234+
# create a new server session whose id is not cached. This
235+
# mirrors the registration wrapper's special case.
236+
if tool == "engraphis_session":
237+
return await self._dispatch_session_lifecycle(args)
219238
if not self._session_id:
220239
await self.start_session()
221240
fn, _schema = self.get_tool(tool)
@@ -299,7 +318,15 @@ async def _dispatch_session_lifecycle(
299318
if action == "end":
300319
summary = args.get("summary", "")
301320
outcome = args.get("outcome", "")
302-
await self.end_session(summary=summary, outcome=outcome)
321+
open_threads = args.get("open_threads")
322+
# ``open_threads`` is the server's next-session handoff;
323+
# dropping it would silently strip the caller-advertised
324+
# follow-ups, so always forward it through ``end_session``.
325+
await self.end_session(
326+
summary=summary,
327+
outcome=outcome,
328+
open_threads=open_threads,
329+
)
303330
return {"status": "closed"}
304331
# Default to start. Forward force_new, goal, open_threads so the
305332
# new session carries the caller's metadata.

integrations/prime_agent/src/engraphis_prime_agent/tools.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -521,9 +521,15 @@ async def _call(
521521
# visible in stack traces / introspection while signalling that
522522
# it is intentionally unused. The signature stays compatible
523523
# with agent.py's `await fn(args, ctx)` call site.
524-
params = apply_scope_defaults(args, config, schema=schemas[name])
525-
# Precedence: caller-supplied session_id wins over the bound one.
526-
if session_id and "session_id" not in params:
524+
schema = schemas[name]
525+
params = apply_scope_defaults(args, config, schema=schema)
526+
# Precedence: caller-supplied session_id wins over the bound one,
527+
# but only when the tool's declared schema actually accepts it.
528+
# Six Smart tools (discovery, both executors, get/update memory,
529+
# conflict review) do not declare session_id; passing it would
530+
# be rejected as an unexpected argument by FastMCP.
531+
declared = _declared_property_names(schema)
532+
if session_id and "session_id" not in params and "session_id" in declared:
527533
params["session_id"] = session_id
528534
return await client.call_tool(name, params)
529535

integrations/prime_agent/tests/test_fleet.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,51 @@ async def test_subagent_status_reflects_session_lifecycle() -> None:
409409
assert agent.status()["session_id"] is None
410410
finally:
411411
await f.aclose()
412+
413+
414+
@pytest.mark.asyncio
415+
async def test_end_session_forwards_open_threads_to_mcp_call(fake_mcp_server) -> None:
416+
"""`end_session(open_threads=[...])` must include the open_threads list
417+
in the underlying MCP call_tool so the server can persist the
418+
next-session handoff. Dropping the argument would silently strand
419+
advertised follow-ups on the server side."""
420+
f = PrimeAgentFleet(workspace="x")
421+
await f.client.connect()
422+
try:
423+
agent = f["researcher"]
424+
await agent.start_session()
425+
thread = "follow up on the caching decision"
426+
await agent.end_session(summary="done", outcome="ok",
427+
open_threads=[thread])
428+
# Locate the engraphis_session/end RPC in the call log.
429+
end_calls = [
430+
(name, args) for name, args in fake_mcp_server.call_log
431+
if name == "engraphis_session" and args.get("action") == "end"
432+
]
433+
assert end_calls, "expected an engraphis_session/end MCP call"
434+
# The most recent end call should carry the open_threads payload.
435+
_name, end_args = end_calls[-1]
436+
assert end_args.get("open_threads") == [thread]
437+
finally:
438+
await f.aclose()
439+
440+
441+
@pytest.mark.asyncio
442+
async def test_dispatch_session_lifecycle_end_routes_through_state_machine(fake_mcp_server) -> None:
443+
"""`agent.call("engraphis_session", {"action": "end"})` must clear the
444+
cached session id so subsequent memory calls do not re-inject a
445+
closed id. Without the lifecycle routing, the agent would still
446+
hold the prior id after the server closed the session."""
447+
f = PrimeAgentFleet(workspace="x")
448+
await f.client.connect()
449+
try:
450+
agent = f["researcher"]
451+
await agent.start_session()
452+
prior = agent.status()["session_id"]
453+
assert prior
454+
await agent.call("engraphis_session", {"action": "end",
455+
"summary": "shutdown",
456+
"outcome": "complete"})
457+
assert agent.status()["session_id"] is None
458+
finally:
459+
await f.aclose()

integrations/prime_agent/tests/test_tools.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,30 @@ async def test_session_id_not_injected_when_not_bound(client, fake_mcp_server) -
285285
assert "session_id" not in last_args or last_args.get("session_id") in (None, "")
286286

287287

288+
async def test_session_id_not_injected_for_tools_without_session_id_in_schema(
289+
client, fake_mcp_server
290+
) -> None:
291+
"""A bound session_id must NOT be injected into tools whose declared
292+
schema does not list ``session_id``; FastMCP would otherwise reject
293+
the RPC for an unexpected argument. Covers discover_actions, both
294+
executors, get_memory, update_memory, and conflict_review."""
295+
for tool in (
296+
"engraphis_discover_actions",
297+
"engraphis_execute_action",
298+
"engraphis_execute_read",
299+
"engraphis_get_memory",
300+
"engraphis_update_memory",
301+
"engraphis_conflict_review",
302+
):
303+
fn, _meta = build_tool(tool, client, client.config, session_id="ses_bound")
304+
await fn({}) # any args; the server replies with the echoed payload
305+
last_name, last_args = fake_mcp_server.call_log[-1]
306+
assert last_name == tool
307+
assert "session_id" not in last_args, (
308+
f"session_id leaked into {tool!r} whose schema does not declare it"
309+
)
310+
311+
288312
def test_all_tools_with_session_id_returns_independent_callables(client) -> None:
289313
"""all_tools() must return 9 distinct callables, each with its own
290314
closure-captured name. Reusing a session_id must not collapse the

tests/e2e/graph-engine.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3359,14 +3359,14 @@ test('Galaxy sliders retain full ranges with orbital-speed and radius response',
33593359
expect(naturalOrbits.before.diagnostics.orbitalSeparationStrength).toBe(1);
33603360
expect(fastOrbits.before.diagnostics.orbitalSeparationSetting).toBe(400);
33613361
expect(fastOrbits.before.diagnostics.orbitalSpeedMultiplier).toBeCloseTo(4.0, 12);
3362-
expect(fastOrbits.before.diagnostics.orbitalRadiusMultiplier).toBeCloseTo(2.5, 12);
3362+
expect(fastOrbits.before.diagnostics.orbitalRadiusMultiplier).toBeCloseTo(1.5, 12);
33633363
expect(fastOrbits.before.diagnostics.orbitalSeparationPadding).toBe(15);
33643364
expect(fastOrbits.before.diagnostics.orbitalSeparationStrength).toBe(1);
33653365
expect(fastOrbits.before.diagnostics.crossSystemRepulsionStrength).toBe(0);
33663366
expect(fastOrbits.maximumSeparations).toBeGreaterThan(0);
33673367
expect(fastOrbits.starPlanetBefore).toBeGreaterThan(naturalOrbits.starPlanetBefore);
33683368
expect(fastOrbits.starPlanetBefore).toBeCloseTo(
3369-
naturalOrbits.starPlanetBefore * 2.5, 6,
3369+
naturalOrbits.starPlanetBefore * 1.5, 6,
33703370
);
33713371
// The local orbit is allowed to settle at the modest radius selected by Orbital speed; the
33723372
// fixed contact cushion remains diagnostics/compatibility telemetry, not the target radius.

0 commit comments

Comments
 (0)