forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagentic.py
More file actions
1736 lines (1508 loc) · 73.4 KB
/
Copy pathagentic.py
File metadata and controls
1736 lines (1508 loc) · 73.4 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
"""
Agentic chat system with provider-specific streaming tool use.
This module implements a tool-calling agent that autonomously decides which tools
to use to gather context and answer user questions. Managed gateway traffic uses
the OpenAI-compatible chat-completions contract; direct specialist traffic keeps
Anthropic's native streaming contract.
"""
import json
import uuid
import asyncio
import contextvars
import os
from typing import List, Optional, AsyncGenerator, Any, Tuple
from langchain_core.runnables import RunnableConfig
from langchain_core.callbacks import BaseCallbackHandler
# Context variable to store config for tools
agent_config_context: contextvars.ContextVar[dict] = contextvars.ContextVar('agent_config', default=None)
from models.app import App
from utils.journey_metrics_contract import ClientKind
from utils.observability.journeys import ClientJourneyAttempt
from models.chat import Message, ChatSession, PageContext
from utils.retrieval.tools import (
get_conversations_tool,
search_conversations_tool,
get_memories_tool,
search_memories_tool,
get_action_items_tool,
create_action_item_tool,
update_action_item_tool,
get_omi_product_info_tool,
get_calendar_events_tool,
create_calendar_event_tool,
update_calendar_event_tool,
delete_calendar_event_tool,
get_gmail_messages_tool,
get_apple_health_steps_tool,
get_apple_health_sleep_tool,
get_apple_health_heart_rate_tool,
get_apple_health_workouts_tool,
get_apple_health_summary_tool,
search_files_tool,
manage_daily_summary_tool,
create_chart_tool,
get_screen_activity_tool,
search_screen_activity_tool,
frame_request_runtime_config,
look_at_frame_tool,
save_user_preference_tool,
fetch_url_tool,
traverse_knowledge_graph_tool,
get_entity_timeline_tool,
read_playbook,
search_historical_facts,
search_knowledge,
save_playbook,
create_standing_trigger,
close_fact_tool,
)
from utils.retrieval.tools.app_tools import load_app_tools, get_tool_status_message
from utils.retrieval.tools.conversation_jit_gate import (
append_jit_conversation_retrieval_prompt,
)
from utils.retrieval.tool_result_boundaries import preserve_chat_memory_tool_result_boundary
from utils.retrieval.chat_scope import build_chat_scope
from utils.retrieval.safety import (
AgentSafetyGuard,
CollectedContextReady,
SafetyGuardError,
fit_within_budget,
provider_fallback_reason,
should_retry_provider_error,
INPUT_TOO_LONG_MESSAGE,
)
from utils.retrieval.web_search_gate import WEB_SEARCH_TOOL, request_tools_after_private_taint
from utils.observability.fallback import record_fallback
from utils.llm.byok_errors import handle_llm_error_async
from utils.llm.clients import anthropic_client, ANTHROPIC_AGENT_MODEL, get_llm, num_tokens_from_string
from utils.llm.usage_tracker import reset_usage_context, set_usage_context
from utils.llm.chat import _get_agentic_qa_prompt, get_current_datetime_block, get_user_timezone
from utils.executors import run_blocking, db_executor
from utils.jit_rollout import JITDecisionStage, resolve_jit_rollout
from utils.chat_followup import (
FOLLOWUP_DELIMITER,
FOLLOWUP_PROMPT_SECTION,
FollowUpTailStreamFilter,
split_followup_tail,
)
from database.redis_db import get_cached_user_geolocation
from database.users import get_user_location_context_consent
from models.geolocation import Geolocation
from utils.conversations.location import async_get_google_maps_city
import logging
try:
from utils.llm.gateway_client import should_route_chat_agent_through_gateway
except ImportError:
def should_route_chat_agent_through_gateway() -> bool:
return False
# Import langsmith traceable if available
try:
from langsmith import traceable as _traceable
except ImportError:
def _traceable(**kwargs):
def decorator(func):
return func
return decorator
logger = logging.getLogger(__name__)
async def _resolve_jit_conversation_retrieval(uid: str) -> bool:
"""Resolve the server-owned JIT rollout before constructing chat config.
The conversation tools intentionally accept only the resulting per-request
boolean. They must not perform their own control-plane lookup, and a
caller-provided config value must never be able to enroll itself. Any
unknown/error result therefore stays on the released legacy path.
"""
try:
decision = await resolve_jit_rollout(uid, stage=JITDecisionStage.READ_ONLY)
except Exception as error:
# The control plane is additive. A transient resolver failure must not
# take down an otherwise healthy chat request or activate JIT by
# accident. Keep logs type-only so provider details never enter logs.
logger.warning(
'JIT conversation retrieval authority unavailable; keeping gate off error_type=%s',
type(error).__name__,
)
return False
return decision.permits_work
class _PerplexityWebSearchToolProxy:
"""Lazy adapter for the gateway-only web-search function tool.
Agentic unit tests intentionally load this module with a minimal LangChain
stub. Avoid importing the optional Perplexity tool module at import time,
while retaining the real LangChain tool and gateway implementation when a
managed request actually executes it.
"""
name = 'perplexity_web_search_tool'
description = 'Search the web for current information using Perplexity AI.'
@property
def args_schema(self):
try:
from utils.retrieval.tools.perplexity_tools import perplexity_web_search_tool
return perplexity_web_search_tool.args_schema
except ModuleNotFoundError as error:
if error.name != 'langchain_core.tools':
raise
class _FallbackArgsSchema:
@classmethod
def schema(cls):
return {
'properties': {'query': {'type': 'string'}},
'required': ['query'],
}
return _FallbackArgsSchema
async def ainvoke(self, tool_input, config=None):
from utils.retrieval.tools.perplexity_tools import perplexity_web_search_tool
return await perplexity_web_search_tool.ainvoke(tool_input, config=config)
perplexity_web_search_tool = _PerplexityWebSearchToolProxy()
def _positive_timeout_from_env(name: str, default: float) -> float:
"""Read a positive stream deadline at import time so invalid deploy config fails fast."""
raw_value = os.environ.get(name, str(default))
try:
timeout = float(raw_value)
except (TypeError, ValueError) as error:
raise ValueError(f'{name} must be a number') from error
if timeout <= 0:
raise ValueError(f'{name} must be greater than zero')
return timeout
def _positive_int_from_env(name: str, default: int) -> int:
"""Read a positive attempt count at import time so invalid deploy config fails fast."""
raw_value = os.environ.get(name, str(default))
try:
attempts = int(raw_value)
except (TypeError, ValueError) as error:
raise ValueError(f'{name} must be an integer') from error
if attempts <= 0:
raise ValueError(f'{name} must be greater than zero')
return attempts
# Setup (timezone / prompt / app tools) has its own budget so multi-second Firestore
# work cannot silently consume the post-setup first-stream-event (TTFT) window.
# After setup, the first event must arrive before the client/proxy deadline; afterwards a
# heartbeat keeps a known-long tool call observable while the total deadline
# still prevents an agent task from running without bound.
AGENT_STREAM_SETUP_TIMEOUT_SECONDS = _positive_timeout_from_env('AGENT_STREAM_SETUP_TIMEOUT_SECONDS', 25.0)
AGENT_STREAM_FIRST_EVENT_TIMEOUT_SECONDS = _positive_timeout_from_env('AGENT_STREAM_FIRST_EVENT_TIMEOUT_SECONDS', 25.0)
AGENT_STREAM_PROGRESS_HEARTBEAT_SECONDS = _positive_timeout_from_env('AGENT_STREAM_PROGRESS_HEARTBEAT_SECONDS', 20.0)
AGENT_STREAM_MAX_DURATION_SECONDS = _positive_timeout_from_env('AGENT_STREAM_MAX_DURATION_SECONDS', 150.0)
AGENT_STREAM_CANCEL_GRACE_SECONDS = _positive_timeout_from_env('AGENT_STREAM_CANCEL_GRACE_SECONDS', 2.0)
# How much of the turn budget a retry needs to be worth starting. The silent-interval bound on
# the call itself belongs to the transport (the gateway client, or the shared Anthropic client
# when features are not routed through it) and is deliberately not overridden per request.
AGENT_STREAM_PROVIDER_MIN_RETRY_HEADROOM_SECONDS = _positive_timeout_from_env(
'AGENT_STREAM_PROVIDER_MIN_RETRY_HEADROOM_SECONDS', 45.0
)
AGENT_STREAM_PROVIDER_MAX_ATTEMPTS = _positive_int_from_env('AGENT_STREAM_PROVIDER_MAX_ATTEMPTS', 3)
AGENT_STREAM_PROVIDER_RETRY_BACKOFF_SECONDS = _positive_timeout_from_env(
'AGENT_STREAM_PROVIDER_RETRY_BACKOFF_SECONDS', 1.0
)
# Independent tool_use blocks in one model turn run concurrently. Sequential is
# only required when a later call depends on an earlier result in the same turn
# (rare; default is parallel). Each call still counts toward the safety cap.
AGENT_TOOL_TURN_CONCURRENCY = _positive_int_from_env('AGENT_TOOL_TURN_CONCURRENCY', 8)
_COLLECTED_CONTEXT_TOOL_STUB = (
'Relevant conversations are already collected. Answer the user from that context '
'without calling this tool again.'
)
AGENT_STREAM_PROGRESS_HEARTBEAT = 'Still working…'
AGENT_STREAM_SETUP_PROGRESS = 'Preparing response…'
AGENT_STREAM_TIMEOUT_MESSAGE = 'The response took too long. Please try again.'
AGENT_STREAM_FAILURE_MESSAGE = 'Unable to complete the response. Please try again.'
# File chat still uses direct OpenAI Assistants/vision while gateway feature mode is on;
# until that surface is migrated, fail with a typed user-safe copy instead of the generic canned reply.
FILE_CHAT_GATEWAY_BLOCKED_MESSAGE = (
"File chat isn't available right now. Try again without attachments, or try again later."
)
# Delivered when a provider safety classifier declines the turn. Retrying the same prompt would
# be declined again, so this says the request cannot be answered rather than inviting a retry.
AGENT_REFUSAL_MESSAGE = "I can't help with that one. Try asking me something else."
# Delivered when a loop runs to completion without the model ever emitting text. Unlike a
# refusal this is not a policy decision, so it does invite a retry.
AGENT_EMPTY_ANSWER_MESSAGE = "I wasn't able to put a response together for that. Please try again."
# PROMPT CACHE OPTIMIZATION: This list MUST stay fixed and in this exact order.
# Anthropic caches the tools array as part of the request prefix. If the tool
# definitions are identical across requests they are cached automatically.
# Dynamic per-user app tools are appended AFTER this list so the prefix stays stable.
CORE_TOOLS = [
get_conversations_tool,
search_conversations_tool,
get_memories_tool,
search_memories_tool,
get_action_items_tool,
create_action_item_tool,
update_action_item_tool,
get_omi_product_info_tool,
get_calendar_events_tool,
create_calendar_event_tool,
update_calendar_event_tool,
delete_calendar_event_tool,
get_gmail_messages_tool,
get_apple_health_steps_tool,
get_apple_health_sleep_tool,
get_apple_health_heart_rate_tool,
get_apple_health_workouts_tool,
get_apple_health_summary_tool,
search_files_tool,
manage_daily_summary_tool,
create_chart_tool,
get_screen_activity_tool,
search_screen_activity_tool,
look_at_frame_tool,
save_user_preference_tool,
fetch_url_tool,
traverse_knowledge_graph_tool,
get_entity_timeline_tool,
search_knowledge,
read_playbook,
search_historical_facts,
save_playbook,
create_standing_trigger,
close_fact_tool,
]
# JIT-only tools: schemas must not reach the model for users outside the JIT
# rollout — a legacy user has no ledger/playbook/frame data, so exposing these
# only burns tool-call budget on "no entries found" answers and changes chat
# behavior for the whole fleet. Filtered per request off the same resolved
# rollout boolean that gates the JIT prompt appendix, keeping the tool block
# stable per user within a rollout state. The three ledger write verbs
# (save_playbook, create_standing_trigger, close_fact) mutate the same
# rollout-gated ledger the read tools above expose, so they are gated
# identically.
JIT_ONLY_TOOL_NAMES = frozenset(
tool.name
for tool in (
look_at_frame_tool,
get_entity_timeline_tool,
search_knowledge,
read_playbook,
search_historical_facts,
save_playbook,
create_standing_trigger,
close_fact_tool,
)
)
# Standard tool names (used to detect app tools by exclusion)
STANDARD_TOOL_NAMES = {t.name for t in CORE_TOOLS}
def get_tool_display_name(tool_name: str, tool_obj: Optional[Any] = None) -> str:
"""Convert tool name to user-friendly display name."""
# Check global mapping from app_tools first
status_msg = get_tool_status_message(tool_name)
if status_msg:
return status_msg
# Check tool object for custom status_message
if tool_obj and hasattr(tool_obj, 'status_message') and tool_obj.status_message:
return tool_obj.status_message
tool_display_map = {
'get_calendar_events_tool': 'Checking calendar',
'create_calendar_event_tool': 'Creating calendar event',
'update_calendar_event_tool': 'Updating calendar event',
'delete_calendar_event_tool': 'Deleting calendar event',
'get_gmail_messages_tool': 'Checking Gmail',
'web_search': 'Searching the web',
'get_conversations_tool': 'Searching conversations',
'search_conversations_tool': 'Searching conversations',
'get_memories_tool': 'Searching memories',
'search_memories_tool': 'Searching memories',
'traverse_knowledge_graph_tool': 'Traversing knowledge graph',
'get_entity_timeline_tool': 'Reviewing entity timeline',
'search_knowledge': 'Searching current knowledge',
'read_playbook': 'Reading playbook',
'search_historical_facts': 'Searching historical facts',
'save_playbook': 'Saving playbook',
'create_standing_trigger': 'Creating standing trigger',
'close_fact': 'Closing fact',
'get_action_items_tool': 'Checking action items',
'create_action_item_tool': 'Creating action item',
'update_action_item_tool': 'Updating action item',
'get_omi_product_info_tool': 'Looking up product info',
'manage_daily_summary_tool': 'Updating notification settings',
'create_chart_tool': 'Creating chart',
'get_screen_activity_tool': 'Checking screen activity',
'search_screen_activity_tool': 'Searching screen activity',
'save_user_preference_tool': 'Saving preference',
'fetch_url_tool': 'Reading page',
}
if tool_name in tool_display_map:
return tool_display_map[tool_name]
if 'calendar' in tool_name.lower():
return 'Checking calendar'
elif 'web_search' in tool_name.lower():
return 'Searching the web'
elif 'memory' in tool_name.lower():
return 'Searching memories'
elif 'conversation' in tool_name.lower():
return 'Searching conversations'
elif 'action' in tool_name.lower():
return 'Checking action items'
return tool_name.replace('_', ' ').title()
class AsyncStreamingCallback(BaseCallbackHandler):
"""Callback for streaming LLM responses with data and thought prefixes."""
def __init__(self):
self.queue = asyncio.Queue()
# Sync providers can invoke the nowait methods from an executor worker.
# asyncio.Queue is bound to this request loop, so its mutation must always
# be marshalled back to that loop instead of happening from the worker.
self._loop = asyncio.get_running_loop()
def _put_nowait_threadsafe(self, value: str | None) -> None:
"""Queue a synchronous callback value on the loop that owns the response."""
if self._loop.is_closed():
return
try:
self._loop.call_soon_threadsafe(self.queue.put_nowait, value)
except RuntimeError:
# The request loop can close after a bounded stream is cancelled while
# a non-cooperative sync provider is still unwinding in its worker.
return
async def put_data(self, text):
await self.queue.put(f"data: {text}")
async def put_thought(self, text, app_id: Optional[str] = None):
if app_id:
await self.queue.put(f"think: {text}|app_id:{app_id}")
else:
await self.queue.put(f"think: {text}")
def put_thought_nowait(self, text, app_id: Optional[str] = None):
if app_id:
self._put_nowait_threadsafe(f"think: {text}|app_id:{app_id}")
else:
self._put_nowait_threadsafe(f"think: {text}")
def put_data_nowait(self, text):
self._put_nowait_threadsafe(f"data: {text}")
async def end(self):
await self.queue.put(None)
def end_nowait(self):
self._put_nowait_threadsafe(None)
async def on_llm_new_token(self, token: str, **_kwargs) -> None:
"""Bridge LangChain streaming callbacks for persona chat."""
await self.put_data(token)
async def on_llm_end(self, _response, **_kwargs) -> None:
"""Always terminate the persona callback queue on normal completion."""
await self.end()
async def on_llm_error(self, _error: Exception, **_kwargs) -> None:
"""Terminate the persona callback queue without exposing provider details."""
await self.end()
# ---------------------------------------------------------------------------
# Tool schema conversion: LangChain @tool -> OpenAI chat-completions (live)
# and Anthropic Messages (leftover specialist tests only).
# ---------------------------------------------------------------------------
def _langchain_tool_parameters(lc_tool) -> tuple[str, str, dict]:
"""Shared name/description/JSON-schema extraction for both wire formats."""
schema = lc_tool.args_schema.schema()
properties = {k: v for k, v in schema.get('properties', {}).items() if k != 'config'}
required = [r for r in schema.get('required', []) if r != 'config']
cleaned_properties = {}
for key, value in properties.items():
cleaned_properties[key] = {pk: pv for pk, pv in value.items() if pk != 'title'}
return (
lc_tool.name,
lc_tool.description,
{
'type': 'object',
'properties': cleaned_properties,
'required': required,
},
)
def _langchain_tool_to_openai(lc_tool) -> dict:
"""Convert a LangChain @tool to the chat-completions function shape."""
name, description, parameters = _langchain_tool_parameters(lc_tool)
return {
'type': 'function',
'function': {
'name': name,
'description': description,
'parameters': parameters,
},
}
def _langchain_tool_to_anthropic(lc_tool, defer_loading: bool = False) -> dict:
"""Leftover Anthropic Messages schema. Not the live chat-agent path."""
name, description, parameters = _langchain_tool_parameters(lc_tool)
tool_def = {
"name": name,
"description": description,
"input_schema": parameters,
}
if defer_loading:
tool_def["defer_loading"] = True
return tool_def
# Tool search tool definition — Anthropic's built-in tool discovery
TOOL_SEARCH_TOOL = {
"type": "tool_search_tool_regex_20251119",
"name": "tool_search_tool_regex",
}
def _convert_tools(core_tools: list, app_tools: list = None) -> tuple:
"""Convert tools to the live OpenAI chat-completions function shape.
Anthropic server tools (``web_search``, ``tool_search_tool_regex``) are not
part of this contract. App tools are exposed directly so the model can call
them by name.
"""
all_tools = list(core_tools) + list(app_tools or [])
schemas = [_langchain_tool_to_openai(t) for t in all_tools]
registry = {t.name: t for t in all_tools}
return schemas, registry
def _collected_results_from_config(configurable: dict | None) -> Any:
if not isinstance(configurable, dict):
return None
collected = configurable.get('conversations_collected')
if collected:
return collected
evidence = configurable.get('evidence_references')
return evidence or None
async def _execute_independent_tool_calls(
tool_calls: list,
*,
name_of,
input_of,
id_of, # noqa: ARG001 — caller-facing symmetry with name/input
tool_registry: dict,
configurable: dict,
safety_guard: AgentSafetyGuard,
callback: 'AsyncStreamingCallback',
full_response: list,
result_factory,
) -> list | None:
"""Validate sequentially, run independent tools concurrently, preserve order.
Returns the provider-shaped tool results, or ``None`` when a hard safety
limit ended the stream. ``CollectedContextReady`` stubs remaining calls so
the model can answer from already-collected conversations.
"""
collected = _collected_results_from_config(configurable)
validated: list = []
stub_after = False
for call in tool_calls:
try:
safety_guard.validate_tool_call(name_of(call), input_of(call), collected_results=collected)
warning = safety_guard.should_warn_user()
if warning:
await callback.put_thought(warning)
validated.append(call)
except CollectedContextReady:
stub_after = True
break
except SafetyGuardError as error:
await _put_outcome_text(callback, full_response, f'\n\n{str(error)}')
logger.error('Safety Guard blocked tool call: %s', error)
await callback.end()
return None
for call in validated:
tool_name = name_of(call)
await callback.put_thought(
get_tool_display_name(tool_name, tool_registry.get(tool_name)), app_id=_extract_app_id(tool_name)
)
async def _run_one(call):
tool_name = name_of(call)
try:
return await _execute_tool(tool_name, input_of(call), tool_registry, configurable)
except Exception as error:
logger.error('Tool execution error (%s): %s', tool_name, error)
return f'Error executing tool: {str(error)}'
results_text: list[str] = []
if validated:
semaphore = asyncio.Semaphore(AGENT_TOOL_TURN_CONCURRENCY)
async def _bounded(call):
async with semaphore:
return await _run_one(call)
results_text = list(await asyncio.gather(*[_bounded(call) for call in validated]))
tool_results = []
for call, result in zip(validated, results_text):
tool_name = name_of(call)
logger.info('Tool ended: %s', tool_name)
await _emit_calendar_status(callback, tool_name, result)
try:
safety_guard.check_context_size(result)
except SafetyGuardError as error:
await _put_outcome_text(callback, full_response, f'\n\n{str(error)}')
logger.error('Safety Guard blocked due to context size: %s', error)
await callback.end()
return None
tool_results.append(result_factory(call, result))
if stub_after:
for call in tool_calls[len(validated) :]:
tool_results.append(result_factory(call, _COLLECTED_CONTEXT_TOOL_STUB))
return tool_results
def _convert_anthropic_tools_to_openai(tool_schemas: list[dict]) -> list[dict]:
"""Convert function-shaped Anthropic tools to the chat-completions shape.
Anthropic's server-side ``web_search`` and ``tool_search_tool_regex`` entries
intentionally have no ``input_schema``. They are not part of the OpenAI
contract, so filtering them here keeps the managed lane from receiving an
invalid tool definition. App tools are already present in ``tool_schemas``
and are exposed directly instead of relying on Anthropic tool discovery.
"""
openai_tools = []
for tool in tool_schemas:
input_schema = tool.get('input_schema')
if not isinstance(input_schema, dict):
continue
openai_tools.append(
{
'type': 'function',
'function': {
'name': tool['name'],
'description': tool.get('description', ''),
'parameters': input_schema,
},
}
)
return openai_tools
_MEMORY_RETRIEVAL_TOOLS = frozenset({'get_memories_tool', 'search_memories_tool'})
def _finish_memory_retrieval(attempt: ClientJourneyAttempt, result: str) -> None:
normalized = result.strip().lower()
if not normalized or normalized.startswith('no memories found'):
attempt.degrade('empty_answer')
elif normalized.startswith('error'):
attempt.fail('dependency_unavailable')
else:
attempt.succeed()
@_traceable(name="chat.tool_execution", run_type="tool")
async def _execute_tool(tool_name: str, tool_input: dict, registry: dict, configurable: dict) -> str:
"""Execute a LangChain tool by name, injecting RunnableConfig."""
tool_obj = registry[tool_name]
config = RunnableConfig(configurable=configurable)
client_kind = configurable.get('client_kind')
attempt = (
ClientJourneyAttempt('memory_retrieval', client_kind)
if tool_name in _MEMORY_RETRIEVAL_TOOLS and client_kind is not None
else None
)
try:
result = await tool_obj.ainvoke(tool_input, config=config)
except asyncio.CancelledError:
if attempt is not None:
attempt.cancel()
raise
except Exception:
if attempt is not None:
attempt.fail('dependency_unavailable')
raise
result = preserve_chat_memory_tool_result_boundary(tool_name, str(result))
if attempt is not None:
_finish_memory_retrieval(attempt, result)
return result
# ---------------------------------------------------------------------------
# App ID extraction for non-standard tools
# ---------------------------------------------------------------------------
def _extract_app_id(tool_name: str) -> Optional[str]:
"""Extract app_id from an app tool name (format: appid_toolname)."""
if tool_name not in STANDARD_TOOL_NAMES and '_' in tool_name:
parts = tool_name.split('_', 1)
if len(parts) == 2:
return parts[0]
return None
# ---------------------------------------------------------------------------
# Calendar tool status messages
# ---------------------------------------------------------------------------
async def _emit_calendar_status(callback: AsyncStreamingCallback, tool_name: str, output: str):
"""Emit calendar-specific completion status messages."""
if 'calendar' not in tool_name.lower():
return
if 'create' in tool_name.lower():
if output and ('Successfully created' in output or '✅' in output):
await callback.put_thought('Event created successfully')
elif output and ('Error' in output or 'error' in output.lower()):
await callback.put_thought('Failed to create event')
else:
await callback.put_thought('Creating event...')
elif 'update' in tool_name.lower():
if output and ('Successfully updated' in output or '✅' in output):
await callback.put_thought('Event updated successfully')
elif output and ('Error' in output or 'error' in output.lower()):
await callback.put_thought('Failed to update event')
else:
await callback.put_thought('Updating event...')
elif 'delete' in tool_name.lower():
if output and ('Successfully deleted' in output or '✅' in output):
await callback.put_thought('Event deleted successfully')
elif output and ('Error' in output or 'error' in output.lower()):
await callback.put_thought('Failed to delete event')
else:
await callback.put_thought('Deleting event...')
elif 'get' in tool_name.lower() or 'search' in tool_name.lower():
if output and len(output) > 0:
await callback.put_thought('Found calendar events')
else:
await callback.put_thought('No events found')
# ---------------------------------------------------------------------------
# Message format conversion
# ---------------------------------------------------------------------------
def _messages_to_anthropic(messages: List[Message]) -> list:
"""Convert chat messages to Anthropic API format."""
anthropic_messages = []
for msg in messages:
role = "assistant" if msg.sender == "ai" else "user"
anthropic_messages.append({"role": role, "content": msg.text})
return anthropic_messages
def _inject_current_datetime(anthropic_messages: list, datetime_block: str) -> list:
"""Prepend the current-datetime block to the latest user turn.
The datetime changes every request, so it is kept out of the cache_control system
prefix (which must stay byte-identical for prompt-cache hits) and delivered here in the
user turn instead. Handles both string content (prepended as text) and list/multimodal
content (prepended as a leading text block). Falls back to appending a new user message
only if there is no user turn to attach it to.
"""
if not datetime_block:
return anthropic_messages
for msg in reversed(anthropic_messages):
if msg["role"] != "user":
continue
content = msg.get("content")
if isinstance(content, str):
msg["content"] = f"{datetime_block}\n\n{content}"
elif isinstance(content, list):
msg["content"] = [{"type": "text", "text": datetime_block}, *content]
else:
break # unexpected content shape — fall back to a separate user message
return anthropic_messages
anthropic_messages.append({"role": "user", "content": datetime_block})
return anthropic_messages
async def get_mobile_city(uid: str, platform: Optional[str]) -> Optional[str]:
if platform is None or platform.strip().lower() not in {'ios', 'android'}:
return None
try:
consent = await run_blocking(db_executor, get_user_location_context_consent, uid)
if consent is None or not consent.is_active():
return None
geolocation = await run_blocking(db_executor, get_cached_user_geolocation, uid)
if not geolocation:
return None
validated_geolocation = Geolocation.model_validate(geolocation)
return await async_get_google_maps_city(validated_geolocation.latitude, validated_geolocation.longitude)
except (KeyError, TypeError, ValueError):
return None
except Exception as error:
logger.warning('Mobile city context unavailable error_type=%s', type(error).__name__)
return None
# ---------------------------------------------------------------------------
# Core Anthropic agent streaming loop
# ---------------------------------------------------------------------------
async def _put_answer_text(callback: AsyncStreamingCallback, full_response: list, text: str) -> None:
"""Stream text to the client and record it as part of the answer.
``put_data`` alone reaches only the live stream; the persisted reply and the terminal
``done:`` frame are built from ``full_response``, so text that skips it is overwritten by
the router's canned error when the turn ends.
"""
full_response.append(text)
await callback.put_data(text)
async def _put_outcome_text(callback: AsyncStreamingCallback, full_response: list, text: str) -> None:
"""Record backend-authored text that ends the turn, voiding any follow-up tail.
A model can emit its closing-question marker and still leave tool calls to run. If the turn
then ends on a safety limit or an error, the parser would split the answer at that marker and
drop this message with the rest of the tail — the user would be left with the partial answer
and no reason for it. Backend outcome text supersedes the tail: the marker is removed, so this
message stays in the persisted answer and the failed turn offers no chip. ``full_response`` is
rebuilt when that happens, so no caller may hold an index into it across this call.
"""
joined = ''.join(full_response)
marker = joined.find(FOLLOWUP_DELIMITER)
if marker >= 0:
full_response[:] = [joined[:marker].rstrip()]
await _put_answer_text(callback, full_response, text)
def _has_answer(full_response: list) -> bool:
"""Whether anything the router would render as an answer has been delivered.
List truthiness is not the same question. A stream can emit the inter-iteration separator or
a whitespace-only delta and then fail, which leaves ``full_response`` non-empty while the
persisted reply is still blank to the reader.
"""
return bool(''.join(full_response).strip())
async def _end_with_answer_guarantee(
callback: AsyncStreamingCallback, full_response: list, provider: str
) -> Optional[str]:
"""Close the stream, guaranteeing the turn left the user something to read.
Either loop can run to completion without the model emitting any text: a content filter, an
empty completion, or a tool-only iteration with nothing to say. ``full_response`` is what the
router persists and renders, so returning here silently hands the user a blank answer and
records the turn as a success. Report it as the failure it is instead.
"""
if _has_answer(full_response):
await callback.end()
return None
logger.warning('Chat agent loop finished with no answer provider=%s', provider)
await _put_answer_text(callback, full_response, AGENT_EMPTY_ANSWER_MESSAGE)
await callback.end()
return 'empty_answer'
def _refusal_category(response: Any) -> str:
"""Name the policy category behind a refusal, for logs only.
``stop_details`` is populated only alongside ``stop_reason == "refusal"`` and is absent on
older provider versions, so every level is optional. The category is a fixed provider enum
and carries none of the request content.
"""
details = getattr(response, 'stop_details', None)
category = getattr(details, 'category', None) if details is not None else None
return category if isinstance(category, str) and category else 'unspecified'
async def _run_anthropic_agent_stream(
system_prompt: str,
messages: list,
tool_schemas: list,
tool_registry: dict,
callback: AsyncStreamingCallback,
full_response: list,
safety_guard: AgentSafetyGuard,
configurable: dict,
) -> Optional[str]:
"""Run the Anthropic tool-use loop with streaming.
This replaces LangGraph's create_react_agent + astream_events with a simple
while loop that calls Anthropic's messages API, executes any tool calls,
and feeds results back until the model stops requesting tools.
Returns ``None`` when the loop finished on its own terms, or a short failure reason when it
gave up on the provider.
"""
# System prompt with cache_control for Anthropic prompt caching
# TTL=1h: Anthropic changed default from 1h→5m on 2026-03-06; interactive chat
# sessions have gaps >5min between turns, so the 5-min default kills cache hit rate.
system_blocks = [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
producer_started_at = asyncio.get_running_loop().time()
loop_iteration = 0
# Re-decide the server-side web_search offer inside the loop. The taint
# only appears after tool results are appended; see web_search_gate.py.
server_web_search_withheld = False
while True:
loop_iteration += 1
request_tools, server_web_search_withheld = request_tools_after_private_taint(
tool_schemas, messages, withheld=server_web_search_withheld
)
attempts_made = 0
retried_reason: Optional[str] = None
while True:
attempts_made += 1
first_text_in_iteration = True
text_before_attempt = len(full_response)
try:
async with anthropic_client.messages.stream(
model=ANTHROPIC_AGENT_MODEL,
system=system_blocks,
messages=messages,
tools=request_tools,
max_tokens=8192,
# Anthropic moves this breakpoint to the last cacheable message
# block on every request. That incrementally caches both the
# append-only inter-turn history epoch and each agentic tool-loop
# iteration while the explicit system breakpoint remains stable.
cache_control={"type": "ephemeral", "ttl": "1h"},
) as stream:
async for event in stream:
# Stream text tokens
if event.type == "content_block_delta" and hasattr(event.delta, 'type'):
if event.delta.type == "text_delta":
# Add separator between loop iterations so text doesn't run together
if first_text_in_iteration and loop_iteration > 1 and full_response:
last_char = full_response[-1][-1] if full_response[-1] else ''
first_char = event.delta.text[0] if event.delta.text else ''
if (
last_char
and first_char
and last_char not in (' ', '\n')
and first_char not in (' ', '\n')
):
full_response.append('\n\n')
await callback.put_data('\n\n')
first_text_in_iteration = False
full_response.append(event.delta.text)
await callback.put_data(event.delta.text)
elif event.delta.type == "thinking_delta":
pass # Don't stream thinking to client
# Emit status when tool call starts
elif event.type == "content_block_start":
if hasattr(event.content_block, 'type') and event.content_block.type == "server_tool_use":
server_tool_name = getattr(event.content_block, 'name', '')
if server_tool_name == 'web_search':
await callback.put_thought('Searching the web')
logger.info(f"Server tool invoked: {server_tool_name}")
elif hasattr(event.content_block, 'type') and event.content_block.type == "tool_use":
tool_name = event.content_block.name
# Skip tool_search_tool — handled server-side by Anthropic
if 'tool_search' in tool_name:
logger.info(f"Tool search invoked (server-side)")
continue
app_id = _extract_app_id(tool_name)
tool_obj = tool_registry.get(tool_name)
display_name = get_tool_display_name(tool_name, tool_obj)
await callback.put_thought(display_name, app_id=app_id)
logger.info(f"Tool started: {tool_name}")
# Get final message while stream is still open
response = await stream.get_final_message()
break
except Exception as e:
elapsed = asyncio.get_running_loop().time() - producer_started_at
if should_retry_provider_error(
e,
attempts_made=attempts_made,
max_attempts=AGENT_STREAM_PROVIDER_MAX_ATTEMPTS,
text_already_streamed=len(full_response) > text_before_attempt,
seconds_remaining=AGENT_STREAM_MAX_DURATION_SECONDS - elapsed,
min_headroom_seconds=AGENT_STREAM_PROVIDER_MIN_RETRY_HEADROOM_SECONDS,
):
retried_reason = provider_fallback_reason(e)
logger.warning(
'Agent stream provider call failed, retrying attempt=%d error_type=%s',
attempts_made + 1,
type(e).__name__,
)
await asyncio.sleep(AGENT_STREAM_PROVIDER_RETRY_BACKOFF_SECONDS)
continue
await handle_llm_error_async(e, 'anthropic', feature='chat_agent', model=ANTHROPIC_AGENT_MODEL)
# ``put_data`` alone reaches the live stream but not the persisted answer, so the
# router would overwrite this apology with its own canned error.
await _put_outcome_text(callback, full_response, "\n\nSorry, I encountered an error. Please try again.")
await callback.end()
return f'provider_{type(e).__name__}'
if retried_reason is not None:
record_fallback(
component='other',
from_mode='llm_answer',
to_mode='llm_answer_retried',
reason=retried_reason,
outcome='recovered',
)
# A safety classifier can decline the turn. The response is a normal success with an
# empty (or partial) content list, so the loop would otherwise exit as if the model had
# simply answered nothing: the router sees a blank answer, emits its generic error, and
# records no error at all. Say so through the normal streamed/persisted contract and
# report the turn as failed instead.
if response.stop_reason == "refusal":
logger.warning('Chat agent turn refused by provider category=%s', _refusal_category(response))