omarsol commited on
Commit
4cca78b
·
1 Parent(s): 4f15baa

Add LangSmith tracing support and enhance documentation

Browse files

- Introduced LangSmith tracing capabilities by adding environment variables in .env.example and implementing related functions in agent_tracing.py.
- Updated README.md to include instructions for enabling LangSmith tracing and its integration with the chatbot.
- Refactored chat_service.py to support tracing metadata in agent runs and added warm-up functionality for the retriever.
- Enhanced setup.py to configure LangSmith environment variables automatically.
- Added unit tests for agent tracing functionality to ensure proper behavior and configuration.
- Updated main.py to invoke retriever warm-up during application startup.

.env.example CHANGED
@@ -4,6 +4,11 @@ ANTHROPIC_API_KEY=...
4
  GEMINI_API_KEY=...
5
  COHERE_API_KEY=...
6
 
 
 
 
 
 
7
  # To update documentation/add new course
8
  HF_TOKEN=... https://huggingface.co/settings/tokens to read or write to private DATASET repos e.g. https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data/tree/main
9
  GITHUB_TOKEN=...https://github.com/settings/tokens to use the Github API
 
4
  GEMINI_API_KEY=...
5
  COHERE_API_KEY=...
6
 
7
+ # Optional: trace agent/model/tool runs in LangSmith
8
+ LANGSMITH_API_KEY=...
9
+ LANGSMITH_TRACING=true
10
+ LANGSMITH_PROJECT=ai-tutor-app
11
+
12
  # To update documentation/add new course
13
  HF_TOKEN=... https://huggingface.co/settings/tokens to read or write to private DATASET repos e.g. https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data/tree/main
14
  GITHUB_TOKEN=...https://github.com/settings/tokens to use the Github API
README.md CHANGED
@@ -31,6 +31,7 @@ The Gradio demo is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugg
31
  ```
32
 
33
  The chat model is provider-agnostic. Use the UI field in `provider:model` format, for example `openai:gpt-5.4-mini`. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY`. Anthropic support is wired through the `anthropic` SDK, and Gemini support is wired through Google’s `google-genai` SDK.
 
34
 
35
  3. Run:
36
 
@@ -40,6 +41,20 @@ The Gradio demo is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugg
40
 
41
  Starts the Gradio AI Tutor interface.
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  ### Next.js Frontend — Quick Start
44
 
45
  The repo now also includes a separate Next.js frontend in [frontend](./frontend) that talks to the FastAPI backend instead of the Gradio transport.
 
31
  ```
32
 
33
  The chat model is provider-agnostic. Use the UI field in `provider:model` format, for example `openai:gpt-5.4-mini`. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY`. Anthropic support is wired through the `anthropic` SDK, and Gemini support is wired through Google’s `google-genai` SDK.
34
+ To trace requests in LangSmith, set `LANGSMITH_API_KEY`. The app enables tracing automatically when that key is present unless `LANGSMITH_TRACING=false` is set.
35
 
36
  3. Run:
37
 
 
41
 
42
  Starts the Gradio AI Tutor interface.
43
 
44
+ ### LangSmith Agent Tracing
45
+
46
+ The chatbot is built with `langchain.agents.create_agent()`, so LangSmith can trace the LangGraph/LangChain run tree without extra dependencies. Add these values to `.env`:
47
+
48
+ ```bash
49
+ LANGSMITH_API_KEY=ls_...
50
+ LANGSMITH_TRACING=true
51
+ LANGSMITH_PROJECT=ai-tutor-app
52
+ ```
53
+
54
+ Each chat turn is traced as `ai-tutor-agent-turn` with metadata for the backend thread id, message id, selected sources, requested model, and effective tool list. Child runs capture the model calls and `retrieve_tutor_context` tool executions, including tool inputs and outputs. Provider-side tools such as Gemini `google_search` / `url_context` and Claude `web_search` / `web_fetch` are visible through the model request/response metadata that LangChain receives from those providers.
55
+
56
+ Tracing sends prompts, retrieved snippets, tool inputs, tool outputs, and model responses to LangSmith. Set `LANGSMITH_TRACING=false` to keep tracing disabled while leaving the key in your environment.
57
+
58
  ### Next.js Frontend — Quick Start
59
 
60
  The repo now also includes a separate Next.js frontend in [frontend](./frontend) that talks to the FastAPI backend instead of the Gradio transport.
