diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 003642b..a0e4ec8 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -536,18 +536,6 @@ async def api_bookslm_agent( # run no longer pauses on every subsequent mutating call. ctx.confirmed = True - async def _llm(msgs, tool_schemas): - return await chat_completion( - msgs, - tools=tool_schemas, - provider=req.provider, - model=req.model, - temperature=0.3, - # Tool-call arguments can carry a whole file body (e.g. a generated - # table): leave more room than the plain-chat default. - max_tokens=8192, - ) - async def generate_sse(): import asyncio @@ -561,6 +549,23 @@ async def api_bookslm_agent( yield f"event: error\ndata: {error_data}\n\n" return + # #187: resolve the provider like /chat does — the agent must use + # the same engine the SSE "provider" tag reports (the raw + # req.provider could name an unavailable provider and silently + # fall back to another one via _get_provider_config). + async def _llm(msgs, tool_schemas): + return await chat_completion( + msgs, + tools=tool_schemas, + provider=cfg_name, + model=req.model, + temperature=0.3, + # Tool-call arguments can carry a whole file body (e.g. a + # generated table): leave more room than the plain-chat + # default. + max_tokens=8192, + ) + # Stream tool events live: each executed step is pushed on the # queue by the loop callback and emitted as soon as it happens, # so the UI can grow its « N steps » block while thinking. diff --git a/backend/tools/registry.py b/backend/tools/registry.py index 652802e..18f676f 100644 --- a/backend/tools/registry.py +++ b/backend/tools/registry.py @@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]: return specs +# ponytail: tool schemas are static after import (registration is decorator +# only); keying the cache on len(_REGISTRY) invalidates it if a tool is ever +# registered at runtime. Rebuilding 50 pydantic JSON schemas cost ~27 ms per +# agent/MCP request. +_SCHEMAS_CACHE: dict[Any, list[dict[str, Any]]] = {} + + def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]: - """Return OpenAI-compatible schemas for registered tools.""" - return [spec.openai_schema() for spec in list_tools(scope=scope)] + """Return OpenAI-compatible schemas for registered tools (cached).""" + key = (scope, len(_REGISTRY)) + cached = _SCHEMAS_CACHE.get(key) + if cached is None: + cached = [spec.openai_schema() for spec in list_tools(scope=scope)] + _SCHEMAS_CACHE.clear() + _SCHEMAS_CACHE[key] = cached + return [dict(s) for s in cached] def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None: diff --git a/frontend/index.html b/frontend/index.html index b005234..749838f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4674,9 +4674,9 @@ conversation.
  • - Le bouton « mode agent » active les outils (lire, lister, - chercher) ; les actions de modification demandent une - confirmation avec aperçu des changements. + L'assistant utilise toujours ses outils (lire, lister, + chercher, modifier) ; les actions de modification demandent + une confirmation avec aperçu des changements.
  • Les actions s'affichent dans le fil : l'assistant regroupe les diff --git a/frontend/js/ai-quick-actions.js b/frontend/js/ai-quick-actions.js index 8fb734b..d112407 100644 --- a/frontend/js/ai-quick-actions.js +++ b/frontend/js/ai-quick-actions.js @@ -46,8 +46,8 @@ export const ACTION_CATALOG = Object.freeze([ { id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' }, { id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' }, { id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' }, - { id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true }, - { id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true }, + { id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt' }, + { id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt' }, { id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' }, { id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' }, // ── Code & Scripts ─────────────────────────────────────────────────── diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index bcf3f68..c087514 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -151,9 +151,8 @@ class BooksLM { this._sessions = []; this._currentSessionId = null; this._pendingNewSession = false; - // Agent mode: routes chat through /api/ai/bookslm/agent so the model can - // call read/search tools and propose mutations (confirmation cards). - this._agentMode = this._readAgentMode(); + // #187: the assistant is always in agent mode (tools on /agent endpoint); + // the former header toggle was removed — images still use the /chat path. // Ad-hoc context added with `@` (files/directories) and images attached // by paste or by mentioning an image file. this._adhocFiles = []; @@ -188,33 +187,6 @@ class BooksLM { this._extensions = null; } - _readAgentMode() { - try { - return localStorage.getItem('obsigate-bookslm-agent') === 'true'; - } catch { - return false; - } - } - - _toggleAgentMode() { - this._agentMode = !this._agentMode; - try { - localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false'); - } catch { /* private mode */ } - this._updateAgentToggle(); - } - - _updateAgentToggle() { - if (!this._panel) return; - const btn = this._panel.querySelector('.bookslm-btn-agent'); - if (!btn) return; - btn.classList.toggle('active', this._agentMode); - const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off'); - btn.title = label; - btn.setAttribute('aria-label', label); - btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false'); - } - // ── Public API ────────────────────────────────────────────────────── /** @@ -966,7 +938,6 @@ class BooksLM {
    - @@ -1011,7 +982,6 @@ class BooksLM { panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close()); panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation()); - panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode()); panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => { e.stopPropagation(); this._toggleHistoryMenu(); @@ -1552,10 +1522,9 @@ class BooksLM { if (typeof safeCreateIcons === 'function') safeCreateIcons(); } - /** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */ + /** #97/#99 — Deep Research: a skill-like chip (the agent tools are always on). */ _startDeepResearch() { this._closeExtMenu(); - if (!this._agentMode) this._toggleAgentMode(); this._activeDeepResearch = true; this._renderAttachments(); showToast(t('bookslm.deep_research_started'), 'info'); @@ -2201,15 +2170,14 @@ class BooksLM { } /** Immediate-send the action prompt through the composer (same path as a - * typed message: skills, agent mode and images all keep working). Actions - * flagged `agent` (they mutate the document) transparently switch the - * assistant to agent mode first — same pattern as Deep Research. */ + * typed message: skills and images all keep working). #187: the assistant + * is always agent mode, so the former `agent: true` quick actions need no + * mode switch anymore. */ _runQuickAction(action) { if (!this._panel || !action) return; const { prompt } = actionTexts(action); if (!prompt) return; this._closeActionDrawer(); - if (action.agent && !this._agentMode) this._toggleAgentMode(); const textarea = this._panel.querySelector('textarea'); if (!textarea) return; textarea.value = prompt; @@ -3253,14 +3221,15 @@ class BooksLM { this._saveHistory(); } - /** POST to /chat or /agent depending on the agent-mode toggle. */ + /** POST to /agent (always) or /chat when images are attached. */ _postChat(payload) { - // Images require the plain multimodal chat endpoint (the agent loop is + // #187: the assistant is always agent mode — images are the only case + // that still needs the plain multimodal chat endpoint (the agent loop is // text/tool oriented). const hasImages = Array.isArray(payload.images) && payload.images.length > 0; - const endpoint = (this._agentMode && !hasImages) - ? '/api/ai/bookslm/agent' - : '/api/ai/bookslm/chat'; + const endpoint = hasImages + ? '/api/ai/bookslm/chat' + : '/api/ai/bookslm/agent'; const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) }; return fetch(endpoint, { method: 'POST', diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 49ad0d1..0a9d2f7 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -159,7 +159,8 @@ async function main() { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }), }; globalThis.fetch = async (url, opts) => { - if (String(url).includes("/api/ai/bookslm/chat")) { + // #187: plain-text sends now go to /agent — accept both bookslm endpoints. + if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) { capturedBody = JSON.parse(opts.body); return { ok: true, status: 200, body: { getReader: () => reader } }; } @@ -1171,38 +1172,28 @@ async function main() { assert.equal(b._messages[0].content, "Ancienne conversation"); }); - // ── 14. Agent mode & confirmations (B5) ── - await test("agent mode toggle persists and switches the endpoint", async () => { + // ── 14. Agent mode & confirmations (B5 / #187) ── + await test("#187: no agent toggle — plain requests always target /agent", async () => { localStorage.clear(); const b = new BooksLM(); - assert.equal(b._agentMode, false, "agent mode off by default"); - b._toggleAgentMode(); - assert.equal(b._agentMode, true); - assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true"); - const b2 = new BooksLM(); - assert.equal(b2._agentMode, true, "persisted across instances"); + assert.equal(b._agentMode, undefined, "agent-mode state removed"); + assert.equal(b._toggleAgentMode, undefined, "toggle method removed"); let url = null; globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; }; - b2._abortCtrl = null; - await b2._postChat({ message: "x" }); - assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent"); - b2._agentMode = false; - await b2._postChat({ message: "x" }); - assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat"); + b._abortCtrl = null; + await b._postChat({ message: "x" }); + assert.ok(url.includes("/api/ai/bookslm/agent"), "default targets /agent"); + assert.equal(localStorage.getItem("obsigate-bookslm-agent"), null, "no legacy persistence"); localStorage.clear(); }); - await test("header exposes the agent-mode toggle", () => { + await test("header no longer exposes the agent-mode toggle", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); - const btn = panel.querySelector(".bookslm-btn-agent"); - assert.ok(btn, "agent toggle button present"); - assert.equal(btn.getAttribute("aria-pressed"), "false"); - btn.click(); - assert.equal(btn.getAttribute("aria-pressed"), "true"); + assert.equal(panel.querySelector(".bookslm-btn-agent"), null, "toggle button removed"); panel.remove(); localStorage.clear(); }); @@ -1236,7 +1227,6 @@ async function main() { }; const b = new BooksLM(); - b._agentMode = true; b._panel = b._render(); document.body.appendChild(b._panel); const msg = { @@ -1418,7 +1408,7 @@ async function main() { let posted = null; globalThis.fetch = async (url, opts) => { - if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body); + if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) posted = JSON.parse(opts.body); return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } }; }; await b._sendMessage(); @@ -1432,10 +1422,9 @@ async function main() { localStorage.clear(); }); - // ── 32. Images force the plain chat endpoint (not the agent) (#81) ── - await test("images route to /chat even in agent mode", async () => { + // ── 32. Images force the plain chat endpoint (#81 / #187: always-on agent) ── + await test("images route to /chat, text to /agent", async () => { const b = new BooksLM(); - b._agentMode = true; let url = ""; globalThis.fetch = async (u) => { url = String(u); diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index f969096..c5ec749 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -940,6 +940,31 @@ class TestBooksLMAgentEndpoint: assert "create_file" in captured["system"] assert '"action": "create_file"' not in captured["system"] + def test_agent_llm_uses_resolved_provider(self, bookslm_client, monkeypatch): + """#187: the agent loop must call the provider resolved by + _resolve_provider_name (not the raw request value), so the provider + tag reported by SSE matches the engine that actually answered.""" + import backend.bookslm_routes as routes + from backend.ai_chat import LLMResponse + + seen = {} + + async def fake_chat_completion(messages, **kwargs): + seen["provider"] = kwargs.get("provider") + return LLMResponse(content="ok") + + monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) + monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter") + + token, _ = _login_bookslm(bookslm_client) + resp = bookslm_client.post( + "/api/ai/bookslm/agent", + json={"directory": "", "message": "salut", "mode": "general", "provider": "nope"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + assert seen["provider"] == "openrouter" + def test_agent_tool_call_flow(self, bookslm_client, monkeypatch): import backend.bookslm_routes as routes from backend.ai_chat import LLMResponse, ToolCall diff --git a/tests/test_tools.py b/tests/test_tools.py index 23b0542..b4d6e43 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -93,6 +93,16 @@ class TestRegistry: props = read["function"]["parameters"]["properties"] assert "vault" in props and "path" in props + def test_schemas_cached_but_caller_safe(self): + """#187: repeated calls hit the cache and a caller mutating its copy + (e.g. stripping keys) must never corrupt the next request.""" + a = get_tool_schemas() + b = get_tool_schemas() + assert a == b + assert all(x is not y for x, y in zip(a, b)), "top-level dicts must be fresh copies" + a[0]["poisoned"] = True + assert "poisoned" not in get_tool_schemas()[0] + def test_duplicate_tool_name_raises(self): from backend.tools.registry import tool