fix(ai): garantir une reponse finale quand la boucle d'agent epuise son budget (BUG-052)
This commit is contained in:
+17
-1
@@ -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
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
+85
-17
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
Generated
+1
-1
@@ -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
@@ -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,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",
|
||||||
|
|||||||
@@ -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
@@ -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)**
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user