Skip to content

fix(custom-tools): custom HTTP tools now advertise their configured parameters to the LLM - #59

Open
VictorCano wants to merge 4 commits into
evolution-foundation:developfrom
Tchori-Labs:fix/adk-http-tool-input-schema
Open

fix(custom-tools): custom HTTP tools now advertise their configured parameters to the LLM#59
VictorCano wants to merge 4 commits into
evolution-foundation:developfrom
Tchori-Labs:fix/adk-http-tool-input-schema

Conversation

@VictorCano

@VictorCano VictorCano commented Aug 31, 2026

Copy link
Copy Markdown

Problema

Toda tool HTTP customizada é publicada para o LLM sem schema de entrada. O modelo nunca consegue passar argumento nenhum, então toda requisição sai com body vazio e sem valor dinâmico de path ou query. Só os values estáticos configurados chegam na rede — o que deixa a funcionalidade inútil para qualquer dado dinâmico.

Os dois builders definem a tool como uma closure **kwargs pura e a entregam direto ao FunctionTool, ajustando só __doc__ e __name__. O ADK monta a FunctionDeclaration percorrendo inspect.signature, e esse percurso aceita apenas POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD e KEYWORD_ONLY. **kwargs é VAR_KEYWORD — descartado em silêncio, sem erro e sem warning.

A trava é dupla. O FunctionTool.run_async reusa a mesma assinatura para filtrar os argumentos que chegam:

valid_params = {param for param in inspect.signature(self.func).parameters}
args_to_call = {k: v for k, v in args_to_call.items() if k in valid_params}

Para uma função só com **kwargs, esse conjunto é literalmente {'kwargs'}. Nada é anunciado e nada seria aceito. Medido contra o google-adk==1.19.0 pinado em requirements.txt:33:

declaration.parameters = None
valid_params           = {'kwargs'}

A docstring que lista os parâmetros é só documentação — o ADK não a converte em schema. É esse o sintoma: o agente conhece a tool e a chama vazia.

O defeito existe idêntico em src/services/adk/custom_tools.py e em src/services/adk/tool_builder.py, e é o segundo que está ligado no LlmAgent (llm_agent_builder.py:472). Corrigir só um não mudaria nada em produção.

Correção

Anexar uma inspect.Signature keyword-only real à closure antes de embrulhá-la. Uma mudança resolve as duas metades: from_function_with_options respeita __signature__, então as properties aparecem; e o valid_params do run_async passa a conter os nomes reais, então os argumentos fluem para dentro do **kwargs. O corpo que monta a requisição não precisou de nenhuma alteração.

O helper apply_http_tool_signature fica em custom_tools.py ao lado de strip_modes_meta, que o tool_builder.py já importa — sem módulo novo e sem segunda convenção.

Config Anunciado como Obrigatório?
path_params str sim — placeholder não preenchido gera URL quebrada — exceto se values já responde
query_params escalar str nunca; o escalar configurado segue como fallback
query_params lista não anunciado é juntado e enviado literal; o modelo não opina
body_params de type / element_type só quando required e o nome não está em values
body_type: array + array_param esse array conforme o required dele

Reusa a tabela de tipos que já existe: _map_json_type_to_python foi promovido a map_json_type_to_python (único chamador atualizado) em vez de criar um terceiro mapeamento.

Compatibilidade retroativa: um parâmetro que a configuração já responde é declarado opcional, então uma tool totalmente estática continua disparando com zero argumento do modelo e continua enviando seus valores fixos. Há teste dedicado a isso.

