Skip to content

fix(agents): sanitize custom tool names before sending to the LLM (CRM-499) - #63

Merged
gomessguii merged 3 commits into
developfrom
fix/CRM-499-sanitize-tool-name
Sep 1, 2026
Merged

fix(agents): sanitize custom tool names before sending to the LLM (CRM-499)#63
gomessguii merged 3 commits into
developfrom
fix/CRM-499-sanitize-tool-name

Conversation

@pastoriniMatheus

@pastoriniMatheus pastoriniMatheus commented Sep 1, 2026

Copy link
Copy Markdown

Problema (CRM-499)

Custom tool com espaço/acento/pontuação no nome derruba o chat do agente com 500 opaco. A OpenAI/Anthropic exigem tools[].function.name em ^[a-zA-Z0-9_-]+$:

litellm.BadRequestError: OpenAIException - Invalid 'tools[0].function.name': ... pattern '^[a-zA-Z0-9_-]+$'.

Root cause

O processor mandava o nome cru como function.name (linhas originais, pré-fix): tool_builder.py e custom_tools.py, em http_tool.__name__ = name. Nome inválido → 400 do provedor → 500 opaco (sem dizer qual tool).

Fix

  • Novo src/utils/tool_naming.py::sanitize_tool_name() — coage p/ ^[a-zA-Z0-9_-]+, colapsa runs inválidos em _, apara bordas, cap 64 (limite OpenAI), fallback "tool".
  • Aplicado onde o __name__ da custom tool é definido: tool_builder.py:267 e custom_tools.py:243. Dispatch é por __name__, então o nome sanitizado é auto-consistente; o rótulo legível continua na description.
  • Corrige agentes já criados com nome inválido, sem o operador renomear nada.

MCP: verificado — NÃO precisa (e não deve) ser sanitizado

O name do MCP server no banco (ex.: "Brave Search") é usado só p/ roteamento/log (mcp_service.py:157), não vira function.name. O MCPToolset(tool_filter=agent_tools) expõe as tools pelos nomes do protocolo MCP (brave_web_search, válidos). Renomear MCP quebraria o mapeamento name→execução. Então o display name com espaço não causa o 500 — corrige a suspeita do report original.

Testes

  • Unit (local, 16 verdes via pytest): tests/unit/test_tool_name_sanitization.py — todo input vira ^[a-zA-Z0-9_-]+, incl. "testando ferramenta" → "testando_ferramenta", acentos, cap 64, fallback.
  • Integração: tests/unit/test_custom_tools.py::TestHttpToolNameSanitization nos dois builders (CustomToolBuilder, ToolBuilder) — falha se a sanitização for revertida em qualquer um dos dois _create_http_tool.
  • py_compile limpo. A suite completa (importa google.adk) roda no container/CI, não localmente.

Follow-ups (fora deste PR)

  • Validar o nome no cadastro (front + handler Go de custom_tool) com mensagem clara.
  • Melhorar a mensagem de erro (citar o nome da tool).
  • Seeds de MCP com display "Brave Search" — cosmético (não é a causa).

Card: CRM-499

Summary by Sourcery

Prevent custom tool names that violate LLM provider constraints from causing agent requests to fail.

Bug Fixes:

  • Sanitize custom HTTP tool names before exposing them to LLM providers, preventing invalid names from breaking agent chats.
  • Ensure sanitized tool names remain unique when different custom tool names collapse to the same value.
  • Prevent duplicated registration of database-backed tools and their expanded configurations when sanitization changes their names.

Enhancements:

  • Apply consistent provider-compatible naming across both custom tool builders while preserving valid names and dispatch behavior.

Tests:

  • Add unit coverage for name sanitization, provider constraints, length limits, fallbacks, and collision handling.
  • Add builder integration coverage for sanitized names and deduplicated tool construction.

…M (CRM-499)

A custom HTTP tool whose name contains a space/accent/punctuation was sent
verbatim as tools[].function.name, which OpenAI/Anthropic reject (must match
^[a-zA-Z0-9_-]+$). The 400 surfaced as an opaque 500 and left the agent unable
to answer, with no hint of which tool was at fault.

