Skip to content

Commit 32a2e66

Browse files
authored
Merge pull request buerokratt#465 from rootcodelabs/llm-464
Redis response caching with FollowUpDetectorModule for ATC
2 parents aa6a3ce + 908b9cf commit 32a2e66

17 files changed

Lines changed: 3128 additions & 9 deletions

docs/API_TOOL_CALLING.md

Lines changed: 288 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ loop collects all required parameters from the user before the API call is made.
2222
| **Parallel API caller** | Fires all completed endpoint calls concurrently via `asyncio.gather` with batch timeout and partial-failure handling | ✅ Phase 4 Complete |
2323
| **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 |
2424
| **Full wiring** | `APIToolWorkflowExecutor` routes parallel sessions through `MultiEndpointAgenticLoop``MultiAPICaller``MultiResponseFormatterModule` with output guardrails | ✅ Phase 6 Complete |
25+
| **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 |
2526

2627
---
2728

@@ -1042,7 +1043,6 @@ Same API call steps, then `asyncio.to_thread(formatter.forward, ...)` for the sy
10421043

10431044
---
10441045

1045-
10461046
### Constants and Feature Flags
10471047

10481048
Defined in [src/tool_classifier/constants.py](../src/tool_classifier/constants.py) and [src/llm_orchestrator_config/feature_flags.py](../src/llm_orchestrator_config/feature_flags.py):
@@ -1185,3 +1185,290 @@ Bot: "Here's what I found: The address Viru tn 4 is located in Tallinn city cent
11851185
```
11861186

11871187
---
1188+
1189+
1190+
1191+
1192+
## Part 8 — ATC Response Cache
1193+
1194+
### Overview
1195+
1196+
The ATC Response Cache is a two-tier Redis cache that sits inside `_compute_loop_step()` in
1197+
[src/tool_classifier/workflows/api_tool_workflow.py](../src/tool_classifier/workflows/api_tool_workflow.py).
1198+
It is checked on every **new request** (no active session) before the agentic loop is created.
1199+
1200+
Goal: avoid redundant API calls and agentic loop turns when the user is repeating or
1201+
following up on a query that was already answered in the same conversation.
1202+
1203+
Gated by `FeatureFlags.ATC_RESPONSE_CACHE_ENABLED` (`ATC_RESPONSE_CACHE_ENABLED` env var, default `true`).
1204+
Setting it to `false` disables all cache reads and writes without touching any other ATC logic.
1205+
1206+
---
1207+
1208+
### Cache Architecture — Two Tiers
1209+
1210+
#### Tier 1 — L1 Exact Response Cache
1211+
1212+
```
1213+
Key: atc:cache:{chat_id}:{api_name}:{param_hash}
1214+
Value: raw API response JSON (dict or list)
1215+
TTL: per-endpoint cache_ttl_seconds OR ATC_CACHE_DEFAULT_TTL_SECONDS (30 min)
1216+
```
1217+
1218+
Answers the question: *Has this exact conversation called this exact endpoint with these exact params before?*
1219+
1220+
`param_hash` is a 16-character hex digest of the **normalised, sorted** param dict:
1221+
- String values are stripped of whitespace
1222+
- Purely numeric strings (`"2026"`) are cast to `int` before hashing
1223+
- All-alpha strings (enum-like, e.g. `"GET"`, `"EE"`) are lowercased
1224+
- Keys are sorted so order does not matter
1225+
1226+
This means `{year: "2026", country: "EE"}` and `{country: "ee", year: 2026}` produce
1227+
the **same hash** and hit the same cache entry.
1228+
1229+
#### Tier 2 — L2 Last Call Context
1230+
1231+
```
1232+
Key: atc:last:{chat_id}
1233+
Value: JSON list[LastCallContext]
1234+
TTL: ATC_LAST_CALL_TTL_SECONDS (30 min, sliding — reset on every write)
1235+
```
1236+
1237+
Answers the question: *What was the last API call made in this conversation?*
1238+
1239+
Stores a full `LastCallContext` per succeeded endpoint. Single-intent calls write a
1240+
one-element list; multi-intent parallel calls write one entry per succeeded endpoint.
1241+
The follow-up detector searches this list by `api_name` to find the relevant prior call.
1242+
1243+
---
1244+
1245+
### Data Model: `LastCallContext`
1246+
1247+
Defined in [src/models/session_models.py](../src/models/session_models.py).
1248+
1249+
| Field | Type | Description |
1250+
|---|---|---|
1251+
| `api_name` | str | Endpoint name (snake_case) that was called |
1252+
| `endpoint` | dict | Full endpoint payload from Qdrant (params schema, URL, method, etc.) |
1253+
| `collected_params` | dict | Parameter values that were passed to the API call |
1254+
| `raw_response` | Any | Parsed API JSON (dict or list) as returned by `APICaller` |
1255+
| `original_query` | str | User's first-turn query that triggered this API call |
1256+
| `timestamp` | float | Unix timestamp of the call (for staleness reference) |
1257+
1258+
---
1259+
1260+
1261+
### Cache Write Points
1262+
1263+
L1 and L2 are written **after** every successful API call, as a background
1264+
`asyncio.create_task` so they never delay the user-facing response:
1265+
1266+
**Single-intent (`_execute_api_and_format`)**
1267+
After `api_result.success == True` and before the formatter:
1268+
```
1269+
set_l1(chat_id, endpoint["name"], collected_params, response_data, ttl)
1270+
set_l2(chat_id, [LastCallContext(...)])
1271+
```
1272+
1273+
**Multi-intent (`_execute_multi_api_and_format` / `_stream_multi_api_and_format`)**
1274+
After `multi_result` is received, one write per succeeded endpoint:
1275+
```
1276+
for each (endpoint_state, result) where result.success and endpoint.cacheable:
1277+
set_l1(chat_id, endpoint["name"], endpoint_state.collected_params, result.response_data, ttl)
1278+
append LastCallContext to contexts_list
1279+
set_l2(chat_id, contexts_list) ← one write for all endpoints
1280+
```
1281+
1282+
---
1283+
1284+
### Cache Read Logic in `_compute_loop_step`
1285+
1286+
The cache block runs only when:
1287+
- No active Redis session exists (fresh request, not mid-loop)
1288+
- Not a parallel multi-intent query (`not all_matched`)
1289+
- `endpoint.cacheable == True`
1290+
- `ATC_RESPONSE_CACHE_ENABLED == True`
1291+
1292+
```
1293+
New request → no session → endpoint resolved
1294+
1295+
1296+
── L1 check ──────────────────────────────────────────────────────────
1297+
get_l1(chat_id, endpoint["name"], pre_extracted_params)
1298+
1299+
├─ HIT → _LoopStep(kind="cached_response", cache_source="L1")
1300+
│ formatter receives cached raw response — no API call, no loop
1301+
1302+
└─ MISS → continue to L2
1303+
1304+
── L2 check ──────────────────────────────────────────────────────────
1305+
get_l2(chat_id) → find entry where api_name == endpoint["name"]
1306+
1307+
├─ No match → fall through to normal agentic loop
1308+
1309+
└─ Match found → FollowUpDetectorModule (DSPy via asyncio.to_thread)
1310+
1311+
│ Inputs: user_query, previous_query, previous_params, params_schema
1312+
1313+
├─ "response_question"
1314+
│ → _LoopStep(kind="cached_response", cache_source="L2",
1315+
│ cached_raw_response=matching.raw_response)
1316+
│ no API call; formatter answers from the previous response
1317+
1318+
├─ "param_update"
1319+
│ merged = {**matching.collected_params, **updated_params}
1320+
│ missing = _missing_required_params(schema, merged)
1321+
1322+
│ missing == []
1323+
│ ├─ hashes equal (params unchanged)
1324+
│ │ → try L1 with matching.collected_params
1325+
│ │ hit → cached_response (L1)
1326+
│ │ miss → cached_response (L2 raw_response)
1327+
│ └─ hashes differ (genuinely new params)
1328+
│ → _LoopStep(kind="api_call", collected_params=merged)
1329+
│ API called directly — entire agentic loop skipped
1330+
1331+
│ missing != []
1332+
│ → context["seeded_params"] = merged
1333+
│ fall through to agentic loop — only asks for gaps
1334+
1335+
└─ "new_intent"
1336+
→ ignore L2; fall through to normal agentic loop
1337+
1338+
On any FollowUpDetectorModule exception → fall through to normal loop (fail-open)
1339+
```
1340+
1341+
---
1342+
1343+
### Component: `FollowUpDetectorModule`
1344+
1345+
Defined in [src/tool_classifier/follow_up_detector.py](../src/tool_classifier/follow_up_detector.py).
1346+
1347+
DSPy `Predict` module that classifies the relationship between the new user query
1348+
and the previous API call. Run via `asyncio.to_thread` to avoid blocking the event loop.
1349+
1350+
**Inputs:**
1351+
1352+
| Input | Description |
1353+
|---|---|
1354+
| `user_query` | The new user message |
1355+
| `previous_query` | The user's original question that triggered the last API call |
1356+
| `previous_params` | JSON of param values from the last call |
1357+
| `params_schema` | JSON of the endpoint's param schema |
1358+
1359+
**Output — three possible values for `follow_up_type`:**
1360+
1361+
| Value | Meaning | Action |
1362+
|---|---|---|
1363+
| `response_question` | User is asking about the data already returned | Pass L2 `raw_response` to formatter; no API call |
1364+
| `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 |
1365+
| `new_intent` | Completely unrelated query | Ignore L2; run normal agentic loop from scratch |
1366+
1367+
**Security:** `updated_params` from the LLM is validated against the endpoint's param
1368+
schema — keys not in the schema are silently dropped to prevent injection.
1369+
1370+
**Fail-open:** any exception returns `{follow_up_type: "new_intent", updated_params: {}}`
1371+
so the user is never blocked.
1372+
1373+
---
1374+
1375+
### Param Seeding
1376+
1377+
When the L2 `param_update` path finds that merged params are still incomplete,
1378+
it sets `context["seeded_params"] = merged` before falling through to the agentic loop.
1379+
1380+
`AgenticLoop.run_turn()` and `stream_run_turn()` accept an optional `seeded_params` argument.
1381+
On turn 0, the seeds are merged into `collected_params` **before** any extraction runs:
1382+
1383+
```python
1384+
if turn_count == 0 and seeded_params:
1385+
collected_params = {**seeded_params, **collected_params}
1386+
```
1387+
1388+
The merge order means existing `collected_params` win — seeds cannot overwrite values
1389+
that were already explicitly provided. The seeds are also stored directly in the new
1390+
Redis session (`APIToolSession.collected_params = seeded_params`) so they survive
1391+
across HTTP requests.
1392+
1393+
**Effect:** the agentic loop starts with inherited values already populated and only
1394+
generates a question for the genuinely missing params.
1395+
1396+
---
1397+
1398+
### L2 Invalidation on Intent Switch
1399+
1400+
When an intent switch is detected in `ToolClassifier.classify()` (user mid-session for
1401+
endpoint A sends a message that strongly matches endpoint B), both the session and the
1402+
L2 key are cleaned up:
1403+
1404+
```python
1405+
await session_store.delete(request.chatId) # existing behaviour
1406+
if FeatureFlags.ATC_RESPONSE_CACHE_ENABLED:
1407+
await ATCCacheStore().invalidate_l2(request.chatId)
1408+
```
1409+
1410+
`invalidate_l2` deletes only the `atc:last:{chat_id}` key. L1 keys are **not** deleted —
1411+
they are param-hash-scoped and expire on their own TTL. Deleting L1 would provide no
1412+
safety benefit and would waste valid cached data.
1413+
1414+
---
1415+
1416+
### Cache Constants and Feature Flag
1417+
1418+
Defined in [src/tool_classifier/constants.py](../src/tool_classifier/constants.py) and
1419+
[src/llm_orchestrator_config/feature_flags.py](../src/llm_orchestrator_config/feature_flags.py):
1420+
1421+
| Name | Value | Description |
1422+
|---|---|---|
1423+
| `ATC_CACHE_KEY_PREFIX` | `atc:cache` | Redis key prefix for L1 entries |
1424+
| `ATC_LAST_CALL_KEY_PREFIX` | `atc:last` | Redis key prefix for L2 entries |
1425+
| `ATC_CACHE_DEFAULT_TTL_SECONDS` | `1800` | Default L1 TTL (30 min); overridable per endpoint via `cache_ttl_seconds` |
1426+
| `ATC_LAST_CALL_TTL_SECONDS` | `1800` | L2 TTL (30 min, sliding) |
1427+
| `ATC_RESPONSE_CACHE_ENABLED` | `true` (env) | Master kill-switch — disables all reads and writes when `false` |
1428+
1429+
---
1430+
1431+
### Cache Component Reference
1432+
1433+
| Class / File | Responsibility |
1434+
|---|---|
1435+
| `ATCCacheStore` ([src/utils/atc_cache_store.py](../src/utils/atc_cache_store.py)) | All Redis operations for L1 and L2; param normalisation and hashing |
1436+
| `FollowUpDetectorModule` ([src/tool_classifier/follow_up_detector.py](../src/tool_classifier/follow_up_detector.py)) | DSPy classifier for follow-up type detection |
1437+
| `LastCallContext` ([src/models/session_models.py](../src/models/session_models.py)) | Pydantic model stored in L2 |
1438+
| `_compute_loop_step` ([src/tool_classifier/workflows/api_tool_workflow.py](../src/tool_classifier/workflows/api_tool_workflow.py)) | Where L1 + L2 are read and routing decisions are made |
1439+
| `_execute_api_and_format` / `_stream_api_and_format` | Where L1 + L2 are written after single-intent calls |
1440+
| `_execute_multi_api_and_format` / `_stream_multi_api_and_format` | Where L1 + L2 are written after parallel calls |
1441+
| `ToolClassifier.classify` ([src/tool_classifier/classifier.py](../src/tool_classifier/classifier.py)) | L2 invalidation on intent switch |
1442+
1443+
---
1444+
1445+
### End-to-End Cache Example
1446+
1447+
```
1448+
Turn 1 — "What are public holidays in Estonia in 2026?"
1449+
Agentic loop collects {countryIsoCode:"EE", validFrom:"2026-01-01", validTo:"2026-12-31"}
1450+
API called → 12 holidays returned
1451+
L1 written: atc:cache:{id}:get_national_holidays:{hash({EE,2026-01-01,2026-12-31})}
1452+
L2 written: atc:last:{id} = [LastCallContext{api_name="get_national_holidays", ...}]
1453+
1454+
Turn 2 — "Same for Latvia?"
1455+
No session → L1 miss (country changed) → L2 hit
1456+
FollowUpDetectorModule → param_update, updated_params={countryIsoCode:"LV"}
1457+
merged = {countryIsoCode:"LV", validFrom:"2026-01-01", validTo:"2026-12-31"}
1458+
no missing params + hashes differ → api_call step
1459+
API called directly — zero agentic loop turns
1460+
L1 + L2 updated with new result
1461+
1462+
Turn 3 — "Which of those is a bank holiday?"
1463+
No session → L1 miss → L2 hit
1464+
FollowUpDetectorModule → response_question
1465+
Formatter receives Latvia raw_response from L2 → answers from cached data
1466+
No API call, no loop
1467+
1468+
Turn 4 — "What is the weather in Tallinn?"
1469+
Classifier: get_weather matched (different endpoint)
1470+
Intent switch → session_store.delete + invalidate_l2
1471+
Fresh session for get_weather starts with empty L1 and L2
1472+
```
1473+
1474+
---

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ unfixable = []
117117

