From cc025695ac435efda34db7e2ac5545f50e0e0a26 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 20 May 2026 22:13:46 -0400 Subject: [PATCH] fix: prevent master agent asking for clarification when receipts are uploaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a zip/image arrives via /upload, the LLM was classifying the message as needs_clarification=True (because the chat body was just a filename like "download (8).zip", not an instruction), and the early return on line 91 fired before the receipts safety guard on line 106, so the guard never executed. master_agent: move the receipts safety guard to BEFORE the needs_clarification early-return. If extra_context contains receipts, unconditionally set needs_clarification=False and ensure expenses_agent is in the agents list — the LLM cannot veto an upload with a question. upload router: normalize empty or filename-only messages (e.g. when the user drops a file in Discuss chat with no text) to "Create an expense report from these uploaded receipts." so the LLM intent classification also has a sensible string to work with. Co-Authored-By: Claude Sonnet 4.6 --- agent_service/agents/master_agent.py | 16 +++++++++++----- agent_service/routers/upload.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/agent_service/agents/master_agent.py b/agent_service/agents/master_agent.py index d6dfa73..0f8c005 100644 --- a/agent_service/agents/master_agent.py +++ b/agent_service/agents/master_agent.py @@ -88,6 +88,17 @@ class MasterAgent: await self._memory.append_message(user_id, 'user', message, directive_id) context = await self._build_context(user_id, message) intent = await self._classify_intent(context, message) + + # Safety guard: when receipts arrive via the upload flow, always + # route to expenses_agent — regardless of what the LLM decided. + # MUST run BEFORE the needs_clarification early-return; otherwise an + # LLM that asks "please provide more context" blocks a valid upload. + if (extra_context or {}).get('receipts'): + intent.needs_clarification = False + intent.clarification_question = None + if 'expenses_agent' not in intent.agents: + intent.agents.append('expenses_agent') + if intent.needs_clarification: q = intent.clarification_question or 'Could you provide more details?' await self._memory.append_message(user_id, 'assistant', q, directive_id) @@ -101,11 +112,6 @@ class MasterAgent: await self._log_directive_complete(directive_id, 'complete', response_text) return MasterResponse(directive_id=directive_id, response=response_text, status='complete') - # When receipts are present (upload flow), always dispatch expenses_agent - # even if the user's message is a one-word reply like "skip duplicates". - if (extra_context or {}).get('receipts') and 'expenses_agent' not in intent.agents: - intent.agents.append('expenses_agent') - intent.needs_clarification = False access = await self._check_access(user_id, intent.agents) if not access.allowed: denied = ', '.join(access.denied_agents) diff --git a/agent_service/routers/upload.py b/agent_service/routers/upload.py index 2dd7016..e55fabd 100644 --- a/agent_service/routers/upload.py +++ b/agent_service/routers/upload.py @@ -1,9 +1,14 @@ from __future__ import annotations import asyncio import logging +import re import uuid from typing import List, Optional +# Matches messages that are just a filename (e.g. "download (8).zip", "receipt.jpg"). +# When the chat body is only the filename, the LLM has nothing useful to classify. +_FILENAME_ONLY_RE = re.compile(r'^[\w\s\-.()\[\]]+\.\w{2,6}$') + from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile, status from ..config import get_settings @@ -35,6 +40,14 @@ async def upload( from concurrent.futures import ThreadPoolExecutor _ocr_executor = ThreadPoolExecutor(max_workers=2) + # Normalise message: if the user sent no text (or the chat body is just the + # filename Odoo auto-inserts), give the master agent a clear instruction so + # it routes to expenses_agent rather than asking for clarification. + stripped = (message or '').strip() + if not stripped or _FILENAME_ONLY_RE.match(stripped): + message = 'Create an expense report from these uploaded receipts.' + logger.debug('upload: normalised empty/filename message for user_id=%s', user_id) + receipts: list[dict] = [] loop = asyncio.get_event_loop() for f in files: