Files
tracer-cloud--opensre/docs/investigation-tool-calling.md
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

165 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
1. **Tools**`get_registered_tools("investigation")`, filtered with `tool.is_available(...)`.
2. **Schemas**`llm.tool_schemas(tools)` from `get_llm(LLMRole.AGENT)` (built in `core/llm/client_builders.py`; client classes in `core/llm/transports/sdk/agent_clients.py`).
Each client class shapes schemas for its API (function definitions, tool specs, CLI prompt JSON, etc.).
3. **Invoke**`llm.invoke(messages, system=..., tools=tool_schemas)`; the model returns tool calls.
4. **Execute** — Tools run locally; results are appended as user/assistant turns the **same** client can read on the next invoke.
5. **Seed path** — Before the loop, `_build_seed_calls` may inject deterministic tool runs; synthetic
assistant + tool-result messages must match the active client
(`tools/investigation/stages/gather_evidence/agent.py`).
```text
investigate/agent.py → get_llm(LLMRole.AGENT) → *AgentClient.tool_schemas / invoke
tools/* (input_schema, extract_params, run)
```
### 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`) |
When adding a provider, implement **both** `tool_schemas()` and the message shapes the runtime loop
already branches on (or extend those branches). Do not assume one vendors 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, bare `items: {}`). A given
LLM API may require a **single string** `type`, explicit `items`, and a closed set of keys. Unit
tests that only check “has properties” miss union `type` arrays.
- **Many tools in one request** — Investigation sends a **relevance-selected** set of tool schemas in a
single invoke (`select_investigation_tools` in `tools/investigation/stages/gather_evidence/tools.py`:
the planner's `planned_actions` when present, otherwise alert-relevant sources first, capped at
`MAX_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 in
`tool_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: object` with a `properties` dict.
- [ ] **Single `type`** — Prefer one string per node (`"string"`, `"object"`, `"array"`). Avoid
`"type": ["object", "null"]`; use optional fields via `anyOf`/`oneOf`, omit from `required`, or
document that a provider adapter will normalize (and add adapter + test in the same PR).
- [ ] **Arrays** — Always set `items` with an explicit `type` or `properties` (never empty `{}`).
- [ ] **Composites**`$ref`, `$defs`, `allOf`, `anyOf`, `oneOf`, `nullable` may need a normalizer
in the client adapter; do not add them to public schemas without updating that adapter and tests.
- [ ] **Stability** — Tool call `id` values must stay consistent between the assistant turn that
requests tools and the following tool-result turn for that providers format.
Run tool unit tests under `tests/tools/`. After schema changes, run the registry **strict adapter**
contract (uses the strictest normalizer currently wired in the repo):
```bash
uv run python -m pytest tests/core/runtime/llm/test_investigation_tool_schemas.py -q
```
Shared assertions live in `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 `MessageMapper` (`core.messages`) — `to_assistant_provider_message`, `tool_results_from_execution`, and `synthetic_assistant_tool_call` — each must match your invoke parser. |
Checklist when adding or changing a client:
- [ ] `tool_schemas` output matches what `invoke` sends (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.dumps` on 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.
Provider-specific modules (e.g. strict JSON Schema helpers) stay beside the client; keep investigation
logic in `investigation.py` as dispatch only.
### LiteLLM transport
Route all API providers through LiteLLM with a global transport switch (no change to
`LLM_PROVIDER`):
```bash
export OPENSRE_LLM_TRANSPORT=litellm
```
When set to `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](/llm-providers#litellm-transport).
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.id`** across 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 `invoke` already produced earlier in the thread.
- [ ] **`zip(tool_calls, results, strict=True)`** when pairing calls to results.
Extend `tests/agent/test_investigation.py` when you add a client branch for synthetic/assistant messages.
## Verification
Minimum before merging schema or client changes:
```bash
uv run python -m pytest tests/core/runtime/llm/test_investigation_tool_schemas.py -q
uv run python -m pytest tests/core/runtime/llm/test_agent_llm_client.py tests/agent/test_investigation.py -q
```
When touching a specific provider, also verify end-to-end with that provider configured:
```bash
uv run opensre
# /investigate <fixture.json> # interactive shell
# or: opensre investigate -i <fixture.json>
```
Use the same `LLM_PROVIDER` / model users report in issues; unit tests alone are not enough for
adapter strictness gaps.
## Related docs
- [core/llm/AGENTS.md](https://github.com/Tracer-Cloud/opensre/blob/main/core/llm/AGENTS.md) — API provider wiring and env keys
- [integrations/llm_cli/AGENTS.md](https://github.com/Tracer-Cloud/opensre/blob/main/integrations/llm_cli/AGENTS.md) — subprocess CLI providers
- [AGENTS.md](https://github.com/Tracer-Cloud/opensre/blob/main/AGENTS.md) — repo map and PR checklist