118118

119119
"src/utils/api_tool_session_store.py" = ["ANN401"] # Dynamic Pydantic model field updates via **kwargs
120+
"src/utils/atc_cache_store.py" = ["ANN401"] # raw_response / return type are parsed JSON (dict or list) — Any is the correct annotation
120121
"src/tool_classifier/workflows/api_tool_workflow.py" = ["ANN401", "N815"] # Dynamic guardrails adapter + orchestration service Any types; camelCase _MinimalRequest field for API contract
121122

122123
[tool.ruff.format]

src/api_tool_indexer/main_indexer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ async def index_endpoint(endpoint_data: EndpointData) -> IndexingResult:
320320
params=endpoint_data.params,
321321
enriched_context=enriched_context,
322322
service_id=endpoint_data.service_id,
323+
cacheable=endpoint_data.cacheable,
324+
cache_ttl_seconds=endpoint_data.cache_ttl_seconds,
323325
point_type="example",
324326
example_text=example,
325327
embedding=ex_embedding,
@@ -351,6 +353,8 @@ async def index_endpoint(endpoint_data: EndpointData) -> IndexingResult:
351353
params=endpoint_data.params,
352354
enriched_context=enriched_context,
353355
service_id=endpoint_data.service_id,
356+
cacheable=endpoint_data.cacheable,
357+
cache_ttl_seconds=endpoint_data.cache_ttl_seconds,
354358
point_type="summary",
355359
embedding=summary_embedding,
356360
sparse_indices=summary_sparse.indices,

