API Tool Calling enables the LLM module to discover and invoke external API endpoints in response to user queries. Endpoints are registered, semantically indexed in Qdrant, and retrieved at query time using hybrid search. Once matched, a multi-turn agentic loop collects all required parameters from the user before the API call is made.
| Component | What it does | Status |
|---|---|---|
| Indexing pipeline | Takes an endpoint definition → enriches it with LLM context → stores hybrid vectors in Qdrant | Complete |
| Tool classifier | At query time, routes to the best matching endpoint via hybrid search + LLM disambiguation | Complete |
| Multi-intent detection | Score-band gate triggers IntentDecomposer (DSPy) to decompose a multi-intent query into focused sub-queries; each sub-query is matched in parallel via asyncio.gather |
Phase 1 & 2 Complete |
| Agentic loop | Multi-turn parameter collection with session persistence, language-aware clarifying questions, param correction, continuation prompt, and intent-switch detection | Complete |
| API caller | Execute collected params against the real API endpoint, with circuit-breaker protection and localized error handling | Complete |
| Response formatter | Convert raw API JSON into a natural-language answer via DSPy, streamed token-by-token to the GUI | Complete |
| Multi-endpoint loop | Merges param schemas for all parallel endpoints; collects params across turns with a single deduplicated clarifying question per turn; distributes values back per endpoint | Phase 3 Complete |
| Parallel API caller | Fires all completed endpoint calls concurrently via asyncio.gather with batch timeout and partial-failure handling |
Phase 4 Complete |
| Multi-response formatter | DSPy module that synthesises N API results into a single coherent natural-language answer; supports streaming and blocking execution | Phase 5 Complete |
| Full wiring | APIToolWorkflowExecutor routes parallel sessions through MultiEndpointAgenticLoop → MultiAPICaller → MultiResponseFormatterModule with output guardrails |
Phase 6 Complete |
| ATC Response Cache | Two-tier Redis cache (L1 exact-match + L2 follow-up context) that eliminates redundant API calls and enables intelligent follow-up handling without re-running the agentic loop | Complete |
Ruuter DSL (/api-tools/index)
↓ HTTP POST
CronManager (api_tool_indexer job)
↓ exec
api_tool_indexer.sh (bash)
↓ python3
main_indexer.py (indexing pipeline)
↓ upsert
api_tool_collection (Qdrant)
↑ query at runtime
APISemanticSearcher (src/tool_classifier/api_semantic_searcher.py)
↑ called by
ToolClassifier._try_api_tool_classification()
│
├─ score ≥ HIGH_CONFIDENCE → single path (unchanged)
│
└─ score in ambiguous band → IntentDecomposer (DSPy)
│
├─ mode=single → top candidate, existing path
└─ mode=parallel → asyncio.gather(search per sub-query)
↓ ClassificationResult(execution_mode=parallel, matched_endpoints=[...])
↓ ClassificationResult(workflow=API_TOOL_CALLING)
APIToolWorkflowExecutor (src/tool_classifier/workflows/api_tool_workflow.py)
│
├─ execution_mode=single → AgenticLoop
└─ execution_mode=parallel → MultiEndpointAgenticLoop
↓ merged schema; one clarifying question per turn
↓ distributes extracted values back per endpoint
APIToolSessionStore (Redis, keyed by chat_id, 30-min TTL)
↓ all endpoints completed
├─ single → APICaller
└─ parallel → MultiAPICaller → asyncio.gather per endpoint
↓ batch timeout: MULTI_API_BATCH_TIMEOUT (30 s)
↓ raw JSON responses (partial-failure safe)
├─ single → APIResponseFormatterModule
└─ parallel → MultiResponseFormatterModule (DSPy)
↓ buffer-first guardrails validation
↓ SSE token stream
User (GUI)
Defined in DSL/Ruuter.public/rag-search/POST/api-tools/index.yml.
Request body (sent from Postman while no UI exists):
{
"endpointId": "a3f7c2d1-84e6-4b19-92f3-d51c7e890ab2",
"serviceId": "",
"name": "get_national_holidays",
"description": "Fetch national holidays for a specific country to see when they have public days off.",
"method": "GET",
"url": "https://openholidaysapi.org/PublicHolidays",
"visibility": "public",
"type": "custom_endpoint",
"params": [
{"name": "countryIsoCode", "type": "string", "required": true, "description": "The 2-letter ISO country code (e.g., EE for Estonia, DE for Germany)"},
{"name": "languageIsoCode", "type": "string", "required": false, "description": "The 2-letter ISO language code (e.g., ET, EN)"},
{"name": "validFrom", "type": "date", "required": false, "description": "Start date for the holiday search (YYYY-MM-DD)"},
{"name": "validTo", "type": "date", "required": false, "description": "End date for the holiday search (YYYY-MM-DD)"}
]
}Ruuter URL-encodes params (JSON array → encodeURIComponent) and forwards everything to
CronManager via:
POST http://cron-manager:8080/execute/api_tool_indexer/index_endpoint
?endpoint_id=...&name=...¶ms=%5B...%5D
Defined in DSL/CronManager/DSL/api_tool_indexer.yml.
index_endpoint:
trigger: off # Not scheduled — on-demand only
type: exec
command: "/app/scripts/api_tool_indexer.sh"
allowedEnvs: ['endpoint_id', 'service_id', 'name', 'description',
'method', 'url', 'visibility', 'type', 'params']trigger: off means this job is never run on a schedule — it only executes when
CronManager receives an HTTP POST /execute/api_tool_indexer/index_endpoint call.
The query params from Ruuter are injected as environment variables for the shell script.
Defined in DSL/CronManager/script/api_tool_indexer.sh.
What it does (in order):
- Validates required env vars (
endpoint_id,name,description,url) - Activates the pre-built Python venv at
/app/python_virtual_env - Installs required packages via
uv pip install(httpx,pydantic,qdrant-client,loguru) - Sets
PYTHONPATHto include/app/src - URL-decodes the
paramsenv var back to a JSON array and writes it to a temp file - Invokes
main_indexer.pywith CLI args (avoids shell parsing issues with JSON) - Cleans up the temp file; exits with the Python exit code
Defined in src/api_tool_indexer/main_indexer.py.
Entry point: index_endpoint(endpoint_data: EndpointData) → IndexingResult
The pipeline runs 5 sequential steps:
Builds a structured prompt from the endpoint's name, description, method, URL, and
params, then calls the internal /generate-context endpoint via LLMAPIClient.
The CONTEXT_TEMPLATE (in constants.py) instructs the LLM to generate a rich
semantic description covering:
- What the user wants to accomplish by calling this endpoint
- Key terms and synonyms
- Related concepts and use cases
- Common natural language phrasings
- Response is in the same language as the description (Estonian / English / Russian)
embed_text is then assembled as:
{name}. {description}. {enriched_context}. Parameters: {params_summary}
where params_summary is a semicolon-separated one-liner like:
countryIsoCode (string, required): ISO country code; validFrom (date, optional): Start date
embed_text is sent to Azure OpenAI text-embedding-3-large via LLMAPIClient.create_embedding().
Returns a 3072-dimensional float vector (cosine similarity space).
embed_text is tokenised and hashed using compute_sparse_vector() from
src/tool_classifier/sparse_encoder.py
(shared with the tool classifier).
tokens = regex word-split of lowercase embed_text
index = MD5(token)[:4 bytes] % 50_000 (hash to vocab space)
value = term frequency (collisions are accumulated)
Returns SparseVector(indices=[...], values=[...]) — sorted for consistency.
ApiToolQdrantManager.delete_endpoint_point(endpoint_id) filters by endpoint_id
field in the payload and deletes the point before upserting. This ensures re-indexing
the same endpoint never creates duplicates.
A PointStruct is built:
PointStruct(
id=endpoint_id, # UUID used directly as Qdrant point ID
vector={
"dense": [v1, v2, ..., v3072],
"sparse": {"indices": [...], "values": [...]},
},
payload={ # Stored metadata — no extra DB lookup needed
"endpoint_id": "...",
"name": "get_national_holidays",
"description": "...",
"url": "https://openholidaysapi.org/PublicHolidays",
"method": "GET",
"params": [...],
"enriched_context": "...",
"service_id": "...",
},
)Upserted into the api_tool_collection Qdrant collection.
Created automatically on first run by ApiToolQdrantManager.ensure_collection().
Collection: api_tool_collection
Vectors:
"dense" → VectorParams(size=3072, distance=COSINE)
"sparse" → SparseVectorParams(index=SparseIndexParams(on_disk=False))
One point per endpoint. The full EnrichedEndpoint payload is stored so the agentic
loop can execute the API call without an additional database round-trip.
EndpointData — input to the pipeline (from Postman / DB):
| Field | Type | Required | Description |
|---|---|---|---|
endpoint_id |
UUID | Unique identifier | |
name |
str | snake_case function name | |
description |
str | Human-readable purpose | |
url |
str | Full target API URL | |
method |
str | GET or POST |
|
params |
List[Dict] | Parameter schema [{name, type, required, description}] |
|
service_id |
UUID | Parent service group | |
visibility |
str | public or private (default: public) |
|
type |
str | Endpoint type (default: custom_endpoint) |
ParamSchema — schema for each param:
| Field | Type | Description |
|---|---|---|
name |
str | Parameter name |
type |
str | string, date, datetime, integer, boolean, number |
required |
bool | Whether the caller must supply this param |
description |
str | Human-readable description |
datetimetype: normalised toYYYY-MM-DDTHH:MM:SSZbyParamExtractionModule._validate_param_type(). Useful for APIs that require ISO 8601 datetime strings (e.g. electricity price endpoints).
At query time, ToolClassifier in src/tool_classifier/classifier.py the layer by layer execution happens
- Service search →
intent_collections(Qdrant) — existing Bürokratt services - API Tool search →
api_tool_collection(Qdrant) — registered API tool endpoints
API tool search (_try_api_tool_classification) is triggered when:
SERVICE_WORKFLOW_ENABLED=false(service workflow disabled globally)- Dense service search returns no results
- Service cosine score falls below
DENSE_MIN_THRESHOLD
It is always tried before falling back to Context/RAG.
Defined in src/tool_classifier/api_semantic_searcher.py.
Instantiated once in ToolClassifier.__init__() and reuses the shared Qdrant httpx.AsyncClient.
Constructor:
APISemanticSearcher(
embedding_service=orchestration_service, # generates dense embeddings
qdrant_client=self._qdrant_client, # shared connection pool
disambiguator=None, # optional: inject for testing
)Key constants (from constants.py):
| Constant | Value | Purpose |
|---|---|---|
API_TOOL_COLLECTION |
api_tool_collection |
Qdrant collection name |
API_TOOL_SEARCH_TOP_K |
5 |
Max hybrid results |
API_TOOL_MIN_THRESHOLD |
cosine threshold | Below this → no match |
API_TOOL_HIGH_CONFIDENCE_THRESHOLD |
cosine threshold | Above this → high confidence |
API_TOOL_SCORE_GAP_THRESHOLD |
gap threshold | Minimum lead over runner-up |
User query
│
├─ precomputed_embedding provided? → reuse it (no extra API call)
└─ otherwise → generate dense embedding via embedding_service
│
▼
Step 1: Dense search (api_tool_collection)
→ Real cosine similarity scores per endpoint
│
├─ No results → return []
├─ top_cosine < API_TOOL_MIN_THRESHOLD → return []
└─ continue
│
▼
Step 2: Hybrid search (dense + sparse/BM25 + RRF)
→ Best-ranked results by RRF fusion score
│ Falls back to dense results if hybrid returns nothing
│
▼
Step 3: Annotate confidence for each hybrid result
│
│ cosine lookup: dense_cosine_map[endpoint_id]
│ └─ fallback: point["cosine_score"] (sparse-driven result)
│ └─ skip if neither available
│
│ effective_gap = this_cosine − best_other_cosine_in_dense
│
├─ i==0 AND cosine ≥ HIGH_THRESHOLD AND effective_gap ≥ GAP_THRESHOLD → "high"
├─ cosine ≥ MIN_THRESHOLD → "medium"
└─ else → skip
│
▼
Step 4: Resolve to exactly one result
├─ high-confidence result exists → return immediately
├─ single medium + large gap → return directly
└─ multiple medium OR small gap → LLM disambiguation
│
└─ EndpointDisambiguatorModule (DSPy + asyncio.to_thread)
→ picks winner or returns None
→ None means no match → return []
When ToolClassifier.classify() already generated a dense embedding for the service
search, it passes it as precomputed_embedding to _try_api_tool_classification:
api_tool_result = await self._try_api_tool_classification(
query, request, precomputed_embedding=query_embedding
)APISemanticSearcher.search() skips the embedding step entirely when this is provided,
saving one embedding API call per request.
Used when multiple medium-confidence endpoints score similarly and no clear winner can be determined from cosine scores alone.
- DSPy
Predictmodule withEndpointDisambiguationSignature - Inputs:
user_query+candidates(JSON list of{endpoint_id, name, description, cosine_score}) - Output:
best_endpoint_id— the winningendpoint_id, or"none"if no match - Run via
asyncio.to_thread()to avoid blocking the async event loop - Understands Estonian, Russian, and English queries
API tool calling is gated by FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED.
When false, _try_api_tool_classification returns None immediately without
touching Qdrant.
Defined in src/tool_classifier/workflows/api_tool_workflow.py.
Handles WorkflowType.API_TOOL_CALLING after ToolClassifier.classify() has set
matched_endpoint in the context dict.
Responsibilities:
- Turn 1 (new session): reads
context["matched_endpoint"], creates a newAPIToolSessionin Redis, runs the first agentic loop turn. - Turn 2-N (resume): loads the existing session from Redis, runs the next turn.
- Fast path: if the endpoint has no required params, immediately calls the API without starting a session.
- Clarifying question: when params are still missing, streams the LLM-generated
question token-by-token via SSE. Each token is one
format_sseframe; the stream ends with anENDframe. - API call: when all params are collected, calls
APICaller.call()then streams the natural-language answer fromAPIResponseFormatterModule.stream_forward()token-by-token via SSE. - Max turns: deletes the session and returns
Noneto trigger RAG fallback.
Streaming architecture:
Both clarifying questions and final responses are streamed token-by-token.
_compute_loop_step() is the single source of truth — it returns a _LoopStep
tagged as "question", "api_call", or "fallback". execute_streaming() then
handles each case:
"question" → iterate step.question_tokens (real DSPy tokens)
→ yield format_sse(chat_id, token) per token → yield END
"api_call" → APICaller.call() [blocking HTTP]
→ async for token in APIResponseFormatterModule.stream_forward()
→ yield format_sse(chat_id, token) per token → yield END
Defined in src/tool_classifier/agentic_loop.py.
AgenticLoop is stateless — it carries no internal state between HTTP requests.
All state is passed in as arguments (loaded from Redis by the workflow executor before
calling run_turn) and saved back to Redis inside run_turn before returning.
Defined in src/models/session_models.py.
Stored in Redis keyed by chat_id with a 30-minute sliding TTL.
| Field | Type | Description |
|---|---|---|
chat_id |
str | Unique conversation identifier |
state |
str | Current state (collecting_params, etc.) |
selected_endpoint |
dict | Full endpoint payload from Qdrant |
collected_params |
dict | Parameters collected so far |
turn_count |
int | Number of turns elapsed |
max_turns |
int | Max turns before fallback (default: 5) |
awaiting_continuation |
bool | True when continuation prompt has been shown |
detected_language |
str | Language from first message (en, et, ru) — persisted so all clarifying questions use the same language |
original_query |
str | The user’s first message that triggered the session — preserved across turns so the response formatter always receives the full original intent, not just the last short follow-up (e.g. "from 2026-04-01 to 2026-04-30") |
APIToolWorkflowExecutor._run()
│
├─ Load session from Redis (or create new)
│
└─ AgenticLoop.run_turn(
user_message, conversation_history,
params_schema, collected_params,
turn_count, max_turns, awaiting_continuation,
session_language
)
│
├─ AWAITING_CONTINUATION_DECISION?
│ yes → parse yes/no from user_message
│ yes → clear flag, continue collecting
│ no → return MAX_TURNS_REACHED (RAG fallback)
│
├─ ParamExtractionModule.forward()
│ → DSPy extracts params from user_message + conversation_history
│ → uses session_language for all questions
│ → new values OVERWRITE old (allows corrections)
│
├─ All required params present? → COMPLETED
│
├─ turn_count reached CONTINUATION_TURN (default: 3)?
│ → set awaiting_continuation=True
│ → return AWAITING_CONTINUATION_DECISION
│ → question = localized CONTINUATION_QUESTION (EN/ET/RU)
│
└─ else → generate clarifying question for next missing param
→ return NEEDS_INPUT
│
└─ Save updated session to Redis
Language persistence:
The language is detected once from the user's first message and stored in
APIToolSession.detected_language. All subsequent clarifying questions and the
continuation prompt are generated in that language, even when follow-up replies
like "yes" or "2026-01-01" are too short to re-detect reliably.
Supported: en (default), et (Estonian), ru (Russian).
Parameter correction:
If the user says "No, use Russia instead of Estonia", the extractor overwrites the
previously collected countryIsoCode value. There is no guard preventing
re-extraction of already-collected params — new values always win.
Continuation prompt:
After CONTINUATION_TURN turns without completing, the loop asks the user whether
to continue. If the user says no (or anything not in the yes-list), the session is
abandoned and the request falls back to the RAG workflow.
Localized continuation questions are defined in
src/tool_classifier/constants.py:
CONTINUATION_QUESTION, CONTINUATION_QUESTION_ET, CONTINUATION_QUESTION_RU.
Constants (in src/tool_classifier/constants.py):
| Constant | Value | Description |
|---|---|---|
CONTINUATION_TURN |
3 |
Turn at which the continuation prompt is shown |
Defined in src/tool_classifier/api_caller.py.
Executes the external HTTP request once all required parameters have been collected by the agentic loop.
Supported methods: GET (params → query string) and POST (params → JSON body).
Timeout: API_CALL_TIMEOUT seconds (from constants.py). Overridable per-call.
Return type: APICallResult
| Field | Type | Description |
|---|---|---|
success |
bool | True for 2xx responses |
status_code |
int | HTTP status code; 0 for network/timeout/circuit-breaker failures |
response_data |
Any | Parsed JSON on success; raw parsed error body on 4xx; empty string on all other failures |
error |
str | None | Localized user-facing error message on failure; None on success |
Error handling:
| Failure type | status_code |
response_data |
error field |
|---|---|---|---|
| 4xx (client error, e.g. bad params) | actual code | Raw parsed body (preserved for agentic loop re-prompting) | Localized CLIENT_ERROR_MESSAGES |
| 5xx (server error) | actual code | "" |
Localized SERVICE_UNAVAILABLE_MESSAGES |
| Timeout | 0 |
"" |
Localized SERVICE_TIMEOUT_MESSAGES |
| Network error | 0 |
"" |
Localized SERVICE_TIMEOUT_MESSAGES |
| Redirect not followed | 3xx |
"" |
Localized REDIRECT_NOT_FOLLOWED_MESSAGES |
| Circuit breaker open | 0 |
"" |
Localized CIRCUIT_BREAKER_OPEN_MESSAGES |
4xx responses do not trip the circuit breaker — they indicate bad input, not a server outage. The agentic loop can re-prompt the user for corrected values.
Language-aware errors: all error messages are localized using session.detected_language
(et, en, ru). The message constants are defined in
src/tool_classifier/constants.py.
Part of api_caller.py. One breaker instance per URL, shared across requests for the
lifetime of the APICaller instance.
CLOSED → OPEN: after CIRCUIT_BREAKER_FAILURE_THRESHOLD consecutive server/network failures
OPEN → HALF_OPEN: after CIRCUIT_BREAKER_COOLDOWN_SECONDS
HALF_OPEN → CLOSED: on first successful probe call
HALF_OPEN → OPEN: on first failed probe call
When OPEN, call() returns immediately without making an HTTP request.
Constants (in src/tool_classifier/constants.py):
| Constant | Description |
|---|---|
CIRCUIT_BREAKER_FAILURE_THRESHOLD |
Consecutive failures before opening |
CIRCUIT_BREAKER_COOLDOWN_SECONDS |
Seconds to wait before probing |
Defined in src/tool_classifier/api_response_formatter.py.
Converts the raw API JSON response into a natural-language answer using DSPy.
Supports both blocking (forward) and streaming (stream_forward) execution.
DSPy Signature: APIResponseFormatterSignature
| Input field | Description |
|---|---|
user_query |
The user's original question |
api_response |
Raw API JSON as a string (truncated to _MAX_RESPONSE_BYTES = 50 KB) |
endpoint_description |
Short description of what the endpoint does |
response_language |
"English", "Estonian", or "Russian" — derived from detected_language |
| Output field | Description |
|---|---|
formatted_answer |
Clean natural-language answer, no raw JSON or markdown headers |
Defined in src/utils/api_tool_session_store.py.
Redis-backed store. Key format: session:{chat_id}. TTL resets on every update().
Operations: save(), get(), update(), delete().
Turn 1: new query matches API tool endpoint
→ session CREATED (state=collecting_params)
→ clarifying question returned
Turn 2-N: user replies
→ session LOADED → loop runs → session UPDATED
Final turn: all params collected
→ session DELETED
→ completed JSON returned
OR: max turns reached / user says "no" to continuation
→ session DELETED
→ None returned → RAG fallback
Defined in ToolClassifier.classify() — the session-resume short-circuit block.
Before resuming an active session, the classifier runs _try_api_tool_classification()
on the new message. If it matches a different endpoint with sufficient confidence,
the old session is abandoned and the new query starts fresh:
new_api_match = await self._try_api_tool_classification(query, request)
if (
new_api_match is not None
and new_api_match.metadata["matched_endpoint"]["name"] != endpoint_name
):
await session_store.delete(request.chatId)
return new_api_match # start new session for different endpointThe test endpoint hardcodes chatId="test-session" for all requests. Because every
test user shares this ID, any incomplete session would be resumed by the next
unrelated test query.
Fix: the test endpoint deletes "test-session" from Redis at the start of
every request, before classification runs. This makes each test query a fresh
single-turn request.
Trade-off: multi-turn API tool flows cannot be tested via the test-LLM page.
The session is wiped before turn 2 can use it. To test multi-turn flows, use the
production /orchestrate/stream endpoint (which uses unique chatId per tab) or
the integration test script.
Turn 1 — User: "What are the public holidays in Estonia?"
│
▼
ToolClassifier.classify()
│
├─ No active session in Redis for this chat_id
├─ Dense search (intent_collections) → low cosine → below threshold
└─ _try_api_tool_classification()
└─ APISemanticSearcher.search()
├─ Dense: get_public_holidays cosine=0.87 → high confidence
└─ return [APIToolSearchResult(name="get_public_holidays", ...)]
└─ ClassificationResult(workflow=API_TOOL_CALLING, metadata={matched_endpoint: {...}})
│
▼
APIToolWorkflowExecutor._run()
├─ No existing session → create new APIToolSession (turn_count=0, language=en, original_query="What are the public holidays in Estonia?")
└─ AgenticLoop.run_turn(turn_count=0, history=[])
├─ ParamExtractionModule: no params in "What are the public holidays in Estonia?"
│ but countryIsoCode=EE can be inferred → extracted
├─ Missing: validFrom, validTo
└─ NEEDS_INPUT → "Which date range would you like? (validFrom, validTo)"
│
Session saved to Redis
▼
Bot: "Which date range would you like? Please provide validFrom and validTo (YYYY-MM-DD)."
---
Turn 2 — User: "This year, 2026-01-01 to 2026-12-31"
│
▼
ToolClassifier.classify()
├─ Active session found for chat_id → run intent-switch check
├─ _try_api_tool_classification("This year, 2026-01-01 to 2026-12-31")
│ → cosine=0.12 < threshold → no new API tool match
└─ Same endpoint → resume session → ClassificationResult(reason=active_session_resume)
│
▼
APIToolWorkflowExecutor._run()
└─ AgenticLoop.run_turn(turn_count=1, collected_params={countryIsoCode: "EE"})
├─ ParamExtractionModule: extracts validFrom=2026-01-01, validTo=2026-12-31
├─ All required params present
└─ COMPLETED
│
Session DELETED from Redis
▼
APIToolWorkflowExecutor._stream_api_and_format()
├─ user_query = session.original_query → "What are the public holidays in Estonia?"
├─ APICaller.call(GET https://openholidaysapi.org/PublicHolidays, params={countryIsoCode,validFrom,validTo})
│ → status=200, response_data=[{"name": "New Year's Day", ...}, ...]
└─ APIResponseFormatterModule.stream_forward(user_query, api_response, description, language="en")
→ DSPy StreamResponse tokens yielded one by one
→ format_sse(chat_id, "Here are the public holidays ") ...
→ format_sse(chat_id, "END")
│
▼
Bot: "Here are the public holidays in Estonia for 2026:\n- New Year's Day (1 Jan)\n- ..." ← streamed token-by-token
Defined in tests/api_tool_eval/integration_test_agentic_loop.py.
Runs end-to-end against the live service at http://localhost:8100 via /orchestrate.
Each scenario uses a unique chatId (UUID) so sessions are fully isolated.
uv run --no-project --with requests python tests/api_tool_eval/integration_test_agentic_loop.py \
--no-fail-fast \
--output tests/api_tool_eval/integration-results.json| # | Scenario | Turns | What it validates |
|---|---|---|---|
| 1 | Single-turn complete | 1 | Vehicle tax with plate number in first message → immediate API call + formatted response |
| 2 | Multi-turn EN | 2 | Public holidays, country extracted turn 1, dates provided turn 2 → API call + formatted response |
| 3 | Multi-turn ET | 2 | School holidays in Estonian → language-aware classification + Estonian response |
| 4 | No-params fast path | 1 | Parliament votings endpoint has no required params → immediate API call without session |
| 5 | Address search | 2 | Two-turn address lookup |
| 6 | Electricity prices | 2 | datetime params across two turns |
| 7 | Session isolation | 2 | Two different chat IDs — no param leak between sessions |
| 8 | AWAITING_CONTINUATION → yes | 4+ | User says “yes” at continuation prompt → loop resumes → API call on completion |
| 9 | MAX_TURNS_REACHED | 5+ | User never provides params → falls back to RAG |
Status: All phases complete. Phase 1 (intent detection + parallel search), Phase 2 (session model extension), Phase 3 (multi-endpoint agentic loop), Phase 4 (parallel API caller), Phase 5 (multi-response formatter), and Phase 6 (full wiring in workflow executor) are production-ready.
A multi-intent query like "What are the public holidays in Estonia and what is the current electricity price?" produces a diluted embedding — the dense vector sits between two endpoints rather than close to either one. The cosine score lands in the ambiguous band (≥ API_TOOL_MIN_THRESHOLD, < API_TOOL_HIGH_CONFIDENCE_THRESHOLD) rather than producing a clean high-confidence hit.
Phase 1 adds a score-band gate that intercepts these ambiguous results and passes the raw query to IntentDecomposer. If two or more distinct intents are detected, sub-queries are searched in parallel and the results are stored in the session for eventual multi-endpoint execution.
cosine ≥ HIGH_CONFIDENCE_THRESHOLD → single path, existing code unchanged
cosine in [MIN_THRESHOLD, HIGH_CONFIDENCE) → ambiguous band → IntentDecomposer
cosine < MIN_THRESHOLD → no match → RAG fallback
The gate only fires when:
FeatureFlags.MULTI_INTENT_ENABLED = true(env:MULTI_INTENT_ENABLED, defaulttrue)- The top result was not already LLM-validated by the disambiguator (
llm_validated=False)
The llm_validated flag on APIToolSearchResult prevents double LLM calls: when the disambiguator has already selected a winner it sets llm_validated=True, so the IntentDecomposer gate is skipped for that result.
When APISemanticSearcher runs LLM disambiguation and the disambiguator rejects all candidates (returns winner_id=None):
- Old behaviour: return
[]→ classified as RAG/CONTEXT even when the query was multi-intent - New behaviour: if there were multiple medium-confidence candidates, return the top cosine result without
llm_validated=Trueso the IntentDecomposer gate can run
This is the key fix that allows multi-intent queries to reach IntentDecomposer instead of falling through to RAG.
DSPy module — receives the raw user query
| Output | Description |
|---|---|
mode |
"single" or "parallel" |
sub_queries |
List of focused sub-queries when mode=parallel; empty for single |
Conservative by design: returns "single" on any failure or ambiguity — never forces a parallel path.
Run asynchronously via asyncio.to_thread(self, user_query) (calls __call__, not forward, to avoid a DSPy warning).
Sub-query count is capped at MULTI_API_MAX_ENDPOINTS = 3.
When mode=parallel, each sub-query is independently searched against api_tool_collection using asyncio.gather:
results = await asyncio.gather(
*[self._api_searcher.search(q, ...) for q in sub_queries]
)Each search generates its own focused embedding — no dilution from the combined query.
Results are deduplicated by endpoint name (a single endpoint matched by two sub-queries counts once). If fewer than 2 distinct endpoints are found after dedup, the parallel result is discarded and the classifier falls back to the single path.
class ExecutionMode(str, Enum):
SINGLE = "single"
PARALLEL = "parallel"Inherits from str so values serialize cleanly to JSON. Always compare against the enum member (e.g. == ExecutionMode.PARALLEL), not a string literal.
| Key | Type | Description |
|---|---|---|
execution_mode |
ExecutionMode |
SINGLE or PARALLEL |
matched_endpoint |
dict | Set for single mode |
matched_endpoints |
list[dict] | Set for parallel mode — all deduplicated endpoints |
Defined in src/models/session_models.py.
Tracks per-endpoint collection state within a parallel session:
| Field | Type | Default | Description |
|---|---|---|---|
endpoint |
dict | required | Full endpoint payload |
collected_params |
dict | {} |
Params gathered for this endpoint so far |
completed |
bool | False |
Flipped to True when all required params for this endpoint have been collected; API execution happens later, gated on AgenticLoopStatus.COMPLETED at the loop level |
Three new fields added — all optional with safe defaults so existing Redis sessions deserialize without error:
| Field | Type | Default | Description |
|---|---|---|---|
execution_mode |
str | "single" |
"single" or "parallel" — drives which loop handles this session |
parallel_endpoints |
list[EndpointSessionState] | [] |
One entry per matched endpoint; empty in single mode |
active_endpoint_index |
int | 0 |
Reserved for future sequential endpoint-tracking logic; not currently read or written by the parallel workflow |
The existing single-mode fields (selected_endpoint, collected_params, turn_count, etc.) are completely unchanged. Single-mode sessions have execution_mode="single" and parallel_endpoints=[].
When context["execution_mode"] == ExecutionMode.PARALLEL, the workflow captures all matched endpoints from context and populates parallel_endpoints:
APIToolSession(
...
execution_mode="parallel",
parallel_endpoints=[
EndpointSessionState(endpoint=e) for e in all_matched
],
)Defined in src/tool_classifier/multi_agentic_loop.py.
MultiEndpointAgenticLoop replaces AgenticLoop for parallel sessions. It is stateless between HTTP requests — all mutable state is held in the EndpointSessionState list passed in on each call.
Key behaviours:
- Merged schema: All endpoint
paramsschemas are merged and deduplicated by param name. A param shared across two endpoints is asked once and applied to both. - One question per turn: The loop generates a single clarifying question covering the next highest-priority missing param across all endpoints.
- Per-endpoint distribution: After extraction,
_distribute_params()copies each extracted value to every endpoint whose schema includes that param name. - Completion tracking: An endpoint is marked
completed=Truein itsEndpointSessionStateonce all its required params are present. The loop returnsCOMPLETEDonly when every endpoint is completed. - Turn limit: Determined by
_compute_turn_limits(num_endpoints):- Multi-intent (
num_endpoints > 1): fixedMULTI_INTENT_MAX_TURNS(6). - Single-intent (
num_endpoints == 1):min(3 × num_endpoints, MULTI_API_MAX_TURNS)— scales with endpoint count, capped at 9.
- Multi-intent (
- Continuation threshold: Also from
_compute_turn_limits:- Multi-intent: fixed
MULTI_INTENT_CONTINUATION_TURN(4). - Single-intent:
num_endpoints + 1.
- Multi-intent: fixed
stream_run_turn signature:
await multi_loop.stream_run_turn(
chat_id=chat_id,
user_message=request.message,
conversation_history=conversation_history,
endpoint_states=session.parallel_endpoints, # list[EndpointSessionState]
turn_count=session.turn_count,
awaiting_continuation=session.awaiting_continuation,
session_language=effective_session_language,
)Returns (AgenticLoopResult, list[str]) — the result and pre-tokenised question tokens.
Defined in src/tool_classifier/multi_api_caller.py.
MultiAPICaller wraps APICaller and fires all endpoint calls concurrently via asyncio.gather.
Key design decisions:
- Reuses the shared
APICallerinstance so per-URL circuit breaker state is preserved across single and batch invocations. - Expects a
"call_params"key on each endpoint dict (distinct from the"params"schema list) to prevent the schema descriptor from being forwarded to the HTTP call. - A
MULTI_API_BATCH_TIMEOUT(30 s) caps total wall-clock time. Pending tasks are cancelled on timeout and replaced with failure results — the caller always receives a fully-populatedMultiAPICallResult. - Results are returned in the same order as the input endpoint list.
- Partial failure is safe — a failed endpoint produces
APICallResult(success=False, error=<localized message>)without affecting other endpoints.
Usage:
call_payloads = [
{**state.endpoint, "call_params": state.collected_params}
for state in parallel_endpoints
]
multi_result = await MultiAPICaller(api_caller).call_all(
call_payloads, language=detected_language
)Defined in src/tool_classifier/multi_response_formatter.py.
MultiResponseFormatterModule is a DSPy module that synthesises N API results into one unified natural-language answer.
DSPy Signature: MultiResponseFormatterSignature
| Input field | Description |
|---|---|
user_query |
The user's original first-turn question |
api_results_block |
Formatted block of all API results (name, description, data) |
num_results |
Number of results being synthesised |
response_language |
"English", "Estonian", or "Russian" |
custom_instructions |
Optional operator prompt overrides |
| Output field | Description |
|---|---|
unified_answer |
Single coherent natural-language answer covering all endpoints |
Rules enforced by signature:
- Always write in
response_languageregardless of API data language. - Address every result — do not silently omit any endpoint.
- Gracefully acknowledge failed or empty results without dwelling on them.
- No raw JSON, no markdown headers, no follow-up invitation sentences.
Max input size: 100 KB across all results combined (_MAX_TOTAL_RESPONSE_BYTES).
Methods: forward(user_query, api_results, detected_language) (blocking) and stream_forward_multi(user_query, api_results, detected_language) (async token iterator).
Defined in src/tool_classifier/workflows/api_tool_workflow.py.
_LoopStep now has four possible kind values:
kind |
Meaning |
|---|---|
"api_call" |
Single endpoint; all params collected; call API and format |
"multi_api_call" |
Parallel endpoints; call all APIs concurrently and merge results |
"question" |
Agentic loop needs more input; return question to user |
"fallback" |
Nothing to do; caller falls back to RAG |
Parallel fast-path: When all matched endpoints have no required params, _compute_loop_step skips session creation and returns a "multi_api_call" step immediately.
Streaming path (_stream_multi_api_and_format):
- Builds
call_params-keyed payloads for eachEndpointSessionState. - Calls
MultiAPICaller.call_all()— all HTTP calls fire concurrently. - Collects tokens from
MultiResponseFormatterModule.stream_forward_multi()into a buffer. - Runs output guardrails on the full buffered response before yielding any token to the client.
- Yields tokens one-by-one via
format_sse, then yieldsformat_sse(chat_id, "END").
Blocking path (_execute_multi_api_and_format):
Same API call steps, then asyncio.to_thread(formatter.forward, ...) for the synthesis step.
Defined in src/tool_classifier/constants.py and src/llm_orchestrator_config/feature_flags.py:
| Name | Value | Description |
|---|---|---|
MULTI_INTENT_ENABLED |
true (env override) |
Feature flag — set MULTI_INTENT_ENABLED=false to disable the IntentDecomposer gate globally |
MULTI_API_MAX_ENDPOINTS |
3 |
Hard cap on parallel sub-queries per request |
MULTI_API_MAX_TURNS |
9 |
Absolute cap on turns for any parallel session (min(3×N, 9) per session) |
MULTI_API_BATCH_TIMEOUT |
30 |
Seconds before the parallel HTTP batch is cancelled and partial results returned |
API_TOOL_HIGH_CONFIDENCE_THRESHOLD |
0.60 |
Cosine score above which single-path is taken immediately |
API_TOOL_MIN_THRESHOLD |
0.40 |
Minimum score for any match (below → RAG) |
API_TOOL_INTENT_SWITCH_THRESHOLD |
0.50 |
Minimum cosine for the new match to trigger intent-switch detection |
Turn 1 — User: "Can you find an address for me and also calculate my vehicle tax?"
│
▼
APISemanticSearcher.search()
→ top result: search_address, cosine=0.54 (ambiguous band)
→ disambiguator rejects both candidates (multi-intent dilution)
→ returns top candidate WITHOUT llm_validated=True
│
▼
_try_api_tool_classification() — gate fires
→ MULTI_INTENT_ENABLED=true AND not llm_validated
→ IntentDecomposer (DSPy, asyncio.to_thread)
→ mode=parallel
→ sub_queries=["address lookup and location search", "vehicle tax calculation"]
│
▼
asyncio.gather(
search("address lookup and location search") → search_address, cosine=0.82
search("vehicle tax calculation") → get_vehicle_tax_info, cosine=0.79
)
→ 2 distinct endpoints after dedup → parallel path confirmed
│
▼
ClassificationResult(
workflow=API_TOOL_CALLING,
metadata={
execution_mode: ExecutionMode.PARALLEL,
matched_endpoints: [search_address, get_vehicle_tax_info]
}
)
│
▼
APIToolWorkflowExecutor._compute_loop_step()
→ no existing session → create new:
APIToolSession(
execution_mode="parallel",
selected_endpoint=search_address, # first endpoint
original_query="Can you find an address...",
parallel_endpoints=[
EndpointSessionState(endpoint=search_address, collected_params={}),
EndpointSessionState(endpoint=get_vehicle_tax_info, collected_params={}),
],
turn_count=0,
max_turns=6, # min(3×2, 9)
)
→ MultiEndpointAgenticLoop.stream_run_turn(endpoint_states=[...], turn_count=0)
→ merged schema: {address, regNr, calculationYear} (deduped across both endpoints)
→ nothing extracted from turn-1 message (intent query, not param values)
→ missing: [address, regNr, calculationYear]
→ NEEDS_INPUT → clarifying question
│
Session saved to Redis, _LoopStep(kind="question")
▼
Bot: "To help you, I need a few details: the address you'd like to look up,
your vehicle registration number (regNr), and the calculation year for the vehicle tax."
───────────────────────────────────────────────────────────────
Turn 2 — User: "123ABC"
│
▼
ToolClassifier.classify()
→ Active session found → intent-switch check
→ "123ABC" cosine < API_TOOL_INTENT_SWITCH_THRESHOLD → no switch
→ ClassificationResult(reason=active_session_resume)
│
▼
MultiEndpointAgenticLoop.stream_run_turn(turn_count=1)
→ ParamExtractionModule extracts regNr="123ABC"
→ _distribute_params: regNr → get_vehicle_tax_info.collected_params
→ still missing: [address, calculationYear]
→ NEEDS_INPUT → clarifying question covers ALL remaining missing params
▼
Bot: "Got it! I still need two more things: the address you'd like to look up,
and the calculation year for the vehicle tax."
───────────────────────────────────────────────────────────────
Turn 3 — User: "Viru tn 4, Tallinn and year 2026"
│
▼
MultiEndpointAgenticLoop.stream_run_turn(turn_count=2)
→ ParamExtractionModule extracts address="Viru tn 4, Tallinn", calculationYear="2026"
→ _distribute_params:
address → search_address.collected_params → completed=True
calculationYear → get_vehicle_tax_info.collected_params
→ get_vehicle_tax_info now has [regNr, calculationYear] → completed=True
→ ALL endpoints completed → AgenticLoopStatus.COMPLETED
│
Session DELETED from Redis
_LoopStep(kind="multi_api_call", parallel_endpoints=[...])
▼
APIToolWorkflowExecutor._stream_multi_api_and_format()
│
├─ Build call_payloads:
│ [{...search_address, call_params: {address: "Viru tn 4, Tallinn"}},
│ {...get_vehicle_tax_info, call_params: {regNr: "123ABC", calculationYear: "2026"}}]
│
├─ MultiAPICaller.call_all(payloads, language="en")
│ asyncio.gather(
│ GET /address-search?address=Viru+tn+4%2C+Tallinn → 200 OK, address JSON
│ GET /vehicle-tax?regNr=123ABC&year=2026 → 200 OK, tax JSON
│ ) # batch_timeout=30 s; 2/2 succeeded
│
├─ MultiResponseFormatterModule.stream_forward_multi(
│ user_query=session.original_query, # full first-turn message
│ api_results=[("search_address", ..., address_data),
│ ("get_vehicle_tax_info", ..., tax_data)],
│ detected_language="en"
│ )
│ → DSPy streams unified answer tokens
│ → buffer-first: collect all tokens
│
├─ Output guardrails on full buffered response → passed
│
└─ yield format_sse(chat_id, token) per token → yield format_sse(chat_id, "END")
▼
Bot: "Here's what I found: The address Viru tn 4 is located in Tallinn city centre
(full address: Viru tn 4, 10111 Tallinn). For vehicle 123ABC, the estimated
vehicle tax for 2026 is €127.40." ← streamed token-by-token
The ATC Response Cache is a two-tier Redis cache that sits inside _compute_loop_step() in
src/tool_classifier/workflows/api_tool_workflow.py.
It is checked on every new request (no active session) before the agentic loop is created.
Goal: avoid redundant API calls and agentic loop turns when the user is repeating or following up on a query that was already answered in the same conversation.
Gated by FeatureFlags.ATC_RESPONSE_CACHE_ENABLED (ATC_RESPONSE_CACHE_ENABLED env var, default true).
Setting it to false disables all cache reads and writes without touching any other ATC logic.
Key: atc:cache:{chat_id}:{api_name}:{param_hash}
Value: raw API response JSON (dict or list)
TTL: per-endpoint cache_ttl_seconds OR ATC_CACHE_DEFAULT_TTL_SECONDS (30 min)
Answers the question: Has this exact conversation called this exact endpoint with these exact params before?
param_hash is a 16-character hex digest of the normalised, sorted param dict:
- String values are stripped of whitespace
- Purely numeric strings (
"2026") are cast tointbefore hashing - All-alpha strings (enum-like, e.g.
"GET","EE") are lowercased - Keys are sorted so order does not matter
This means {year: "2026", country: "EE"} and {country: "ee", year: 2026} produce
the same hash and hit the same cache entry.
Key: atc:last:{chat_id}
Value: JSON list[LastCallContext]
TTL: ATC_LAST_CALL_TTL_SECONDS (30 min, sliding — reset on every write)
Answers the question: What was the last API call made in this conversation?
Stores a full LastCallContext per succeeded endpoint. Single-intent calls write a
one-element list; multi-intent parallel calls write one entry per succeeded endpoint.
The follow-up detector searches this list by api_name to find the relevant prior call.
Defined in src/models/session_models.py.
| Field | Type | Description |
|---|---|---|
api_name |
str | Endpoint name (snake_case) that was called |
endpoint |
dict | Full endpoint payload from Qdrant (params schema, URL, method, etc.) |
collected_params |
dict | Parameter values that were passed to the API call |
raw_response |
Any | Parsed API JSON (dict or list) as returned by APICaller |
original_query |
str | User's first-turn query that triggered this API call |
timestamp |
float | Unix timestamp of the call (for staleness reference) |
L1 and L2 are written after every successful API call, as a background
asyncio.create_task so they never delay the user-facing response:
Single-intent (_execute_api_and_format)
After api_result.success == True and before the formatter:
set_l1(chat_id, endpoint["name"], collected_params, response_data, ttl)
set_l2(chat_id, [LastCallContext(...)])
Multi-intent (_execute_multi_api_and_format / _stream_multi_api_and_format)
After multi_result is received, one write per succeeded endpoint:
for each (endpoint_state, result) where result.success and endpoint.cacheable:
set_l1(chat_id, endpoint["name"], endpoint_state.collected_params, result.response_data, ttl)
append LastCallContext to contexts_list
set_l2(chat_id, contexts_list) ← one write for all endpoints
The cache block runs only when:
- No active Redis session exists (fresh request, not mid-loop)
- Not a parallel multi-intent query (
not all_matched) endpoint.cacheable == TrueATC_RESPONSE_CACHE_ENABLED == True
New request → no session → endpoint resolved
│
▼
── L1 check ──────────────────────────────────────────────────────────
get_l1(chat_id, endpoint["name"], pre_extracted_params)
│
├─ HIT → _LoopStep(kind="cached_response", cache_source="L1")
│ formatter receives cached raw response — no API call, no loop
│
└─ MISS → continue to L2
│
── L2 check ──────────────────────────────────────────────────────────
get_l2(chat_id) → find entry where api_name == endpoint["name"]
│
├─ No match → fall through to normal agentic loop
│
└─ Match found → FollowUpDetectorModule (DSPy via asyncio.to_thread)
│
│ Inputs: user_query, previous_query, previous_params, params_schema
│
├─ "response_question"
│ → _LoopStep(kind="cached_response", cache_source="L2",
│ cached_raw_response=matching.raw_response)
│ no API call; formatter answers from the previous response
│
├─ "param_update"
│ merged = {**matching.collected_params, **updated_params}
│ missing = _missing_required_params(schema, merged)
│
│ missing == []
│ ├─ hashes equal (params unchanged)
│ │ → try L1 with matching.collected_params
│ │ hit → cached_response (L1)
│ │ miss → cached_response (L2 raw_response)
│ └─ hashes differ (genuinely new params)
│ → _LoopStep(kind="api_call", collected_params=merged)
│ API called directly — entire agentic loop skipped
│
│ missing != []
│ → context["seeded_params"] = merged
│ fall through to agentic loop — only asks for gaps
│
└─ "new_intent"
→ ignore L2; fall through to normal agentic loop
On any FollowUpDetectorModule exception → fall through to normal loop (fail-open)
Defined in src/tool_classifier/follow_up_detector.py.
DSPy Predict module that classifies the relationship between the new user query
and the previous API call. Run via asyncio.to_thread to avoid blocking the event loop.
Inputs:
| Input | Description |
|---|---|
user_query |
The new user message |
previous_query |
The user's original question that triggered the last API call |
previous_params |
JSON of param values from the last call |
params_schema |
JSON of the endpoint's param schema |
Output — three possible values for follow_up_type:
| Value | Meaning | Action |
|---|---|---|
response_question |
User is asking about the data already returned | Pass L2 raw_response to formatter; no API call |
param_update |
User wants the same endpoint with different/additional params | Merge new params into previous; go to API directly if complete, else seed the loop |
new_intent |
Completely unrelated query | Ignore L2; run normal agentic loop from scratch |
Security: updated_params from the LLM is validated against the endpoint's param
schema — keys not in the schema are silently dropped to prevent injection.
Fail-open: any exception returns {follow_up_type: "new_intent", updated_params: {}}
so the user is never blocked.
When the L2 param_update path finds that merged params are still incomplete,
it sets context["seeded_params"] = merged before falling through to the agentic loop.
AgenticLoop.run_turn() and stream_run_turn() accept an optional seeded_params argument.
On turn 0, the seeds are merged into collected_params before any extraction runs:
if turn_count == 0 and seeded_params:
collected_params = {**seeded_params, **collected_params}The merge order means existing collected_params win — seeds cannot overwrite values
that were already explicitly provided. The seeds are also stored directly in the new
Redis session (APIToolSession.collected_params = seeded_params) so they survive
across HTTP requests.
Effect: the agentic loop starts with inherited values already populated and only generates a question for the genuinely missing params.
When an intent switch is detected in ToolClassifier.classify() (user mid-session for
endpoint A sends a message that strongly matches endpoint B), both the session and the
L2 key are cleaned up:
await session_store.delete(request.chatId) # existing behaviour
if FeatureFlags.ATC_RESPONSE_CACHE_ENABLED:
await ATCCacheStore().invalidate_l2(request.chatId)invalidate_l2 deletes only the atc:last:{chat_id} key. L1 keys are not deleted —
they are param-hash-scoped and expire on their own TTL. Deleting L1 would provide no
safety benefit and would waste valid cached data.
Defined in src/tool_classifier/constants.py and src/llm_orchestrator_config/feature_flags.py:
| Name | Value | Description |
|---|---|---|
ATC_CACHE_KEY_PREFIX |
atc:cache |
Redis key prefix for L1 entries |
ATC_LAST_CALL_KEY_PREFIX |
atc:last |
Redis key prefix for L2 entries |
ATC_CACHE_DEFAULT_TTL_SECONDS |
1800 |
Default L1 TTL (30 min); overridable per endpoint via cache_ttl_seconds |
ATC_LAST_CALL_TTL_SECONDS |
1800 |
L2 TTL (30 min, sliding) |
ATC_RESPONSE_CACHE_ENABLED |
true (env) |
Master kill-switch — disables all reads and writes when false |
| Class / File | Responsibility |
|---|---|
ATCCacheStore (src/utils/atc_cache_store.py) |
All Redis operations for L1 and L2; param normalisation and hashing |
FollowUpDetectorModule (src/tool_classifier/follow_up_detector.py) |
DSPy classifier for follow-up type detection |
LastCallContext (src/models/session_models.py) |
Pydantic model stored in L2 |
_compute_loop_step (src/tool_classifier/workflows/api_tool_workflow.py) |
Where L1 + L2 are read and routing decisions are made |
_execute_api_and_format / _stream_api_and_format |
Where L1 + L2 are written after single-intent calls |
_execute_multi_api_and_format / _stream_multi_api_and_format |
Where L1 + L2 are written after parallel calls |
ToolClassifier.classify (src/tool_classifier/classifier.py) |
L2 invalidation on intent switch |
Turn 1 — "What are public holidays in Estonia in 2026?"
Agentic loop collects {countryIsoCode:"EE", validFrom:"2026-01-01", validTo:"2026-12-31"}
API called → 12 holidays returned
L1 written: atc:cache:{id}:get_national_holidays:{hash({EE,2026-01-01,2026-12-31})}
L2 written: atc:last:{id} = [LastCallContext{api_name="get_national_holidays", ...}]
Turn 2 — "Same for Latvia?"
No session → L1 miss (country changed) → L2 hit
FollowUpDetectorModule → param_update, updated_params={countryIsoCode:"LV"}
merged = {countryIsoCode:"LV", validFrom:"2026-01-01", validTo:"2026-12-31"}
no missing params + hashes differ → api_call step
API called directly — zero agentic loop turns
L1 + L2 updated with new result
Turn 3 — "Which of those is a bank holiday?"
No session → L1 miss → L2 hit
FollowUpDetectorModule → response_question
Formatter receives Latvia raw_response from L2 → answers from cached data
No API call, no loop
Turn 4 — "What is the weather in Tallinn?"
Classifier: get_weather matched (different endpoint)
Intent switch → session_store.delete + invalidate_l2
Fresh session for get_weather starts with empty L1 and L2