Continua o EVO-2125 (#34), que mexeu nos mesmos arquivos.

Os três commits

  1. A correção do schema — o que está descrito acima.
  2. Cinco defeitos achados revisando o commit 1. Override do modelo divergindo entre query string e body, porque os laços de defaults liam values cru enquanto o body lia o merge. Parâmetro obrigatório cujo nome o Python não expressa (user-id) sendo descartado em silêncio e a tool disparando incompleta — agora vira alias, com o mapa alias -> nome real e remapeamento na entrada da closure. required ignorado em body do tipo array. Array aninhado emitindo ARRAY sem items, que é exatamente a restrição do Gemini que o helper existe para satisfazer. Config malformada (type=['object'], array_param=123) abortando a construção do agente inteiro.
  3. Duas exposições deixadas em aberto pelo commit 2. A docstring contradizia o schema anunciado, nomeando user-id enquanto a property era user_id — e como o ADK descarta descrição por parâmetro, a docstring é o único canal de descrição, então o modelo lia dois nomes diferentes. E os laços da docstring indexavam param_config['type'] e ['description'] direto, quebrando na mesma config não validada que o commit 2 tinha blindado um bloco adiante.

Testes

A suíte existente não tinha como pegar isso: todo teste chama built.func(**kwargs) direto, batendo na closure crua e pulando tanto a FunctionDeclaration quanto o filtro de argumentos. O test_metadata_is_not_sent_in_the_body chega a afirmar que title="hello" chega no body — provando que a closure funciona, enquanto o caminho real via ADK nunca conseguiria entregar title.

Os testes novos passam por built._get_declaration() e await built.run_async(args=..., tool_context=None), parametrizados sobre os dois builders.

Commit 1   RED no develop:      18 failed, 27 passed     GREEN: 45 passed
Commit 2   RED (src stashed):   14 failed,  9 passed     GREEN: 23 passed
Commit 3   RED (src stashed):   12 failed,  4 passed     GREEN: 16 passed

test_custom_tools.py                                     81 passed
pytest tests/unit -q --ignore=tests/unit/test_exception_handlers.py
                                                        403 passed

O último é o comando exato do job unit em .github/workflows/ci.yml:33-34. Todo teste novo falha contra o código que ele protege — a única exceção deliberada é o guard de "nome comum nunca é reescrito", verde dos dois lados por construção.

Cada commit é verde sozinho (45 → 67 → 81), verificado extraindo cada árvore com git archive.

Sem regressão de lint. flake8 nos quatro arquivos alterados: 11 achados antes, os mesmos 11 depois (mesmos códigos, só os números de linha deslocados). black --check: os dois arquivos que ele reformataria já reformatariam em origin/develop, e a interseção entre os trechos do black e nossas linhas adicionadas é zero nos quatro arquivos. Maior linha adicionada: 87 caracteres, dentro do line-length = 88. O make lint do repo falha com 2236 achados em develop; não pioramos em um único.

Smoke test numa tool real do ToolBuilder com path note-id, query lang + tags: ['a', 1], body title / user-id / broken: None:

note_id [note-id]: The note id
lang: pt
tags: List[a, 1]
title (string, Required): Headline
user_id [user-id] (string, Required): Author
broken (string, Optional)

properties anunciadas: ['broken', 'lang', 'note_id', 'title', 'user_id']

Todo nome documentado corresponde a uma property publicada.

Dois comportamentos fixados por sonda, não por suposição

  • declaration.parameters.required é sempre None no ADK 1.19.0 — o valor é calculado mas não sobrevive até o modelo. A obrigatoriedade é fixada pelo comportamento do run_async: argumento obrigatório ausente não chega ao endpoint e retorna o erro de retry do ADK nomeando o parâmetro.
  • Parâmetro opcional que o modelo omite não é injetado como None; o run_async repassa só o que foi enviado, então o if param in all_values do corpo continua correto.

Mudança de comportamento a observar

Um query_params escalar é documentado como descrição mas usado em runtime como fallback. Expô-lo como argumento opcional significa que o modelo pode passar um onde antes ia sempre o valor configurado. É a correção pretendida, mas é mudança real de tráfego para qualquer tool que tenha escrito esses escalares como valores e não como descrições — vale um smoke test numa tool de tenant existente antes do rollout.

Notas de processo

  • CHANGELOG.md não foi tocado de propósito. O arquivo foi editado por 6 commits em toda a história do repo, todos de corte de release, e não existe seção [Unreleased]. Desde o último (c3cb21c), 58 commits entraram em develop e nenhum o tocou. Adicionar entrada seria o desvio.
  • Os blocos de cabeçalho @important não foram atualizados. A cláusula pede registrar quem alterou e quando, mas nenhum dos 40 commits recentes — nem nenhum commit desde a importação inicial — jamais atualizou um. Mexer tornaria este o único diff da história a fazê-lo. Faço com prazer se for a preferência de vocês.
  • PR vindo de fork: o job build-pr se auto-pula aqui pela própria condição (docker-publish.yml:25, head.repo.full_name == github.repository), então a ausência do check Build PR image é por desenho, não falha. Feliz em reabrir a partir de um branch interno se preferirem.

Produzido com apoio de assistente de IA e revisado de forma independente por um segundo modelo; os commits carregam Co-authored-by. Toda afirmação acima foi verificada executando código — os RED/GREEN são reproduzíveis a partir dos SHAs.

Summary by Sourcery

Advertise custom HTTP tool parameters to the LLM and ensure supplied values are correctly forwarded to their endpoints.

Bug Fixes:

  • Expose configured path, query, and body parameters from custom HTTP tools so LLM-supplied arguments reach requests through the ADK execution path.
  • Preserve dynamic overrides, required-parameter handling, array bodies, and non-Python parameter names while maintaining static-value compatibility.
  • Prevent malformed parameter configurations and non-string query-list values from breaking tool construction or request execution.

Enhancements:

  • Centralize HTTP tool signature generation, type annotations, parameter aliasing, and documentation across both custom HTTP tool builders.
  • Promote the shared JSON-to-Python type mapper for reuse by HTTP tool schema generation.

Tests:

  • Add coverage for published ADK schemas, runtime argument forwarding, required and static parameters, aliases, arrays, malformed configurations, generated descriptions, and query serialization across both builders.

Tchorizo and others added 3 commits August 30, 2026 22:41
…arameters to the LLM

Both HTTP tool builders returned a `def http_tool(**kwargs)` closure. ADK
derives a tool's FunctionDeclaration by walking `inspect.signature(func)` and
it skips VAR_KEYWORD outright, so every custom HTTP tool was published with
`declaration.parameters is None` — not one of its configured path, query or
body parameters was visible to the model.

The same signature locks a second door: `FunctionTool.run_async` filters the
incoming arguments against it, and the only valid parameter name was `kwargs`.
Even a forced tool call therefore delivered nothing to the request builder, so
only the static `values` ever reached the endpoint. That is why a tool with a
required `title` body param would happily POST an empty body.

Both halves are fixed by attaching a real keyword-only `inspect.Signature` to
the closure, derived from the tool's own configuration. A parameter is only
mandatory when the configuration has no value for it, so tools that today rely
entirely on their canned `values` keep firing with zero model arguments. An
array parameter always declares an element type, because Gemini rejects an
ARRAY schema with no `items`. A configured name that Python cannot declare, or
that collides with a name ADK fills itself, costs that one parameter instead of
the whole tool.

The existing tests all invoked `built.func(...)` directly, which bypasses both
broken links; the new ones go through `_get_declaration()` and `run_async()`
and fail on the unfixed code.

Continues EVO-2125 (evolution-foundation#34) on src/services/adk/custom_tools.py.

Co-authored-by: Claude <noreply@anthropic.com>
…d malformed configs

A configured parameter whose name is not a Python identifier — `user-id`, a
keyword, or one of the names ADK fills itself — used to be left out of the
published schema. For a required parameter that meant the model was never asked
for it and the request went out without it. Such a parameter is now declared
under a deterministic stand-in identifier and translated back to its configured
name before the request is built, so the endpoint still sees the name it
expects. Names that are already usable are untouched, and two names that
sanitise alike no longer collapse into one.

An array body marked required was always advertised as optional, so ADK fired
the call with `[]` instead of asking for it; it now follows the same rule as an
object body. A nested array declared ARRAY items with no items of their own,
the very constraint Gemini rejects, and now names its innermost element type.

Parameter configuration is unvalidated JSON, so a type could arrive as a list
and a parameter name as a number. Either one raised, and inline http_tools are
built in an unguarded loop, so a single bad row took the whole agent build with
it. Unusable types now fall back to string and unusable names are skipped.

Finally, the pass that injects the static default values read the raw defaults
rather than the effective merge, so a value the model overrode travelled as the
canned one in the query string and as the override in the body — one field,
one request, two values. Both now carry the override.

Co-authored-by: Claude <noreply@anthropic.com>
…realmente recebe

Um parâmetro cujo nome configurado o Python não aceita — `user-id`, uma
palavra reservada, o `tool_context` que o ADK preenche — é declarado sob um
identificador substituto, e é esse nome que vai para o schema. A docstring
gerada, porém, continuava listando o nome configurado: como o ADK descarta
as descrições por parâmetro do schema, a docstring é o único canal onde o
modelo lê o significado de cada campo, e ele lia um nome ali e outro no
schema — convite a mandar o argumento errado, que o ADK então filtra em
silêncio. A docstring passa a ser composta depois da assinatura, a partir do
mapeamento de apelidos: lidera com o nome que o modelo pode usar e mantém o
nome configurado entre colchetes, para quem escreveu a configuração
reconhecer o próprio parâmetro.

As linhas de documentação também indexavam `type` e `description`
diretamente. Configuração de parâmetro é JSON não validado, então um corpo
sem `description`, sem `type`, ou que nem é um dicionário, estourava ali —
fora do trecho já endurecido contra exatamente isso. Como os http_tools
inline são construídos em laço sem proteção, uma única linha incompleta
derrubava o build do agente inteiro; agora ela é lida pelos mesmos coercers
da assinatura. Vale também para uma lista de query params com elementos que
não são texto.

Co-authored-by: Claude <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewer's Guide

Custom HTTP tools now expose configured dynamic parameters through real keyword-only signatures, allowing ADK to advertise and forward them while preserving static defaults. The shared implementation also handles arrays, invalid parameter names, malformed configuration, documentation consistency, and override semantics, with comprehensive runtime-path tests for both builders.

Sequence diagram for dynamic custom HTTP tool arguments

sequenceDiagram
    participant LLM
    participant FunctionTool
    participant http_tool
    participant HTTP_API
    LLM->>FunctionTool: run_async(args)
    FunctionTool->>FunctionTool: inspect.signature(http_tool)
    FunctionTool->>http_tool: **kwargs with declared parameters
    http_tool->>http_tool: apply alias mapping and merge values
    http_tool->>HTTP_API: requests.request(...)
    HTTP_API-->>http_tool: HTTP response
    http_tool-->>FunctionTool: tool result
    FunctionTool-->>LLM: response
Loading

Flow diagram for custom HTTP parameter publication

flowchart TD
    A[HTTP tool configuration] --> B[apply_http_tool_signature]
    B --> C{Parameter category}
    C -->|path| D[Declare string parameter]
    C -->|query scalar| E[Declare optional string parameter]
    C -->|query list| F[Keep configured list literal]
    C -->|body| G[Declare mapped JSON type]
    C -->|array body| H[Declare typed array parameter]
    D --> I[Set keyword-only __signature__]
    E --> I
    G --> I
    H --> I
    I --> J[ADK advertises properties and filters valid arguments]
    J --> K[http_tool remaps aliases and merges model values]
    F --> K
    K --> L[Build path, query, and body request]
Loading

File-Level Changes

Change Details Files
Generate and attach real keyword-only signatures for configured HTTP tool parameters so ADK both advertises them to the LLM and forwards model-supplied arguments at runtime.
  • Add shared signature construction for path, scalar query, object-body, and array-body parameters with Python type annotations and required/static-value handling.
  • Sanitize invalid, keyword, reserved, and colliding configured names into aliases, then remap aliases before request construction.
  • Reuse the centralized JSON-to-Python type mapper and harden type/config parsing against malformed persisted JSON.
src/services/adk/custom_tools.py
src/services/adk/tool_builder.py
src/utils/schema_utils.py
Keep generated tool documentation and HTTP payload behavior consistent with the published parameter schema.
  • Document aliases using the model-visible name while retaining the configured endpoint name, and tolerate incomplete descriptions or non-string query-list values.
  • Ensure model overrides take precedence over static defaults in query and body payloads.
  • Preserve static-only tool behavior and correctly enforce required path, body, and array-body parameters.
src/services/adk/custom_tools.py
src/services/adk/tool_builder.py
Add end-to-end coverage through ADK declaration generation and argument filtering for both custom HTTP tool builders.
  • Verify advertised schemas, annotations, array item metadata, aliases, required handling, static compatibility, and malformed configurations.
  • Verify model arguments reach URLs, query strings, object bodies, and array bodies with correct override behavior.
  • Cover generated documentation names and descriptions.
tests/unit/test_custom_tools.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

@VictorCano
VictorCano marked this pull request as ready for review August 31, 2026 01:50

@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 1 issue

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

## Individual Comments

### Comment 1
<location path="src/services/adk/custom_tools.py" line_range="210" />
<code_context>
         # Adds query parameters
         for param, value in query_params.items():
             if isinstance(value, list):
-                param_docs.append(f"{param}: List[{', '.join(value)}]")
+                # The configured list is sent verbatim, and it is unvalidated
+                # JSON: joining it raw breaks the build on the first number.
</code_context>
<issue_to_address>
**issue (bug_risk):** A list-valued query parameter containing a non-string item still raises `TypeError` in `",".join(value)`, so the tool returns its generic execution error instead of sending the configured list. The new coercion only converts items while generating the docstring and does not fix request construction.

**Triggers:** When a configured query list contains an integer, boolean, null, or another non-string JSON value.

**Suggested fix:** Convert each item with `str(item)` before joining in the runtime query-building loops as well as in the docstring loops.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and model-supplied values will now reach arbitrary configured HTTP endpoints, including POST or other mutating requests, so a signature or aliasing mistake could send unintended data or trigger an unintended external action. Reverting prevents future calls but cannot undo requests that were already made.

Blocking findings: src/services/adk/custom_tools.py:210


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/services/adk/custom_tools.py
…esposta de fallback

O commit anterior desta branch ensinou o laço da docstring a converter cada
elemento antes de juntar, porque `", ".join(value)` estourava na construção
do agente no primeiro item que não fosse texto. A correção parou ali: o
caminho da requisição, nos dois builders, continuava com o `",".join(value)`
cru.

`query_params` é declarado como `Dict[str, Union[str, List[str]]]`, mas é
persistido em coluna JSON sem validação e a API em Go aceita
`map[string]interface{}` — uma lista com número, booleano ou null chega
mesmo aqui. O `TypeError` acontecia dentro da closure da tool, onde o
`except Exception` largo o engolia: a tool devolvia o `fallback_response`
configurado sem nunca ter feito a requisição, e o modelo recebia uma
mensagem de erro genérica no lugar da lista que estava configurada. Falha
silenciosa: nada no comportamento externo dizia que o problema era o tipo de
um item da lista.

Os dois laços de runtime passam a converter cada item, mantendo o separador
de cada lugar — `","` no fio, `", "` na docstring.

Co-authored-by: Claude <noreply@anthropic.com>
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