From bd37bcd1ec1dd3ebe1363872d9cd38afa472a1b7 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 6 Jul 2026 20:42:00 -0700 Subject: [PATCH] fix(core): keep agent-tool render idempotent so no-persona sessions share the tool constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _render_agent_tool_descriptions rebuilt self._tools and reassigned it on every session init, deep-copying task_agent even with no model aliases and no personas to inject — the single-model CLI case the docstring says is skipped. This regressed after the persona-discoverability change removed the early 'if self._registry is None: return' guard, breaking the session._tools is INTERACTIVE_TOOLS invariant (test_session_without_mcp). Gate the reassignment on whether a description actually changed: keep the original tool object when the render is a no-op, fork self._tools only when something was injected. Restores the shared-constant invariant, makes repeated renders idempotent, and preserves clear-stale-on-reload (an emptied registry still takes the changed path). --- turnstone/core/session.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 9ddedff1..b7ecfb92 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -2589,6 +2589,7 @@ class ChatSession: persona_base[fn["name"]] = (prop or {}).get("description", "") new_tools: list[dict[str, Any]] = [] + changed = False for tool in self._tools: fn = tool.get("function") or {} name = fn.get("name", "") @@ -2621,8 +2622,23 @@ class ChatSession: if prop is not None: base = persona_base.get(name, "") prop["description"] = f"{base} {persona_line}".strip() if persona_line else base - new_tools.append(new_tool) - self._tools = new_tools + if new_tool == tool: + # The rendered description is identical to what this tool + # already carries — nothing dynamic to inject (no aliases, + # no personas). Keep the original object so a session with + # nothing to render keeps sharing the pristine module-level + # tool list instead of allocating a per-session copy. + new_tools.append(tool) + else: + new_tools.append(new_tool) + changed = True + # Swap in the rebuilt list only when a description actually changed. + # This keeps the render idempotent and allocation-free when there is + # nothing to inject, so the shared constants (e.g. INTERACTIVE_TOOLS) + # stay referenced by such sessions; a reload that clears a prior + # render still takes the ``changed`` path and replaces the stale list. + if changed: + self._tools = new_tools @staticmethod def _persona_property(props: dict[str, Any]) -> dict[str, Any] | None: