Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bt_servant_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
32 changes: 32 additions & 0 deletions bt_servant_engine/adapters/user_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand All @@ -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",
]
17 changes: 17 additions & 0 deletions bt_servant_engine/apps/api/routes/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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()

Expand Down
1 change: 1 addition & 0 deletions bt_servant_engine/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
"{"
Expand Down
2 changes: 2 additions & 0 deletions bt_servant_engine/core/intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
8 changes: 8 additions & 0 deletions bt_servant_engine/core/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
...
Expand Down
100 changes: 100 additions & 0 deletions bt_servant_engine/services/brain_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 (
Expand Down Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]],
Expand Down Expand Up @@ -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,
Expand All @@ -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"],
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion bt_servant_engine/services/brain_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand Down
Loading