src/api_tool_indexer/models.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ class EndpointData(BaseModel):
3939
)
4040
visibility: str = Field(default="private", description="public or private")
4141
type: str = Field(default="custom_endpoint", description="Endpoint type")
42+
cacheable: bool = Field(
43+
default=True,
44+
description=(
45+
"Set False for endpoints returning sensitive/personal data "
46+
"(e.g. document status). Disables all L1/L2 cache writes."
47+
),
48+
)
49+
cache_ttl_seconds: Optional[int] = Field(
50+
default=None,
51+
ge=1,
52+
description=(
53+
"Per-endpoint L1 TTL override in seconds. "
54+
"None = use ATC_CACHE_DEFAULT_TTL_SECONDS."
55+
),
56+
)
4257

4358

4459
class EnrichedEndpoint(BaseModel):
@@ -69,6 +84,22 @@ class EnrichedEndpoint(BaseModel):
6984
)
7085
service_id: Optional[str] = Field(default=None, description="Parent service UUID")
7186

87+
cacheable: bool = Field(
88+
default=True,
89+
description=(
90+
"Propagated from EndpointData. False disables all L1/L2 cache writes "
91+
"for this endpoint at query time."
92+
),
93+
)
94+
cache_ttl_seconds: Optional[int] = Field(
95+
default=None,
96+
ge=1,
97+
description=(
98+
"Per-endpoint L1 TTL override propagated from EndpointData. "
99+
"None = use ATC_CACHE_DEFAULT_TTL_SECONDS."
100+
),
101+
)
102+
72103
# Point type — controls which text was embedded for this point
73104
point_type: str = Field(
74105
default="summary",

src/api_tool_indexer/qdrant_manager.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ def upsert_endpoint_points(self, enriched_points: List[EnrichedEndpoint]) -> boo
218218
219219
Payload fields stored on every point:
220220
endpoint_id, name, description, url, method, params,
221-
enriched_context, service_id, point_type, example_text (example only)
221+
enriched_context, service_id, point_type, cacheable,
222+
cache_ttl_seconds, example_text (example only)
222223
223224
Args:
224225
enriched_points: List of EnrichedEndpoint instances (examples + summary).
@@ -256,6 +257,8 @@ def upsert_endpoint_points(self, enriched_points: List[EnrichedEndpoint]) -> boo
256257
"enriched_context": enriched.enriched_context,
257258
"service_id": enriched.service_id,
258259
"point_type": enriched.point_type,
260+
"cacheable": enriched.cacheable,
261+
"cache_ttl_seconds": enriched.cache_ttl_seconds,
259262
}
260263
if enriched.example_text is not None:
261264
payload["example_text"] = enriched.example_text

0 commit comments

Comments
 (0)