From 5237cab75625122e2605be131b8fb86cdd46a36a Mon Sep 17 00:00:00 2001 From: outpoints <12377381+outpoints@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:28:09 -0700 Subject: [PATCH] fix(honcho): thread logical cwd through agent construction (cherry picked from commit b1d7207c45311be658592c6ad34ee84634fed0ee) --- acp_adapter/session.py | 1 + agent/agent_init.py | 7 +++- run_agent.py | 2 +- tests/acp_adapter/test_session.py | 14 +++++-- tests/agent/test_memory_user_id.py | 10 ++++- tests/tui_gateway/test_compute_host_phase1.py | 42 +++++++++++++++++++ .../test_session_resume_db_ownership.py | 6 +-- tests/tui_gateway/test_tui_gateway_server.py | 21 ++++++++-- tui_gateway/compute_host.py | 1 + tui_gateway/methods_session.py | 17 +++++--- tui_gateway/server.py | 9 ++-- website/docs/user-guide/features/honcho.md | 4 +- 12 files changed, 113 insertions(+), 21 deletions(-) diff --git a/acp_adapter/session.py b/acp_adapter/session.py index e1af050255..067dd78d1c 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -397,6 +397,7 @@ class SessionManager: "platform": "acp", "quiet_mode": True, "session_id": session_id, "session_db": self._get_db(), "enabled_toolsets": _expand_acp_enabled_toolsets(["hermes-acp"], mcp_server_names=configured_mcp_servers), "model": model or default_model, + "cwd": cwd, } try: runtime = resolve_runtime_provider(requested=requested_provider or config_provider) diff --git a/agent/agent_init.py b/agent/agent_init.py index 890f25b405..ad1a8ceb21 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1224,6 +1224,8 @@ def _memory_provider_init_kwargs(agent, platform) -> Dict[str, Any]: _val = getattr(agent, f"_{_ident}") if _val: kwargs[_ident] = _val + if agent.session_cwd: + kwargs["cwd"] = agent.session_cwd # Profile identity for per-profile provider scoping with suppress(Exception): from hermes_cli.profiles import get_active_profile_name @@ -2210,13 +2212,15 @@ def init_agent( fallback_model: Dict[str, Any] = None, credential_pool=None, checkpoints_enabled: bool = False, checkpoint_max_snapshots: int = 20, checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, - requested_provider: str = None, capabilities: Optional[Dict[str, bool]] = None, + requested_provider: str = None, capabilities: Optional[Dict[str, bool]] = None, cwd: Optional[str] = None, ): """Initialize the AI Agent (body of :meth:`AIAgent.__init__`). Non-obvious parameters: max_iterations: default unlimited (sys.maxsize); the budget is shared with subagents. requested_provider: provider identity before runtime canonicalization. + cwd: logical session workspace, available to memory providers during construction; + None or empty leaves the runtime cwd resolver unpinned. openrouter_min_coding_score: coding-score floor for ``openrouter/pareto-code`` only. clarify_callback: ``(question, choices) -> str``; None → the clarify tool errors. reasoning_config: None → ``{"enabled": True, "effort": "medium"}`` on OpenRouter. @@ -2232,6 +2236,7 @@ def init_agent( setattr(agent, _name, _params[_name]) for _name in _GATEWAY_IDENTITY_PARAMS: setattr(agent, f"_{_name}", _params[_name]) + agent.session_cwd = cwd # Shared iteration budget: parent creates, children inherit. agent.iteration_budget = iteration_budget or IterationBudget(max_iterations) # CLI replaces this with _cprint so raw ANSI status lines go through prompt_toolkit's diff --git a/run_agent.py b/run_agent.py index 7b0a038d62..61263d4ebf 100644 --- a/run_agent.py +++ b/run_agent.py @@ -269,7 +269,7 @@ class AIAgent( checkpoints_enabled: bool = False, checkpoint_max_snapshots: int = 20, checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, requested_provider: str = None, - capabilities: Dict[str, bool] | None = None, + capabilities: Dict[str, bool] | None = None, cwd: str | None = None, ): """Forwarder — see ``agent.agent_init.init_agent`` (same keyword parameters, minus ``tool_delay``).""" init_kwargs = {k: v for k, v in locals().items() if k not in ("self", "tool_delay")} diff --git a/tests/acp_adapter/test_session.py b/tests/acp_adapter/test_session.py index 24d59c8bdf..d95c1dfb51 100644 --- a/tests/acp_adapter/test_session.py +++ b/tests/acp_adapter/test_session.py @@ -66,12 +66,19 @@ class TestCreateSession: assert fetched is state - def test_make_agent_stamps_session_cwd_for_codex_runtime(self, monkeypatch): + def test_make_agent_uses_session_cwd_during_init_and_stamps_runtime( + self, monkeypatch, tmp_path + ): + workspace = tmp_path / "workspace" + workspace.mkdir() + observed = {} + class FakeAgent: model = "fake-model" def __init__(self, **kwargs): self.kwargs = kwargs + observed["cwd"] = kwargs.get("cwd") monkeypatch.setattr("run_agent.AIAgent", FakeAgent) monkeypatch.setattr( @@ -106,9 +113,10 @@ class TestCreateSession: ) monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda task_id, cwd: None) - state = SessionManager(db=None).create_session(cwd="/tmp/project") + state = SessionManager(db=None).create_session(cwd=str(workspace)) - assert state.agent.session_cwd == "/tmp/project" + assert observed["cwd"] == str(workspace) + assert state.agent.session_cwd == str(workspace) diff --git a/tests/agent/test_memory_user_id.py b/tests/agent/test_memory_user_id.py index b688a08f14..82b6b0215d 100644 --- a/tests/agent/test_memory_user_id.py +++ b/tests/agent/test_memory_user_id.py @@ -99,7 +99,7 @@ class TestMemoryManagerUserIdThreading: assert p2._init_kwargs.get("user_id") == "slack_U12345" assert p2._init_kwargs.get("platform") == "slack" - def test_session_title_provenance_reaches_provider(self): + def test_session_title_provenance_and_cwd_reach_provider(self, tmp_path): from run_agent import AIAgent provider = RecordingProvider() @@ -127,12 +127,20 @@ class TestMemoryManagerUserIdThreading: base_url="https://openrouter.ai/api/v1", quiet_mode=True, skip_context_files=True, + platform="telegram", session_id="session-with-title", session_db=session_db, + gateway_session_key="agent:main:telegram:dm:42", + cwd=str(tmp_path), ) assert provider._init_kwargs["session_title"] == "Generated title" assert provider._init_kwargs["session_title_source"] == "llm" + assert ( + provider._init_kwargs["gateway_session_key"] + == "agent:main:telegram:dm:42" + ) + assert provider._init_kwargs["cwd"] == str(tmp_path) agent.close() diff --git a/tests/tui_gateway/test_compute_host_phase1.py b/tests/tui_gateway/test_compute_host_phase1.py index a9eda851ef..c15000aa17 100644 --- a/tests/tui_gateway/test_compute_host_phase1.py +++ b/tests/tui_gateway/test_compute_host_phase1.py @@ -5,6 +5,7 @@ import sys import threading import time from pathlib import Path +from types import SimpleNamespace import pytest @@ -17,6 +18,47 @@ from tui_gateway.host_supervisor import ( ) +def test_agent_build_receives_seeded_workspace_cwd(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(server, "_sessions", {}) + + observed: dict[str, str] = {} + + def fake_make_agent(*_args, **_kwargs): + observed["cwd"] = _kwargs["cwd_override"] + return SimpleNamespace(session_id="conversation-id") + + def fake_init_session(sid, key, agent, history, *, cwd=None, **_kwargs): + server._sessions[sid] = { + "agent": agent, + "session_key": key, + "history": history, + "cwd": cwd, + } + + monkeypatch.setattr(server, "_make_agent", fake_make_agent) + monkeypatch.setattr(server, "_transfer_db_to_agent", lambda *_args: False) + monkeypatch.setattr(server, "_init_session", fake_init_session) + + host = ComputeHost(stdout=io.StringIO(), heartbeat_secs=0) + try: + host._ensure_server_session( + server, + { + "sid": "ui-session", + "session_key": "conversation-id", + "cwd": str(workspace), + "source": "desktop", + "history": [], + }, + ) + finally: + host.close() + + assert observed["cwd"] == str(workspace) + + def _json_lines(out: io.StringIO) -> list[dict]: frames = [] for line in out.getvalue().splitlines(): diff --git a/tests/tui_gateway/test_session_resume_db_ownership.py b/tests/tui_gateway/test_session_resume_db_ownership.py index 547db55988..f03ea6b661 100644 --- a/tests/tui_gateway/test_session_resume_db_ownership.py +++ b/tests/tui_gateway/test_session_resume_db_ownership.py @@ -305,7 +305,7 @@ def test_resume_keeps_profile_db_open_after_ownership_transfer(profile_dbs, monk monkeypatch.setattr("hermes_state_registry.acquire", _factory) monkeypatch.setattr(server, "_make_agent", _fake_make_agent) monkeypatch.setattr(server, "_init_session", _fake_init_session) - monkeypatch.setattr(server, "_set_session_context", lambda _target: []) + monkeypatch.setattr(server, "_set_session_context", lambda _target, cwd=None: []) monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None) monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {}) monkeypatch.setattr(server, "_session_info", lambda agent, *a: {"model": "test"}) @@ -352,7 +352,7 @@ def test_resume_drops_half_built_session_when_init_session_raises( server, "_make_agent", lambda *a, **k: types.SimpleNamespace(model="test") ) monkeypatch.setattr(server, "_init_session", _fake_init_session) - monkeypatch.setattr(server, "_set_session_context", lambda _target: []) + monkeypatch.setattr(server, "_set_session_context", lambda _target, cwd=None: []) monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None) monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {}) @@ -396,7 +396,7 @@ def test_resume_eager_never_transfers_shared_launch_db(profile_dbs, monkeypatch) monkeypatch.setattr(server, "_make_agent", _fake_make_agent) monkeypatch.setattr(server, "_init_session", _fake_init_session) - monkeypatch.setattr(server, "_set_session_context", lambda _target: []) + monkeypatch.setattr(server, "_set_session_context", lambda _target, cwd=None: []) monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None) monkeypatch.setattr( server, "_stored_session_runtime_overrides", lambda _found: {} diff --git a/tests/tui_gateway/test_tui_gateway_server.py b/tests/tui_gateway/test_tui_gateway_server.py index aedced354f..ec58712125 100644 --- a/tests/tui_gateway/test_tui_gateway_server.py +++ b/tests/tui_gateway/test_tui_gateway_server.py @@ -3760,7 +3760,7 @@ def test_session_resume_follows_compression_tip(monkeypatch, tmp_path): monkeypatch.setattr(server, "_get_db", lambda: db) monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) - monkeypatch.setattr(server, "_set_session_context", lambda target: []) + monkeypatch.setattr(server, "_set_session_context", lambda target, cwd=None: []) monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None) monkeypatch.setattr(server, "_make_agent", fake_make_agent) monkeypatch.setattr( @@ -3821,7 +3821,7 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch): monkeypatch.setattr(server, "_get_db", lambda: FakeDB()) monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) - monkeypatch.setattr(server, "_set_session_context", lambda target: []) + monkeypatch.setattr(server, "_set_session_context", lambda target, cwd=None: []) monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None) monkeypatch.setattr(server, "_make_agent", fake_make_agent) monkeypatch.setattr(server, "_session_info", lambda agent, *a: {"model": agent.model, "provider": agent.provider}) @@ -3906,6 +3906,7 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path): def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs): captured["agent_db"] = session_db + captured["agent_cwd"] = kwargs.get("cwd_override") return types.SimpleNamespace(model="test/model") monkeypatch.setenv("TERMINAL_CWD", str(launch_cwd)) @@ -3913,7 +3914,11 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path): monkeypatch.setattr("hermes_state_registry.acquire", lambda db_path=None: profile_db) monkeypatch.setattr(server, "_get_db", lambda: launch_db) monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) - monkeypatch.setattr(server, "_set_session_context", lambda target: []) + monkeypatch.setattr( + server, + "_set_session_context", + lambda target, cwd=None: captured.setdefault("context_cwd", cwd) or [], + ) monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None) monkeypatch.setattr(server, "_make_agent", fake_make_agent) monkeypatch.setattr(server, "_SlashWorker", FakeWorker) @@ -3944,6 +3949,8 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path): assert "error" not in resp sid = resp["result"]["session_id"] assert captured["agent_db"] is profile_db + assert captured["context_cwd"] == str(profile_cwd) + assert captured["agent_cwd"] == str(profile_cwd) assert server._sessions[sid]["cwd"] == str(profile_cwd) assert resp["result"]["info"]["cwd"] == str(profile_cwd) assert "launch_update" not in captured @@ -15894,6 +15901,7 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): def _fake_make_agent(*a, **k): seen["agent_session_db"] = k.get("session_db") + seen["agent_cwd"] = k.get("cwd_override") return FakeAgent() monkeypatch.setattr(server, "_make_agent", _fake_make_agent) @@ -15927,6 +15935,7 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): # not just the row. Otherwise its own flushes (and a later compression # rotation) land on the launch db, splitting the lineage again. assert isinstance(seen.get("agent_session_db"), ProfileDB) + assert seen.get("agent_cwd") == str(tmp_path) finally: for k in list(server._sessions): server._sessions.pop(k, None) @@ -16396,6 +16405,10 @@ def test_session_branch_uses_persisted_display_history_after_compaction(monkeypa def set_session_title(self, _key, _title): return True + def set_auto_title(self, _key, _title, *, source="llm"): + seen["title_source"] = source + return True + def get_session(self, key): return {"id": key, "cwd": str(tmp_path)} @@ -16450,6 +16463,7 @@ def test_session_branch_uses_persisted_display_history_after_compaction(monkeypa ) assert "result" in response, response + assert seen.get("title_source") == "derived" assert [message["content"] for message in seen["msgs"]] == [ "first question", "first answer", @@ -21201,6 +21215,7 @@ def test_session_branch_keeps_reasoning_fields(monkeypatch, tmp_path): ) assert resp.get("result"), f"got error: {resp.get('error')}" + assert db.get_session_title_source("branch-key") == SessionDB.TITLE_SOURCE_DERIVED assistant = _branched_assistant(db, "branch-key") assert assistant["reasoning"] == BRANCH_REASONING assert assistant["reasoning_content"] == BRANCH_REASONING_CONTENT diff --git a/tui_gateway/compute_host.py b/tui_gateway/compute_host.py index ee404e98f5..5d4abdb2ca 100644 --- a/tui_gateway/compute_host.py +++ b/tui_gateway/compute_host.py @@ -324,6 +324,7 @@ class ComputeHost: reasoning_config_override=frame.get("reasoning_config_override"), service_tier_override=frame.get("service_tier_override"), platform_override=frame.get("source"), + cwd_override=str(frame.get("cwd") or "") or None, context_cwd_is_launch_artifact=bool( frame.get("context_cwd_is_launch_artifact", False)), session_db=session_db, auth_user_id=frame.get("auth_user_id")) diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 01edabe3b0..c0692aae04 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -77,7 +77,7 @@ def _profile_build_scope(profile_home): def _make_agent_in_context(sid: str, key: str, **kwargs): """``_make_agent`` with the session context bound for the build and cleared after.""" - tokens = _set_session_context(key) + tokens = _set_session_context(key, cwd=kwargs.get("cwd_override")) try: return _make_agent(sid, key, session_id=key, **kwargs) finally: @@ -221,7 +221,7 @@ def _billing_pending_change(result: dict) -> dict: # ── session.create / list / most_recent / facts ────────────────────── def _persist_branch(db, new_key: str, parent_key: str, title: str, history: list, *, source, cwd, profile_name, - copy_fields=(), compensate: bool = False) -> None: + copy_fields=(), compensate: bool = False, title_source: str = "user") -> None: """Branch child row + parent transcript (bounded-chunk transactions) + title. ``_branched_from`` keeps the row visible in list_sessions_rich() (the live parent never matches the legacy end_reason='branched' heuristic); NULL ``profile_name`` rows drop out of profile-keyed sidebar matching / deep links. ``compensate`` @@ -240,7 +240,10 @@ def _persist_branch(db, new_key: str, parent_key: str, title: str, history: list db.append_messages_batch( new_key, [{"role": msg.get("role", "user"), "content": msg.get("content"), **{field: msg.get(field) for field in copy_fields}} for msg in history], chunk_rows=500) - db.set_session_title(new_key, title) + if title_source == "user": + db.set_session_title(new_key, title) + else: + db.set_auto_title(new_key, title, source=title_source) except Exception as exc: from hermes_state_errors import is_disk_full_error if compensate and not is_disk_full_error(exc): @@ -261,7 +264,8 @@ def _seed_branch_row(record: dict, key: str, parent_session_id: str, history: li return _persist_branch(db, key, parent_session_id, _branch_title(db, parent_session_id), history, source=source, cwd=record["cwd"], - profile_name=profile_name_for_home(profile_home) or _current_profile_name(), compensate=True) + profile_name=profile_name_for_home(profile_home) or _current_profile_name(), + compensate=True, title_source="derived") record["pending_title"] = None # The first submit's _persist_branch_seed is the fallback for a failed seed, not a second copy. record["_branch_seed_persisted"] = True @@ -795,6 +799,7 @@ def _resume_eager(ctx: _Resume) -> dict: stored_runtime_overrides = _stored_session_runtime_overrides(ctx.found) agent = _make_agent_in_context( sid, ctx.target, session_db=ctx.db, platform_override=source, + cwd_override=ctx.profile_resume_cwd or None, context_cwd_is_launch_artifact=(source in _LAUNCH_CWD_NOT_A_WORKSPACE and not ctx.profile_resume_cwd), auth_user_id=_transport_auth_user_id(current_transport()), **stored_runtime_overrides) except Exception as e: @@ -1931,6 +1936,7 @@ def _build_branch_agent(session: dict, new_sid: str, new_key: str, history: list try: with _profile_build_scope(parent_home): agent = _make_agent_in_context(new_sid, new_key, session_db=branch_db, platform_override=source, + cwd_override=_session_cwd(session), context_cwd_is_launch_artifact=_context_cwd_is_launch_artifact(session), auth_user_id=parent_user_id) _init_session(new_sid, new_key, agent, list(history), cols=session.get("cols", 80), @@ -1991,7 +1997,8 @@ def _(rid, params: dict, session: dict) -> dict: home = session.get("profile_home") _persist_branch(db, new_key, old_key, title, history, source=source, cwd=_session_cwd(session), profile_name=profile_name_for_home(home) or _current_profile_name(), - copy_fields=_BRANCH_COPY_FIELDS) + copy_fields=_BRANCH_COPY_FIELDS, + title_source="user" if params.get("name") else "derived") except Exception as e: return _err(rid, 5008, f"branch failed: {e}") try: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5e1215fc5e..0325334c12 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -990,7 +990,7 @@ def _deferred_build_agent_kwargs(current: dict, session_db) -> dict: runtime identity (like the eager resume's overrides splat) so the build can't drop the provider. No stored runtime, or an unroutable provider → this session's picked model/effort/tier, else the default.""" kw = {"session_db": session_db, "context_cwd_is_launch_artifact": _context_cwd_is_launch_artifact(current), - "platform_override": _session_source(current)} + "platform_override": _session_source(current), "cwd_override": _session_cwd(current)} if resume_sid := current.get("resume_session_id"): kw["session_id"] = resume_sid resume_overrides = current.get("resume_runtime_overrides") @@ -1049,6 +1049,8 @@ def _attach_built_agent(current: dict, agent) -> None: if _title_hint := str(current.get("pending_title") or "").strip(): agent._session_title_hint = _title_hint current["agent"] = agent + # A workspace move can land while construction is still in flight. + _register_session_cwd(current) _session_todo_state(current) # Baseline for the per-turn config sync (profile home override still active). current["config_model_seen"] = _config_model_target() @@ -1123,7 +1125,7 @@ def _start_agent_build(sid: str, session: dict) -> None: # why — a turn admitted against this record refuses with the real reason (#111531). current["agent_error"] = AGENT_BUILD_ABANDONED return - tokens = _set_session_context(key) + tokens = _set_session_context(key, cwd=_session_cwd(current)) # Global-remote: bind the session profile's HERMES_HOME and hand the agent that profile's db — # DEDICATED and ours until _transfer_db_to_agent in the finally; FAIL CLOSED rather than # binding the launch DB and bleeding rows into the wrong state.db. @@ -2337,7 +2339,7 @@ def _make_agent( model_override: dict | str | None = None, provider_override: str | None = None, reasoning_config_override: dict | None = None, service_tier_override: str | None = None, platform_override: str | None = None, context_cwd_is_launch_artifact: bool | None = None, - auth_user_id: str | None = None): + cwd_override: str | None = None, auth_user_id: str | None = None): # AC-4 test seam: dead unless armed by the isolated certify harness. from tui_gateway.synthetic_turn import maybe_build_synthetic_agent synthetic = maybe_build_synthetic_agent(session_id or key, model_override) @@ -2374,6 +2376,7 @@ def _make_agent( providers_allowed=_pr.get("only"), providers_ignored=_pr.get("ignore"), providers_order=_pr.get("order"), provider_sort=_pr.get("sort"), provider_require_parameters=_pr.get("require_parameters", False), provider_data_collection=_pr.get("data_collection"), platform=platform, session_id=session_id or key, + cwd=cwd_override, # The dashboard login identity reaches memory providers as the runtime user, like a gateway user id. # Builds that run before the record exists (branch, eager resume, compute host) pass it explicitly. user_id=auth_user_id if auth_user_id is not None else _session_auth_user_id(session), diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index cc64246c2e..efa0eab197 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -140,7 +140,9 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and - `per-repo` — one session per git repository. - `global` — single session across all directories. -Automatically generated Hermes titles are display metadata and do not override `sessionStrategy`. An explicit user `/title` remains an intentional session-name override for non-`per-session` CLI sessions. +Automatically generated Hermes titles are display metadata and do not override `sessionStrategy`. An explicit user title remains an intentional session-name override for non-gateway, non-`per-session` sessions. + +Sessions created before title provenance was recorded retain legacy behavior: because an old automatic title cannot be distinguished from an old user title, a title with no source is treated as an explicit override. **Recall mode** controls how memory flows into conversations: - `hybrid` — context auto-injected into system prompt AND tools available (model decides when to query).