fix: assistant IA toujours en mode agent, retrait du bouton toggle #187

- Bouton « mode agent » du panneau supprimé : l'assistant est toujours
  agent (toute requête texte part sur /api/ai/bookslm/agent, les images
  restent sur /chat multimodal). Les mutations gardent la confirmation
  two-step. Deep Research et les quick actions `agent: true` basculaient
  déjà le mode en silence : le toggle ne protégeait plus rien.
- /agent résout le provider comme /chat (req.provider brut transmis à
  l'adapter pouvait désigner un fournisseur indisponible et retomber
  silencieusement sur un autre que l'étiquette SSE affichée).
- Schémas des outils mis en cache par (scope, taille du registre) avec
  copies fraîches par appelant (~27 ms de pydantic économisées par
  requête agent/MCP).
- Aide in-app réécrite, i18n FR/EN (retrait de ai.agent_mode_*),
  tests frontend adaptés (ai.test.mjs 100/100) + non-régression pytest
  (provider résolu, cache sûr).
This commit is contained in:
2026-10-07 08:16:56 -04:00
parent 69c3817579
commit 778fa65b4c
8 changed files with 99 additions and 88 deletions
+17 -12
View File
@@ -536,18 +536,6 @@ async def api_bookslm_agent(
# run no longer pauses on every subsequent mutating call. # run no longer pauses on every subsequent mutating call.
ctx.confirmed = True 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(): async def generate_sse():
import asyncio import asyncio
@@ -561,6 +549,23 @@ async def api_bookslm_agent(
yield f"event: error\ndata: {error_data}\n\n" yield f"event: error\ndata: {error_data}\n\n"
return 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 # Stream tool events live: each executed step is pushed on the
# queue by the loop callback and emitted as soon as it happens, # queue by the loop callback and emitted as soon as it happens,
# so the UI can grow its « N steps » block while thinking. # so the UI can grow its « N steps » block while thinking.
+15 -2
View File
@@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]:
return specs 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]]: def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]:
"""Return OpenAI-compatible schemas for registered tools.""" """Return OpenAI-compatible schemas for registered tools (cached)."""
return [spec.openai_schema() for spec in list_tools(scope=scope)] 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: def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None:
+3 -3
View File
@@ -4674,9 +4674,9 @@
conversation. conversation.
</li> </li>
<li data-i18n="help.assistant_agent"> <li data-i18n="help.assistant_agent">
Le bouton « mode agent » active les outils (lire, lister, L'assistant utilise toujours ses outils (lire, lister,
chercher) ; les actions de modification demandent une chercher, modifier) ; les actions de modification demandent
confirmation avec aperçu des changements. une confirmation avec aperçu des changements.
</li> </li>
<li data-i18n="help.assistant_agent_run"> <li data-i18n="help.assistant_agent_run">
Les actions s'affichent dans le fil : l'assistant regroupe les Les actions s'affichent dans le fil : l'assistant regroupe les
+2 -2
View File
@@ -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: '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: '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: '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', 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', agent: true }, { 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: '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' }, { id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
// ── Code & Scripts ─────────────────────────────────────────────────── // ── Code & Scripts ───────────────────────────────────────────────────
+12 -43
View File
@@ -151,9 +151,8 @@ class BooksLM {
this._sessions = []; this._sessions = [];
this._currentSessionId = null; this._currentSessionId = null;
this._pendingNewSession = false; this._pendingNewSession = false;
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can // #187: the assistant is always in agent mode (tools on /agent endpoint);
// call read/search tools and propose mutations (confirmation cards). // the former header toggle was removed — images still use the /chat path.
this._agentMode = this._readAgentMode();
// Ad-hoc context added with `@` (files/directories) and images attached // Ad-hoc context added with `@` (files/directories) and images attached
// by paste or by mentioning an image file. // by paste or by mentioning an image file.
this._adhocFiles = []; this._adhocFiles = [];
@@ -188,33 +187,6 @@ class BooksLM {
this._extensions = null; 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 ────────────────────────────────────────────────────── // ── Public API ──────────────────────────────────────────────────────
/** /**
@@ -966,7 +938,6 @@ class BooksLM {
</div> </div>
<span class="bookslm-qa-badge hidden" data-qa-context=""></span> <span class="bookslm-qa-badge hidden" data-qa-context=""></span>
<div class="bookslm-header-actions"> <div class="bookslm-header-actions">
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button> <button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button> <button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button> <button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
@@ -1011,7 +982,6 @@ class BooksLM {
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close()); panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation()); 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) => { panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this._toggleHistoryMenu(); this._toggleHistoryMenu();
@@ -1552,10 +1522,9 @@ class BooksLM {
if (typeof safeCreateIcons === 'function') safeCreateIcons(); 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() { _startDeepResearch() {
this._closeExtMenu(); this._closeExtMenu();
if (!this._agentMode) this._toggleAgentMode();
this._activeDeepResearch = true; this._activeDeepResearch = true;
this._renderAttachments(); this._renderAttachments();
showToast(t('bookslm.deep_research_started'), 'info'); 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 /** Immediate-send the action prompt through the composer (same path as a
* typed message: skills, agent mode and images all keep working). Actions * typed message: skills and images all keep working). #187: the assistant
* flagged `agent` (they mutate the document) transparently switch the * is always agent mode, so the former `agent: true` quick actions need no
* assistant to agent mode first — same pattern as Deep Research. */ * mode switch anymore. */
_runQuickAction(action) { _runQuickAction(action) {
if (!this._panel || !action) return; if (!this._panel || !action) return;
const { prompt } = actionTexts(action); const { prompt } = actionTexts(action);
if (!prompt) return; if (!prompt) return;
this._closeActionDrawer(); this._closeActionDrawer();
if (action.agent && !this._agentMode) this._toggleAgentMode();
const textarea = this._panel.querySelector('textarea'); const textarea = this._panel.querySelector('textarea');
if (!textarea) return; if (!textarea) return;
textarea.value = prompt; textarea.value = prompt;
@@ -3253,14 +3221,15 @@ class BooksLM {
this._saveHistory(); 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) { _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). // text/tool oriented).
const hasImages = Array.isArray(payload.images) && payload.images.length > 0; const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
const endpoint = (this._agentMode && !hasImages) const endpoint = hasImages
? '/api/ai/bookslm/agent' ? '/api/ai/bookslm/chat'
: '/api/ai/bookslm/chat'; : '/api/ai/bookslm/agent';
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) }; const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, { return fetch(endpoint, {
method: 'POST', method: 'POST',
+15 -26
View File
@@ -159,7 +159,8 @@ async function main() {
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }), read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }),
}; };
globalThis.fetch = async (url, opts) => { 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); capturedBody = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => reader } }; return { ok: true, status: 200, body: { getReader: () => reader } };
} }
@@ -1171,38 +1172,28 @@ async function main() {
assert.equal(b._messages[0].content, "Ancienne conversation"); assert.equal(b._messages[0].content, "Ancienne conversation");
}); });
// ── 14. Agent mode & confirmations (B5) ── // ── 14. Agent mode & confirmations (B5 / #187) ──
await test("agent mode toggle persists and switches the endpoint", async () => { await test("#187: no agent toggle — plain requests always target /agent", async () => {
localStorage.clear(); localStorage.clear();
const b = new BooksLM(); const b = new BooksLM();
assert.equal(b._agentMode, false, "agent mode off by default"); assert.equal(b._agentMode, undefined, "agent-mode state removed");
b._toggleAgentMode(); assert.equal(b._toggleAgentMode, undefined, "toggle method removed");
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");
let url = null; let url = null;
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; }; globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
b2._abortCtrl = null; b._abortCtrl = null;
await b2._postChat({ message: "x" }); await b._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent"); assert.ok(url.includes("/api/ai/bookslm/agent"), "default targets /agent");
b2._agentMode = false; assert.equal(localStorage.getItem("obsigate-bookslm-agent"), null, "no legacy persistence");
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
localStorage.clear(); 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 b = new BooksLM();
const panel = b._render(); const panel = b._render();
b._panel = panel; b._panel = panel;
document.body.appendChild(panel); document.body.appendChild(panel);
const btn = panel.querySelector(".bookslm-btn-agent"); assert.equal(panel.querySelector(".bookslm-btn-agent"), null, "toggle button removed");
assert.ok(btn, "agent toggle button present");
assert.equal(btn.getAttribute("aria-pressed"), "false");
btn.click();
assert.equal(btn.getAttribute("aria-pressed"), "true");
panel.remove(); panel.remove();
localStorage.clear(); localStorage.clear();
}); });
@@ -1236,7 +1227,6 @@ async function main() {
}; };
const b = new BooksLM(); const b = new BooksLM();
b._agentMode = true;
b._panel = b._render(); b._panel = b._render();
document.body.appendChild(b._panel); document.body.appendChild(b._panel);
const msg = { const msg = {
@@ -1418,7 +1408,7 @@ async function main() {
let posted = null; let posted = null;
globalThis.fetch = async (url, opts) => { 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 }) }) } }; return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
}; };
await b._sendMessage(); await b._sendMessage();
@@ -1432,10 +1422,9 @@ async function main() {
localStorage.clear(); localStorage.clear();
}); });
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ── // ── 32. Images force the plain chat endpoint (#81 / #187: always-on agent) ──
await test("images route to /chat even in agent mode", async () => { await test("images route to /chat, text to /agent", async () => {
const b = new BooksLM(); const b = new BooksLM();
b._agentMode = true;
let url = ""; let url = "";
globalThis.fetch = async (u) => { globalThis.fetch = async (u) => {
url = String(u); url = String(u);
+25
View File
@@ -940,6 +940,31 @@ class TestBooksLMAgentEndpoint:
assert "create_file" in captured["system"] assert "create_file" in captured["system"]
assert '"action": "create_file"' not 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): def test_agent_tool_call_flow(self, bookslm_client, monkeypatch):
import backend.bookslm_routes as routes import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall from backend.ai_chat import LLMResponse, ToolCall
+10
View File
@@ -93,6 +93,16 @@ class TestRegistry:
props = read["function"]["parameters"]["properties"] props = read["function"]["parameters"]["properties"]
assert "vault" in props and "path" in props 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): def test_duplicate_tool_name_raises(self):
from backend.tools.registry import tool from backend.tools.registry import tool