fix(ai): garantir une reponse finale quand la boucle d'agent epuise son budget (BUG-052)
CI / lint (push) Successful in 1m31s
CI / security (push) Successful in 1m2s
CI / test (push) Successful in 3m25s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m41s

This commit is contained in:
2026-09-16 23:28:56 -04:00
parent 62cff271d8
commit e3434d19ea
13 changed files with 155 additions and 30 deletions
+17 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.7.2**. > [Unreleased](#unreleased). La dernière version livrée est **2.7.3**.
--- ---
@@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.7.3] — 2026-09-16
### Corrigé
- **BUG-052 - Assistant IA : recherche web sans réponse finale (étapes et sources affichées, aucun texte)** :
quand le budget d'itérations (`DEFAULT_MAX_ITERATIONS = 10`) ou le quota d'appels d'outils
était épuisé pendant que le modèle enchaînait encore des recherches/lectures, la boucle
d'agent renvoyait un contenu vide → la conversation n'affichait que les étapes et les
sources. La boucle effectue désormais un **dernier appel sans outil** qui demande au modèle
de synthétiser les informations recueillies (`_finalize_answer`), avec un repli déterministe
listant les sources si cet appel échoue ou reste vide. Les appels d'outils non atteints du
lot en cours de quota reçoivent un résultat `deferred` pour garder la conversation valide.
Fichier : `backend/agent/loop.py`. Tests : `tests/test_agent_loop.py` (+2).
---
## [2.7.2] — 2026-09-16 ## [2.7.2] — 2026-09-16
### Corrigé ### Corrigé
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.7.2-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.7.3-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog ## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.7.2). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.7.3).
--- ---
*Projet : ObsiGate | Version : 2.7.2 | Dernière mise à jour : Juin 2026* *Projet : ObsiGate | Version : 2.7.3 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.7.2-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.7.3-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog ## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.7.2). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.7.3).
--- ---
*Project: ObsiGate | Version: 2.7.2 | Last updated: May 2026* *Project: ObsiGate | Version: 2.7.3 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.7.2 2.7.3
+85 -17
View File
@@ -40,6 +40,15 @@ MAX_TOOL_RESULT_CHARS = 100_000
# Quota: maximum tool calls executed per agent run (``BOOKSLM_MAX_TOOL_CALLS``). # Quota: maximum tool calls executed per agent run (``BOOKSLM_MAX_TOOL_CALLS``).
DEFAULT_MAX_TOOL_CALLS = int(os.environ.get("BOOKSLM_MAX_TOOL_CALLS", "25")) DEFAULT_MAX_TOOL_CALLS = int(os.environ.get("BOOKSLM_MAX_TOOL_CALLS", "25"))
# Sent as a last user turn when the loop stopped before the model produced an
# answer (iteration/quota budget exhausted while it was still calling tools).
_FINALIZE_INSTRUCTION = (
"N'appelle plus aucun outil. Réponds maintenant directement à l'utilisateur, "
"en français, à partir des informations déjà recueillies ci-dessus. "
"Structure la réponse en Markdown, cite les liens sources utiles, et si les "
"informations sont insuffisantes, dis-le explicitement."
)
# Stopping reasons # Stopping reasons
STOP_DONE = "done" STOP_DONE = "done"
STOP_MAX_ITERATIONS = "max_iterations" STOP_MAX_ITERATIONS = "max_iterations"
@@ -109,8 +118,8 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
} }
def _deferred_tool_message(call: Any) -> dict[str, Any]: def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
"""Answer a tool call that was not reached because the run paused. """Answer a tool call that was not reached because the run stopped early.
A single LLM response may carry several tool calls. When one of them is A single LLM response may carry several tool calls. When one of them is
mutating and pauses the run for confirmation, the assistant message already mutating and pauses the run for confirmation, the assistant message already
@@ -125,7 +134,7 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]:
"name": call.name, "name": call.name,
"content": json.dumps({ "content": json.dumps({
"status": "deferred", "status": "deferred",
"reason": ( "reason": reason or (
"Not executed: the run paused to confirm an earlier tool call. " "Not executed: the run paused to confirm an earlier tool call. "
"Re-issue this call if it is still needed." "Re-issue this call if it is still needed."
), ),
@@ -133,6 +142,69 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]:
} }
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
"""Deterministic non-empty answer built from the gathered tool results.
Used only if the final synthesis call fails or returns nothing, so a turn
never ends on an empty message (BUG-052).
"""
lines: list[str] = []
for record in executed:
data = record.result
if not isinstance(data, dict):
continue
for item in (data.get("results") or [])[:5]:
if not isinstance(item, dict):
continue
title = item.get("title") or item.get("url") or ""
url = item.get("url") or ""
lines.append(f"- [{title}]({url})" if url else f"- {title}")
if data.get("url") and data.get("text"):
title = data.get("title") or data["url"]
lines.append(f"- [{title}]({data['url']})")
if not lines:
return "Je n'ai pas pu produire de réponse à partir des résultats obtenus."
unique = list(dict.fromkeys(lines))
return "Voici les sources pertinentes trouvées :\n" + "\n".join(unique)
async def _finalize_answer(
llm: Callable[..., Any],
convo: list[dict[str, Any]],
executed: list[ToolCallRecord],
steps: list[dict[str, Any]],
iterations: int,
stopped: str,
) -> AgentResult:
"""Guarantee a textual answer when the loop stopped before producing one.
Web research often exhausts the iteration budget while the model is still
calling tools; returning ``content=""`` left the conversation with steps and
sources but no answer. One final tool-less call asks the model to synthesize
the gathered results, and a deterministic source list is used as a last
resort (BUG-052).
"""
content = ""
if executed:
try:
response = await llm(
[*convo, {"role": "user", "content": _FINALIZE_INSTRUCTION}], []
)
content = (response.content or "").strip()
except Exception as e:
logger.warning(f"Agent final synthesis failed: {e}")
if not content:
content = _fallback_summary(executed)
return AgentResult(
content=content,
messages=convo,
tool_calls=executed,
steps=steps,
iterations=iterations,
stopped=stopped,
)
def _execute_confirmed( def _execute_confirmed(
ctx: ToolContext, ctx: ToolContext,
confirm_pending: dict[str, Any], confirm_pending: dict[str, Any],
@@ -275,13 +347,14 @@ async def run_agent(
for index, call in enumerate(response.tool_calls): for index, call in enumerate(response.tool_calls):
if quota is not None and len(executed) >= quota: if quota is not None and len(executed) >= quota:
logger.warning(f"Agent reached the tool-call quota ({quota})") logger.warning(f"Agent reached the tool-call quota ({quota})")
return AgentResult( # Keep the conversation valid for the synthesis call: the
content=response.content or "", # assistant message announced every tool call of the batch.
messages=convo, for skipped in response.tool_calls[index:]:
tool_calls=executed, convo.append(_deferred_tool_message(
steps=steps, skipped, "Not executed: the tool-call quota was reached."
iterations=iteration, ))
stopped=STOP_QUOTA_EXCEEDED, return await _finalize_answer(
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
) )
try: try:
result = call_tool(call.name, ctx, call.arguments) result = call_tool(call.name, ctx, call.arguments)
@@ -327,11 +400,6 @@ async def run_agent(
}) })
logger.warning(f"Agent reached max iterations ({max_iterations})") logger.warning(f"Agent reached max iterations ({max_iterations})")
return AgentResult( return await _finalize_answer(
content="", llm, convo, executed, steps, max_iterations, STOP_MAX_ITERATIONS
messages=convo,
tool_calls=executed,
steps=steps,
iterations=max_iterations,
stopped=STOP_MAX_ITERATIONS,
) )
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.7.2" version = "2.7.3"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.7.2" version = "2.7.3"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.7.2", "version": "2.7.3",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+2
View File
@@ -161,6 +161,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-049* | [🔵 MINEUR] Assistant IA : icône du bouton « + » invisible (largeur SVG nulle) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/style.css` | Ouvrir l'assistant et observer le bouton « + » | Sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (la règle générique `padding: 8px 16px` sur un bouton 32 px annulait la largeur de contenu) | Vérifié navigateur : SVG 0 px → 18 px. Journal 2026-09-16 | | *BUG-049* | [🔵 MINEUR] Assistant IA : icône du bouton « + » invisible (largeur SVG nulle) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/style.css` | Ouvrir l'assistant et observer le bouton « + » | Sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (la règle générique `padding: 8px 16px` sur un bouton 32 px annulait la largeur de contenu) | Vérifié navigateur : SVG 0 px → 18 px. Journal 2026-09-16 |
| *BUG-050* | [🟡 IMPORTANT] Assistant IA : échec de la création d'un sous-dossier contenant un fichier (appels d'outils parallèles + confirmation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py` | Mode Agent : « crée le dossier X et un fichier Y dedans » puis Appliquer | `backend/agent/loop.py` : résultats « deferred » (`_deferred_tool_message`) pour les `tool_calls` non atteints lors d'une pause de confirmation ; `backend/services/mutations.py` : `create_directory(..., exist_ok=True)` ; `backend/tools/service.py` + `backend/bookslm.py` : consignes `create_file` (parents auto-créés, chemin imbriqué unique) | Cause : le message assistant listait plusieurs `tool_calls` mais la pause n'ajoutait le résultat que du seul appel confirmé → conversation invalide (tool_call_id sans réponse) au resume. Tests : `tests/test_agent_loop.py` (+1), `tests/test_tools_mutations.py` (+1), `tests/test_api_main.py` (+1) | | *BUG-050* | [🟡 IMPORTANT] Assistant IA : échec de la création d'un sous-dossier contenant un fichier (appels d'outils parallèles + confirmation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py` | Mode Agent : « crée le dossier X et un fichier Y dedans » puis Appliquer | `backend/agent/loop.py` : résultats « deferred » (`_deferred_tool_message`) pour les `tool_calls` non atteints lors d'une pause de confirmation ; `backend/services/mutations.py` : `create_directory(..., exist_ok=True)` ; `backend/tools/service.py` + `backend/bookslm.py` : consignes `create_file` (parents auto-créés, chemin imbriqué unique) | Cause : le message assistant listait plusieurs `tool_calls` mais la pause n'ajoutait le résultat que du seul appel confirmé → conversation invalide (tool_call_id sans réponse) au resume. Tests : `tests/test_agent_loop.py` (+1), `tests/test_tools_mutations.py` (+1), `tests/test_api_main.py` (+1) |
| *BUG-051* | [🟡 IMPORTANT] Assistant IA : la recherche web répond toujours « je ne peux pas accéder à internet » (mode agent ou non) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/tools/web.py`, `tests/test_web_tools.py` | Assistant : « fais une recherche sur l'horaire du Canadien de Montréal 2026-2027 » | `backend/tools/web.py` : chaîne de repli sans clé — SearXNG puis DuckDuckGo (HTML sans JS) puis Bing (HTML), premier fournisseur non vide retenu (`provider`), replis désactivables via `OBSIGATE_WEB_FALLBACK=0` | Cause : l'instance SearXNG par défaut (`search.dracodev.net`) remonte 0 résultat (moteurs amont suspendus/CAPTCHA) → le modèle en déduisait une absence d'accès réseau. Tests : `tests/test_web_tools.py` (+4) | | *BUG-051* | [🟡 IMPORTANT] Assistant IA : la recherche web répond toujours « je ne peux pas accéder à internet » (mode agent ou non) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/tools/web.py`, `tests/test_web_tools.py` | Assistant : « fais une recherche sur l'horaire du Canadien de Montréal 2026-2027 » | `backend/tools/web.py` : chaîne de repli sans clé — SearXNG puis DuckDuckGo (HTML sans JS) puis Bing (HTML), premier fournisseur non vide retenu (`provider`), replis désactivables via `OBSIGATE_WEB_FALLBACK=0` | Cause : l'instance SearXNG par défaut (`search.dracodev.net`) remonte 0 résultat (moteurs amont suspendus/CAPTCHA) → le modèle en déduisait une absence d'accès réseau. Tests : `tests/test_web_tools.py` (+4) |
| *BUG-052* | [🟡 IMPORTANT] Assistant IA : recherche web sans réponse finale (10 étapes + sources affichées, aucun texte dans la conversation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `tests/test_agent_loop.py` | Assistant (mode agent) : recherche web qui enchaîne 10 étapes puis n'affiche aucune réponse | `backend/agent/loop.py` : `_finalize_answer` — dernier appel LLM sans outil (instruction de synthèse) quand le budget d'itérations/quota est épuisé, repli déterministe `_fallback_summary` (liste des sources), résultats `deferred` pour les appels non atteints du lot en quota | Cause : `content=""` renvoyé sur `STOP_MAX_ITERATIONS`/`STOP_QUOTA_EXCEEDED` alors que le modèle appelait encore des outils. Tests : `tests/test_agent_loop.py` (+2) |
| | | | | | | | | | | | | | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -213,6 +214,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-16 | BUG-050 | Correction | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py`, `tests/test_agent_loop.py`, `tests/test_tools_mutations.py`, `tests/test_api_main.py`, `docs/features/ai-tools-mcp.md`, `CHANGELOG.md` | **BUG-050** : création d'un sous-dossier contenant un fichier en mode Agent. (1) La boucle d'agent renvoyait la conversation sans réponse pour les `tool_calls` non atteints lorsqu'un appel mutateur déclenchait une confirmation → le provider rejetait le tour de reprise (« tool_call_id » orphelin) ; les appels restants reçoivent désormais un résultat `deferred` explicite (`_deferred_tool_message`) que le modèle réémet après confirmation. (2) `create_directory` est idempotent côté outil IA (`exist_ok=True`, succès si le dossier existe), le REST restant strict (409). (3) Consignes renforcées : `create_file` crée les dossiers parents, un seul appel avec chemin imbriqué suffit (`backend/bookslm.py`, descriptions d'outils). Vérifié : pytest 1091 passed / 6 skipped, ruff 0 (backend), mypy 0 (71 fichiers), tests frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-050 | Correction | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py`, `tests/test_agent_loop.py`, `tests/test_tools_mutations.py`, `tests/test_api_main.py`, `docs/features/ai-tools-mcp.md`, `CHANGELOG.md` | **BUG-050** : création d'un sous-dossier contenant un fichier en mode Agent. (1) La boucle d'agent renvoyait la conversation sans réponse pour les `tool_calls` non atteints lorsqu'un appel mutateur déclenchait une confirmation → le provider rejetait le tour de reprise (« tool_call_id » orphelin) ; les appels restants reçoivent désormais un résultat `deferred` explicite (`_deferred_tool_message`) que le modèle réémet après confirmation. (2) `create_directory` est idempotent côté outil IA (`exist_ok=True`, succès si le dossier existe), le REST restant strict (409). (3) Consignes renforcées : `create_file` crée les dossiers parents, un seul appel avec chemin imbriqué suffit (`backend/bookslm.py`, descriptions d'outils). Vérifié : pytest 1091 passed / 6 skipped, ruff 0 (backend), mypy 0 (71 fichiers), tests frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-051 | Correction | `backend/tools/web.py`, `tests/test_web_tools.py`, `docs/features/ai-tools-roadmap.md`, `docs/features/ai-assistant-conversation-ux.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-051** : `web_search` ne dépend plus d'une seule instance SearXNG. Nouvelle chaîne de fournisseurs (`_provider_chain`) : SearXNG (auto-hébergé, JSON) → DuckDuckGo (`html.duckduckgo.com/html/`, extraction `result__a`/`result__snippet`, décodage du lien `uddg=`) → Bing (`www.bing.com/search`, extraction `h2 > a` + `p.b_lineclamp*`, décodage de la redirection `u=a1<base64url>`), UA navigateur, premier fournisseur non vide retenu et exposé (`provider`). Le champ `warning` final liste les fournisseurs essayés ; replis désactivables via `OBSIGATE_WEB_FALLBACK=0` ; erreur `web_search_unavailable` uniquement si tous les fournisseurs sont injoignables. Vérifié : pytest 1082 passed / 6 skipped (14 erreurs MCP préexistantes, sans lien), ruff 0 (backend), mypy 0 (`backend/tools/web.py`), `tests/test_web_tools.py` 13/13, recherche live Bing (horaire Canadiens) sur l'hôte. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-051 | Correction | `backend/tools/web.py`, `tests/test_web_tools.py`, `docs/features/ai-tools-roadmap.md`, `docs/features/ai-assistant-conversation-ux.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-051** : `web_search` ne dépend plus d'une seule instance SearXNG. Nouvelle chaîne de fournisseurs (`_provider_chain`) : SearXNG (auto-hébergé, JSON) → DuckDuckGo (`html.duckduckgo.com/html/`, extraction `result__a`/`result__snippet`, décodage du lien `uddg=`) → Bing (`www.bing.com/search`, extraction `h2 > a` + `p.b_lineclamp*`, décodage de la redirection `u=a1<base64url>`), UA navigateur, premier fournisseur non vide retenu et exposé (`provider`). Le champ `warning` final liste les fournisseurs essayés ; replis désactivables via `OBSIGATE_WEB_FALLBACK=0` ; erreur `web_search_unavailable` uniquement si tous les fournisseurs sont injoignables. Vérifié : pytest 1082 passed / 6 skipped (14 erreurs MCP préexistantes, sans lien), ruff 0 (backend), mypy 0 (`backend/tools/web.py`), `tests/test_web_tools.py` 13/13, recherche live Bing (horaire Canadiens) sur l'hôte. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-051 (complément) | Correction | `backend/tools/web.py`, `CHANGELOG.md` | **BUG-051** suite : un `User-Agent` navigateur seul ne suffit pas — Bing renvoie une SERP factice (résultats sans rapport, ex. « highest paying jobs » / « Sam Reid ») aux requêtes sans en-têtes de navigation. Ajout de `BROWSER_HEADERS` (`Accept-Language`, `Sec-Fetch-*`, `Upgrade-Insecure-Requests`) pour DuckDuckGo et Bing. Vérifié **en conteneur** (`obsigate-test`, v2.7.1) : `web_search('Canadien de Montreal horaire matchs 2026 2027')` → `provider: bing`, 5 résultats pertinents (nhl.com/fr/canadiens, rds.ca, fr.wikipedia.org). | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-051 (complément) | Correction | `backend/tools/web.py`, `CHANGELOG.md` | **BUG-051** suite : un `User-Agent` navigateur seul ne suffit pas — Bing renvoie une SERP factice (résultats sans rapport, ex. « highest paying jobs » / « Sam Reid ») aux requêtes sans en-têtes de navigation. Ajout de `BROWSER_HEADERS` (`Accept-Language`, `Sec-Fetch-*`, `Upgrade-Insecure-Requests`) pour DuckDuckGo et Bing. Vérifié **en conteneur** (`obsigate-test`, v2.7.1) : `web_search('Canadien de Montreal horaire matchs 2026 2027')` → `provider: bing`, 5 résultats pertinents (nhl.com/fr/canadiens, rds.ca, fr.wikipedia.org). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-052 | Correction | `backend/agent/loop.py`, `tests/test_agent_loop.py`, `CHANGELOG.md` | **BUG-052** : la boucle d'agent ne rendait plus jamais de réponse vide. `_finalize_answer` : à l'épuisement du budget d'itérations (`STOP_MAX_ITERATIONS`) ou du quota d'appels (`STOP_QUOTA_EXCEEDED`), un dernier appel LLM **sans outil** reçoit une instruction de synthèse (« N'appelle plus aucun outil. Réponds maintenant… ») et son texte devient la réponse ; si l'appel échoue ou reste vide, `_fallback_summary` compose une liste déterministe des sources (`web_search`/`fetch_url`) pour ne jamais renvoyer un tour vide. Les `tool_calls` non atteints lors d'un arrêt sur quota reçoivent un résultat `deferred` (conversation valide pour la synthèse). Vérifié : `tests/test_agent_loop.py` 16/16 (+2 : synthèse finale, repli sources), suite complète 1084 passed / 6 skipped (14 erreurs MCP préexistantes), ruff 0 (backend), mypy 0 (`backend/agent/loop.py`). | 🟢 corrigé (en attente vérif utilisateur) |
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.7.2 | **Dernière mise à jour :** 2026-09-16 > **Version :** 2.7.3 | **Dernière mise à jour :** 2026-09-16
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
+5
View File
@@ -80,6 +80,11 @@ audit) et **jamais** avec un token en dur :
- Livré : la note intermédiaire du modèle devient une étape visible. - Livré : la note intermédiaire du modèle devient une étape visible.
- Extension possible : exposer les itérations de la boucle (`iterations`) comme - Extension possible : exposer les itérations de la boucle (`iterations`) comme
étapes de planification quand un outil de plan est ajouté. étapes de planification quand un outil de plan est ajouté.
- **Garantie de réponse finale — ✅ livré (BUG-052)** : à l'épuisement du budget
d'itérations ou du quota d'appels d'outils, `_finalize_answer` déclenche un
dernier appel LLM **sans outil** (instruction de synthèse) ; un repli
déterministe liste les sources si cet appel échoue. Une recherche web ne peut
plus se terminer sur une conversation sans texte.
## 4. Transverse — à faire avec la phase 2 ## 4. Transverse — à faire avec la phase 2
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.7.2", "version": "2.7.3",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js", "main": "patch.js",
"directories": { "directories": {
+34
View File
@@ -182,6 +182,40 @@ class TestConfirmationAndLimits:
assert result.iterations == 2 assert result.iterations == 2
assert len(result.tool_calls) == 2 assert len(result.tool_calls) == 2
@pytest.mark.asyncio
async def test_max_iterations_synthesizes_final_answer(self, monkeypatch):
"""BUG-052: exhausting the budget must still produce an answer."""
_register(monkeypatch, "_echo", lambda ctx, params: {"value": 1})
llm = ScriptedLLM([
LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]),
LLMResponse(tool_calls=[ToolCall(id="2", name="_echo", arguments={})]),
LLMResponse(content="synthèse finale"),
])
result = await run_agent(
[{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=2
)
assert result.stopped == STOP_MAX_ITERATIONS
assert result.content == "synthèse finale"
# The last call is tool-less and carries the synthesis instruction.
assert llm.calls[-1]["tools"] == []
assert "N'appelle plus aucun outil" in llm.calls[-1]["messages"][-1]["content"]
@pytest.mark.asyncio
async def test_max_iterations_falls_back_to_sources(self, monkeypatch):
"""An empty/failed synthesis still returns the gathered sources."""
_register(monkeypatch, "_search", lambda ctx, params: {
"results": [{"title": "T", "url": "https://ex.dev/a"}]
})
llm = ScriptedLLM([
LLMResponse(tool_calls=[ToolCall(id="1", name="_search", arguments={})]),
LLMResponse(content=""),
])
result = await run_agent(
[{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=1
)
assert result.stopped == STOP_MAX_ITERATIONS
assert "https://ex.dev/a" in result.content
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# Permissions (index-backed) # Permissions (index-backed)