scripts/agent_tracing.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ DEFAULT_LANGSMITH_PROJECT = "ai-tutor-app"
6
+
7
+ TRUE_VALUES = {"1", "true", "t", "yes", "y", "on"}
8
+ FALSE_VALUES = {"0", "false", "f", "no", "n", "off"}
9
+
10
+
11
+ def parse_env_bool(value: str | None) -> bool | None:
12
+ if value is None:
13
+ return None
14
+
15
+ normalized = value.strip().lower()
16
+ if normalized in TRUE_VALUES:
17
+ return True
18
+ if normalized in FALSE_VALUES:
19
+ return False
20
+ return None
21
+
22
+
23
+ def langsmith_tracing_enabled() -> bool:
24
+ explicit = parse_env_bool(os.getenv("LANGSMITH_TRACING"))
25
+ if explicit is not None:
26
+ return explicit
27
+ return bool(os.getenv("LANGSMITH_API_KEY"))
28
+
29
+
30
+ def configure_langsmith_environment() -> None:
31
+ """Apply app defaults for LangSmith without requiring code changes in deploys."""
32
+ if os.getenv("LANGSMITH_API_KEY") and os.getenv("LANGSMITH_TRACING") is None:
33
+ os.environ["LANGSMITH_TRACING"] = "true"
34
+
35
+ if not langsmith_tracing_enabled():
36
+ return
37
+
38
+ if not os.getenv("LANGSMITH_PROJECT"):
39
+ os.environ["LANGSMITH_PROJECT"] = DEFAULT_LANGSMITH_PROJECT
scripts/api.py CHANGED
@@ -16,11 +16,11 @@ from fastapi.staticfiles import StaticFiles
16
  from pydantic import BaseModel, Field
17
 
18
  from .chat_service import (
19
- get_retriever,
20
  is_anthropic_model,
21
  is_google_genai_model,
22
  message_content_to_text,
23
  stream_chat,
 
24
  )
25
  from .chat_types import ChatEvent, ChatRequest, ChatTurn
