-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathapi_tool_workflow.py
More file actions
1611 lines (1466 loc) · 70.7 KB
/
Copy pathapi_tool_workflow.py
File metadata and controls
1611 lines (1466 loc) · 70.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""API Tool Calling Workflow Executor — Layer 2 of the classification chain."""
import asyncio
import time
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Coroutine,
Dict,
List,
Literal,
Optional,
Protocol,
Union,
cast,
)
from src.loki_logger import LokiLogger
from llm_orchestrator_config.feature_flags import FeatureFlags
from models.request_models import (
OrchestrationRequest,
OrchestrationResponse,
TestOrchestrationResponse,
)
from models.session_models import APIToolSession, EndpointSessionState, LastCallContext
from tool_classifier.agentic_loop import AgenticLoop
from tool_classifier.api_caller import APICaller
from tool_classifier.api_response_formatter import APIResponseFormatterModule
from tool_classifier.base_workflow import BaseWorkflow
from tool_classifier.enums import AgenticLoopStatus, ExecutionMode
from tool_classifier.param_extractor import ParamExtractionModule
from utils.api_tool_session_store import APIToolSessionStore
from utils.conversation_history_helpers import get_conversation_history
from utils.conversation_history_store import ConversationHistoryStore
from utils.atc_cache_store import ATCCacheStore
from tool_classifier.constants import ATC_CACHE_DEFAULT_TTL_SECONDS
from tool_classifier.follow_up_detector import FollowUpDetectorModule
from tool_classifier.multi_agentic_loop import MultiEndpointAgenticLoop
from tool_classifier.multi_api_caller import MultiAPICaller
from tool_classifier.multi_response_formatter import MultiResponseFormatterModule
logger = LokiLogger(service_name="api-tool-calling")
if TYPE_CHECKING:
from guardrails.nemo_rails_adapter import NeMoRailsAdapter
class OrchestrationServiceProtocol(Protocol):
"""Protocol for orchestration service methods used by this workflow."""
def format_sse(
self,
chat_id: str,
content: str,
buttons: Optional[List[Dict[str, Any]]] = None,
) -> str:
"""Format a payload as an SSE message."""
...
async def handle_output_guardrails(
self,
guardrails_adapter: Any, # noqa: ANN401 — NeMoRailsAdapter, avoids circular import
generated_response: Union[OrchestrationResponse, TestOrchestrationResponse],
request: OrchestrationRequest,
costs_metric: Dict[str, Dict[str, Any]],
) -> Union[OrchestrationResponse, TestOrchestrationResponse]:
"""Check output guardrails and return (possibly replaced) response."""
...
async def store_streaming_inference(
self,
request: OrchestrationRequest,
final_answer: str,
) -> None:
"""Store streaming inference data for production/testing environments."""
...
@dataclass
class _LoopStep:
"""Shared result type from :meth:`APIToolWorkflowExecutor._compute_loop_step`.
``kind`` drives both the sync and streaming execution paths:
* ``"api_call"`` — single endpoint; all params collected; call API and format.
Populates: ``endpoint``, ``collected_params``, ``user_query``.
* ``"multi_api_call"`` — parallel endpoints; call all APIs concurrently and merge results.
Populates: ``parallel_endpoints``, ``user_query``.
``endpoint`` and ``collected_params`` are empty/unused.
* ``"question"`` — agentic loop needs more input; return ``question`` to user.
Populates: ``question``, ``question_tokens``.
* ``"fallback"`` — nothing to do; caller should fall back to RAG.
No additional fields are populated.
* ``"cached_response"`` — cache hit (L1 or L2); skip APICaller; format ``cached_raw_response``.
Populates: ``endpoint``, ``cached_raw_response``, ``collected_params``, ``user_query``, ``cache_source``.
"""
kind: Literal[
"api_call", "multi_api_call", "question", "fallback", "cached_response"
]
chat_id: str = ""
endpoint: Dict[str, Any] = field(default_factory=dict)
parallel_endpoints: List[EndpointSessionState] = field(default_factory=list)
collected_params: Dict[str, Any] = field(default_factory=dict)
detected_language: str = "en"
user_query: str = ""
question: str = ""
question_tokens: List[str] = field(default_factory=list)
custom_instructions: str = ""
cached_raw_response: Any = None
cache_source: str = "L1"
class APIToolWorkflowExecutor(BaseWorkflow):
"""Executes API Tool Calling workflow (Layer 2).
Handles queries that matched an API endpoint in api_tool_collection.
On the first turn for a chat_id the matched endpoint is read from context
(populated by ToolClassifier.classify()). Subsequent turns resume from the
persisted Redis session — context["matched_endpoint"] is ignored once a
session exists.
The executor manages the agentic loop lifecycle:
- creates the session on turn 1
- resumes it on turns 2-N
- deletes it on COMPLETED or MAX_TURNS_REACHED
When all required params are collected (COMPLETED) the executor calls the
external API via :class:`APICaller` and formats the raw response into
natural-language using :class:`APIResponseFormatterModule`. The formatted
answer is returned directly to the user.
"""
def __init__(
self, orchestration_service: Optional[OrchestrationServiceProtocol] = None
) -> None:
self.orchestration_service = orchestration_service
self._api_caller = APICaller()
self._prompt_config_loader = (
getattr(orchestration_service, "prompt_config_loader", None)
if orchestration_service is not None
else None
)
self._background_tasks: set[asyncio.Task[None]] = set()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None:
"""Keep fire-and-forget tasks alive until they finish."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._discard_background_task)
def _discard_background_task(self, task: asyncio.Task[None]) -> None:
"""Remove a completed background task and log any uncaught exception."""
self._background_tasks.discard(task)
if task.cancelled():
return
exception = task.exception()
if exception is not None:
logger.warning(f"APIToolWorkflow: background task failed: {exception}")
def _get_session_store(self) -> Optional[APIToolSessionStore]:
"""Return the session store from the orchestration service, or None."""
if self.orchestration_service is None:
return None
return getattr(self.orchestration_service, "session_store", None)
def _get_conversation_history_store(self) -> Optional[ConversationHistoryStore]:
"""Return the conversation history store from the orchestration service, or None."""
if self.orchestration_service is None:
return None
return getattr(self.orchestration_service, "conversation_history_store", None)
def _get_guardrails_adapter(
self, environment: str, connection_id: Optional[str] = None
) -> Optional["NeMoRailsAdapter"]:
"""Return the NeMoRailsAdapter for *environment*, or None if unavailable."""
if self.orchestration_service is None:
return None
shared = getattr(self.orchestration_service, "shared_guardrails_adapters", {})
if environment in shared:
return shared[environment]
# Fallback: per-request initialisation (slower but safe)
safe_init = getattr(
self.orchestration_service, "_safe_initialize_guardrails", None
)
if safe_init is not None:
return safe_init(environment, connection_id)
return None
async def _get_custom_instructions(self) -> str:
"""Fetch custom prompt instructions from the loader, or return empty string.
Mirrors LLMOrchestrationService._get_custom_instructions_for_response_generation.
The PromptConfigurationLoader has a 5-minute TTL cache, so this is cheap.
Returns empty string on any failure so existing behaviour is preserved.
Runs the synchronous requests call in a thread pool via asyncio.to_thread so
that a cache miss or slow Ruuter response never blocks the event loop.
"""
if self._prompt_config_loader is None:
return ""
try:
custom_prompt = await asyncio.to_thread(
self._prompt_config_loader.get_custom_instructions
)
return custom_prompt if custom_prompt else ""
except Exception as e:
logger.error(f"APIToolWorkflow: failed to fetch custom instructions: {e}")
return ""
def _build_agentic_loop(
self, session_store: Any, custom_instructions: str = ""
) -> AgenticLoop:
"""Construct a fresh AgenticLoop for one request."""
return AgenticLoop(
session_store=session_store,
param_extractor=ParamExtractionModule(
custom_instructions=custom_instructions
),
)
@staticmethod
def _language_from_custom_instructions(custom_instructions: str) -> Optional[str]:
"""Detect the directed response language from custom instructions.
Returns the ISO language code (``'en'``, ``'et'``, or ``'ru'``) when the
instructions contain a recognisable language directive, or ``None`` when no
directive is found.
English is checked first so that a prompt written in Estonian that says
"respond in English" (or the Estonian equivalent ``"inglise keeles"``) is
correctly identified as directing English responses. This ensures that both
the hardcoded continuation question and the LLM-generated clarifying questions
use the same directed response language.
``'inglise'`` (Estonian for "English") and ``'английск'`` (Russian stem for
"English") are recognised so prompts written entirely in those languages work.
Args:
custom_instructions: The raw custom instructions string from the
``PromptConfigurationLoader``.
Returns:
``'en'``, ``'et'``, ``'ru'``, or ``None``.
"""
if not custom_instructions:
return None
lower = custom_instructions.lower()
# "inglise" = Estonian for "English"; "английск" covers "английский/английском"
if "english" in lower or "inglise" in lower or "английск" in lower:
return "en"
if "estonian" in lower or "eesti" in lower:
return "et"
if "russian" in lower or "vene" in lower or "русск" in lower:
return "ru"
return None
@staticmethod
def _required_params(params: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [p for p in params if isinstance(p, dict) and p.get("required", False)]
@staticmethod
def _missing_required_params(
schema: List[Dict[str, Any]], collected: Dict[str, Any]
) -> List[str]:
"""Return names of required params from *schema* not yet present in *collected*."""
missing: list[str] = []
for p in schema:
if not isinstance(p, dict) or not p.get("required", False):
continue
name = p.get("name")
if not isinstance(name, str) or not name:
continue
if name not in collected:
missing.append(name)
return missing
async def _execute_api_and_format(
self,
chat_id: str,
endpoint: Dict[str, Any],
collected_params: Dict[str, Any],
user_query: str,
detected_language: str,
custom_instructions: str = "",
) -> OrchestrationResponse:
"""Call the external API with collected params and return a formatted response.
On success, the raw API response is converted to natural language by
:class:`APIResponseFormatterModule`.
On any failure the localized error message is returned directly to the user.
"""
url = endpoint.get("url", "")
method = endpoint.get("method", "GET")
description = endpoint.get("description", "")
# L1 cache check — collected_params is complete here, so the hash matches
# what was stored on the previous successful call with the same params.
if FeatureFlags.ATC_RESPONSE_CACHE_ENABLED and endpoint.get("cacheable", True):
_cache_store = ATCCacheStore()
_cached = await _cache_store.get_l1(
chat_id, endpoint.get("name", ""), collected_params
)
if _cached is not None:
logger.info(
f"[{chat_id}] ATC cache: L1 hit for {endpoint.get('name')!r} "
f"— skipping API call"
)
return await self._format_cached_response(
chat_id=chat_id,
endpoint=endpoint,
user_query=user_query,
detected_language=detected_language,
cached_raw_response=_cached,
collected_params=collected_params,
custom_instructions=custom_instructions,
cache_source="L1",
)
logger.info(
f"[{chat_id}] APIToolWorkflow: calling API "
f"{method} {url} with params={list(collected_params.keys())}"
)
api_result = await self._api_caller.call(
url=url,
method=method,
params=collected_params,
language=detected_language,
)
if api_result.success:
logger.info(
f"[{chat_id}] APIToolWorkflow: API call succeeded "
f"(status={api_result.status_code})"
)
if FeatureFlags.ATC_RESPONSE_CACHE_ENABLED and endpoint.get(
"cacheable", True
):
_ep_name = endpoint.get("name", "")
_ttl_override = endpoint.get("cache_ttl_seconds")
_ttl = (
_ttl_override
if isinstance(_ttl_override, int) and _ttl_override > 0
else ATC_CACHE_DEFAULT_TTL_SECONDS
)
_resp_data = api_result.response_data
async def _write_l1_l2() -> None:
try:
_cs = ATCCacheStore()
await _cs.set_l1(
chat_id, _ep_name, collected_params, _resp_data, _ttl
)
await _cs.set_l2(
chat_id,
[
LastCallContext(
api_name=_ep_name,
endpoint=endpoint,
collected_params=collected_params,
raw_response=_resp_data,
original_query=user_query,
timestamp=time.time(),
)
],
)
except Exception as _exc:
logger.warning(
f"[{chat_id}] ATC cache: background write failed: {_exc}"
)
self._create_background_task(_write_l1_l2())
formatter = APIResponseFormatterModule(
custom_instructions=custom_instructions
)
content = await asyncio.to_thread(
formatter.forward,
user_query=user_query,
api_response=api_result.response_data,
endpoint_description=description,
detected_language=detected_language,
collected_params=collected_params,
)
else:
logger.warning(
f"[{chat_id}] APIToolWorkflow: API call failed "
f"(status={api_result.status_code}, error={api_result.error!r})"
)
content = api_result.error or ""
return OrchestrationResponse(
chatId=chat_id,
llmServiceActive=True,
questionOutOfLLMScope=False,
inputGuardFailed=False,
content=content,
)
@staticmethod
def _build_question_response(chat_id: str, question: str) -> OrchestrationResponse:
return OrchestrationResponse(
chatId=chat_id,
llmServiceActive=True,
questionOutOfLLMScope=False,
inputGuardFailed=False,
content=question,
)
async def _execute_multi_api_and_format(
self,
chat_id: str,
parallel_endpoints: List[EndpointSessionState],
user_query: str,
detected_language: str,
custom_instructions: str = "",
) -> OrchestrationResponse:
"""Call all parallel endpoints concurrently and return a merged natural-language response.
Builds a ``call_params``-keyed payload for each :class:`EndpointSessionState`,
dispatches all calls concurrently via :class:`MultiAPICaller`, then synthesises
the results into one unified answer with :class:`MultiResponseFormatterModule`.
"""
call_payloads = [
{**state.endpoint, "call_params": state.collected_params}
for state in parallel_endpoints
]
ep_names = [
state.endpoint.get("name", "<unnamed>") for state in parallel_endpoints
]
logger.info(
f"[{chat_id}] APIToolWorkflow: parallel — calling {len(call_payloads)} APIs "
f"concurrently: {ep_names}"
)
multi_caller = MultiAPICaller(self._api_caller)
multi_result = await multi_caller.call_all(
call_payloads, language=detected_language
)
logger.info(
f"[{chat_id}] APIToolWorkflow: parallel batch complete — "
f"{sum(r.success for r in multi_result.results)}/{len(multi_result.results)} succeeded"
)
_pairs = list(zip(parallel_endpoints, multi_result.results, strict=True))
if FeatureFlags.ATC_RESPONSE_CACHE_ENABLED:
async def _write_multi_cache() -> None:
try:
_cs = ATCCacheStore()
_ctxs: list[LastCallContext] = []
for _state, _result in _pairs:
if _result.success and _state.endpoint.get("cacheable", True):
_ttl_override = _state.endpoint.get("cache_ttl_seconds")
_ttl = (
_ttl_override
if isinstance(_ttl_override, int) and _ttl_override > 0
else ATC_CACHE_DEFAULT_TTL_SECONDS
)
await _cs.set_l1(
chat_id,
_state.endpoint.get("name", ""),
_state.collected_params,
_result.response_data,
_ttl,
)
_ctxs.append(
LastCallContext(
api_name=_state.endpoint.get("name", ""),
endpoint=_state.endpoint,
collected_params=_state.collected_params,
raw_response=_result.response_data,
original_query=user_query,
timestamp=time.time(),
)
)
if _ctxs:
await _cs.set_l2(chat_id, _ctxs)
except Exception as _exc:
logger.warning(
f"[{chat_id}] ATC cache: background multi-write failed: {_exc}"
)
self._create_background_task(_write_multi_cache())
api_results = [
(
state.endpoint.get("name", ""),
state.endpoint.get("description", ""),
result.response_data if result.success else result.error or "",
state.collected_params,
)
for state, result in zip(
parallel_endpoints, multi_result.results, strict=True
)
]
formatter = MultiResponseFormatterModule(
custom_instructions=custom_instructions
)
content = await asyncio.to_thread(
formatter.forward,
user_query=user_query,
api_results=api_results,
detected_language=detected_language,
)
return OrchestrationResponse(
chatId=chat_id,
llmServiceActive=True,
questionOutOfLLMScope=False,
inputGuardFailed=False,
content=content,
)
async def _stream_multi_api_and_format(
self,
chat_id: str,
parallel_endpoints: List[EndpointSessionState],
user_query: str,
detected_language: str,
orchestration_service: OrchestrationServiceProtocol,
request: OrchestrationRequest,
costs_metric: Optional[Dict[str, Any]] = None,
custom_instructions: str = "",
) -> AsyncIterator[str]:
"""Call all parallel APIs concurrently, then stream the merged answer token by token.
API calls are pre-resolved before streaming starts so the LLM synthesis step
receives all results at once. Uses the same buffer-first guardrails approach as
:meth:`_stream_api_and_format` — the full response is assembled and validated
before any token is sent to the client.
"""
call_payloads = [
{**state.endpoint, "call_params": state.collected_params}
for state in parallel_endpoints
]
ep_names = [
state.endpoint.get("name", "<unnamed>") for state in parallel_endpoints
]
logger.info(
f"[{chat_id}] APIToolWorkflow (streaming): parallel — calling {len(call_payloads)} APIs "
f"concurrently: {ep_names}"
)
multi_caller = MultiAPICaller(self._api_caller)
multi_result = await multi_caller.call_all(
call_payloads, language=detected_language
)
logger.info(
f"[{chat_id}] APIToolWorkflow (streaming): parallel batch complete — "
f"{sum(r.success for r in multi_result.results)}/{len(multi_result.results)} succeeded"
)
_pairs = list(zip(parallel_endpoints, multi_result.results, strict=True))
if FeatureFlags.ATC_RESPONSE_CACHE_ENABLED:
async def _write_multi_cache() -> None:
try:
_cs = ATCCacheStore()
_ctxs: list[LastCallContext] = []
for _state, _result in _pairs:
if _result.success and _state.endpoint.get("cacheable", True):
_ttl_override = _state.endpoint.get("cache_ttl_seconds")
_ttl = (
_ttl_override
if isinstance(_ttl_override, int) and _ttl_override > 0
else ATC_CACHE_DEFAULT_TTL_SECONDS
)
await _cs.set_l1(
chat_id,
_state.endpoint.get("name", ""),
_state.collected_params,
_result.response_data,
_ttl,
)
_ctxs.append(
LastCallContext(
api_name=_state.endpoint.get("name", ""),
endpoint=_state.endpoint,
collected_params=_state.collected_params,
raw_response=_result.response_data,
original_query=user_query,
timestamp=time.time(),
)
)
if _ctxs:
await _cs.set_l2(chat_id, _ctxs)
except Exception as _exc:
logger.warning(
f"[{chat_id}] ATC cache: background multi-write failed: {_exc}"
)
self._create_background_task(_write_multi_cache())
api_results = [
(
state.endpoint.get("name", ""),
state.endpoint.get("description", ""),
result.response_data if result.success else result.error or "",
state.collected_params,
)
for state, result in zip(
parallel_endpoints, multi_result.results, strict=True
)
]
formatter = MultiResponseFormatterModule(
custom_instructions=custom_instructions
)
buffered_tokens = [
token
async for token in formatter.stream_forward_multi(
user_query=user_query,
api_results=api_results,
detected_language=detected_language,
)
]
full_response = "".join(buffered_tokens)
final_answer = full_response # Track what gets sent to user
guardrails_passed = True
if orchestration_service is not None:
guardrails_adapter = self._get_guardrails_adapter(
request.environment, request.connection_id
)
if guardrails_adapter is not None:
dummy_response = OrchestrationResponse(
chatId=chat_id,
llmServiceActive=True,
questionOutOfLLMScope=False,
inputGuardFailed=False,
content=full_response,
)
checked = await orchestration_service.handle_output_guardrails(
guardrails_adapter,
dummy_response,
request,
costs_metric if costs_metric is not None else {},
)
if checked.content != full_response:
logger.warning(
f"[{chat_id}] APIToolWorkflow (streaming): "
f"parallel output blocked by guardrails"
)
yield orchestration_service.format_sse(chat_id, checked.content)
final_answer = checked.content
guardrails_passed = False
if guardrails_passed:
for token in buffered_tokens:
yield orchestration_service.format_sse(chat_id, token)
await orchestration_service.store_streaming_inference(request, final_answer)
yield orchestration_service.format_sse(chat_id, "END")
# ------------------------------------------------------------------
# Core loop handler — shared by async and streaming paths
# ------------------------------------------------------------------
async def _compute_loop_step(
self,
request: OrchestrationRequest,
context: Dict[str, Any],
) -> _LoopStep:
"""Run one agentic loop turn and return a tagged outcome.
This is the single source of truth for session management and loop logic.
Both the sync (:meth:`execute_async`) and streaming (:meth:`execute_streaming`)
paths call this method and then handle the result in their own way:
* ``"api_call"`` → call API + format response (blocking or streaming)
* ``"question"`` → return clarifying question to user
* ``"fallback"`` → no valid state; caller falls back to RAG
"""
chat_id = request.chatId
session_store = self._get_session_store()
# ── Try to resume an existing session ────────────────────────────
session: Optional[APIToolSession] = None
if session_store is not None:
session = await session_store.get(chat_id)
custom_instructions = await self._get_custom_instructions()
if session is not None:
# Resume path — endpoint comes from persisted session
endpoint = session.selected_endpoint
if endpoint is None:
logger.warning(
f"[{chat_id}] APIToolWorkflow: session has no endpoint — deleting"
)
if session_store is not None:
await session_store.delete(chat_id)
return _LoopStep(kind="fallback", chat_id=chat_id)
if session.execution_mode == ExecutionMode.PARALLEL.value:
ep_names = [s.endpoint.get("name") for s in session.parallel_endpoints]
logger.info(
f"[{chat_id}] APIToolWorkflow: resuming parallel session "
f"(turn={session.turn_count}, endpoints={ep_names})"
)
else:
logger.info(
f"[{chat_id}] APIToolWorkflow: resuming session "
f"(turn={session.turn_count}, endpoint={endpoint.get('name')!r})"
)
else:
# New-session path — endpoint must come from classifier context.
# For parallel execution_mode, drive param collection for the first
# endpoint (Phase 3 MultiEndpointAgenticLoop will advance the index).
all_matched: list[dict[str, Any]] = []
if context.get("execution_mode") == ExecutionMode.PARALLEL:
all_matched = context.get("matched_endpoints", [])
endpoint = all_matched[0] if all_matched else None
if endpoint:
logger.info(
f"[{chat_id}] APIToolWorkflow: parallel mode — "
f"starting param collection for first endpoint "
f"{endpoint.get('name')!r} "
f"({len(all_matched)} endpoints total)"
)
else:
endpoint = context.get("matched_endpoint")
if not endpoint:
logger.warning(
f"[{chat_id}] APIToolWorkflow: no matched_endpoint in context "
f"and no active session — falling back"
)
return _LoopStep(kind="fallback", chat_id=chat_id)
params_schema: List[Dict[str, Any]] = endpoint.get("params", [])
# L1 + L2 cache checks — single-mode new queries only; both guarded by
# the ATC_RESPONSE_CACHE_ENABLED kill-switch.
if (
FeatureFlags.ATC_RESPONSE_CACHE_ENABLED
and not all_matched
and endpoint.get("cacheable", True)
):
_cache_store = ATCCacheStore()
# ── L1: exact param-hash hit ─────────────────────────────────
_cached = await _cache_store.get_l1(
chat_id,
endpoint.get("name", ""),
context.get("pre_extracted_params", {}),
)
if _cached is not None:
logger.info(
f"[{chat_id}] ATC cache: L1 hit for {endpoint.get('name')!r}"
)
return _LoopStep(
kind="cached_response",
chat_id=chat_id,
endpoint=endpoint,
cached_raw_response=_cached,
detected_language=getattr(request, "_detected_language", "en"),
user_query=request.message,
custom_instructions=custom_instructions,
collected_params=context.get("pre_extracted_params", {}),
)
# ── L2: follow-up routing based on last call context ─────────
_last_calls = await _cache_store.get_l2(chat_id)
if _last_calls:
_matching = next(
(c for c in _last_calls if c.api_name == endpoint.get("name")),
None,
)
if _matching is not None:
try:
_detector = FollowUpDetectorModule()
_det_result = await asyncio.to_thread(
_detector.forward,
user_query=request.message,
previous_query=_matching.original_query,
previous_params=_matching.collected_params,
params_schema=endpoint.get("params", []),
)
if _det_result["follow_up_type"] == "response_question":
logger.info(
f"[{chat_id}] ATC cache: L2 follow-up — response_question"
)
return _LoopStep(
kind="cached_response",
chat_id=chat_id,
endpoint=endpoint,
cached_raw_response=_matching.raw_response,
detected_language=getattr(
request, "_detected_language", "en"
),
user_query=request.message,
custom_instructions=custom_instructions,
cache_source="L2",
collected_params=_matching.collected_params,
)
elif _det_result["follow_up_type"] == "param_update":
_updated = _det_result["updated_params"]
_merged = {**_matching.collected_params, **_updated}
_missing = self._missing_required_params(
endpoint.get("params", []), _merged
)
if not _missing:
# If the merged params are identical to the previous
# call's params (e.g. no genuine new params survived
# schema validation), the user is re-requesting the
# same data → serve from L2 directly without an API
# call or L1 lookup.
if ATCCacheStore._param_hash(
_merged
) == ATCCacheStore._param_hash(
_matching.collected_params
):
# Params unchanged — prefer L1 (exact hash hit
# with the actual previously-collected params).
# L1 uses a TTL so it may have expired; fall
# back to L2 raw_response in that case.
_l1_data = await _cache_store.get_l1(
chat_id,
endpoint.get("name", ""),
_matching.collected_params,
)
if _l1_data is not None:
logger.info(
f"[{chat_id}] ATC cache: L2 follow-up — param_update "
f"(params unchanged), L1 hit — serving from L1"
)
return _LoopStep(
kind="cached_response",
chat_id=chat_id,
endpoint=endpoint,
cached_raw_response=_l1_data,
detected_language=getattr(
request, "_detected_language", "en"
),
user_query=request.message,
custom_instructions=custom_instructions,
cache_source="L1",
collected_params=_matching.collected_params,
)
logger.info(
f"[{chat_id}] ATC cache: L2 follow-up — param_update "
f"(params unchanged), L1 miss — serving from L2"
)
return _LoopStep(
kind="cached_response",
chat_id=chat_id,
endpoint=endpoint,
cached_raw_response=_matching.raw_response,
detected_language=getattr(
request, "_detected_language", "en"
),
user_query=request.message,
custom_instructions=custom_instructions,
cache_source="L2",
collected_params=_matching.collected_params,
)
logger.info(
f"[{chat_id}] ATC cache: L2 follow-up — param_update "
f"(all params present), calling API directly"
)
return _LoopStep(
kind="api_call",
chat_id=chat_id,
endpoint=endpoint,
collected_params=_merged,
detected_language=getattr(
request, "_detected_language", "en"
),
user_query=request.message,
custom_instructions=custom_instructions,
)
else:
logger.info(
f"[{chat_id}] ATC cache: L2 follow-up — param_update "
f"(missing: {_missing}), seeding agentic loop"
)
context["seeded_params"] = _merged
# new_intent → fall through to normal agentic-loop path
except Exception as _exc:
logger.warning(
f"[{chat_id}] ATC cache: L2 follow-up detection failed: "
f"{_exc} — falling through to normal path"
)
# Fast path — skip the agentic loop when no required params need collecting.
# user_query falls back to request.message on the fast path because no session
# exists yet (original_query is only stored once a session is created).
user_query_for_fast_path = request.message
if all_matched:
# Parallel mode: fast-path only when ALL endpoints have no required params.
if all(
not self._required_params(ep.get("params", []))
for ep in all_matched
):
logger.info(
f"[{chat_id}] APIToolWorkflow: parallel fast path — "
f"all {len(all_matched)} endpoints have no required params"
)
return _LoopStep(
kind="multi_api_call",
chat_id=chat_id,
parallel_endpoints=[
EndpointSessionState(endpoint=e) for e in all_matched
],
detected_language=getattr(request, "_detected_language", "en"),
user_query=user_query_for_fast_path,
custom_instructions=custom_instructions,
)
elif not self._required_params(params_schema):
# Single mode: the only endpoint has no required params.
logger.info(
f"[{chat_id}] APIToolWorkflow: endpoint {endpoint.get('name')!r} "
f"has no required params — fast path"
)
return _LoopStep(
kind="api_call",
chat_id=chat_id,
endpoint=endpoint,
collected_params={},
detected_language=getattr(request, "_detected_language", "en"),
user_query=user_query_for_fast_path,
custom_instructions=custom_instructions,
)
# Create a new session before running the first loop turn
_seeded_params = context.get("seeded_params", {})
if session_store is not None:
new_session = APIToolSession(
chat_id=chat_id,
state="collecting_params",
selected_endpoint=endpoint,
collected_params=_seeded_params,
turn_count=0,
max_turns=5,
awaiting_continuation=False,
detected_language=getattr(request, "_detected_language", "en"),
original_query=request.message,
execution_mode=ExecutionMode.PARALLEL.value
if all_matched
else ExecutionMode.SINGLE.value,
parallel_endpoints=[
EndpointSessionState(endpoint=e) for e in all_matched
],
)
await session_store.save(new_session)
session = new_session
else:
logger.warning(
f"[{chat_id}] APIToolWorkflow: Redis unavailable — "
f"running loop without session persistence"
)
session = APIToolSession(
chat_id=chat_id,
state="collecting_params",
selected_endpoint=endpoint,
collected_params=_seeded_params,
turn_count=0,
max_turns=5,
awaiting_continuation=False,
detected_language=getattr(request, "_detected_language", "en"),
original_query=request.message,
execution_mode=ExecutionMode.PARALLEL.value
if all_matched
else ExecutionMode.SINGLE.value,
parallel_endpoints=[
EndpointSessionState(endpoint=e) for e in all_matched
],
)
# ── Run one loop turn ─────────────────────────────────────────────
if session_store is None:
logger.warning(
f"[{chat_id}] APIToolWorkflow: session store unavailable — "
f"agentic loop running without persistence"
)
# If custom_instructions contain a language directive (e.g. "respond in English"
# inside an Estonian-language prompt), use that directed language for all
# user-facing questions — both the LLM-generated clarifying questions and the
# hardcoded continuation question. Falls back to the session-detected language.
effective_session_language = (
self._language_from_custom_instructions(custom_instructions)
or session.detected_language
)