Investigation tool calling
Contributor guide for the investigation ReAct loop: tool schemas, LLM invoke payloads, and conversation messages. Applies to every provider the agent uses (Anthropic, OpenAI-compatible, CLI-backed, Bedrock, and future clients)—not one vendor.Architecture
The investigation agent does not call integration APIs through the LLM. The flow is:- Tools —
get_registered_tools("investigation"), filtered withtool.is_available(...). - Schemas —
llm.tool_schemas(tools)fromget_llm(LLMRole.AGENT)(built incore/llm/client_builders.py; client classes incore/llm/transports/sdk/agent_clients.py). Each client class shapes schemas for its API (function definitions, tool specs, CLI prompt JSON, etc.). - Invoke —
llm.invoke(messages, system=..., tools=tool_schemas); the model returns tool calls. - Execute — Tools run locally; results are appended as user/assistant turns the same client can read on the next invoke.
- Seed path — Before the loop,
_build_seed_callsmay inject deterministic tool runs; synthetic assistant + tool-result messages must match the active client (tools/investigation/stages/gather_evidence/agent.py).
Where code lives
| Concern | Location |
|---|---|
| Provider routing | core/llm/factory.py (get_llm, resolve_llm_route) and core/llm/client_builders.py |
| Native SDK clients | core/llm/transports/sdk/agent_clients.py, core/llm/transports/sdk/llm_clients.py |
| LiteLLM transport | core/llm/transports/litellm/clients.py, core/llm/transports/litellm/routing.py (when OPENSRE_LLM_TRANSPORT=litellm) |
| Chat / non-agent LLM | core/llm/transports/sdk/llm_clients.py (separate client classes; routing shared via factory.py) |
| Investigation loop & message dispatch | tools/investigation/stages/gather_evidence/ and core/ |
| Provider-specific schema/message helpers | Next to the client implementing tool_schemas() (strict normalizers live beside that client) |
| Tool definitions | tools/ (input_schema, public_input_schema) |
tool_schemas() and the message shapes the runtime loop
already branches on (or extend those branches). Do not assume one vendor’s JSON tool format works elsewhere.
Why bugs are easy to miss
- JSON Schema draft-07 vs API strictness — Tool authors often use patterns that validate in draft-07
(
"type": ["object", "null"],anyOf,nullable, implicit objects, bareitems: {}). A given LLM API may require a single stringtype, explicititems, and a closed set of keys. Unit tests that only check “has properties” miss uniontypearrays. - Many tools in one request — Investigation sends a relevance-selected set of tool schemas in a
single invoke (
select_investigation_toolsintools/investigation/stages/gather_evidence/tools.py: the planner’splanned_actionswhen present, otherwise alert-relevant sources first, capped atMAX_AGENT_TOOL_SCHEMAS). It is still many schemas at once, so one invalid schema can fail the whole call (HTTP 400, “invalid tools”, etc.) even when the alert never uses that tool. Tool descriptions and parameters live only in these schemas — the alert-context user message no longer re-lists them. - Separate client classes — The non-agent reasoning clients (
transports/sdk/llm_clients.py) and the tool-calling agent clients (transports/sdk/agent_clients.py) are distinct; a schema or normalizer fix in one does not apply to the other. Provider-specific normalizers must run intool_schemas()(or shared helpers the client calls). - Contract tests can lag APIs — Registry-wide schema tests must encode the strictest rules your shipped adapters enforce. Extend assertions when production shows a new rejection reason.
Tool input_schema (authoring)
When adding or changing tools under tools/:
- Top-level — Investigation tools use
type: objectwith apropertiesdict. - Single
type— Prefer one string per node ("string","object","array"). Avoid"type": ["object", "null"]; use optional fields viaanyOf/oneOf, omit fromrequired, or document that a provider adapter will normalize (and add adapter + test in the same PR). - Arrays — Always set
itemswith an explicittypeorproperties(never empty{}). - Composites —
$ref,$defs,allOf,anyOf,oneOf,nullablemay need a normalizer in the client adapter; do not add them to public schemas without updating that adapter and tests. - Stability — Tool call
idvalues must stay consistent between the assistant turn that requests tools and the following tool-result turn for that provider’s format.
tests/tools/. After schema changes, run the registry strict adapter
contract (uses the strictest normalizer currently wired in the repo):
tests/core/runtime/llm/investigation_tool_schema_contract.py. When you add a
stricter provider adapter, point test_investigation_tool_schemas.py at its normalizer and extend
the contract module if the API rejects new patterns. Bedrock-specific unit tests stay in
tests/core/runtime/llm/test_bedrock_converse.py (no duplicate registry test there).
Provider adapters (transports/sdk/agent_clients.py)
Each *AgentClient should own:
| Responsibility | Notes |
|---|---|
tool_schemas(tools) | Map RegisteredTool / public_input_schema → API payload. Never pass raw schemas if the API is strict. |
invoke(..., tools=...) | Attach schemas the API expects; handle retries and map errors to RuntimeError with actionable text. |
| Message compatibility | Investigation builds history via MessageFormatter (core.messages) — assistant_from_response, tool_results_from_execution, and synthetic_assistant_tool_call — each must match your invoke parser. |
-
tool_schemasoutput matches whatinvokesends (no duplicate or divergent normalization). - New JSON Schema patterns in tools → update the adapter normalizer and contract tests in the same PR.
- Serialized payload round-trips like the SDK will send it (e.g.
json.dumpson the tools list). - Validation errors from the API (“missing field type”, “invalid tools”) → treat as schema/adapter bugs first.
- Throttling / rate limits: align with existing retry policy in sibling clients.
investigation.py as dispatch only.
LiteLLM transport
Route all API providers through LiteLLM with a global transport switch (no change toLLM_PROVIDER):
litellm, both investigation (get_llm(LLMRole.AGENT)) and non-agent LLM calls
(get_llm(LLMRole.REASONING), get_llm(LLMRole.CLASSIFICATION), get_llm(LLMRole.TOOLCALL)) use
core/llm/transports/litellm/clients.py via litellm.completion. Leave unset or set to sdk to use
native vendor SDK clients under core/llm/transports/sdk/.
Supported providers: anthropic, openai, bedrock, and OpenAI-compatible providers
(deepseek, groq, openrouter, gemini, nvidia, minimax, ollama), plus
azure-openai (always via LiteLLM). Set the matching API key and model env vars from
.env.example as usual. User-facing setup: LLM Providers.
CLI-backed providers (codex, claude-code, opencode, kimi, copilot, etc.) always use
their subprocess path regardless of this setting.
Investigation messages (investigation.py)
- Same
ToolCall.idacross synthetic seed assistant message, tool results, and evidence keys. - Provider-specific IDs — Use opaque ids only when the client requires them (e.g. length/format);
keep stable
seed_{tool.name}(or equivalent) where history/tests expect predictable ids. - Block vs string content — Some APIs require content as structured blocks, not raw strings
(including after guardrails). Match what
invokealready produced earlier in the thread. -
zip(tool_calls, results, strict=True)when pairing calls to results.
tests/agent/test_investigation.py when you add a client branch for synthetic/assistant messages.
Verification
Minimum before merging schema or client changes:LLM_PROVIDER / model users report in issues; unit tests alone are not enough for
adapter strictness gaps.
Related docs
- core/llm/AGENTS.md — API provider wiring and env keys
- integrations/llm_cli/AGENTS.md — subprocess CLI providers
- AGENTS.md — repo map and PR checklist
Tracer