26
  from .setup import (
@@ -78,14 +78,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
78
  `Could not connect to tenant default_tenant`. Touching the retriever
79
  at startup forces single-threaded tenant init.
80
  """
81
- if os.environ.get("COHERE_API_KEY"):
82
- try:
83
- get_retriever()
84
- except Exception as exc: # pragma: no cover - diagnostic logging only
85
- logfire.warn(
86
- "Retriever warm-up failed; first retrieval call may retry.",
87
- error=str(exc),
88
- )
89
  yield
90
 
91
 
 
16
  from pydantic import BaseModel, Field
17
 
18
  from .chat_service import (
 
19
  is_anthropic_model,
20
  is_google_genai_model,
21
  message_content_to_text,
22
  stream_chat,
23
+ warm_up_retriever,
24
  )
25
  from .chat_types import ChatEvent, ChatRequest, ChatTurn
26
  from .setup import (
 
78
  `Could not connect to tenant default_tenant`. Touching the retriever
79
  at startup forces single-threaded tenant init.
80
  """
81
+ warm_up_retriever()
 
 
 
 
 
 
 
82
  yield
83
 
84
 
scripts/chat_service.py CHANGED
@@ -4,6 +4,7 @@ import json
4
  import os
5
  from dataclasses import dataclass
6
  from functools import lru_cache
 
7
  from typing import Any, AsyncIterator
8
  from uuid import uuid4
9
 
@@ -37,6 +38,7 @@ ANSWER_HEADER = "**Answer**"
37
  LEGACY_THOUGHTS_DETAILS_OPEN = "<details><summary>Gemini thoughts</summary>"
38
  LEGACY_THOUGHTS_DETAILS_OPEN_EXPANDED = "<details open><summary>Gemini thoughts</summary>"
39
  CHECKPOINTER = InMemorySaver()
 
40
 
41
 
42
  @dataclass(frozen=True)
@@ -45,7 +47,7 @@ class AppContext:
45
 
46
 
47
  @lru_cache(maxsize=1)
48
- def get_retriever() -> LocalChromaRetriever:
49
  ensure_local_vector_db()
50
  cohere_api_key = os.environ["COHERE_API_KEY"]
51
  return LocalChromaRetriever(
@@ -57,6 +59,23 @@ def get_retriever() -> LocalChromaRetriever:
57
  )
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  RETRIEVE_TUTOR_CONTEXT_SCHEMA = {
61
  "title": "retrieve_tutor_context",
62
  "description": "Retrieve relevant course and documentation context for an AI tutor question.",
@@ -431,6 +450,72 @@ def build_agent(
431
  )
432
 
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  def extract_web_search_queries(response_metadata: Any) -> list[str]:
435
  """Pull the queries Gemini ran against google_search from grounding metadata."""
436
  if not isinstance(response_metadata, dict):
@@ -643,7 +728,7 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
643
 
644
  async for chunk in agent.astream(
645
  {"messages": [{"role": "user", "content": request.query}]},
646
- thread_config(active_thread_id),
647
  context=AppContext(allowed_sources=request.source_keys),
648
  stream_mode=["messages", "updates"],
649
  version="v2",
 
4
  import os
5
  from dataclasses import dataclass
6
  from functools import lru_cache
7
+ from threading import Lock
8
  from typing import Any, AsyncIterator
9
  from uuid import uuid4
10
 
 
38
  LEGACY_THOUGHTS_DETAILS_OPEN = "<details><summary>Gemini thoughts</summary>"
39
  LEGACY_THOUGHTS_DETAILS_OPEN_EXPANDED = "<details open><summary>Gemini thoughts</summary>"
40
  CHECKPOINTER = InMemorySaver()
41
+ _RETRIEVER_INIT_LOCK = Lock()
42
 
43
 
44
  @dataclass(frozen=True)
 
47
 
48
 
49
  @lru_cache(maxsize=1)
50
+ def _build_retriever() -> LocalChromaRetriever:
51
  ensure_local_vector_db()
52
  cohere_api_key = os.environ["COHERE_API_KEY"]
53
  return LocalChromaRetriever(
 
59
  )
60
 
61
 
62
+ def get_retriever() -> LocalChromaRetriever:
63
+ with _RETRIEVER_INIT_LOCK:
64
+ return _build_retriever()
65
+
66
+
67
+ def warm_up_retriever() -> None:
68
+ if not os.environ.get("COHERE_API_KEY"):
69
+ return
70
+ try:
71
+ get_retriever()
72
+ except Exception as exc: # pragma: no cover - diagnostic logging only
73
+ logfire.warn(
74
+ "Retriever warm-up failed; first retrieval call may retry.",
75
+ error=str(exc),
76
+ )
77
+
78
+
79
  RETRIEVE_TUTOR_CONTEXT_SCHEMA = {
80
  "title": "retrieve_tutor_context",
81
  "description": "Retrieve relevant course and documentation context for an AI tutor question.",
 
450
  )
451
 
452
 
453
+ def model_provider_and_name(model_name: str) -> tuple[str, str]:
454
+ provider_model = normalize_model_name(model_name)
455
+ provider, _, actual_model = provider_model.partition(":")
456
+ return provider or "unknown", actual_model or provider_model
457
+
458
+
459
+ def effective_tool_names(
460
+ model_name: str,
461
+ enabled_tools: tuple[str, ...],
462
+ ) -> tuple[str, ...]:
463
+ names = ["retrieve_tutor_context"]
464
+ enabled = set(enabled_tools)
465
+ if is_google_genai_model(model_name):
466
+ if "web_search" in enabled:
467
+ names.append("google_search")
468
+ if "url_context" in enabled:
469
+ names.append("url_context")
470
+ elif is_anthropic_model(model_name):
471
+ if "web_search" in enabled:
472
+ names.append("web_search")
473
+ if "web_fetch" in enabled:
474
+ names.append("web_fetch")
475
+ return tuple(names)
476
+
477
+
478
+ def agent_run_config(
479
+ request: ChatRequest,
480
+ active_thread_id: str,
481
+ message_id: str,
482
+ ) -> dict[str, Any]:
483
+ provider, actual_model = model_provider_and_name(request.model_name)
484
+ tools = effective_tool_names(request.model_name, request.enabled_tools)
485
+ source_labels = [
486
+ SOURCE_KEY_TO_LABEL.get(source_key, source_key)
487
+ for source_key in request.source_keys
488
+ ]
489
+ config = thread_config(active_thread_id)
490
+ config.update(
491
+ {
492
+ "run_name": "ai-tutor-agent-turn",
493
+ "tags": [
494
+ "ai-tutor-app",
495
+ "knowledge-base-chatbot",
496
+ f"provider:{provider}",
497
+ f"model:{actual_model}",
498
+ *(f"tool:{tool_name}" for tool_name in tools),
499
+ ],
500
+ "metadata": {
501
+ "app": "ai-tutor-app",
502
+ "thread_id": active_thread_id,
503
+ "conversation_id": active_thread_id,
504
+ "message_id": message_id,
505
+ "model_provider": provider,
506
+ "model_name": actual_model,
507
+ "requested_model": request.model_name,
508
+ "available_tools": list(tools),
509
+ "enabled_tool_toggles": list(request.enabled_tools),
510
+ "source_keys": list(request.source_keys),
511
+ "source_labels": source_labels,
512
+ "include_reasoning": bool(request.include_reasoning),
513
+ },
514
+ }
515
+ )
516
+ return config
517
+
518
+
519
  def extract_web_search_queries(response_metadata: Any) -> list[str]:
520
  """Pull the queries Gemini ran against google_search from grounding metadata."""
521
  if not isinstance(response_metadata, dict):
 
728
 
729
  async for chunk in agent.astream(
730
  {"messages": [{"role": "user", "content": request.query}]},
731
+ agent_run_config(request, active_thread_id, message_id),
732
  context=AppContext(allowed_sources=request.source_keys),
733
  stream_mode=["messages", "updates"],
734
  version="v2",
scripts/main.py CHANGED
@@ -2,7 +2,12 @@ from __future__ import annotations
2
 
3
  import gradio as gr
4
 
5
- from .chat_service import ChatRequest, normalize_history, stream_chat
 
 
 
 
 
6
  from .gradio_presenter import GradioPresenterState
7
  from .setup import (
8
  AVAILABLE_SOURCES_UI,
@@ -63,7 +68,7 @@ model = gr.Textbox(
63
  label="Model (provider:model)",
64
  value=DEFAULT_MODEL_NAME,
65
  interactive=False,
66
- placeholder="openai:gpt-5.4-mini | anthropic:claude-opus-4-6 | google-genai:gemini-3.1-pro-preview",
67
  )
68
  enable_web_search = gr.Checkbox(
69
  label="Web search",
@@ -128,7 +133,13 @@ with gr.Blocks(
128
  gr.ChatInterface(
129
  fn=generate_completion,
130
  chatbot=chatbot,
131
- additional_inputs=[sources, model, thread_id, enable_web_search, enable_url_read],
 
 
 
 
 
 
132
  additional_outputs=[thread_id],
133
  additional_inputs_accordion=accordion,
134
  api_name="chat",
@@ -136,4 +147,5 @@ with gr.Blocks(
136
 
137
 
138
  if __name__ == "__main__":
 
139
  demo.launch(server_name="0.0.0.0", server_port=7860, debug=False, share=False)
 
2
 
3
  import gradio as gr
4
 
5
+ from .chat_service import (
6
+ ChatRequest,
7
+ normalize_history,
8
+ stream_chat,
9
+ warm_up_retriever,
10
+ )
11
  from .gradio_presenter import GradioPresenterState
12
  from .setup import (
13
  AVAILABLE_SOURCES_UI,
 
68
  label="Model (provider:model)",
69
  value=DEFAULT_MODEL_NAME,
70
  interactive=False,
71
+ placeholder="openai:gpt-5.4-mini | anthropic:claude-opus-4-6 | google-genai:gemini-3.5-flash",
72
  )
73
  enable_web_search = gr.Checkbox(
74
  label="Web search",
 
133
  gr.ChatInterface(
134
  fn=generate_completion,
135
  chatbot=chatbot,
136
+ additional_inputs=[
137
+ sources,
138
+ model,
139
+ thread_id,
140
+ enable_web_search,
141
+ enable_url_read,
142
+ ],
143
  additional_outputs=[thread_id],
144
  additional_inputs_accordion=accordion,
145
  api_name="chat",
 
147
 
148
 
149
  if __name__ == "__main__":
150
+ warm_up_retriever()
151
  demo.launch(server_name="0.0.0.0", server_port=7860, debug=False, share=False)
scripts/setup.py CHANGED
@@ -12,22 +12,31 @@ from data.scraping_scripts.source_registry import (
12
  SOURCE_KEY_TO_LABEL,
13
  SOURCE_UI_TO_KEY,
14
  )
 
 
15
  from .utils import init_mongo_db
16
 
17
  load_dotenv(override=True)
 
18
  try:
19
  logfire.configure()
20
  except Exception:
21
  pass
22
 
 
 
 
 
 
 
23
  VECTOR_DB_DIR = "data/chroma-db-all_sources"
24
  VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
25
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
26
  BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
27
- DEFAULT_MODEL_NAME = "google-genai:gemini-3-flash-preview"
28
 
29
  AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
30
- {"id": "google-genai:gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
31
  {"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
32
  )
33
 
 
12
  SOURCE_KEY_TO_LABEL,
13
  SOURCE_UI_TO_KEY,
14
  )
15
+
16
+ from .agent_tracing import configure_langsmith_environment, langsmith_tracing_enabled
17
  from .utils import init_mongo_db
18
 
19
  load_dotenv(override=True)
20
+ configure_langsmith_environment()
21
  try:
22
  logfire.configure()
23
  except Exception:
24
  pass
25
 
26
+ if langsmith_tracing_enabled():
27
+ logfire.info(
28
+ "LangSmith tracing enabled.",
29
+ project=os.getenv("LANGSMITH_PROJECT", "default"),
30
+ )
31
+
32
  VECTOR_DB_DIR = "data/chroma-db-all_sources"
33
  VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
34
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
35
  BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
36
+ DEFAULT_MODEL_NAME = "google-genai:gemini-3.5-flash"
37
 
38
  AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
39
+ {"id": "google-genai:gemini-3.5-flash", "label": "Gemini 3.5 Flash"},
40
  {"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
41
  )
42
 
tests/test_agent_tracing.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from scripts.agent_tracing import (
8
+ DEFAULT_LANGSMITH_PROJECT,
9
+ configure_langsmith_environment,
10
+ langsmith_tracing_enabled,
11
+ parse_env_bool,
12
+ )
13
+
14
+
15
+ class AgentTracingTestCase(unittest.TestCase):
16
+ def test_parse_env_bool(self) -> None:
17
+ self.assertIs(parse_env_bool(None), None)
18
+ self.assertTrue(parse_env_bool("true"))
19
+ self.assertTrue(parse_env_bool("1"))
20
+ self.assertFalse(parse_env_bool("false"))
21
+ self.assertFalse(parse_env_bool("0"))
22
+ self.assertIs(parse_env_bool("maybe"), None)
23
+
24
+ def test_api_key_enables_langsmith_by_default(self) -> None:
25
+ with patch.dict(os.environ, {"LANGSMITH_API_KEY": "ls_test"}, clear=True):
26
+ configure_langsmith_environment()
27
+
28
+ self.assertTrue(langsmith_tracing_enabled())
29
+ self.assertEqual(os.environ["LANGSMITH_TRACING"], "true")
30
+ self.assertEqual(os.environ["LANGSMITH_PROJECT"], DEFAULT_LANGSMITH_PROJECT)
31
+
32
+ def test_explicit_disable_wins_over_api_key(self) -> None:
33
+ with patch.dict(
34
+ os.environ,
35
+ {"LANGSMITH_API_KEY": "ls_test", "LANGSMITH_TRACING": "false"},
36
+ clear=True,
37
+ ):
38
+ configure_langsmith_environment()
39
+
40
+ self.assertFalse(langsmith_tracing_enabled())
41
+ self.assertNotIn("LANGSMITH_PROJECT", os.environ)
42
+
43
+ def test_project_is_preserved(self) -> None:
44
+ with patch.dict(
45
+ os.environ,
46
+ {
47
+ "LANGSMITH_API_KEY": "ls_test",
48
+ "LANGSMITH_TRACING": "true",
49
+ "LANGSMITH_PROJECT": "custom-project",
50
+ },
51
+ clear=True,
52
+ ):
53
+ configure_langsmith_environment()
54
+
55
+ self.assertEqual(os.environ["LANGSMITH_PROJECT"], "custom-project")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ unittest.main()
tests/test_chat_service.py CHANGED
@@ -6,8 +6,13 @@ from unittest.mock import patch
6
 
7
  from langchain_core.messages import AIMessage, HumanMessage
8
 
9
- from scripts.chat_service import sync_thread_with_history
10
- from scripts.chat_types import ChatTurn
 
 
 
 
 
11
 
12
 
13
  class FakeAgent:
@@ -23,6 +28,99 @@ class FakeAgent:
23
 
24
 
25
  class ChatServiceTestCase(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def test_shorter_history_branches_to_fresh_thread(self) -> None:
27
  agent = FakeAgent(
28
  [
 
6
 
7
  from langchain_core.messages import AIMessage, HumanMessage
8
 
9
+ from scripts.chat_service import (
10
+ agent_run_config,
11
+ build_agent,
12
+ effective_tool_names,
13
+ sync_thread_with_history,
14
+ )
15
+ from scripts.chat_types import ChatRequest, ChatTurn
16
 
17
 
18
  class FakeAgent:
 
28
 
29
 
30
  class ChatServiceTestCase(unittest.TestCase):
31
+ def test_effective_tool_names_follow_provider(self) -> None:
32
+ self.assertEqual(
33
+ effective_tool_names(
34
+ "google-genai:gemini-3.5-flash",
35
+ ("web_search", "url_context", "web_fetch"),
36
+ ),
37
+ ("retrieve_tutor_context", "google_search", "url_context"),
38
+ )
39
+ self.assertEqual(
40
+ effective_tool_names(
41
+ "anthropic:claude-haiku-4-5",
42
+ ("web_search", "url_context", "web_fetch"),
43
+ ),
44
+ ("retrieve_tutor_context", "web_search", "web_fetch"),
45
+ )
46
+
47
+ def test_agent_run_config_adds_langsmith_metadata(self) -> None:
48
+ request = ChatRequest(
49
+ query="What is RAG?",
50
+ source_keys=("langchain", "transformers"),
51
+ model_name="google-genai:gemini-3.5-flash",
52
+ include_reasoning=True,
53
+ enabled_tools=("web_search",),
54
+ )
55
+
56
+ config = agent_run_config(request, "thread_123", "message_456")
57
+
58
+ self.assertEqual(config["configurable"], {"thread_id": "thread_123"})
59
+ self.assertEqual(config["run_name"], "ai-tutor-agent-turn")
60
+ self.assertIn("provider:google-genai", config["tags"])
61
+ self.assertIn("tool:retrieve_tutor_context", config["tags"])
62
+ self.assertIn("tool:google_search", config["tags"])
63
+ self.assertEqual(config["metadata"]["thread_id"], "thread_123")
64
+ self.assertEqual(config["metadata"]["conversation_id"], "thread_123")
65
+ self.assertEqual(config["metadata"]["message_id"], "message_456")
66
+ self.assertEqual(
67
+ config["metadata"]["available_tools"],
68
+ ["retrieve_tutor_context", "google_search"],
69
+ )
70
+ self.assertEqual(
71
+ config["metadata"]["source_keys"],
72
+ ["langchain", "transformers"],
73
+ )
74
+
75
+ def test_build_agent_cache_keys_include_tool_toggles(self) -> None:
76
+ build_agent.cache_clear()
77
+ created_agents = []
78
+
79
+ def fake_create_agent(**kwargs):
80
+ agent = types.SimpleNamespace(kwargs=kwargs)
81
+ created_agents.append(agent)
82
+ return agent
83
+
84
+ try:
85
+ with (
86
+ patch("scripts.chat_service.build_chat_model", return_value=object()),
87
+ patch(
88
+ "scripts.chat_service.build_system_prompt", return_value="prompt"
89
+ ),
90
+ patch(
91
+ "scripts.chat_service.create_agent", side_effect=fake_create_agent
92
+ ),
93
+ ):
94
+ with_web_tools = build_agent(
95
+ "google-genai:gemini-3.5-flash",
96
+ enabled_tools=("web_search", "url_context"),
97
+ include_thoughts=True,
98
+ )
99
+ without_web_tools = build_agent(
100
+ "google-genai:gemini-3.5-flash",
101
+ enabled_tools=(),
102
+ include_thoughts=True,
103
+ )
104
+ without_web_tools_again = build_agent(
105
+ "google-genai:gemini-3.5-flash",
106
+ enabled_tools=(),
107
+ include_thoughts=True,
108
+ )
109
+ finally:
110
+ build_agent.cache_clear()
111
+
112
+ self.assertIsNot(with_web_tools, without_web_tools)
113
+ self.assertIs(without_web_tools, without_web_tools_again)
114
+ self.assertEqual(len(created_agents), 2)
115
+
116
+ enabled_tool_defs = created_agents[0].kwargs["tools"]
117
+ disabled_tool_defs = created_agents[1].kwargs["tools"]
118
+ self.assertIn({"google_search": {}}, enabled_tool_defs)
119
+ self.assertIn({"url_context": {}}, enabled_tool_defs)
120
+ self.assertEqual(disabled_tool_defs, [enabled_tool_defs[0]])
121
+ self.assertEqual(len(created_agents[0].kwargs["middleware"]), 1)
122
+ self.assertEqual(created_agents[1].kwargs["middleware"], [])
123
+
124
  def test_shorter_history_branches_to_fresh_thread(self) -> None:
125
  agent = FakeAgent(
126
  [