diff --git a/bt_servant_engine/__init__.py b/bt_servant_engine/__init__.py index a9a988e..1569ea8 100644 --- a/bt_servant_engine/__init__.py +++ b/bt_servant_engine/__init__.py @@ -1,6 +1,6 @@ """Core package placeholder for upcoming onion architecture refactor.""" -BT_SERVANT_VERSION = "1.3.7" +BT_SERVANT_VERSION = "1.3.8" BT_SERVANT_RELEASES_URL = "https://github.com/unfoldingWord/bt-servant-engine/releases" __all__ = ["BT_SERVANT_VERSION", "BT_SERVANT_RELEASES_URL"] diff --git a/bt_servant_engine/adapters/user_state.py b/bt_servant_engine/adapters/user_state.py index 07253b3..be27179 100644 --- a/bt_servant_engine/adapters/user_state.py +++ b/bt_servant_engine/adapters/user_state.py @@ -193,6 +193,30 @@ def set_user_agentic_strength(user_id: str, strength: str) -> None: db.upsert(updated, cond) +def get_user_dev_agentic_mcp(user_id: str) -> Optional[bool]: + """Return whether the user enabled the dev MCP agentic mode.""" + q = Query() + cond = cast(QueryLike, q.user_id == user_id) + raw = get_user_db().table("users").get(cond) + result = cast(Optional[Dict[str, Any]], raw) + value = result.get("dev_agentic_mcp") if result else None + return bool(value) if value is not None else None + + +def set_user_dev_agentic_mcp(user_id: str, enabled: bool) -> None: + """Persist the user's preference for the dev MCP agentic mode.""" + q = Query() + db = get_user_db().table("users") + cond = cast(QueryLike, q.user_id == user_id) + existing_raw = db.get(cond) + existing = cast(Optional[Dict[str, Any]], existing_raw) + updated: Dict[str, Any] = ( + existing.copy() if isinstance(existing, dict) else {"user_id": user_id} + ) + updated["dev_agentic_mcp"] = bool(enabled) + db.upsert(updated, cond) + + def set_first_interaction(user_id: str, is_first: bool) -> None: """Set whether this is the user's first interaction.""" q = Query() @@ -254,6 +278,12 @@ def get_agentic_strength(self, user_id: str) -> str | None: def set_agentic_strength(self, user_id: str, strength: str) -> None: set_user_agentic_strength(user_id, strength) + def get_dev_agentic_mcp(self, user_id: str) -> bool | None: + return get_user_dev_agentic_mcp(user_id) + + def set_dev_agentic_mcp(self, user_id: str, enabled: bool) -> None: + set_user_dev_agentic_mcp(user_id, enabled) + def set_first_interaction(self, user_id: str, is_first: bool) -> None: set_first_interaction(user_id, is_first) @@ -274,6 +304,8 @@ def is_first_interaction(self, user_id: str) -> bool: "set_user_last_response_language", "get_user_agentic_strength", "set_user_agentic_strength", + "get_user_dev_agentic_mcp", + "set_user_dev_agentic_mcp", "set_first_interaction", "is_first_interaction", ] diff --git a/bt_servant_engine/apps/api/routes/webhooks.py b/bt_servant_engine/apps/api/routes/webhooks.py index 9dad029..ca659f5 100644 --- a/bt_servant_engine/apps/api/routes/webhooks.py +++ b/bt_servant_engine/apps/api/routes/webhooks.py @@ -93,6 +93,16 @@ def _compute_agentic_strengths( return effective, user_strength +def _compute_dev_agentic_mcp( + user_id: str, user_state: UserStatePort +) -> tuple[bool, Optional[bool]]: + """Return effective dev MCP flag and stored user preference (if any).""" + user_pref = user_state.get_dev_agentic_mcp(user_id=user_id) + system_pref = bool(getattr(config, "BT_DEV_AGENTIC_MCP", False)) + effective = user_pref if user_pref is not None else system_pref + return effective, user_pref + + @router.get("/meta-whatsapp") async def verify_webhook(request: Request): """Meta webhook verification endpoint following the standard handshake.""" @@ -480,6 +490,10 @@ async def _invoke_brain( context.user_message.user_id, context.user_state, ) + effective_dev_agentic_mcp, user_dev_agentic_mcp = _compute_dev_agentic_mcp( + context.user_message.user_id, + context.user_state, + ) brain_payload: dict[str, Any] = { "user_id": context.user_message.user_id, "user_query": user_query, @@ -490,6 +504,7 @@ async def _invoke_brain( user_id=context.user_message.user_id ), "agentic_strength": effective_agentic_strength, + "dev_agentic_mcp": effective_dev_agentic_mcp, "perf_trace_id": context.user_message.message_id, "progress_enabled": config.PROGRESS_MESSAGES_ENABLED, "progress_messenger": progress_sender, @@ -498,6 +513,8 @@ async def _invoke_brain( } if user_agentic_strength is not None: brain_payload["user_agentic_strength"] = user_agentic_strength + if user_dev_agentic_mcp is not None: + brain_payload["user_dev_agentic_mcp"] = user_dev_agentic_mcp loop = asyncio.get_event_loop() ctx = copy_context() diff --git a/bt_servant_engine/core/config.py b/bt_servant_engine/core/config.py index d3ab7fb..f19cb00 100644 --- a/bt_servant_engine/core/config.py +++ b/bt_servant_engine/core/config.py @@ -76,6 +76,7 @@ class Settings(BaseSettings): CACHE_RAG_FINAL_MAX_ENTRIES: int = Field(default=1500) DATA_DIR: Path = Field(default=Path("/data")) + BT_DEV_AGENTIC_MCP: bool = Field(default=False) OPENAI_PRICING_JSON: str = Field( default=( "{" diff --git a/bt_servant_engine/core/intents.py b/bt_servant_engine/core/intents.py index bd127a2..05268b3 100644 --- a/bt_servant_engine/core/intents.py +++ b/bt_servant_engine/core/intents.py @@ -22,6 +22,8 @@ class IntentType(str, Enum): SET_RESPONSE_LANGUAGE = "set-response-language" CLEAR_RESPONSE_LANGUAGE = "clear-response-language" SET_AGENTIC_STRENGTH = "set-agentic-strength" + SET_DEV_AGENTIC_MCP = "set-dev-agentic-mcp" + CLEAR_DEV_AGENTIC_MCP = "clear-dev-agentic-mcp" CONVERSE_WITH_BT_SERVANT = "converse-with-bt-servant" diff --git a/bt_servant_engine/core/ports.py b/bt_servant_engine/core/ports.py index c628aa8..6ce8033 100644 --- a/bt_servant_engine/core/ports.py +++ b/bt_servant_engine/core/ports.py @@ -110,6 +110,14 @@ def set_agentic_strength(self, user_id: str, strength: str) -> None: """Persist the agentic strength preference.""" ... + def get_dev_agentic_mcp(self, user_id: str) -> bool | None: + """Return whether the dev MCP agentic mode is enabled for the user.""" + ... + + def set_dev_agentic_mcp(self, user_id: str, enabled: bool) -> None: + """Persist the dev MCP agentic mode preference.""" + ... + def set_first_interaction(self, user_id: str, is_first: bool) -> None: """Mark whether ``user_id`` is on their first interaction.""" ... diff --git a/bt_servant_engine/services/brain_nodes.py b/bt_servant_engine/services/brain_nodes.py index 4e51bb4..6c374e7 100644 --- a/bt_servant_engine/services/brain_nodes.py +++ b/bt_servant_engine/services/brain_nodes.py @@ -4,6 +4,7 @@ brain decision graph. These are thin wrappers that delegate to service modules while handling state extraction and dependency injection. """ +# pylint: disable=too-many-lines from __future__ import annotations @@ -14,6 +15,7 @@ from openai import OpenAI from bt_servant_engine.core.config import config +from bt_servant_engine.core.intents import IntentType from bt_servant_engine.core.logging import get_logger from bt_servant_engine.core.ports import ChromaPort, UserStatePort from bt_servant_engine.services.openai_utils import ( @@ -41,6 +43,7 @@ model_for_agentic_strength as _model_for_agentic_strength, preprocess_user_query as _preprocess_user_query_impl, resolve_agentic_strength as _resolve_agentic_strength, + resolve_dev_agentic_mcp as _resolve_dev_agentic_mcp, generate_continuation_actions as _generate_continuation_actions_impl, ) from bt_servant_engine.services.passage_selection import ( @@ -59,11 +62,15 @@ AgenticStrengthRequest, ClearResponseLanguageDependencies, ClearResponseLanguageRequest, + DevAgenticMCPDependencies, + DevAgenticMCPRequest, ResponseLanguageDependencies, ResponseLanguageRequest, clear_response_language as clear_response_language_impl, set_agentic_strength as set_agentic_strength_impl, + set_dev_agentic_mcp as set_dev_agentic_mcp_impl, set_response_language as set_response_language_impl, + clear_dev_agentic_mcp as clear_dev_agentic_mcp_impl, ) from bt_servant_engine.services.intents.passage_intents import ( ListenToScriptureRequest, @@ -119,6 +126,10 @@ build_translation_helps_messages as build_translation_helps_messages_impl, prepare_translation_helps as prepare_translation_helps_impl, ) +from bt_servant_engine.services.mcp_agentic import ( + MCPAgenticDependencies, + run_agentic_mcp, +) from bt_servant_engine.services.status_messages import get_effective_response_language from bt_servant_engine.services import runtime from utils.bsb import BOOK_MAP as BSB_BOOK_MAP @@ -444,6 +455,30 @@ def set_agentic_strength(state: Any) -> dict: return set_agentic_strength_impl(request, dependencies) +def set_dev_agentic_mcp(state: Any) -> dict: + """Enable the developer MCP agentic mode for the user.""" + + s = _brain_state(state) + user_state = _user_state_port() + request = DevAgenticMCPRequest(user_id=s["user_id"]) + dependencies = DevAgenticMCPDependencies( + set_user_dev_agentic_mcp=user_state.set_dev_agentic_mcp + ) + return set_dev_agentic_mcp_impl(request, dependencies) + + +def clear_dev_agentic_mcp(state: Any) -> dict: + """Disable the developer MCP agentic mode for the user.""" + + s = _brain_state(state) + user_state = _user_state_port() + request = DevAgenticMCPRequest(user_id=s["user_id"]) + dependencies = DevAgenticMCPDependencies( + set_user_dev_agentic_mcp=user_state.set_dev_agentic_mcp + ) + return clear_dev_agentic_mcp_impl(request, dependencies) + + def _collect_truncation_notices( protected_items: Iterable[dict[str, Any]], normal_items: Iterable[dict[str, Any]], @@ -779,6 +814,22 @@ def handle_get_passage_summary(state: Any) -> dict: s = _brain_state(state) intent_query = _intent_query_for_node(state, "handle_get_passage_summary_node") + dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s)) + if dev_agentic_mcp: + agentic_deps = MCPAgenticDependencies( + openai_client=open_ai_client, + extract_cached_tokens_fn=_extract_cached_input_tokens, + ) + logger.info("[agentic-mcp] using dev MCP flow for get-passage-summary") + response_text = run_agentic_mcp( + agentic_deps, + user_message=intent_query, + intent=IntentType.GET_PASSAGE_SUMMARY, + ) + return { + "responses": [{"intent": IntentType.GET_PASSAGE_SUMMARY, "response": response_text}] + } + agentic_strength = _resolve_agentic_strength(cast(dict[str, Any], s)) selection_request = PassageSelectionRequest( query=intent_query, @@ -800,6 +851,22 @@ def handle_get_passage_keywords(state: Any) -> dict: s = _brain_state(state) intent_query = _intent_query_for_node(state, "handle_get_passage_keywords_node") + dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s)) + if dev_agentic_mcp: + agentic_deps = MCPAgenticDependencies( + openai_client=open_ai_client, + extract_cached_tokens_fn=_extract_cached_input_tokens, + ) + logger.info("[agentic-mcp] using dev MCP flow for get-passage-keywords") + response_text = run_agentic_mcp( + agentic_deps, + user_message=intent_query, + intent=IntentType.GET_PASSAGE_KEYWORDS, + ) + return { + "responses": [{"intent": IntentType.GET_PASSAGE_KEYWORDS, "response": response_text}] + } + selection_request = PassageSelectionRequest( query=intent_query, query_lang=s["query_language"], @@ -815,6 +882,23 @@ def handle_get_translation_helps(state: Any) -> dict: s = _brain_state(state) intent_query = _intent_query_for_node(state, "handle_get_translation_helps_node") agentic_strength = _resolve_agentic_strength(cast(dict[str, Any], s)) + dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s)) + + if dev_agentic_mcp: + agentic_deps = MCPAgenticDependencies( + openai_client=open_ai_client, + extract_cached_tokens_fn=_extract_cached_input_tokens, + ) + logger.info("[agentic-mcp] using dev MCP flow for get-translation-helps") + response_text = run_agentic_mcp( + agentic_deps, + user_message=intent_query, + intent=IntentType.GET_TRANSLATION_HELPS, + ) + return { + "responses": [{"intent": IntentType.GET_TRANSLATION_HELPS, "response": response_text}] + } + request = TranslationHelpsRequestParams( client=open_ai_client, query=intent_query, @@ -839,6 +923,20 @@ def handle_retrieve_scripture(state: Any) -> dict: s = _brain_state(state) intent_query = _intent_query_for_node(state, "handle_retrieve_scripture_node") + dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s)) + if dev_agentic_mcp: + agentic_deps = MCPAgenticDependencies( + openai_client=open_ai_client, + extract_cached_tokens_fn=_extract_cached_input_tokens, + ) + logger.info("[agentic-mcp] using dev MCP flow for retrieve-scripture") + response_text = run_agentic_mcp( + agentic_deps, + user_message=intent_query, + intent=IntentType.RETRIEVE_SCRIPTURE, + ) + return {"responses": [{"intent": IntentType.RETRIEVE_SCRIPTURE, "response": response_text}]} + agentic_strength = _resolve_agentic_strength(cast(dict[str, Any], s)) selection_request = PassageSelectionRequest( query=intent_query, @@ -918,6 +1016,8 @@ def _sample_for_language_detection(text: str) -> str: "determine_intents", "set_response_language", "set_agentic_strength", + "set_dev_agentic_mcp", + "clear_dev_agentic_mcp", "translate_responses", "translate_text", "determine_query_language", diff --git a/bt_servant_engine/services/brain_orchestrator.py b/bt_servant_engine/services/brain_orchestrator.py index d34ba31..de3e41d 100644 --- a/bt_servant_engine/services/brain_orchestrator.py +++ b/bt_servant_engine/services/brain_orchestrator.py @@ -38,6 +38,8 @@ IntentType.CLEAR_RESPONSE_LANGUAGE: 101, IntentType.SET_RESPONSE_LANGUAGE: 100, IntentType.SET_AGENTIC_STRENGTH: 99, + IntentType.SET_DEV_AGENTIC_MCP: 98, + IntentType.CLEAR_DEV_AGENTIC_MCP: 97, # Scripture retrieval: Get the text before analyzing it IntentType.RETRIEVE_SCRIPTURE: 80, IntentType.LISTEN_TO_SCRIPTURE: 79, # Audio variant of retrieval @@ -68,6 +70,8 @@ IntentType.SET_RESPONSE_LANGUAGE: "set_response_language_node", IntentType.CLEAR_RESPONSE_LANGUAGE: "clear_response_language_node", IntentType.SET_AGENTIC_STRENGTH: "set_agentic_strength_node", + IntentType.SET_DEV_AGENTIC_MCP: "set_dev_agentic_mcp_node", + IntentType.CLEAR_DEV_AGENTIC_MCP: "clear_dev_agentic_mcp_node", IntentType.PERFORM_UNSUPPORTED_FUNCTION: "handle_unsupported_function_node", IntentType.RETRIEVE_SYSTEM_INFORMATION: "handle_system_information_request_node", IntentType.CONVERSE_WITH_BT_SERVANT: "converse_with_bt_servant_node", @@ -291,6 +295,8 @@ class BrainState(TypedDict, total=False): user_response_language: str agentic_strength: str user_agentic_strength: str + dev_agentic_mcp: bool + user_dev_agentic_mcp: bool transformed_query: str docs: List[Dict[str, Any]] collection_used: str @@ -549,7 +555,7 @@ def process_intents(state: Any) -> List[Hashable]: return cast(List[Hashable], sends) -def create_brain(): +def create_brain(): # noqa: PLR0915 """Assemble and compile the LangGraph for the BT Servant brain.""" def _make_state_graph(schema: Any) -> StateGraph: @@ -596,6 +602,14 @@ def _should_show_translation_progress(state: Any) -> bool: "set_agentic_strength_node", wrap_node_with_timing(brain_nodes.set_agentic_strength, "set_agentic_strength_node"), ) + builder.add_node( + "set_dev_agentic_mcp_node", + wrap_node_with_timing(brain_nodes.set_dev_agentic_mcp, "set_dev_agentic_mcp_node"), + ) + builder.add_node( + "clear_dev_agentic_mcp_node", + wrap_node_with_timing(brain_nodes.clear_dev_agentic_mcp, "clear_dev_agentic_mcp_node"), + ) builder.add_node( "query_vector_db_node", wrap_node_with_timing( @@ -746,6 +760,8 @@ def _should_show_translation_progress(state: Any) -> bool: builder.add_edge("set_response_language_node", "translate_responses_node") builder.add_edge("clear_response_language_node", "translate_responses_node") builder.add_edge("set_agentic_strength_node", "translate_responses_node") + builder.add_edge("set_dev_agentic_mcp_node", "translate_responses_node") + builder.add_edge("clear_dev_agentic_mcp_node", "translate_responses_node") # After chunking, finish. Do not loop back to translate, which can recreate # the long message and trigger an infinite chunk cycle. diff --git a/bt_servant_engine/services/intents/settings_intents.py b/bt_servant_engine/services/intents/settings_intents.py index a3040b6..9eddb27 100644 --- a/bt_servant_engine/services/intents/settings_intents.py +++ b/bt_servant_engine/services/intents/settings_intents.py @@ -227,6 +227,50 @@ def set_agentic_strength( } +@dataclass(slots=True) +class DevAgenticMCPRequest: + """Inputs required to toggle the dev MCP agentic mode.""" + + user_id: str + + +@dataclass(slots=True) +class DevAgenticMCPDependencies: + """Persistence callbacks for dev MCP agentic mode.""" + + set_user_dev_agentic_mcp: Callable[[str, bool], Any] + + +def set_dev_agentic_mcp( + request: DevAgenticMCPRequest, + dependencies: DevAgenticMCPDependencies, +) -> dict[str, Any]: + """Enable the agentic MCP flow for the current user.""" + + dependencies.set_user_dev_agentic_mcp(request.user_id, True) + response_text = "Developer MCP mode enabled for translation helps." + return { + "responses": [{"intent": IntentType.SET_DEV_AGENTIC_MCP, "response": response_text}], + "dev_agentic_mcp": True, + "user_dev_agentic_mcp": True, + } + + +def clear_dev_agentic_mcp( + request: DevAgenticMCPRequest, + dependencies: DevAgenticMCPDependencies, +) -> dict[str, Any]: + """Disable the agentic MCP flow for the current user.""" + + dependencies.set_user_dev_agentic_mcp(request.user_id, False) + response_text = "Developer MCP mode disabled. Using the standard flow." + return { + "responses": [{"intent": IntentType.CLEAR_DEV_AGENTIC_MCP, "response": response_text}], + "dev_agentic_mcp": False, + "user_dev_agentic_mcp": False, + } + + @dataclass(slots=True) class ClearResponseLanguageRequest: """Inputs required to clear the stored response language.""" @@ -266,7 +310,11 @@ def clear_response_language( "ClearResponseLanguageDependencies", "AgenticStrengthRequest", "AgenticStrengthDependencies", + "DevAgenticMCPRequest", + "DevAgenticMCPDependencies", "set_response_language", "set_agentic_strength", + "set_dev_agentic_mcp", + "clear_dev_agentic_mcp", "clear_response_language", ] diff --git a/bt_servant_engine/services/mcp_agentic.py b/bt_servant_engine/services/mcp_agentic.py new file mode 100644 index 0000000..003eb31 --- /dev/null +++ b/bt_servant_engine/services/mcp_agentic.py @@ -0,0 +1,360 @@ +"""Agentic MCP helper for Translation Helps. + +This module provides a lightweight planner/executor/finalizer flow that lets +the LLM pick which Translation Helps MCP tools/prompts to call, validate the +arguments, execute them, and compose a final response. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from dataclasses import dataclass +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, cast + +from openai import OpenAI +from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam +from openai.types.responses.easy_input_message_param import EasyInputMessageParam +from pydantic import BaseModel, Field +from translation_helps import TranslationHelpsClient # type: ignore # pylint: disable=import-error + +from bt_servant_engine.core.intents import IntentType +from bt_servant_engine.core.logging import get_logger +from bt_servant_engine.services.openai_utils import track_openai_usage +from utils.perf import add_tokens + +logger = get_logger(__name__) + +ALLOWED_AGENTIC_TOOLS: set[str] = { + "prompt_translation-helps-for-passage", + "prompt_get-translation-words-for-passage", + "prompt_get-translation-academy-for-passage", + "fetch_translation_notes", + "fetch_translation_word_links", + "fetch_translation_word", + "fetch_translation_academy", + "fetch_translation_questions", + "fetch_scripture", +} + +PLAN_SYSTEM_PROMPT = """ +You select the best MCP tools/prompts to answer the user's request. + +Rules: +- Choose 1-3 calls from the allowed list. +- Use the required parameters shown in the manifest. +- Do not guess parameters; prefer the user's wording and references. +- Return ONLY a JSON object matching the schema. +""" + +FINALIZER_SYSTEM_PROMPT = """ +You are composing a reply using ONLY the provided MCP tool results. +- Be concise and helpful. +- If scripture text appears, keep it verbatim. +- If the intent is get-passage-summary, produce a concise summary (3–5 sentences max). +- If nothing useful is present, say so briefly. +- Offer 2 short follow-up questions that relate to the data retrieved. +""" + +FAILURE_MESSAGE_EN = ( + "Sorry. I couldn't figure out what to do. Please pass this along to my creators." +) + + +class ToolCall(BaseModel): + """Single MCP invocation request.""" + + name: str + args: Dict[str, Any] = Field(default_factory=dict) + + +class Plan(BaseModel): + """Structured plan produced by the planner LLM.""" + + calls: List[ToolCall] = Field(default_factory=list) + rationale: Optional[str] = None + + +@dataclass(slots=True) +class MCPToolSpec: + """Metadata for validating tool calls.""" + + name: str + required: List[str] + is_prompt: bool = False + + +@dataclass(slots=True) +class MCPAgenticDependencies: + """External helpers needed by the agentic MCP flow.""" + + openai_client: OpenAI + extract_cached_tokens_fn: Callable[..., Any] + + +async def _load_manifest(client) -> Dict[str, MCPToolSpec]: + """Load tools/prompts and reduce to the allowed manifest.""" + specs: Dict[str, MCPToolSpec] = {} + tools = await client.list_tools() + for tool in tools: + name = str(tool.get("name", "")).strip() + if name not in ALLOWED_AGENTIC_TOOLS: + continue + schema = tool.get("inputSchema") or {} + required = schema.get("required") or [] + required_list = [str(item) for item in required if isinstance(item, str)] + specs[name] = MCPToolSpec(name=name, required=required_list, is_prompt=False) + + prompts = await client.list_prompts() + for prompt in prompts: + name = str(prompt.get("name", "")).strip() + full_name = f"prompt_{name}" if name else "" + if full_name not in ALLOWED_AGENTIC_TOOLS: + continue + args = prompt.get("arguments") or [] + required_args: list[str] = [] + for arg in args: + if isinstance(arg, Mapping) and arg.get("required"): + arg_name = arg.get("name") + if isinstance(arg_name, str): + required_args.append(arg_name) + specs[full_name] = MCPToolSpec(name=full_name, required=required_args, is_prompt=True) + return specs + + +def _manifest_summary(specs: Mapping[str, MCPToolSpec]) -> str: + """Return a compact JSON manifest for the planner prompt.""" + summary = [ + { + "name": spec.name, + "required": spec.required, + "is_prompt": spec.is_prompt, + } + for spec in specs.values() + ] + return json.dumps(summary, ensure_ascii=False) + + +def _plan_calls( + deps: MCPAgenticDependencies, + user_message: str, + intent: IntentType, + manifest_summary: str, + prior_error: str | None = None, +) -> Plan: + """Ask the LLM to select MCP calls.""" + + messages: list[EasyInputMessageParam] = [ + { + "role": "user", + "content": ( + f"User request: {user_message}\n" + f"Intent: {intent.value}\n" + f"Allowed tools: {manifest_summary}\n" + "Return a JSON object with calls and optional rationale." + ), + } + ] + if prior_error: + messages.append( + { + "role": "user", + "content": f"The previous plan failed validation: {prior_error}", + } + ) + + resp = deps.openai_client.responses.parse( + model="gpt-4o", + instructions=PLAN_SYSTEM_PROMPT, + input=cast(Any, messages), + text_format=Plan, + temperature=0, + store=False, + ) + usage = getattr(resp, "usage", None) + track_openai_usage(usage, "gpt-4o", deps.extract_cached_tokens_fn, add_tokens) + parsed = resp.output_parsed + return parsed if isinstance(parsed, Plan) else Plan(calls=[]) + + +def _validate_plan(plan: Plan, specs: Mapping[str, MCPToolSpec]) -> str | None: + """Validate the planned calls against the manifest.""" + for call in plan.calls: + spec = specs.get(call.name) + if spec is None: + return f"unknown tool: {call.name}" + if not isinstance(call.args, dict): + return f"arguments for {call.name} must be an object" + missing = [ + req for req in spec.required if req not in call.args or call.args[req] in (None, "") + ] + if missing: + return f"{call.name} is missing required fields: {', '.join(missing)}" + if not plan.calls: + return "no calls were provided" + return None + + +async def _execute_prompt(client, name: str, params: Dict[str, Any]) -> str: + """Execute a prompt via the REST endpoint.""" + if not client._http_client: # pylint: disable=protected-access + raise RuntimeError("MCP client HTTP session is not initialized.") + url = client.server_url.replace("/api/mcp", "/api/execute-prompt") + response = await client._http_client.post( # pylint: disable=protected-access + url, + json={"promptName": name, "parameters": params}, + ) + response.raise_for_status() + data = response.json() + return json.dumps(data, ensure_ascii=False) + + +def _extract_text_from_content(content: Iterable[Any]) -> str: + """Extract text blobs from MCP tool content.""" + chunks: list[str] = [] + for item in content: + if isinstance(item, dict): + if "text" in item: + chunks.append(str(item.get("text", ""))) + elif item.get("type") == "text": + chunks.append(str(item.get("text", ""))) + elif isinstance(item, str): + chunks.append(item) + return "".join(chunks) + + +async def _execute_calls( + client, + plan: Plan, + specs: Mapping[str, MCPToolSpec], +) -> list[dict[str, Any]]: + """Execute planned calls and capture text outputs.""" + results: list[dict[str, Any]] = [] + for call in plan.calls: + spec = specs[call.name] + try: + logger.info( + "[agentic-mcp] Calling %s (prompt=%s) with args=%s", + call.name, + spec.is_prompt, + sorted(call.args.keys()), + ) + if spec.is_prompt: + prompt_name = call.name.replace("prompt_", "", 1) + text = await _execute_prompt(client, prompt_name, call.args) + else: + resp = await client.call_tool(call.name, call.args) + content = resp.get("content", []) if isinstance(resp, dict) else [] + text = _extract_text_from_content(content) + if not text and resp.get("text"): + text = str(resp["text"]) + results.append({"name": call.name, "args": call.args, "content": text}) + logger.info("[agentic-mcp] Completed %s (chars=%d)", call.name, len(text or "")) + except Exception as exc: # pylint: disable=broad-except + logger.warning("[agentic-mcp] %s failed: %s", call.name, exc) + results.append({"name": call.name, "args": call.args, "content": f"[ERROR] {exc}"}) + return results + + +def _finalize_response( + deps: MCPAgenticDependencies, + user_message: str, + intent: IntentType, + tool_results: list[dict[str, Any]], +) -> str: + """Ask the LLM to compose the final reply from tool outputs.""" + messages: list[ChatCompletionMessageParam] = [ + {"role": "system", "content": FINALIZER_SYSTEM_PROMPT}, + {"role": "user", "content": f"User request: {user_message}\nIntent: {intent.value}"}, + { + "role": "assistant", + "content": json.dumps(tool_results, ensure_ascii=False), + }, + ] + completion = deps.openai_client.chat.completions.create( + model="gpt-4o", + messages=cast(Any, messages), + temperature=0.4, + ) + usage = getattr(completion, "usage", None) + track_openai_usage(usage, "gpt-4o", deps.extract_cached_tokens_fn, add_tokens) + content = completion.choices[0].message.content + if isinstance(content, list): + return "".join(part.get("text", "") if isinstance(part, dict) else "" for part in content) + return content or "" + + +async def _run_agentic_mcp_async( + deps: MCPAgenticDependencies, + user_message: str, + intent: IntentType, + max_plan_attempts: int = 2, +) -> str: + """Async workflow to plan, validate, execute, and finalize MCP calls.""" + server_url = os.getenv("MCP_SERVER_URL") + client_options = {"serverUrl": server_url} if server_url else {} + mcp_client = TranslationHelpsClient(client_options) + await mcp_client.connect() + try: + manifest = await _load_manifest(mcp_client) + if not manifest: + logger.warning("[agentic-mcp] Manifest is empty; aborting.") + return FAILURE_MESSAGE_EN + logger.info("[agentic-mcp] Loaded manifest with %d tools", len(manifest)) + + manifest_summary = _manifest_summary(manifest) + validation_error: str | None = None + plan: Plan = Plan(calls=[]) + + for _ in range(max_plan_attempts): + plan = _plan_calls( + deps, + user_message=user_message, + intent=intent, + manifest_summary=manifest_summary, + prior_error=validation_error, + ) + validation_error = _validate_plan(plan, manifest) + if validation_error is None: + logger.info( + "[agentic-mcp] Plan validated with %d call(s): %s", + len(plan.calls), + ", ".join(call.name for call in plan.calls), + ) + break + if validation_error is not None: + logger.info("[agentic-mcp] Plan failed after retries: %s", validation_error) + return FAILURE_MESSAGE_EN + + tool_results = await _execute_calls(mcp_client, plan, manifest) + logger.info( + "[agentic-mcp] Executed %d calls successfully for intent=%s", + len(tool_results), + intent.value, + ) + final_response = _finalize_response(deps, user_message, intent, tool_results) + logger.info( + "[agentic-mcp] Final response length=%d chars for intent=%s", + len(final_response or ""), + intent.value, + ) + return final_response + finally: + await mcp_client.close() + + +def run_agentic_mcp( + deps: MCPAgenticDependencies, + user_message: str, + intent: IntentType, +) -> str: + """Public entry point for the agentic MCP flow.""" + try: + return asyncio.run(_run_agentic_mcp_async(deps, user_message=user_message, intent=intent)) + except Exception as exc: # pylint: disable=broad-except + logger.error("[agentic-mcp] Unexpected failure: %s", exc, exc_info=True) + return FAILURE_MESSAGE_EN + + +__all__ = ["MCPAgenticDependencies", "run_agentic_mcp", "FAILURE_MESSAGE_EN"] diff --git a/bt_servant_engine/services/preprocessing.py b/bt_servant_engine/services/preprocessing.py index 23a1bcb..b68a2bd 100644 --- a/bt_servant_engine/services/preprocessing.py +++ b/bt_servant_engine/services/preprocessing.py @@ -339,6 +339,14 @@ The user wants to change the agentic strength of the assistant's responses (for example: "Set my agentic strength to low", "Increase the detail of your answers"). Supported levels: normal, low, very_low. + + The user wants to enable the developer MCP agentic mode for translation helps/key passages (for example: + "turn on dev MCP mode", "use the MCP developer mode", "enable the agentic MCP flow"). + + + The user wants to disable the developer MCP agentic mode and return to the standard flow (for example: + "turn off dev MCP mode", "stop using the MCP developer mode", "disable agentic MCP"). + The user wants information about the BT Servant system itself—its resources, capabilities, uptime, data sources, or other operational details. @@ -404,6 +412,14 @@ translate the French version of John 1:1 into Spanish translate-scripture + + enable the MCP developer mode + set-dev-agentic-mcp + + + stop using the MCP dev flow + clear-dev-agentic-mcp + Please provide the text of Job 1:1-5. retrieve-scripture @@ -604,6 +620,18 @@ def resolve_agentic_strength(state: dict[str, Any]) -> str: return "normal" +def resolve_dev_agentic_mcp(state: dict[str, Any]) -> bool: + """Return whether the dev MCP agentic mode is enabled.""" + + for key in ("dev_agentic_mcp", "user_dev_agentic_mcp"): + val = state.get(key) + if isinstance(val, bool): + return val + + configured = getattr(config, "BT_DEV_AGENTIC_MCP", False) + return bool(configured) + + def model_for_agentic_strength( agentic_strength: str, *, diff --git a/env.example b/env.example index f5736c7..668584c 100644 --- a/env.example +++ b/env.example @@ -44,3 +44,6 @@ HEALTHCHECK_API_TOKEN=change-me-for-health # Agentic creativity tuning for LLM calls (normal | low) AGENTIC_STRENGTH=normal + +# Developer toggle for agentic MCP flow (false by default) +BT_DEV_AGENTIC_MCP=false diff --git a/pyproject.toml b/pyproject.toml index 90b43be..cda5bea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bt-servant-engine" -version = "1.3.7" +version = "1.3.8" description = "BT Servant Engine — strict onion/hexagonal refactor baseline" readme = "README.md" requires-python = ">=3.12" @@ -22,6 +22,7 @@ dependencies = [ "tinydb>=4.8", "typing-extensions>=4.12", "python-json-logger>=2.0.7", + "translation-helps-mcp-client>=1.2.0", ] [project.optional-dependencies] @@ -73,6 +74,9 @@ max-args = 5 # PLR0913 [tool.ruff.lint.mccabe] max-complexity = 10 # C901 +[tool.deptry.package_module_name_map] +"translation-helps-mcp-client" = "translation_helps" + [tool.mypy] python_version = "3.12" warn_unused_configs = true diff --git a/requirements.txt b/requirements.txt index 8f148ed..c5e16a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -132,6 +132,7 @@ tokenizers==0.21.1 tqdm==4.67.1 twilio==9.5.2 typer==0.15.2 +translation-helps-mcp-client==1.2.0 typing-inspect==0.9.0 typing-inspection==0.4.0 typing_extensions==4.13.2 diff --git a/tests/adapters/test_user_state_adapter.py b/tests/adapters/test_user_state_adapter.py index b65b028..d14bc7c 100644 --- a/tests/adapters/test_user_state_adapter.py +++ b/tests/adapters/test_user_state_adapter.py @@ -81,6 +81,19 @@ def test_agentic_strength_roundtrip(temp_user_db: TinyDB) -> None: assert user_state.get_user_agentic_strength(user_id) is None +def test_dev_agentic_mcp_roundtrip(temp_user_db: TinyDB) -> None: + """Dev MCP toggle round-trips boolean values.""" + del temp_user_db + user_id = "dev-mcp" + assert user_state.get_user_dev_agentic_mcp(user_id) is None + + user_state.set_user_dev_agentic_mcp(user_id, True) + assert user_state.get_user_dev_agentic_mcp(user_id) is True + + user_state.set_user_dev_agentic_mcp(user_id, False) + assert user_state.get_user_dev_agentic_mcp(user_id) is False + + def test_first_interaction_flags(temp_user_db: TinyDB) -> None: """First-interaction flag flips and persists via adapter helpers.""" del temp_user_db @@ -94,7 +107,10 @@ def test_first_interaction_flags(temp_user_db: TinyDB) -> None: assert user_state.is_first_interaction(user_id) is True -def test_user_state_adapter_methods_delegate(monkeypatch: pytest.MonkeyPatch) -> None: +# pylint: disable=too-many-locals +def test_user_state_adapter_methods_delegate( # noqa: PLR0915 + monkeypatch: pytest.MonkeyPatch, +) -> None: """Adapter delegates work to the module-level helpers.""" adapter = user_state.UserStateAdapter() @@ -134,6 +150,13 @@ def fake_get_strength(uid: str) -> str | None: def fake_set_strength(uid: str, strength: str) -> None: record(f"set_strength:{uid}:{strength}") + def fake_get_dev_mcp(uid: str) -> bool | None: + record(f"get_dev:{uid}") + return cast(bool | None, None) + + def fake_set_dev_mcp(uid: str, enabled: bool) -> None: + record(f"set_dev:{uid}:{enabled}") + def fake_set_first(uid: str, val: bool) -> None: record(f"set_first:{uid}:{val}") @@ -150,6 +173,8 @@ def fake_is_first(uid: str) -> bool: monkeypatch.setattr(user_state, "set_user_last_response_language", fake_set_last_lang) monkeypatch.setattr(user_state, "get_user_agentic_strength", fake_get_strength) monkeypatch.setattr(user_state, "set_user_agentic_strength", fake_set_strength) + monkeypatch.setattr(user_state, "get_user_dev_agentic_mcp", fake_get_dev_mcp) + monkeypatch.setattr(user_state, "set_user_dev_agentic_mcp", fake_set_dev_mcp) monkeypatch.setattr(user_state, "set_first_interaction", fake_set_first) monkeypatch.setattr(user_state, "is_first_interaction", fake_is_first) @@ -162,6 +187,8 @@ def fake_is_first(uid: str) -> bool: adapter.set_last_response_language("u1", "nl") adapter.get_agentic_strength("u1") adapter.set_agentic_strength("u1", "normal") + adapter.get_dev_agentic_mcp("u1") + adapter.set_dev_agentic_mcp("u1", True) adapter.set_first_interaction("u1", False) adapter.is_first_interaction("u1") @@ -175,6 +202,8 @@ def fake_is_first(uid: str) -> bool: "set_last_lang:u1:nl", "get_strength:u1", "set_strength:u1:normal", + "get_dev:u1", + "set_dev:u1:True", "set_first:u1:False", "is_first:u1", ] diff --git a/tests/conftest.py b/tests/conftest.py index 9a6833d..66f8f41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,6 +84,14 @@ def set_agentic_strength(self, user_id: str, strength: str) -> None: raise ValueError(f"Invalid agentic strength: {strength}") self.save_user_state(user_id, {"agentic_strength": normalized}) + def get_dev_agentic_mcp(self, user_id: str) -> bool | None: + state = self._states.get(user_id, {}) + value = state.get("dev_agentic_mcp") + return bool(value) if value is not None else None + + def set_dev_agentic_mcp(self, user_id: str, enabled: bool) -> None: + self.save_user_state(user_id, {"dev_agentic_mcp": bool(enabled)}) + def set_first_interaction(self, user_id: str, is_first: bool) -> None: self.save_user_state(user_id, {"first_interaction": is_first})