Add sanitize_tool_name() and apply it where the ADK function name is set
(tool_builder.py, custom_tools.py). We dispatch these tools by __name__, so the
sanitized name is self-consistent; the readable label stays in the description.
This also repairs existing agents with invalid tool names, no operator action
needed.

MCP tools are intentionally NOT sanitized: their function names come from the
MCP protocol (already valid) and are the key the toolset uses to route
execution back to the server, so renaming would break the call. The seeded MCP
display names ("Brave Search", "Sequential Thinking") are only used for
routing/logging, never as function.name.

Tests: tests/unit/test_tool_name_sanitization.py (16 cases) proves every input
maps to ^[a-zA-Z0-9_-]+$, including the client's "testando ferramenta".
@sourcery-ai

sourcery-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Reviewer's Guide

Sanitizes custom HTTP tool names before they reach the LLM, keeping ADK dispatch consistent while preventing provider rejections caused by spaces, accents, punctuation, or excessive length; MCP names remain untouched because they are protocol routing keys.

Sequence diagram for sanitized custom tool execution

sequenceDiagram
    participant Agent as Agent
    participant Builder as Custom tool builder
    participant Sanitizer as sanitize_tool_name
    participant LLM as LLM provider
    participant Tool as Custom HTTP tool

    Agent->>Builder: Create custom HTTP tool
    Builder->>Sanitizer: sanitize_tool_name(name)
    Sanitizer-->>Builder: Valid function name
    Builder->>Tool: Set http_tool.__name__
    Builder->>LLM: Send function.name
    LLM-->>Agent: Accept tool definition
    Agent->>Tool: Dispatch by __name__
    Tool-->>Agent: Return HTTP result
Loading

File-Level Changes

Change Details Files
Introduces a provider-safe naming utility for custom LLM tools.
  • Replaces invalid-character runs with underscores and collapses repeated underscores.
  • Trims edge separators, caps names at 64 characters, and uses a fallback for empty results.
  • Preserves already-valid names except for length normalization.
src/utils/tool_naming.py
Applies sanitized names consistently at both custom HTTP tool construction paths.
  • Sanitizes the function name before wrapping tools for ADK dispatch.
  • Keeps readable custom labels in the tool description while using the sanitized name for routing.
  • Leaves MCP tool names unchanged because MCP protocol names drive toolset dispatch.
src/services/adk/custom_tools.py
src/services/adk/tool_builder.py
Adds focused regression coverage for tool-name normalization.
  • Tests spaces, accents, punctuation, repeated whitespace, edge trimming, valid names, fallback behavior, and the 64-character limit.
  • Verifies all representative outputs match the provider-required name pattern.
tests/unit/test_tool_name_sanitization.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/utils/tool_naming.py" line_range="43-44" />
<code_context>
+        return FALLBACK_TOOL_NAME
+
+    sanitized = _INVALID_RUN.sub("_", name)
+    sanitized = _REPEAT_UNDERSCORE.sub("_", sanitized).strip("_-")
+    sanitized = sanitized[:MAX_TOOL_NAME_LENGTH].strip("_-")
+    return sanitized or FALLBACK_TOOL_NAME
</code_context>
<issue_to_address>
**issue (bug_risk):** Sanitization is not injective: distinct custom tools such as `"a b"` and `"a_b"`, or two names differing after the 64th character, receive the same `__name__`. Both tools are then registered with the same provider function name, so tool declarations collide and dispatch cannot reliably identify which endpoint to execute.

**Triggers:** When an agent contains custom tools whose sanitized names are equal.

**Suggested fix:** Detect sanitized-name collisions while building the tool list and reject or disambiguate them before constructing the provider payload.
</issue_to_address>

### Comment 2
<location path="src/services/adk/custom_tools.py" line_range="243" />
<code_context>
+        # space/accent breaks the whole turn). We dispatch this tool by
+        # __name__, so the sanitized name stays self-consistent; the readable
+        # label lives in __doc__/description.
+        http_tool.__name__ = sanitize_tool_name(name)

         return FunctionTool(func=http_tool)
</code_context>
<issue_to_address>
**issue (bug_risk):** The duplicate-suppression check compares a raw `http_tool_config["name"]` with `built_from_ids`, which now contains sanitized `tool.func.__name__` values. When a tool is present both in `custom_tool_ids` and in the expanded `http_tools` config with an invalid name, the check fails and builds the same tool twice under the same sanitized name.

**Triggers:** When an agent has both `custom_tool_ids` and the expanded `http_tools` representation of a tool whose name requires sanitization.

**Suggested fix:** Compare `sanitize_tool_name(http_tool_config.get("name"))` with `built_from_ids`, or perform duplicate suppression using a stable tool ID.
</issue_to_address>

### Comment 3
<location path="src/utils/tool_naming.py" line_range="43" />
<code_context>
+        return FALLBACK_TOOL_NAME
+
+    sanitized = _INVALID_RUN.sub("_", name)
+    sanitized = _REPEAT_UNDERSCORE.sub("_", sanitized).strip("_-")
+    sanitized = sanitized[:MAX_TOOL_NAME_LENGTH].strip("_-")
+    return sanitized or FALLBACK_TOOL_NAME
</code_context>
<issue_to_address>
**issue:** `sanitize_tool_name()` collapses repeated underscores even though `foo__bar` already matches the documented provider pattern and the docstring promises that already-valid names pass through unchanged except for length capping. This silently changes existing tool identifiers and can create additional collisions.

**Triggers:** When an existing custom tool name contains consecutive underscores.

**Suggested fix:** Only collapse underscores introduced by invalid runs, or update the documented contract and explicitly account for the resulting identifier changes.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 3 findings to address first, and sanitization, truncation, or collisions can cause distinct custom tools to expose the same name and dispatch a model tool call incorrectly, potentially invoking the wrong HTTP action. Reverting prevents future misrouting, but any external request already made would need to be checked or repaired separately.

Blocking findings: src/utils/tool_naming.py:44, src/services/adk/custom_tools.py:243, src/utils/tool_naming.py:43


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/utils/tool_naming.py
Comment thread src/services/adk/custom_tools.py Outdated
Comment thread src/utils/tool_naming.py
…e (CRM-499)

Integration guard over CustomToolBuilder and ToolBuilder: a tool named
"testando ferramenta" must build with __name__ == "testando_ferramenta", and a
valid name is preserved. Reverting the sanitize call at either _create_http_tool
site makes these fail (the raw space survives). Runs in the container/CI lane
(imports google.adk), alongside the standalone sanitizer unit test.
@pastoriniMatheus

Copy link
Copy Markdown
Author

Cobertura de integração adicionada (commit 87882e2)

tests/unit/test_custom_tools.py::TestHttpToolNameSanitization — parametrizado nos dois builders (CustomToolBuilder, ToolBuilder):

  • "testando ferramenta"built.func.__name__ == "testando_ferramenta"
  • nome válido (get_weather) preservado

Negative-provable: reverter o sanitize_tool_name(...) em qualquer um dos dois _create_http_tool faz esses testes falharem (o espaço cru sobrevive) — garante que o fix está ligado nos 2 sites, não só que o helper existe.

Roda no lane container/CI (importa google.adk), junto do unit standalone test_tool_name_sanitization.py (16 casos, verdes localmente). py_compile limpo.

Sanitizing __name__ made it diverge from the raw name the expanded config still
carries, and the dedup compared the two spellings. An agent holding both
custom_tool_ids and the http_tools those ids expand to therefore built the tool
twice under one function name — the same payload the provider rejects, so the
sanitization was undone by the guard it walked past.

Both builders now compare on the sanitized name, and each _create_http_tool is
handed the names already taken so two tools never answer to the same one:
distinct names can collapse alike ("minha ferramenta" and "minha_ferramenta") or
share their first 64 chars. The name is settled before FunctionTool captures it,
since the declaration is generated from the live __name__ while dispatch uses
the captured one — renaming later would split them.

sanitize_tool_name also returns a name the providers already accept untouched:
squeezing "my__tool" into "my_tool" renamed tools that work today for no gain.
A rename is logged with both spellings so the name the LLM sees can be traced
back to the catalog.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sourcery assessment

Approved.

@gomessguii
gomessguii merged commit a8e1bd4 into develop Sep 1, 2026
5 checks passed
@gomessguii
gomessguii deleted the fix/CRM-499-sanitize-tool-name branch September 1, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants