From 07e7967dfdfce3ec106467121d00421cb27b5f7d Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 11:16:17 -0400 Subject: [PATCH 01/20] fix(leadgen): keep the lead profile from being replaced by its section node Scope the unique user index to LeadProfile and resolve the record by user_node_id so a section save cannot wipe the anchor row. Co-authored-by: Cursor --- jvagent/action/leadgen/leadgen_action.py | 9 +- jvagent/action/leadgen/store.py | 39 ++++++- tests/action/leadgen/test_store_resolution.py | 102 ++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/action/leadgen/test_store_resolution.py diff --git a/jvagent/action/leadgen/leadgen_action.py b/jvagent/action/leadgen/leadgen_action.py index 6b4aac4e..668f1f26 100644 --- a/jvagent/action/leadgen/leadgen_action.py +++ b/jvagent/action/leadgen/leadgen_action.py @@ -120,7 +120,9 @@ async def _handle_custom_tool( from .store import LeadRecord - record = await LeadRecord.get_or_create_for_user(user) + record = await LeadRecord.get_or_create_for_user( + user, required_fields=spec.get_required_fields() or None + ) fn = load_hook_function(spec, tdef.function or tdef.name) if fn is None: return json.dumps({"error": f"hook {tdef.function} not found"}) @@ -144,4 +146,9 @@ async def _handle_custom_tool( return json.dumps( {"ok": True, "messages": result.messages, "extra": result.extra} ) + # A dict result is the tool's own payload (e.g. handle_sync_to_sheet + # returns {"ok": False, "error": ...}). Surface it verbatim so a failure + # reaches the model instead of being hidden under an outer "ok": true. + if isinstance(result, dict): + return json.dumps(result, default=str) return json.dumps({"ok": True, "result": str(result)}) diff --git a/jvagent/action/leadgen/store.py b/jvagent/action/leadgen/store.py index a5162178..37bf08a5 100644 --- a/jvagent/action/leadgen/store.py +++ b/jvagent/action/leadgen/store.py @@ -53,7 +53,13 @@ class LeadRecordNode(Node): __entity_name__: ClassVar[Optional[str]] = "LeadProfileNode" - user_node_id: str = attribute(indexed=True, default="") + # Not ``indexed=True``: a single-field index on ``context.user_node_id`` + # would take the same auto-generated name as LeadRecord's *unique* index on + # the same field, and the two entities would fight over one physical index + # (the section node's save then REPLACEs the anchor row). The compound + # ``user_node_category`` index below leads with ``user_node_id``, so + # section lookups stay indexed without the collision. + user_node_id: str = attribute(default="") user_id: str = attribute(indexed=True, default="") category: str = attribute(indexed=True, default="") title: str = attribute(default="") @@ -79,10 +85,19 @@ class LeadRecord(Node): __entity_name__: ClassVar[Optional[str]] = "LeadProfile" + # Unique per user, but the partial filter MUST scope to this entity. + # ``LeadRecordNode`` (entity ``LeadProfileNode``) also stores + # ``context.user_node_id``; without ``entity == "LeadProfile"`` the two + # share one physical unique index and the section node's save REPLACEs the + # anchor row via ``INSERT OR REPLACE``, wiping ``yaml_frontmatter`` to ``{}``. + # (Same shape as ConversationHealthState's ``conversation_health_state_agent``.) user_node_id: str = attribute( indexed=True, index_unique=True, - index_partial_filter_expression={"context.user_node_id": {"$gt": ""}}, + index_partial_filter_expression={ + "entity": "LeadProfile", + "context.user_node_id": {"$gt": ""}, + }, default="", ) user_id: str = attribute(indexed=True, default="") @@ -203,16 +218,31 @@ async def log_conversation(self, entry: str) -> bool: @classmethod async def get_for_user(cls, user: "User") -> Optional["LeadRecord"]: + """Return the user's LeadRecord. + + Resolves by the indexed ``user_node_id`` first: that column is the + authoritative key and keeps working even when the ``User -> LeadRecord`` + edge set has drifted (dangling edges from replaced rows, duplicate + connections), which the graph traversal alone cannot see. Falls back to + traversal only for legacy rows written before ``user_node_id`` was + backfilled. + """ + user_node_id = getattr(user, "id", None) + if user_node_id: + record = await cls.find_one(user_node_id=user_node_id) + if record: + return record return await user.node(node=LeadRecord, direction="out") @classmethod async def get_or_create_for_user( cls, user: "User", required_fields: Optional[List[str]] = None ) -> "LeadRecord": - existing = await cls.get_for_user(user) field_list = required_fields or list(DEFAULT_REQUIRED_FIELDS) fields_csv = ", ".join(field_list) + existing = await cls.get_for_user(user) + if existing: if not existing.user_node_id: existing.user_node_id = user.id @@ -229,6 +259,9 @@ async def get_or_create_for_user( await existing.save() return existing + # No match by key or edge. Re-check by the indexed key before creating: + # a second create for the same ``user_node_id`` would REPLACE the live + # row (unique index) and orphan its edges, losing captured fields. race = await cls.find_one(user_node_id=user.id) if race: return race diff --git a/tests/action/leadgen/test_store_resolution.py b/tests/action/leadgen/test_store_resolution.py new file mode 100644 index 00000000..15b6d1fc --- /dev/null +++ b/tests/action/leadgen/test_store_resolution.py @@ -0,0 +1,102 @@ +"""Regression tests for LeadRecord persistence identity. + +The bug: the unique index backing ``LeadRecord.user_node_id`` was not scoped to +the ``LeadProfile`` entity, so the section node (``LeadRecordNode``, which also +stores ``context.user_node_id``) shared one physical unique index with the +anchor. Saving a section via ``append_to_section`` REPLACED the anchor row, +wiping ``yaml_frontmatter`` to ``{}``. A later ``get_or_create_for_user`` then +saw an empty profile and ``leadgen__sync_to_sheet`` bailed with +``profile incomplete`` — which also suppressed the sales email. + +The failure is SQLite-specific: ``SQLiteDB.save`` uses ``INSERT OR REPLACE``, +so a second row colliding on a unique index deletes the first. The default test +backend (JsonDB) has no such behaviour, so the behavioural tests bind SQLite +explicitly and close it so the aiosqlite worker thread cannot outlive the test. +""" + +from __future__ import annotations + +import uuid + +import pytest + +from jvagent.action.leadgen.store import LeadRecord, LeadRecordNode + + +def _user_node_id_index(cls) -> dict: + """The single-field index definition on ``context.user_node_id``, if any.""" + for index in cls.get_indexes(): + if index.get("field") == "context.user_node_id": + return index + return {} + + +def test_lead_record_user_index_is_entity_scoped(): + """The unique key must be partial on ``entity == 'LeadProfile'``. + + Without the entity scope the section node (``LeadProfileNode``) collides on + the same physical index and REPLACEs the anchor. + """ + index = _user_node_id_index(LeadRecord) + assert index, "LeadRecord must index context.user_node_id" + assert index.get("unique") is True + partial = index.get("partialFilterExpression") or {} + assert partial.get("entity") == "LeadProfile" + + +def test_section_node_does_not_declare_competing_unique_index(): + """The section node must not declare its own unique ``user_node_id`` index. + + A single-field index on the same field would take the same auto-generated + name as the anchor's and the two entities would fight over one index. + """ + index = _user_node_id_index(LeadRecordNode) + assert index.get("unique") is not True + + +@pytest.mark.asyncio +async def test_append_to_section_does_not_wipe_lead_record(): + """Writing a narrative section must not clobber the anchor's fields.""" + from jvspatial.core.context import ( + GraphContext, + clear_default_context, + set_default_context, + ) + from jvspatial.db.sqlite import SQLiteDB + + from jvagent.memory.user import User + + db = SQLiteDB(db_path=":memory:") + ctx = GraphContext(database=db) + set_default_context(ctx) + try: + user = await User.create(memory_id="mem-1", user_id=f"u_{uuid.uuid4().hex[:8]}") + record = await LeadRecord.get_or_create_for_user( + user, required_fields=["name", "organization", "email", "phone"] + ) + await record.update_yaml( + { + "name": "Tharick", + "email": "jtharick@example.com", + "phone": "+5926431530", + "organization": "Personal", + "interested_products": "2 steel toe safety boots, size 42", + } + ) + + await record.append_to_section( + "conversation_summaries", "Set: name = 'Tharick'" + ) + + fresh = await LeadRecord.get(record.id) + assert fresh is not None, "anchor row must survive the section write" + assert fresh.get_yaml().get("email") == "jtharick@example.com" + assert fresh.get_missing_fields() == [] + + # Capture (required_fields) and the custom-tool path (none) still agree. + sync = await LeadRecord.get_or_create_for_user(user) + assert sync.id == record.id + assert sync.get_missing_fields() == [] + finally: + await db.close() + clear_default_context() From 0c1271e4528548fee6fbb3e5b5d14d359e1cb6f0 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 11:16:31 -0400 Subject: [PATCH 02/20] fix(mcp-oauth): persist Google token expiry so first use does not force a refresh Store the access-token expiry from the code exchange and normalize it to the naive-UTC form google-auth can parse. Co-authored-by: Cursor --- jvagent/action/google/google_action.py | 32 +++- jvagent/action/mcp_oauth/endpoints.py | 8 + .../google/test_google_sheets_mcp_oauth.py | 77 +++++++++ .../mcp_oauth/test_google_exchange_expiry.py | 149 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 tests/action/mcp_oauth/test_google_exchange_expiry.py diff --git a/jvagent/action/google/google_action.py b/jvagent/action/google/google_action.py index 3807d406..8c065680 100644 --- a/jvagent/action/google/google_action.py +++ b/jvagent/action/google/google_action.py @@ -1,5 +1,6 @@ import json import logging +from datetime import datetime from typing import Any, ClassVar, Dict, List, Optional, Union from google.auth.transport.requests import Request @@ -16,6 +17,35 @@ _OIDC_SCOPES = frozenset({"openid", "profile", "email"}) +def _normalize_expiry(value: Any) -> Optional[str]: + """Coerce a stored expiry into the naive-UTC ISO form google-auth parses. + + ``Credentials.from_authorized_user_info`` parses expiry with:: + + datetime.strptime(expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S") + + so a trailing ``+00:00`` offset raises ``ValueError`` and fractional + seconds are dropped. We store ``datetime.now(timezone.utc).isoformat()`` + (which has both), so normalise here to keep the parse lossless and safe. + """ + if not value: + return None + if isinstance(value, datetime): + dt = value + elif isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if dt.tzinfo is not None: + from datetime import timezone + + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt.strftime("%Y-%m-%dT%H:%M:%S") + + class GoogleAction(Action): """Base class for Google Workspace actions. Login is MCP OAuth (MCPOAuthToken).""" @@ -191,7 +221,7 @@ def _credentials_from_mcp_payload(self, token_data: Dict[str, Any]) -> Any: "client_secret": env_secret or token_data.get("client_secret") or "", "scopes": scopes, } - expiry = token_data.get("expiry") + expiry = _normalize_expiry(token_data.get("expiry")) if expiry: token_info["expiry"] = expiry return Credentials.from_authorized_user_info(token_info, scopes) diff --git a/jvagent/action/mcp_oauth/endpoints.py b/jvagent/action/mcp_oauth/endpoints.py index 2314b841..fffae230 100644 --- a/jvagent/action/mcp_oauth/endpoints.py +++ b/jvagent/action/mcp_oauth/endpoints.py @@ -696,6 +696,13 @@ async def _exchange_google_code( ) scopes = granted_scopes_from_token_response(tokens, fallback_scopes) + # Persist the access-token expiry. Without it google-auth's + # ``from_authorized_user_info`` defaults expiry to ``utcnow() - 3:45`` and + # every freshly authorized token is treated as already expired, forcing an + # immediate refresh on first use. Mirror the Microsoft branch below. + expires_in = int(tokens.get("expires_in") or 3600) + expiry = datetime.now(timezone.utc) + timedelta(seconds=max(0, expires_in - 60)) + payload = { "type": "authorized_user", "client_id": creds["client_id"], @@ -705,6 +712,7 @@ async def _exchange_google_code( "token_uri": "https://oauth2.googleapis.com/token", "scopes": scopes, "account_alias": account_name, + "expiry": expiry.isoformat(), } if service: payload["mcp_services"] = [service] diff --git a/tests/action/google/test_google_sheets_mcp_oauth.py b/tests/action/google/test_google_sheets_mcp_oauth.py index de6fab1f..47413fa2 100644 --- a/tests/action/google/test_google_sheets_mcp_oauth.py +++ b/tests/action/google/test_google_sheets_mcp_oauth.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from datetime import datetime, timedelta, timezone from types import ModuleType from unittest.mock import AsyncMock, MagicMock, patch @@ -395,3 +396,79 @@ async def test_gmail_does_not_load_sheets_token(): account, token, _node = await action._load_mcp_token() assert account is None assert token is None + + +# ── expiry normalization (fresh tokens must not look already expired) ── + + +def test_normalize_expiry_strips_offset_and_fraction(): + """google-auth's parser rejects ``+00:00`` and drops fractional seconds. + + We store ``datetime.now(timezone.utc).isoformat()``, so the rebuild path + must normalise before ``from_authorized_user_info``. + """ + from jvagent.action.google.google_action import _normalize_expiry + + assert _normalize_expiry("2026-09-28T21:32:47.123456+00:00") == ( + "2026-09-28T21:32:47" + ) + assert _normalize_expiry("2026-09-28T21:32:47Z") == "2026-09-28T21:32:47" + assert _normalize_expiry(datetime(2026, 9, 28, 21, 32, 47)) == ( + "2026-09-28T21:32:47" + ) + assert _normalize_expiry(None) is None + assert _normalize_expiry("not-a-date") is None + + +@pytest.mark.parametrize("cls", _ACTIONS) +def test_credentials_from_payload_fresh_token_is_valid(cls): + """A freshly built payload (with our isoformat expiry) yields valid creds.""" + action = cls() + token_data = { + "type": "authorized_user", + "refresh_token": "rtok", + "client_id": "cid", + "client_secret": "csecret", + "token_uri": "https://oauth2.googleapis.com/token", + "scopes": [*cls.SCOPES, "openid"], + "expiry": (datetime.now(timezone.utc) + timedelta(seconds=3540)).isoformat(), + } + creds = action._credentials_from_mcp_payload(token_data) + assert creds.valid is True + assert creds.expired is False + + +@pytest.mark.parametrize("cls", _ACTIONS) +def test_credentials_from_payload_offset_without_fraction_is_valid(cls): + """``...:47+00:00`` (no fractional seconds) is the shape google-auth's + parser rejects outright; normalization must keep it parseable.""" + action = cls() + token_data = { + "type": "authorized_user", + "refresh_token": "rtok", + "client_id": "cid", + "client_secret": "csecret", + "token_uri": "https://oauth2.googleapis.com/token", + "scopes": [*cls.SCOPES, "openid"], + "expiry": (datetime.now(timezone.utc) + timedelta(seconds=3540)) + .replace(microsecond=0) + .isoformat(), + } + creds = action._credentials_from_mcp_payload(token_data) + assert creds.valid is True + + +@pytest.mark.parametrize("cls", _ACTIONS) +def test_credentials_from_payload_without_expiry_is_expired(cls): + """The old shape (no expiry) is what forced the post-auth refresh.""" + action = cls() + token_data = { + "type": "authorized_user", + "refresh_token": "rtok", + "client_id": "cid", + "client_secret": "csecret", + "token_uri": "https://oauth2.googleapis.com/token", + "scopes": [*cls.SCOPES, "openid"], + } + creds = action._credentials_from_mcp_payload(token_data) + assert creds.expired is True diff --git a/tests/action/mcp_oauth/test_google_exchange_expiry.py b/tests/action/mcp_oauth/test_google_exchange_expiry.py new file mode 100644 index 00000000..5dfa691e --- /dev/null +++ b/tests/action/mcp_oauth/test_google_exchange_expiry.py @@ -0,0 +1,149 @@ +"""Google OAuth callback must persist an access-token expiry. + +Regression: ``_exchange_google_code`` wrote a payload without ``expiry``. +google-auth's ``from_authorized_user_info`` then defaults expiry to +``utcnow() - REFRESH_THRESHOLD``, so every freshly authorized token was treated +as already expired and an immediate refresh fired on first use. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from jvagent.action.mcp_oauth import endpoints as ep + + +def _fake_httpx_client(token_response: dict, info_response: dict): + """An ``httpx.AsyncClient`` stand-in for the token + userinfo calls.""" + + class _Resp: + def __init__(self, payload: dict, status_code: int = 200): + self._payload = payload + self.status_code = status_code + self.text = str(payload) + + def json(self): + return self._payload + + class _Client: + def __init__(self): + self._token_response = token_response + self._info_response = info_response + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, data=None): + return _Resp(self._token_response) + + async def get(self, url, headers=None, timeout=None): + return _Resp(self._info_response) + + return _Client + + +@pytest.mark.asyncio +async def test_google_callback_persists_future_expiry(monkeypatch): + monkeypatch.setenv( + "GOOGLE_CLIENT_SECRETS_JSON", + '{"web": {"client_id": "cid", "client_secret": "cs", ' + '"auth_uri": "https://accounts.google.com/o/oauth2/auth", ' + '"token_uri": "https://oauth2.googleapis.com/token"}}', + ) + + saved: dict = {} + + action = SimpleNamespace( + id="n.MCPOAuthAction.test", + agent_id="a1", + enabled=True, + ) + + async def _save(server, account, payload, service=None): + saved["server"] = server + saved["account"] = account + saved["payload"] = payload + + action.save_oauth_token_for_service = AsyncMock(side_effect=_save) + + record = SimpleNamespace(redirect_uri="https://x/callback") + + tokens = { + "access_token": "at", + "refresh_token": "rt", + "expires_in": 3599, + "scope": "https://www.googleapis.com/auth/spreadsheets", + } + info = {"email": "ops@example.com"} + + with ( + patch.object( + ep, + "_google_workspace_mcp_tool_config", + new=AsyncMock(return_value=({}, {})), + ), + patch.object( + ep, + "_enabled_google_oauth_services", + new=AsyncMock(return_value=None), + ), + patch("httpx.AsyncClient", _fake_httpx_client(tokens, info)), + ): + result = await ep._exchange_google_code( + action, "code", record, "integral", service="sheets" + ) + + assert not isinstance(result, ep.HTMLResponse) + + payload = saved["payload"] + assert payload.get("expiry"), "Google token payload must carry an expiry" + + expiry = datetime.fromisoformat(payload["expiry"]) + now = datetime.now(timezone.utc) + # Roughly expires_in seconds out, minus the 60s safety margin. + assert now < expiry <= now + timedelta(seconds=3600) + assert payload["expiry"].endswith("+00:00") + + +@pytest.mark.asyncio +async def test_google_callback_defaults_expiry_when_absent(monkeypatch): + """A token response without ``expires_in`` still gets a future expiry.""" + monkeypatch.setenv( + "GOOGLE_CLIENT_SECRETS_JSON", + '{"web": {"client_id": "cid", "client_secret": "cs"}}', + ) + + saved: dict = {} + action = SimpleNamespace(id="n.MCPOAuthAction.test", agent_id="a1", enabled=True) + + async def _save(server, account, payload, service=None): + saved["payload"] = payload + + action.save_oauth_token_for_service = AsyncMock(side_effect=_save) + record = SimpleNamespace(redirect_uri="https://x/callback") + + tokens = {"access_token": "at", "refresh_token": "rt"} + with ( + patch.object( + ep, + "_google_workspace_mcp_tool_config", + new=AsyncMock(return_value=({}, {})), + ), + patch.object( + ep, + "_enabled_google_oauth_services", + new=AsyncMock(return_value=None), + ), + patch("httpx.AsyncClient", _fake_httpx_client(tokens, {"email": "o@e.com"})), + ): + await ep._exchange_google_code(action, "code", record, "integral", service="") + + expiry = datetime.fromisoformat(saved["payload"]["expiry"]) + assert expiry > datetime.now(timezone.utc) From 8d2286ad6fcca8338a4782567e0c76817f75f15c Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 11:17:00 -0400 Subject: [PATCH 03/20] feat(orchestrator): hide skills by AccessControl group Let a skill declare access-action plus allowed or denied groups so the catalog fails closed when the visitor is not in the right group. Co-authored-by: Cursor --- CHANGELOG.md | 10 +++ .../orchestrator_interact_action.py | 64 ++++++++++++++++++- jvagent/action/orchestrator/skills.py | 9 +++ jvagent/scaffold/skill_resolve.py | 28 ++++++++ jvagent/skills/README.md | 29 +++++++++ .../orchestrator/test_skill_channel_gating.py | 64 ++++++++++++++++++- 6 files changed, 200 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c7b671..d011c44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / ## [Unreleased] +### Added + +- **Skill identity gating via AccessControl groups.** Skills may declare + `access-action` plus `allowed-groups` / `denied-groups` so the orchestrator + shows or hides them from the skill catalog based on + `AccessControlAction.user_groups` for that action label. Fail closed when + AccessControl is missing or not enforcing. Handoff uses this for the + customer (`handoff`, denied `staff`) and staff (`handoff_staff`, allowed + `staff`) split; Silvie configures `user_groups.HandoffAction.staff`. + ## [0.1.8rc19] - 2026-09-27 ### Added diff --git a/jvagent/action/orchestrator/orchestrator_interact_action.py b/jvagent/action/orchestrator/orchestrator_interact_action.py index d5e10230..eb969fa9 100644 --- a/jvagent/action/orchestrator/orchestrator_interact_action.py +++ b/jvagent/action/orchestrator/orchestrator_interact_action.py @@ -1137,6 +1137,50 @@ def _skill_channel_allowed(doc: Any, channel: str) -> bool: return False return True + @staticmethod + def _skill_declares_access_gate(doc: Any) -> bool: + action = (getattr(doc, "access_action", "") or "").strip() + allowed = tuple(getattr(doc, "allowed_groups", ()) or ()) + denied = tuple(getattr(doc, "denied_groups", ()) or ()) + return bool(action and (allowed or denied)) + + @staticmethod + def _skill_access_control_allowed( + doc: Any, user_id: str, access_control: Any + ) -> bool: + """Identity gate via AccessControlAction groups (any action label). + + Skills with no ``access-action`` / groups pass. Gated skills fail closed + when AccessControl is missing, not enforcing, or the user is absent / + not in an allowed group / in a denied group. + """ + if not OrchestratorInteractAction._skill_declares_access_gate(doc): + return True + if ( + access_control is None + or not getattr(access_control, "policy_applies", lambda: False)() + ): + return False + uid = (user_id or "").strip() + if not uid: + return False + label = (getattr(doc, "access_action", "") or "").strip() + try: + groups = access_control.get_user_groups(action_label=label) or {} + except Exception: + return False + if not isinstance(groups, dict): + return False + allowed = tuple(getattr(doc, "allowed_groups", ()) or ()) + denied = tuple(getattr(doc, "denied_groups", ()) or ()) + if allowed: + if not any(uid in (groups.get(name) or []) for name in allowed): + return False + if denied: + if any(uid in (groups.get(name) or []) for name in denied): + return False + return True + # ------------------------------------------------------------------ # Tool surface (per-turn; binds the visitor) # ------------------------------------------------------------------ @@ -1535,6 +1579,18 @@ async def _egress_exec( # ``deny-access-directive`` are collected so their message can be # surfaced in skills_section (the model relays it verbatim when the # user's intent matched the blocked skill). + # Identity gate: skills with access-action + allowed/denied-groups are + # shown only when AccessControlAction places the visitor in the right + # groups. Fail closed when AccessControl is absent or not enforcing. + access_control = await self._resolve_action("AccessControlAction") + access_hidden = [ + d + for d in docs + if not self._skill_access_control_allowed( + d, getattr(visitor, "user_id", "") or "", access_control + ) + ] + docs = [d for d in docs if d not in access_hidden] channel = getattr(visitor, "channel", "default") or "default" allowed_docs: List[Any] = [] blocked_docs: List[Any] = [] @@ -1548,11 +1604,15 @@ async def _egress_exec( # so other interviews keep working. Also drop any name the skill listed # in its ``requires_tools``/``allowed-tools`` so a blocked JV skill's # referenced tools aren't surfaced on its behalf. - if blocked_docs: + hidden_docs = list(blocked_docs) + access_hidden + if hidden_docs: + # Prefix drop is only for a channel-blocked skill's own + # ``__*`` custom tools. Shared tool namespaces (e.g. + # ``handoff__*``) drop only the tools each hidden skill declares. blocked_names = {getattr(d, "name", "") for d in blocked_docs} blocked_prefixes = tuple(f"{n}__" for n in blocked_names if n) drop_names: Set[str] = set() - for d in blocked_docs: + for d in hidden_docs: drop_names |= {t for t in getattr(d, "requires_tools", ()) or () if t} for name in list(tools.keys()): if name in drop_names or ( diff --git a/jvagent/action/orchestrator/skills.py b/jvagent/action/orchestrator/skills.py index 68390edd..87e59e76 100644 --- a/jvagent/action/orchestrator/skills.py +++ b/jvagent/action/orchestrator/skills.py @@ -82,6 +82,12 @@ class SkillDoc: allowed_channels: Tuple[str, ...] = () denied_channels: Tuple[str, ...] = () deny_access_directive: str = "" + # AccessControlAction identity gate: ``access_action`` is the user_groups + # scope (action class name). ``allowed_groups`` / ``denied_groups`` decide + # whether the current user_id may see the skill. Empty = no identity gate. + access_action: str = "" + allowed_groups: Tuple[str, ...] = () + denied_groups: Tuple[str, ...] = () digest: str = "" metadata: dict = field(default_factory=dict) @@ -219,6 +225,9 @@ def discover_skill_docs( allowed_channels=tuple(bundle.get("allowed_channels") or ()), denied_channels=tuple(bundle.get("denied_channels") or ()), deny_access_directive=str(bundle.get("deny_access_directive") or ""), + access_action=str(bundle.get("access_action") or ""), + allowed_groups=tuple(bundle.get("allowed_groups") or ()), + denied_groups=tuple(bundle.get("denied_groups") or ()), digest=str(bundle.get("digest") or ""), metadata=bundle.get("metadata") or {}, ) diff --git a/jvagent/scaffold/skill_resolve.py b/jvagent/scaffold/skill_resolve.py index 1ab357c8..189bba68 100644 --- a/jvagent/scaffold/skill_resolve.py +++ b/jvagent/scaffold/skill_resolve.py @@ -35,11 +35,14 @@ def skill_digest(skill_dir: Union[str, Path]) -> str: _KNOWN_FRONTMATTER_KEYS = frozenset( { + "access-action", "allowed-channels", + "allowed-groups", "allowed-tools", "always-active", "coactivate-with", "denied-channels", + "denied-groups", "deny-access-directive", "dependencies", "description", @@ -381,6 +384,28 @@ def parse_skill_bundle( or frontmatter.get("deny_access_directive") or "" ).strip() + # Identity gate via AccessControlAction groups (any action label). + access_action = str( + frontmatter.get("access-action") or frontmatter.get("access_action") or "" + ).strip() + allowed_groups = _normalize_string_list( + frontmatter.get("allowed-groups") or frontmatter.get("allowed_groups"), + skill_file, + key="allowed-groups", + ) + denied_groups = _normalize_string_list( + frontmatter.get("denied-groups") or frontmatter.get("denied_groups"), + skill_file, + key="denied-groups", + ) + if (allowed_groups or denied_groups) and not access_action: + logger.warning( + "Skill bundle %s declares allowed/denied-groups without access-action; " + "ignoring group gate", + skill_file, + ) + allowed_groups = [] + denied_groups = [] scope_hint = ", ".join(str(tag) for tag in tags if str(tag).strip()) if not scope_hint: scope_hint = description @@ -446,6 +471,9 @@ def parse_skill_bundle( "allowed_channels": allowed_channels, "denied_channels": denied_channels, "deny_access_directive": deny_access_directive, + "access_action": access_action, + "allowed_groups": allowed_groups, + "denied_groups": denied_groups, "scope_hint": scope_hint, "source": source, "digest": skill_digest(skill_file), diff --git a/jvagent/skills/README.md b/jvagent/skills/README.md index fe646702..b4cf77a9 100644 --- a/jvagent/skills/README.md +++ b/jvagent/skills/README.md @@ -100,6 +100,9 @@ metadata: | `extends` | JV | SOP inheritance only (body composition). `action:/` loads `/SKILL.md` body; `skill:` inherits another skill's composed body. Separate from `requires-actions`. When `extends: action:…` is set, that action ref is also the **preferred lifecycle binder** for skill hooks (`on_skill_activate`, `prepare_task_lock_turn`, `resolve_task_lock_skill`, etc.). | | `allowed-channels` | both | List of canonical channel names the skill is surfaced on (e.g. `[whatsapp]`). Empty/absent = all channels. Channel is normalized via `normalize_channel` (`web`→`default`). | | `denied-channels` | both | List of canonical channel names the skill is hidden from. Subtracted from `allowed-channels` when both are set. | +| `access-action` | both | AccessControlAction `user_groups` scope (usually an action class name, e.g. `HandoffAction`). Required when `allowed-groups` / `denied-groups` are set. | +| `allowed-groups` | both | Show the skill only if the visitor `user_id` is in at least one of these groups under `access-action` (merged with `default`). | +| `denied-groups` | both | Hide the skill if the visitor `user_id` is in any of these groups under `access-action`. | | `parameters` | both | Standing behavioural rules that apply **while this skill is driving the turn** (ADR-0037). Same `{scope?, condition?, response}` shape an Action declares programmatically, pooled onto the same interaction so the loop prompt and the reply compose read them through one path. `scope` is `response` (default) or `orchestration`. A bare string is an unconditional rule. Contributed only when the skill is **in force** — `always-active`, the active task-lock, or activated this turn — never merely because it is listed. | | `deny-access-directive` | both | Message the model relays verbatim when the user asks for the skill on a non-allowed channel (the skill is hidden; this note is surfaced in `skills_section`). | | `license`, `metadata` | both | Claude-standard fields. `metadata.version` / `metadata.tags` for tracking + discovery cues. | @@ -140,6 +143,32 @@ deny-access-directive: >- account and give you a better quote based on your discounts, etc. ``` +### Identity gating (AccessControl groups) + +Restrict a skill by who the visitor is with `access-action` plus +`allowed-groups` and/or `denied-groups`. Membership is resolved through +`AccessControlAction.user_groups` for that action label (merged with +`default`), the same nesting other actions already use. + +```yaml +access-action: HandoffAction +allowed-groups: + - staff +``` + +```yaml +access-action: HandoffAction +denied-groups: + - staff +``` + +When those keys are set, the skill is **hidden from the whole surface** unless +the visitor passes the gate. Fail closed: missing AccessControlAction, policy +not enforcing, empty `user_id`, or a user not in an allowed group hides a gated +skill. Skills that declare neither groups key are unchanged. Declared +`allowed-tools` on a hidden skill are dropped for that turn (shared tool +namespaces are not wiped by skill-name prefix). + ### Orchestrator `auto_start_skills_on_new_user` List skill names on the orchestrator (`auto_start_skills_on_new_user: [my_skill]`). diff --git a/tests/action/orchestrator/test_skill_channel_gating.py b/tests/action/orchestrator/test_skill_channel_gating.py index 4b470d65..717f80d3 100644 --- a/tests/action/orchestrator/test_skill_channel_gating.py +++ b/tests/action/orchestrator/test_skill_channel_gating.py @@ -315,8 +315,6 @@ async def get_tools(self): OrchestratorInteractAction, "get_responder", AsyncMock(return_value=None) ) - from jvagent.action.orchestrator.skill_tasks import compose_skill_activate_hooks - monkeypatch.setattr( "jvagent.action.orchestrator.skill_tasks.compose_skill_activate_hooks", lambda *a, **k: (None, None), @@ -537,3 +535,65 @@ def test_real_quotation_and_pre_alert_hidden_on_default_channel( ) assert "Message me on WhatsApp for a quote" in section assert "Message me on WhatsApp to check a tracking number" in section + + +# --------------------------------------------------------------------------- +# AccessControl group gating +# --------------------------------------------------------------------------- + + +def _access_doc( + *, + allowed: Optional[tuple] = None, + denied: Optional[tuple] = None, + action: str = "HandoffAction", + name: str = "gated", +) -> SkillDoc: + return SkillDoc( + name=name, + description="d", + body="b", + access_action=action, + allowed_groups=tuple(allowed or ()), + denied_groups=tuple(denied or ()), + ) + + +class _FakeACA: + def __init__(self, groups, enforce=True): + self._groups = groups + self._enforce = enforce + + def policy_applies(self): + return self._enforce + + def get_user_groups(self, action_label=None): + return dict(self._groups.get(action_label, {})) + + +def test_access_gate_passes_when_undeclared() -> None: + doc = SkillDoc(name="open", description="d", body="b") + assert OrchestratorInteractAction._skill_access_control_allowed(doc, "anyone", None) + + +def test_access_gate_allowed_groups() -> None: + doc = _access_doc(allowed=("staff",)) + aca = _FakeACA({"HandoffAction": {"staff": ["111"]}}) + assert OrchestratorInteractAction._skill_access_control_allowed(doc, "111", aca) + assert not OrchestratorInteractAction._skill_access_control_allowed(doc, "999", aca) + + +def test_access_gate_denied_groups() -> None: + doc = _access_doc(denied=("staff",)) + aca = _FakeACA({"HandoffAction": {"staff": ["111"]}}) + assert not OrchestratorInteractAction._skill_access_control_allowed(doc, "111", aca) + assert OrchestratorInteractAction._skill_access_control_allowed(doc, "999", aca) + + +def test_access_gate_fails_closed_without_access_control() -> None: + doc = _access_doc(allowed=("staff",)) + assert not OrchestratorInteractAction._skill_access_control_allowed( + doc, "111", None + ) + aca = _FakeACA({"HandoffAction": {"staff": ["111"]}}, enforce=False) + assert not OrchestratorInteractAction._skill_access_control_allowed(doc, "111", aca) From b6e057dc1b53672e45a10d34ac18c93869b0ff77 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 11:17:17 -0400 Subject: [PATCH 04/20] feat(handoff): add a skill-gated handoff action Give agents contact, notify, and staff Q&A tools behind the handoff skills without changing the existing handoff interact action. Co-authored-by: Cursor --- .planning/reference/actions-catalog.md | 4 +- CHANGELOG.md | 2 + jvagent/action/handoff_action/README.md | 93 + jvagent/action/handoff_action/__init__.py | 5 + .../action/handoff_action/handoff_action.py | 1708 +++++++++++++++++ jvagent/action/handoff_action/info.yaml | 23 + jvagent/action/handoff_action/store.py | 166 ++ jvagent/skills/handoff/SKILL.md | 65 + jvagent/skills/handoff_staff/SKILL.md | 50 + tests/action/test_action_import_guard.py | 2 + tests/action/test_handoff_action.py | 1223 ++++++++++++ 11 files changed, 3340 insertions(+), 1 deletion(-) create mode 100644 jvagent/action/handoff_action/README.md create mode 100644 jvagent/action/handoff_action/__init__.py create mode 100644 jvagent/action/handoff_action/handoff_action.py create mode 100644 jvagent/action/handoff_action/info.yaml create mode 100644 jvagent/action/handoff_action/store.py create mode 100644 jvagent/skills/handoff/SKILL.md create mode 100644 jvagent/skills/handoff_staff/SKILL.md create mode 100644 tests/action/test_handoff_action.py diff --git a/.planning/reference/actions-catalog.md b/.planning/reference/actions-catalog.md index 35189ac7..66320247 100644 --- a/.planning/reference/actions-catalog.md +++ b/.planning/reference/actions-catalog.md @@ -65,6 +65,7 @@ Bases: | jvagent/interview | `InterviewAction` | `Action` | — | Interview tool bundle (`interview__*` tools). Base SOP at action-root `SKILL.md` (extends target, not discovered). Agent interview skills: `agents/.../skills//` with `extends: action:jvagent/interview` + `interview:` frontmatter (ADR-0023) | | jvagent/leadgen | `LeadGenAction` | `Action` | — | Conversational lead capture (`leadgen__*` tools) with spec-driven fields, proactive contact gap-fill, and destination-agnostic auto-sync via the standard MCP interface (sync configured on the action in agent.yaml, or a skill `sync:` block). Base SOP at action-root `SKILL.md`; agent skills use `extends: action:jvagent/leadgen` + `leadgen:` frontmatter | | jvagent/handoff | `HandoffInteractAction` | `InteractAction` | mid | Transfer to human (provides contact details) | +| jvagent/handoff_action | `HandoffAction` | `Action` | — | Skill-gated human-support tools (`handoff__direct_contact`, `handoff__notify`, `handoff__staff_inbox`, `handoff__resolve`). Per-mode channel (WhatsApp/email), staff allowlist + random target, staff Q&A captured into PageIndex (`handoff.md`, public). SOP via the `jvagent/skills/handoff` library skill. 1.0.0 | | jvagent/page_context | `PageContextInteractAction` | `InteractAction` | -250 | Surfaces the embeddable messenger's host-page context (path, title, referrer, dwell, scroll depth, repeat visit) to the model as an **orchestration-scoped** factual parameter. `visitor.data` is not otherwise visible to the model. States facts only — never a next step (thin-harness invariant 3). See [`../../docs/jvmessenger.md`](../../docs/jvmessenger.md) | | jvagent/suggestions | `SuggestionsInteractAction` | `InteractAction` | 100 | LLM-generated quick-reply chips for the embeddable messenger. After the reply, asks a light model for a few short follow-ups and publishes them as `metadata.suggestions` (rendered by jvmessenger). Streaming turns only; no-op without a model. See [`../../docs/jvmessenger.md`](../../docs/jvmessenger.md) | @@ -197,7 +198,7 @@ The "4-file" pattern (`__init__.py`, `{name}.py`, `endpoints.py`, `info.yaml`) in [`action-authoring.md`](action-authoring.md) §2 is **aspirational** — many packages legitimately ship without an `endpoints.py` because they have no HTTP surface (intro, task_creation_interact_action, task_trigger_interact_action, -handoff_interact_action, interview, mcp, +handoff_interact_action, handoff_action, interview, mcp, vectorstore/typesense, web_search/*, stt_action/deepgram, tts_action/elevenlabs, video_generation, pageindex sub-actions). For packages that DO have an `endpoints.py`, registration is via one of two @@ -220,6 +221,7 @@ Both paths are currently functional. AUDIT-actions XC-6 verified. | `ReplyAction` | the Agent's identity (`alias` + `role`), a `LanguageModelAction` (voicing) | | `WebFetchAction` | none (httpx + bs4 + markdownify; SSRF guard) | | `HandoffInteractAction` | `ReplyAction` (polish), `WhatsAppAction` (contact routing) | +| `HandoffAction` | a notify action (default `WhatsAppAction`) + `EmailAction` (per-mode channel), `PageIndexAction` (Q&A ingest) | | `TaskCreationInteractAction` | `WhatsAppAction` (context), `ReplyAction` (formatting) | | Any channel adapter | `ResponseBus` (per-agent, via `Agent.get_response_bus()`) | diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c7b671..5f1d9c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / - **Unified capability discovery (`find_capability`, ADR-0055).** Primary lean discovery meta-tool ranks matching **skills** then **tools** in one observation, with `use_skill` / `load_tool` next-step cues. `find_tool` / `find_skill` remain aliases. Loop protocol, lean partial-list hint, and unknown-tool bounce steer to `find_capability` first so domain SOPs activate instead of find_tool thrash. +- **Skill-gated handoff action (`jvagent/handoff_action`, `HandoffAction`, 1.0.0).** Human-support capability tools — `handoff__direct_contact()` (contact block), `handoff__notify(mode, message, channel, phone_numbers?, emails?)` (`agent_escalation` | `scheduled_callback` | `staff_lookup`, channel `whatsapp` | `email`; `staff_lookup` records a pending question), `handoff__staff_inbox()` and `handoff__resolve(question_id, answer)` (staff-only; append the Q&A to `/handoff.md` and re-ingest it into PageIndex as `doc_name="handoff.md"`, `metadata={"access":"public"}`). Staff identity is the dispatch sender; only `HANDOFF_STAFF_NUMBERS` / `HANDOFF_STAFF_EMAILS` may resolve and write PageIndex, and one target is chosen at random per notification. Gated by the new library skill `jvagent/skills/handoff` (`skill_only_tools: ["handoff__*"]`), which carries the when/how SOP. The action also contributes an orchestration-scoped routing parameter (`key: handoff_routing`). Email channel requires `jvagent/email_action` + MCP Gmail/OAuth (or SendGrid/Outlook). New pending-question store (`HandoffQuestion`). The pre-existing `jvagent/handoff_interact_action` IA is unchanged. + - **Harness excellence runtime (HP-02 … HP-12).** `jvagent.harness.runtime` is the store-backed source of truth for NativeCaller admission, snapshot-keyed caches, TurnRun journals, invocation ledger, durable outbox, session leases, host providers, skill manifests/isolation, traces, and the HP-12 deployment matrix. Process-local bus/caches remain fan-out; JSON/SQLite active-active is unsupported. Docs: `docs/HARNESS_DEPLOYMENT.md`, `docs/skill-isolation.md`. TurnRun checkpoints persist on `Interaction.observability_metrics`; loop resume skips completed IDEMPOTENT invocations; Claude skill staging is snapshot/digest-keyed and refuses untrusted isolation; mutating send/delete/bash tools declare `NON_RETRYABLE`; embed cancel marks TurnRun recovery. HostCapabilityProvider.invoke dispatches a registered host runner; IsolatedExecutor wraps approved backends with no subprocess fallback; dump_store/load_store persist the harness store; file/redis/dynamo lease adapters require an explicit client; skill signatures use HMAC compare_digest; CUCS harness evals live under `tests/conformance/cucs/`; CI adds conformance/two-worker/isolation/load lanes. - **Harness baseline audit (HP-01).** Process-local bus, caches, breakers, and locks inventoried in `.planning/phases/01-contracts-and-baseline/PROCESS-LOCAL-STATE.md` with characterization tests. No replacements. diff --git a/jvagent/action/handoff_action/README.md b/jvagent/action/handoff_action/README.md new file mode 100644 index 00000000..f73a454d --- /dev/null +++ b/jvagent/action/handoff_action/README.md @@ -0,0 +1,93 @@ +# Handoff Action (`jvagent/handoff_action`) + +Human-support capability tools, **skill-gated** by the `handoff` library skill. +The tools stay on the orchestrator surface but refuse to run until a skill +declaring them in `allowed-tools` is active — so configure the orchestrator with +`skill_only_tools: ["handoff__*"]` and enable the `handoff` skill. + +## Tools + +| Tool | Purpose | +|---|---| +| `handoff__contact_details()` | Return office hours plus a random staff number and every staff email from `HandoffAction.staff`. No notification. | +| `handoff__staff_lookup(message, phone_numbers?, emails?)` | Notify staff about a question you cannot answer and record it as pending. Contact is optional. Non-retryable. | +| `handoff__agent_escalation(message, phone_numbers?, emails?)` | Notify staff that the customer wants a person now. Needs a contact. Non-retryable. | +| `handoff__scheduled_callback(message, phone_numbers?, emails?)` | Notify staff to reach the customer later. Needs a contact. Non-retryable. | +| `handoff__pending_questions()` | List customer questions waiting for an answer. | +| `handoff__save_answer(question_id, answer)` | Save the answer for one pending question into `handoff.md` and return a thank-you. Non-retryable. | + +`handoff__staff_lookup` records the question on `HandoffAction.pending_questions` +(reloaded from the Action row before list/get, caches invalidated after write) +on the first call, before staff are notified. Staff are notified once the +customer shares a phone or email, or declines. A later call with the same +contact does not send again. When a later message looks like an answer, call +`handoff__pending_questions`, then `handoff__save_answer`. Pending questions are +shared across conversations for the same Action so staff answering in their own +thread can see customer questions. + +## Channels + +Each tool chooses its channel from `handoff_channels` (`whatsapp` via `handoff_notify_action_type`, +default `WhatsAppAction`) or `"email"` (via `handoff_email_action_type`, default +`EmailAction`). Set the per-mode default in the skill body; the recipient is +**one staff target chosen at random** from the configured list. + +## Configuration + +AccessControlAction `user_groups.HandoffAction.staff` in agent.yaml — a mixed +list of WhatsApp numbers and email addresses. That list is the staff identity +allowlist and the notify/contact target source. Members are classified as +phone or email; notify picks one matching target at random for the channel. + +| Source | Role | +|---|---| +| `user_groups.HandoffAction.staff` | Staff WhatsApp numbers and emails. Any listed sender may save answers. Notify picks one matching target at random. | + +PageIndex target is fixed: `doc_name="handoff.md"`, +`metadata={"access": "public"}`, written to `/handoff.md` (default +`./.files/handoff.md`) — the file is created on first resolve and appended +cumulatively thereafter. + +### Email channel setup + +To use `channel="email"` you must wire outbound **and** inbound email: + +1. Add `jvagent/mcp_oauth`, `jvagent/mcp` (a `google_workspace` server), and + `jvagent/google_gmail_action` (Gmail) — or the SendGrid/Outlook equivalents. +2. Add `jvagent/email_action` with `provider: gmail` (or `sendgrid` / `outlook`). +3. Authorize: `/api/mcp/google_workspace/auth?service=gmail`. +4. Create the **email webhook** (`GET /api/actions/{action_id}/email/webhook-url`) + and point your provider's inbound parse / poll at it (SendGrid Inbound Parse + requires SPF+DKIM pass). +5. Set `EMAIL_DEFAULT_SENDER` (or the provider's from-address). + +See [`../email_action/README.md`](../email_action/README.md) for the provider +matrix. + +## Skill + +The SOP lives in [`jvagent/skills/handoff/SKILL.md`](../../skills/handoff/SKILL.md) +(`allowed-tools` = these four, `requires-actions: [HandoffAction]`, +`extends: action:jvagent/handoff_action`). The action also contributes an +always-on orchestration parameter (`key: handoff_routing`) that tells the loop +when to hand off. + +## Orchestrator wiring (agent.yaml) + +```yaml + - action: jvagent/orchestrator + context: + skill_only_tools: ["handoff__*"] # gated until the handoff skill is active + skills: [..., handoff] + - action: jvagent/handoff_action + context: + enabled: true +``` + +## License + +See the application-level [LICENSE](../../../../LICENSE). + +## Author + +**Tharick Jairam** · jvagent/handoff_action / V75 Inc. diff --git a/jvagent/action/handoff_action/__init__.py b/jvagent/action/handoff_action/__init__.py new file mode 100644 index 00000000..741788a0 --- /dev/null +++ b/jvagent/action/handoff_action/__init__.py @@ -0,0 +1,5 @@ +"""Human handoff action (capability tools).""" + +from .handoff_action import HandoffAction + +__all__ = ["HandoffAction"] diff --git a/jvagent/action/handoff_action/handoff_action.py b/jvagent/action/handoff_action/handoff_action.py new file mode 100644 index 00000000..7e0f83e3 --- /dev/null +++ b/jvagent/action/handoff_action/handoff_action.py @@ -0,0 +1,1708 @@ +"""Human handoff capability action. + +Model-callable tools, split across the ``handoff`` and ``handoff_staff`` skills: + +- ``handoff__contact_details()`` — return the team's email, phone, and hours. +- ``handoff__update_contact(phone_number?, email?)`` — replace the reply address. +- ``handoff__staff_lookup(message, phone_numbers?, emails?)`` — cannot answer. +- ``handoff__agent_escalation(message, phone_numbers?, emails?)`` — person now. +- ``handoff__scheduled_callback(message, phone_numbers?, emails?)`` — later. +- ``handoff__pending_questions()`` — list questions waiting for an answer. +- ``handoff__save_answer(question_id, answer)`` — save the answer for one + pending question and return a thank-you. +- ``handoff__update_chunk(chunk_id, answer)`` — replace one saved chunk. + +Staff identity is the dispatch sender. Prefer AccessControlAction +``HandoffAction.staff`` (phones and emails in one list). One WhatsApp or email +target is chosen at random per notification. +""" + +import json +import logging +import os +import random +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Any, ClassVar, Dict, List, Optional + +from jvspatial.core.annotations import attribute + +from jvagent.action.base import Action +from jvagent.harness.contracts import IdempotencyClass +from jvagent.tooling.tool_decorator import tool +from jvagent.tooling.tool_result import ToolResult + +logger = logging.getLogger(__name__) + +DIRECT_CONTACT_PROMPT = """You can reach a human representative directly using the contact details below: + +Email: {handoff_email} +Phone / WhatsApp: {handoff_phone} +Office Hours: {handoff_hours} + +A team member will assist you as soon as possible.""" + + +AGENT_ESCALATION_PROMPT = """A staff member will reach out to you shortly.""" + + +SCHEDULED_CALLBACK_PROMPT = """I'll arrange for a human representative to follow up with you. + +You can expect a response within the next 24 hours (or the next business day). If your request is urgent, please use the direct contact option for faster assistance.""" + + +STAFF_LOOKUP_PROMPT = """I don't have that information yet, so I'll check with the team and get back to you when they respond.""" + +STAFF_LOOKUP_ASK_PROMPT = """I don't have that right now. May I have your phone number or email so I can get back to you?""" + +HANDOFF_DOC_NAME = "handoff.md" +HANDOFF_DOC_ACCESS = "public" + +_CHANNELS = ("whatsapp", "email") + +#: Orchestration-scoped routing rule. Accumulated onto the interaction by the +#: orchestrator and rendered into the loop prompt, so the model activates the +#: handoff skill and uses the tools when the user asks for a human or the answer +#: is out of reach. Keyed so an agent can override it. +HANDOFF_PARAMETERS: List[Dict[str, Any]] = [ + { + "scope": "orchestration", + "key": "handoff_routing", + "condition": ( + "the user asks for a human / agent / live support, wants a callback, " + "OR you cannot answer their question from the knowledge base or the " + "tools available this turn (a search returned nothing relevant, or " + "the request is outside what you can do)" + ), + "response": ( + "Search the FAQ and available skills first, including a 'do you " + "sell' question. Do not guess or drop the request. Call use_skill " + "with name 'handoff' only after that search returned nothing useful " + "(its tools are gated), then: if the user only wants the team's " + "contact details call handoff__contact_details; otherwise call " + "handoff__agent_escalation (a staff member will reach out), " + "handoff__scheduled_callback (reach out later), or " + "handoff__staff_lookup (you cannot answer). For staff_lookup, " + "message sentence 1 is the natural customer ask only; optional " + "sentence 2 is what was already tried (staff notify only). Do not " + "mention WhatsApp or tell staff to reply. Relay the returned line " + "and do not add that summary." + ), + }, + { + "scope": "orchestration", + "key": "handoff_staff_answer", + "condition": ( + "the latest message, with the conversation so far, looks like an " + "answer rather than a new question" + ), + "response": ( + "Call handoff__pending_questions first (pending questions are " + "unknown until it returns), then handoff__save_answer for the match." + ), + }, +] + + +def _question_field(question: Any, name: str) -> str: + if isinstance(question, dict): + return str(question.get(name) or "") + return str(getattr(question, name, "") or "") + + +def _question_row(question: Any) -> Dict[str, Any]: + """Normalize a pending question (dict or object) for tool output.""" + if isinstance(question, dict): + return { + "id": str(question.get("id") or ""), + "question": str(question.get("question") or ""), + "user_channel": str(question.get("user_channel") or ""), + "user_contact": str(question.get("user_contact") or ""), + "created_at": str(question.get("created_at") or ""), + } + return { + "id": str(getattr(question, "id", "") or ""), + "question": str(getattr(question, "question", "") or ""), + "user_channel": str(getattr(question, "user_channel", "") or ""), + "user_contact": str(getattr(question, "user_contact", "") or ""), + "created_at": str(getattr(question, "created_at", "") or ""), + } + + +def _render_direct_contact(template: str, email: str, phone: str, hours: str) -> str: + """Render the direct-contact block; drop lines whose field is blank.""" + try: + rendered = template.format( + handoff_email=email, handoff_phone=phone, handoff_hours=hours + ) + except (KeyError, IndexError): + return template + cleaned_lines = [] + for line in rendered.split("\n"): + stripped = line.strip() + if stripped.endswith(":") and ( + stripped.lower().startswith("email:") + or stripped.lower().startswith("phone") + ): + continue + cleaned_lines.append(line) + return "\n".join(cleaned_lines) + + +def _lookup_staff_message(message: str, tokens: List[str]) -> str: + """Full staff text with the customer's contact removed (no bold).""" + text = message or "" + for token in tokens: + if token: + text = text.replace(token, "") + kept = [] + for sentence in re.split(r"(?<=[.!?])\s+|\n+", text): + stripped = sentence.strip() + if not stripped: + continue + residual = re.sub( + r"(?i)\b(?:customer|provided|phone|number|email|address|contact)\b", + " ", + stripped, + ) + if not re.sub(r"[^A-Za-z0-9]+", "", residual): + continue + kept.append(stripped) + cleaned = " ".join(kept).strip() + if not cleaned: + cleaned = re.sub(r"\s{2,}", " ", text).strip(" :.") + return cleaned + + +_HANDLING_NOTE_RE = re.compile( + r"(?i)\b(?:" + r"no information found|" + r"faq search found nothing|" + r"search returned nothing|" + r"nothing (?:useful |relevant )?found|" + r"could not (?:find|answer)|" + r"outside (?:the )?(?:assortment|catalog)|" + r"what was already tried" + r")\b" +) + +_ASK_PREFIX_RE = re.compile( + r"(?i)^(Customer asked\s+(?:if|for|about|whether|to)\s+)(.+?)([.!?]?)\s*$" +) + + +def _split_message_sentences(message: str) -> List[str]: + return [ + part.strip() + for part in re.split(r"(?<=[.!?])\s+|\n+", (message or "").strip()) + if part.strip() + ] + + +def _pending_question_text(message: str) -> str: + """Natural customer ask only — first non-handling sentence.""" + sentences = _split_message_sentences(message) + if not sentences: + return (message or "").strip() + for sentence in sentences: + if _HANDLING_NOTE_RE.search(sentence): + continue + return sentence + return sentences[0] + + +def _staff_notes_text(message: str, question: str) -> str: + """Handling notes after the stored question (contact-stripped later).""" + sentences = _split_message_sentences(message) + if not sentences: + return "" + q = (question or "").strip() + notes = [s for s in sentences if s != q] + return " ".join(notes).strip() + + +def _bold_whatsapp_ask(question: str) -> str: + """Wrap the ask core in WhatsApp bold (*...*).""" + text = (question or "").strip() + if not text or "*" in text: + return text + match = _ASK_PREFIX_RE.match(text) + if not match: + return text + prefix, core, punct = match.group(1), match.group(2).strip(), match.group(3) or "" + if not core: + return text + return f"{prefix}*{core}*{punct}" + + +def _staff_outbound( + mode: str, + message: str, + phone_numbers: Optional[List[str]], + emails: Optional[List[str]], + contact: str, +) -> str: + """Staff text: lookup omits the contact; escalation and callback include it.""" + tokens = [ + str(t).strip() for t in (phone_numbers or []) + (emails or []) if str(t).strip() + ] + if mode == "staff_lookup": + cleaned = _lookup_staff_message(message, tokens) + question = _pending_question_text(cleaned) + notes = _staff_notes_text(cleaned, question) + bolded = _bold_whatsapp_ask(question) + if notes: + return f"{bolded} {notes}".strip() + return bolded + text = (message or "").strip() + follow_up = (contact or "").strip() + if follow_up and follow_up not in text: + text = f"{text}\nContact: {follow_up}".strip() + return text + + +def _first_contact( + phone_numbers: Optional[List[str]], emails: Optional[List[str]] +) -> str: + for n in phone_numbers or []: + if str(n).strip(): + return str(n).strip() + for e in emails or []: + if str(e).strip(): + return str(e).strip() + return "" + + +def _dispatch_user_id() -> str: + from jvagent.tooling.tool_executor import get_dispatch_context + + ctx = get_dispatch_context() + return (getattr(ctx, "user_id", "") or "").strip() + + +def _contact_kind(value: str) -> str: + """``whatsapp`` for a phone, ``email`` for an address, else empty.""" + text = (value or "").strip() + if not text or text.lower() == "declined": + return "" + if "@" in text and " " not in text: + return "email" + digits = re.sub(r"\D", "", text) + if digits and len(digits) >= 6 and re.fullmatch(r"[+\d][\d\s()-]*", text): + return "whatsapp" + return "" + + +def _sender_contact(channel: str) -> str: + """Dispatch user id when it is already a phone or email for this channel.""" + user_id = _dispatch_user_id() + if not user_id: + return "" + if channel == "email": + return user_id if "@" in user_id and " " not in user_id else "" + digits = re.sub(r"\D", "", user_id) + if digits and len(digits) >= 6 and re.fullmatch(r"[+\d][\d\s()-]*", user_id): + return user_id + return "" + + +_HANDOFF_CONTACT_KEY = "handoff_contact" +_CONFIRM_MODES = ("agent_escalation", "scheduled_callback") + + +def _issue_keys(message: str) -> set: + """Raw and contact-stripped forms of one staff issue (question side only).""" + raw = (message or "").strip() + keys = {raw} if raw else set() + stripped = _lookup_staff_message(raw, []) + if stripped: + keys.add(stripped) + question = _pending_question_text(stripped or raw) + if question: + keys.add(question) + return keys + + +def _confirm_directive(mode: str, message: str, contact: str) -> ToolResult: + """Ask the user to confirm the issue and contact before staff are notified.""" + tool_name = f"handoff__{mode}" + return ToolResult( + content=( + "Confirm with the user before notifying staff. " + f"The issue is: {message}. The contact is: {contact}. " + "Ask if that issue and contact are correct. If they agree, call " + f"{tool_name} again with the same message and confirmed true. " + "If they give a different phone or email, call " + f"{tool_name} again with the same message and pass it in " + "phone_numbers or emails. Do not say staff were notified." + ) + ) + + +def _relay(line: str) -> ToolResult: + return ToolResult( + content=("Relay this to the user and do not add the staff summary:\n" f"{line}") + ) + + +def _pick_staff(targets: List[str]) -> str: + if not targets: + return "" + return random.choice(targets) + + +_ANSWER_PREFIX = re.compile( + r"^(?:save\s+answer|answer)\s*:\s*", + re.IGNORECASE, +) +_SAVE_ANSWER_PREFIX = re.compile(r"^save\s+answer\s*:\s*", re.IGNORECASE) + +_CONDENSE_SYSTEM = ( + "Rewrite a staff handoff into one short customer question and only the " + "factual answer. Return JSON with keys question and answer. The question " + "is what the customer asked, in one short sentence. The answer is only " + "the fact staff gave, with no instructions to staff." +) + + +def _handoff_graph(pairs: List[tuple], collection_name: str) -> Dict[str, Any]: + """PageIndex graph for handoff.md: one node per short Q&A.""" + root_id = f"n.DocumentRootNode.{uuid.uuid4().hex}" + nodes = [] + edges = [] + edge_ids = [] + for index, (question, answer) in enumerate(pairs): + node_id = f"n.DocumentNode.{uuid.uuid4().hex}" + edge_id = f"e.DocumentContentEdge.{uuid.uuid4().hex}" + section = f"## {question}\n\n{answer}" + edge_ids.append(edge_id) + nodes.append( + { + "id": node_id, + "entity": "DocumentNode", + "type_code": "n", + "edge_ids": [edge_id], + "title": question, + "node_id": f"{index:04d}", + "text": section, + "summary": section, + "prefix_summary": None, + "physical_index": 1, + "start_index": 1, + "end_index": 1, + "structure": "", + "doc_name": HANDOFF_DOC_NAME, + "collection_name": collection_name, + "line_num": 1, + "enabled": True, + "content_type": "substantive", + "hierarchy": [question], + } + ) + edges.append( + { + "id": edge_id, + "entity": "DocumentContentEdge", + "type_code": "e", + "source": root_id, + "target": node_id, + "bidirectional": False, + } + ) + return { + "roots": [ + { + "id": root_id, + "entity": "DocumentRootNode", + "type_code": "n", + "edge_ids": edge_ids, + "doc_name": HANDOFF_DOC_NAME, + "doc_description": None, + "doc_url": None, + "collection_name": collection_name, + "metadata": {"access": HANDOFF_DOC_ACCESS}, + "chunks": len(nodes), + } + ], + "nodes": nodes, + "edges": edges, + } + + +def _append_handoff_chunk( + graph: Dict[str, Any], question: str, answer: str, collection_name: str +) -> Dict[str, Any]: + """Add one Q&A chunk to an exported handoff graph. + + An export with no root starts a new graph. Existing chunk ids stay. + """ + roots = list(graph.get("roots") or []) + if not roots: + return _handoff_graph([(question, answer)], collection_name) + root = dict(roots[0]) + root_id = root.get("id") or f"n.DocumentRootNode.{uuid.uuid4().hex}" + node_id = f"n.DocumentNode.{uuid.uuid4().hex}" + edge_id = f"e.DocumentContentEdge.{uuid.uuid4().hex}" + section = f"## {question}\n\n{answer}" + nodes = list(graph.get("nodes") or []) + edges = list(graph.get("edges") or []) + nodes.append( + { + "id": node_id, + "entity": "DocumentNode", + "type_code": "n", + "edge_ids": [edge_id], + "title": question, + "node_id": f"{len(nodes):04d}", + "text": section, + "summary": section, + "prefix_summary": None, + "physical_index": 1, + "start_index": 1, + "end_index": 1, + "structure": "", + "doc_name": HANDOFF_DOC_NAME, + "collection_name": collection_name, + "line_num": 1, + "enabled": True, + "content_type": "substantive", + "hierarchy": [question], + } + ) + edges.append( + { + "id": edge_id, + "entity": "DocumentContentEdge", + "type_code": "e", + "source": root_id, + "target": node_id, + "bidirectional": False, + } + ) + edge_ids = list(root.get("edge_ids") or []) + edge_ids.append(edge_id) + metadata = dict(root.get("metadata") or {}) + metadata.setdefault("access", HANDOFF_DOC_ACCESS) + root.update( + { + "id": root_id, + "edge_ids": edge_ids, + "doc_name": HANDOFF_DOC_NAME, + "collection_name": collection_name, + "metadata": metadata, + "chunks": len(nodes), + } + ) + return {"roots": [root, *roots[1:]], "nodes": nodes, "edges": edges} + + +def _extract_saved_answer(answer: str, utterance: str = "") -> str: + """Strip a leading save instruction and keep the longer full answer. + + ``save answer:`` / ``answer:`` is removed from the tool argument. When the + live utterance carries a longer ``save answer:`` payload, that payload is + the text stored — a truncated argument must not drop the rest. + """ + cleaned = _ANSWER_PREFIX.sub("", (answer or "").strip(), count=1).strip() + raw_utterance = (utterance or "").strip() + match = _SAVE_ANSWER_PREFIX.match(raw_utterance) + if match: + payload = raw_utterance[match.end() :].strip() + if len(payload) > len(cleaned): + return payload + return cleaned + + +def _mask(value: str) -> str: + """Mask an identifier for logs: keep only the last 4 characters.""" + s = str(value or "") + if len(s) <= 4: + return "***" + return f"***{s[-4:]}" + + +def _truncate(value: Any, limit: int = 300) -> str: + """Truncate a provider result for logging (never includes secrets).""" + try: + text = repr(value) + except Exception: # pragma: no cover - defensive + return "" + return text if len(text) <= limit else text[:limit] + "…" + + +def _handoff_json_path() -> Path: + """``/handoff.json`` (defaults to ``./.files/handoff.json``).""" + from jvagent.core.sandbox import resolve_sandbox_root + + root = resolve_sandbox_root() + path = Path(root) / "handoff.json" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def _chunk_event(chunk_id: str, question: str) -> str: + return f'Handoff chunk {chunk_id} "{question}".' + + +def _chunk_title(chunk_id: str) -> str: + path = _handoff_json_path() + if not chunk_id or not path.exists(): + return "" + try: + graph = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return "" + for node in graph.get("nodes") or []: + if isinstance(node, dict) and node.get("id") == chunk_id: + return str(node.get("title") or "").strip() + return "" + + +def _patch_handoff_json(chunk_id: str, title: str, section: str) -> None: + path = _handoff_json_path() + if not path.exists(): + return + graph = json.loads(path.read_text(encoding="utf-8")) + for node in graph.get("nodes") or []: + if isinstance(node, dict) and node.get("id") == chunk_id: + node["title"] = title + node["text"] = section + node["summary"] = section + break + path.write_text(json.dumps(graph, indent=2) + "\n", encoding="utf-8") + + +class HandoffAction(Action): + """Human handoff tools: direct contact, staff notification, staff Q&A.""" + + tool_namespace: ClassVar[str] = "handoff" + + description: str = attribute( + default=( + "Human handoff tools — show direct contact details, notify staff " + "(WhatsApp or email) for escalation/callback, look up a pending " + "customer question, or resolve it into the knowledge base." + ), + description="Action description", + ) + + parameters: List[Dict[str, Any]] = attribute( + default_factory=lambda: [dict(p) for p in HANDOFF_PARAMETERS], + description=( + "Scoped behavioural parameters this action contributes to the common " + "subsystem. The orchestration-scoped rule tells the loop when to " + "hand off (explicit human ask, or an unanswerable question)." + ), + ) + + direct_contact_prompt: str = attribute( + default=DIRECT_CONTACT_PROMPT, description="Prompt for direct contact" + ) + agent_escalation_prompt: str = attribute( + default=AGENT_ESCALATION_PROMPT, description="Prompt for agent escalation" + ) + scheduled_callback_prompt: str = attribute( + default=SCHEDULED_CALLBACK_PROMPT, description="Prompt for scheduled callback" + ) + staff_lookup_prompt: str = attribute( + default=STAFF_LOOKUP_PROMPT, description="Prompt for staff lookup" + ) + + handoff_hours: str = attribute( + default="Mon-Fri, 9:00 AM - 5:00 PM", description="Office hours phrase." + ) + + handoff_notify_action_type: str = attribute( + default="WhatsAppAction", description="Action class for the whatsapp channel." + ) + handoff_email_action_type: str = attribute( + default="EmailAction", description="Action class for the email channel." + ) + handoff_pageindex_action_type: str = attribute( + default="PageIndexAction", description="Action class used to ingest Q&A." + ) + + handoff_channels: Dict[str, str] = attribute( + default_factory=lambda: { + "agent_escalation": "whatsapp", + "scheduled_callback": "whatsapp", + "staff_lookup": "whatsapp", + }, + description="Per-mode notify channel: whatsapp | email.", + ) + + pending_questions: List[Dict[str, Any]] = attribute( + default_factory=list, + description=( + "Customer questions waiting for a staff answer. Source of truth for " + "handoff__pending_questions / save_answer (reloaded from DB on read)." + ), + ) + + # -- tools ----------------------------------------------------------------- + + def get_capabilities(self) -> List[str]: + if not self.enabled: + return [] + return [ + "Reach a human teammate (contact details, escalation, callback, or " + "checking a pending question with staff)", + ] + + @tool(name="handoff__contact_details") + async def contact_details(self) -> str: + """Return the team's email, phone, and office hours. Use when the user wants those details and no notification should be sent.""" + phone, emails = await self._public_contacts() + return _render_direct_contact( + self.direct_contact_prompt, + email=", ".join(emails), + phone=phone, + hours=self.handoff_hours, + ) + + @tool(name="handoff__update_contact") + async def update_contact( + self, + phone_number: Annotated[ + Optional[str], + "One phone number. Omit when none.", + ] = None, + email: Annotated[ + Optional[str], + "One email address. Omit when none.", + ] = None, + ) -> ToolResult: + """Replace the saved phone or email replies are sent to. Use when the user wants to change where the team reaches them.""" + contact = (phone_number or "").strip() or (email or "").strip() + if not contact: + return ToolResult( + content="handoff failed: pass a phone number or email.", + is_error=True, + ) + prior = await self._saved_contact() + if await self._conversation() is None: + return ToolResult( + content="handoff failed: the contact could not be saved.", + is_error=True, + ) + await self._save_contact(contact) + if await self._saved_contact() != contact: + return ToolResult( + content="handoff failed: the contact could not be saved.", + is_error=True, + ) + if prior: + await self._update_pending_contact(prior, contact) + return _relay(f"Your contact is updated to {contact}.") + + @tool( + name="handoff__staff_lookup", idempotency_class=IdempotencyClass.NON_RETRYABLE + ) + async def staff_lookup( + self, + message: Annotated[ + str, + "Sentence 1: natural customer ask only. Optional sentence 2: what " + "was already tried (staff notify only). Never shown to the user.", + ], + phone_numbers: Annotated[ + Optional[List[str]], + "Phone numbers the user provided. Omit when none.", + ] = None, + emails: Annotated[ + Optional[List[str]], + "Email addresses the user provided. Omit when none.", + ] = None, + contact_declined: Annotated[ + Optional[bool], + "True if the user refused to share a phone or email.", + ] = None, + ) -> ToolResult: + """Record a question you cannot answer and notify staff. Use after a search returned nothing useful.""" + return await self._dispatch_handoff( + "staff_lookup", + message, + phone_numbers, + emails, + contact_declined=bool(contact_declined), + ) + + @tool( + name="handoff__agent_escalation", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) + async def agent_escalation( + self, + message: Annotated[ + str, + "The customer's question and what was already tried. Never shown to the user.", + ], + phone_numbers: Annotated[ + Optional[List[str]], + "Phone numbers the user provided. Omit when none.", + ] = None, + emails: Annotated[ + Optional[List[str]], + "Email addresses the user provided. Omit when none.", + ] = None, + confirmed: Annotated[ + Optional[bool], + "True after the user confirms this issue and contact.", + ] = None, + ) -> ToolResult: + """Notify staff that the customer wants a person now. Use when the user asks for a human or live support.""" + return await self._dispatch_handoff( + "agent_escalation", + message, + phone_numbers, + emails, + confirmed=bool(confirmed), + ) + + @tool( + name="handoff__scheduled_callback", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) + async def scheduled_callback( + self, + message: Annotated[ + str, + "The customer's question and what was already tried. Never shown to the user.", + ], + phone_numbers: Annotated[ + Optional[List[str]], + "Phone numbers the user provided. Omit when none.", + ] = None, + emails: Annotated[ + Optional[List[str]], + "Email addresses the user provided. Omit when none.", + ] = None, + confirmed: Annotated[ + Optional[bool], + "True after the user confirms this issue and contact.", + ] = None, + ) -> ToolResult: + """Notify staff to reach the customer later. Use when the user wants a callback.""" + return await self._dispatch_handoff( + "scheduled_callback", + message, + phone_numbers, + emails, + confirmed=bool(confirmed), + ) + + async def _dispatch_handoff( + self, + mode: str, + message: str, + phone_numbers: Optional[List[str]], + emails: Optional[List[str]], + contact_declined: bool = False, + confirmed: bool = False, + ) -> ToolResult: + channel = self._channel_for(mode) + prior = await self._saved_contact() + provided = _first_contact(phone_numbers, emails) + contact_replaced = bool(provided) and provided != prior + if provided: + await self._save_contact(provided) + contact = provided or prior or _sender_contact(channel) + if mode in _CONFIRM_MODES: + if not contact: + tool_name = f"handoff__{mode}" + return ToolResult( + content=( + "Ask the user for a phone number or email. When they reply, " + f"call {tool_name} again with the same message and pass that " + "contact in phone_numbers or emails." + ) + ) + if contact_replaced or not confirmed: + return _confirm_directive(mode, message, contact) + user_facing = ( + self.agent_escalation_prompt + if mode == "agent_escalation" + else self.scheduled_callback_prompt + ) + elif mode == "staff_lookup" and not contact and not contact_declined: + logger.warning( + "handoff staff_lookup ask path no contact declined=%s %s", + contact_declined, + await self._pending_debug_ids(), + ) + return ToolResult( + content=( + "Ask the user, in a natural line, that you don't have that " + "right now and may they share a phone number or email so you " + "can get back to them. If they share one, call " + "handoff__staff_lookup again with the same message and pass " + "it in phone_numbers or emails. If they refuse, call " + "handoff__staff_lookup again with the same message and " + "contact_declined true." + ) + ) + updating = False + if mode == "staff_lookup": + user_facing = self.staff_lookup_prompt + ids = await self._pending_debug_ids() + open_q = await self._open_lookup(message) + if open_q is not None: + stored = _question_field(open_q, "user_contact").strip() + effective = contact or ("declined" if contact_declined else "") + contact_changed = bool(contact) and stored != contact + question_id = _question_field(open_q, "id") + if stored and not contact_changed: + logger.warning( + "handoff staff_lookup send skipped same contact " + "question_id=%s stored=%r contact=%r declined=%s %s", + question_id, + stored, + contact, + contact_declined, + ids, + ) + return _relay(user_facing) + logger.warning( + "handoff staff_lookup updating question_id=%s " + "stored=%r effective=%r contact=%r declined=%s %s", + question_id, + stored, + effective, + contact, + contact_declined, + ids, + ) + new_contact = contact or effective + await self._update_pending( + question_id, user_contact=new_contact, node=open_q + ) + updating = True + + targets = await self._staff_targets(channel) + if not targets: + logger.error( + "handoff %s: no staff targets for %s (AccessControlAction " + "HandoffAction.staff)", + mode, + channel, + ) + return ToolResult( + content=( + f"handoff failed: no staff {channel} target configured. " + "Ask the operator to set AccessControlAction " + "HandoffAction.staff." + ), + is_error=True, + ) + recipient = _pick_staff(targets) + outbound = _staff_outbound(mode, message, phone_numbers, emails, contact) + if mode == "staff_lookup" and not updating: + logger.warning( + "handoff staff_lookup send path creating pending question " + "contact=%r declined=%s %s", + contact, + contact_declined, + await self._pending_debug_ids(), + ) + recorded = "declined" if contact_declined and not contact else contact + pending_q = _pending_question_text( + _lookup_staff_message( + message, + [ + str(t).strip() + for t in (phone_numbers or []) + (emails or []) + if str(t).strip() + ], + ) + or message + ) + await self._add_pending(pending_q, recorded) + + try: + if channel == "whatsapp": + await self._send_whatsapp(recipient, outbound) + else: + await self._send_email(targets, outbound) + except Exception as exc: + logger.error("handoff %s dispatch failed: %s", mode, exc, exc_info=True) + return ToolResult( + content=( + "handoff failed: the notification could not be sent. " + "Apologize briefly and offer the direct contact option via " + "handoff__contact_details." + ), + is_error=True, + ) + + logger.info( + "handoff %s summary dispatched via %s to %s", mode, channel, recipient + ) + return _relay(user_facing) + + @tool(name="handoff__pending_questions") + async def list_pending_questions(self) -> ToolResult: + """List customer questions waiting for an answer. If the latest message and the conversation so far look like an answer rather than a new question, call this first. Then call handoff__save_answer with the question_id it matches.""" + try: + pending = await self._list_pending() + except Exception: + logger.warning( + "handoff pending_questions failed action_agent_id=%r", + getattr(self, "agent_id", None), + exc_info=True, + ) + return ToolResult( + content="handoff failed: could not list pending questions.", + is_error=True, + ) + if not pending: + logger.warning( + "handoff pending_questions returning empty action_agent_id=%r", + getattr(self, "agent_id", None), + ) + return ToolResult(content="(no pending questions)") + lines = [ + "- id={id} | contact={contact} | Q: {question}".format( + id=_question_field(q, "id") or "unknown", + contact=_question_field(q, "user_contact") or "unknown", + question=_question_field(q, "question")[:300], + ) + for q in pending + ] + return ToolResult( + content=( + "Pending questions:\n" + + "\n".join(lines) + + "\n\nCall handoff__save_answer with the question_id that this " + "answer matches and the full answer text. Do not reply to the " + "user yet." + ) + ) + + @tool(name="handoff__save_answer", idempotency_class=IdempotencyClass.NON_RETRYABLE) + async def save_answer( + self, + question_id: Annotated[str, "id of the pending question being answered."], + answer: Annotated[str, "Full answer to store."], + ) -> ToolResult: + """Save the full answer for one pending question and return a confirmation. Use after handoff__pending_questions, with the matching question_id.""" + from jvagent.tooling.tool_executor import get_tool_visitor + + if not await self._is_staff(): + return ToolResult( + content=( + "handoff failed: only authorized staff may resolve a pending " + "question." + ), + is_error=True, + ) + visitor = get_tool_visitor() + utterance = str(getattr(visitor, "utterance", "") or "") + cleaned = _extract_saved_answer(answer, utterance) + if not cleaned: + return ToolResult( + content="handoff failed: no answer text to save.", + is_error=True, + ) + question = await self._get_pending_question(question_id) + if question is None: + return ToolResult( + content=f"handoff failed: no pending question with id {question_id!r}", + is_error=True, + ) + try: + short_q, short_a, chunk_id = await self._append_and_ingest( + _question_field(question, "question"), cleaned + ) + except Exception as exc: + logger.error("handoff__save_answer ingest failed: %s", exc, exc_info=True) + return ToolResult( + content=( + "handoff failed: could not save the answer to the knowledge " + "base. Report the issue to the team." + ), + is_error=True, + ) + await self._remove_pending(question_id, answer=cleaned, node=question) + await self._reply_to_customer(question, short_q, short_a) + await self._record_chunk_event(chunk_id, short_q) + return ToolResult( + content=( + "Relay this to the staff member as-is: Your answer is saved " + "and will be used in the future to answer this question." + ) + ) + + @tool( + name="handoff__update_chunk", idempotency_class=IdempotencyClass.NON_RETRYABLE + ) + async def update_chunk( + self, + chunk_id: Annotated[str, "id of the handoff chunk to update."], + answer: Annotated[str, "Full answer to store."], + ) -> ToolResult: + """Update the saved answer for one existing chunk and return a confirmation. Use when the user is correcting a saved answer, taking chunk_id from the [EVENT] in history and the answer from their message.""" + from jvagent.tooling.tool_executor import get_tool_visitor + + if not await self._is_staff(): + return ToolResult( + content=( + "handoff failed: only authorized staff may resolve a pending " + "question." + ), + is_error=True, + ) + visitor = get_tool_visitor() + utterance = str(getattr(visitor, "utterance", "") or "") + cleaned = _extract_saved_answer(answer, utterance) + if not cleaned: + return ToolResult( + content="handoff failed: no answer text to save.", + is_error=True, + ) + chunk_id = (chunk_id or "").strip() + title = _chunk_title(chunk_id) + if not chunk_id or not title: + return ToolResult( + content=f"handoff failed: no handoff chunk with id {chunk_id!r}", + is_error=True, + ) + try: + _, short_a = await self._condense_qa(title, cleaned) + section = f"## {title}\n\n{short_a}" + agent = await self.get_agent() + collection = str(getattr(agent, "id", "") or "") or "default" + from jvagent.action.pageindex.documents import update_document_chunk + + updated = await update_document_chunk( + chunk_id, + HANDOFF_DOC_NAME, + collection, + {"text": section, "summary": section}, + ) + except Exception as exc: + logger.error("handoff__update_chunk failed: %s", exc, exc_info=True) + return ToolResult( + content=( + "handoff failed: could not save the answer to the knowledge " + "base. Report the issue to the team." + ), + is_error=True, + ) + if not updated: + return ToolResult( + content=f"handoff failed: no handoff chunk with id {chunk_id!r}", + is_error=True, + ) + _patch_handoff_json(chunk_id, title, section) + await self._record_chunk_event(chunk_id, title) + return ToolResult( + content=( + "Relay this to the staff member as-is: Your answer is updated " + "and will be used in the future to answer this question." + ) + ) + + # -- helpers --------------------------------------------------------------- + + async def _customer_reply(self, question: str, answer: str) -> str: + """Remind the customer of their question, then give the answer.""" + from jvagent.action.utils.call_model import call_model + + fallback = f"You asked: {question}\n\n{answer}" + try: + result = await call_model( + self, + user_prompt=f"Question: {question}\nAnswer: {answer}", + system_prompt=( + "Write a short message to the customer. Remind them of " + "the question they asked, then give the answer. Plain text " + "only. No mention of staff, tools, or a knowledge base." + ), + json_response=False, + use_history=False, + ) + except Exception: + logger.debug("handoff customer reply failed", exc_info=True) + return fallback + text = result.strip() if isinstance(result, str) else "" + return text or fallback + + async def _reply_to_customer( + self, question: Any, short_q: str, short_a: str + ) -> None: + contact = _question_field(question, "user_contact").strip() + kind = _contact_kind(contact) + if not kind: + return + text = await self._customer_reply(short_q, short_a) + try: + if kind == "email": + await self._send_email( + [contact], text, subject=short_q or "Your question" + ) + else: + await self._send_whatsapp(contact, text) + except Exception: + logger.error("handoff reply to customer failed", exc_info=True) + + def _channel_for(self, mode: str) -> str: + channel = ( + str((self.handoff_channels or {}).get(mode, "whatsapp")).strip().lower() + ) + return channel if channel in _CHANNELS else "whatsapp" + + async def _aca_staff_members(self) -> List[str]: + """Non-empty ``HandoffAction.staff`` from AccessControl, else [].""" + try: + # Action.get_action looks up by class name; Agent.get_action is by label. + aca: Any = await self.get_action("AccessControlAction") + except Exception: + return [] + if aca is None or not getattr(aca, "policy_applies", lambda: False)(): + return [] + try: + groups = aca.get_user_groups(action_label="HandoffAction") or {} + except Exception: + return [] + staff = groups.get("staff") if isinstance(groups, dict) else None + if not isinstance(staff, list): + return [] + return [str(m).strip() for m in staff if str(m).strip()] + + async def _public_contacts(self) -> tuple: + """Phone is one random staff number; email is every staff address.""" + numbers = await self._staff_targets("whatsapp") + emails = await self._staff_targets("email") + phone = _pick_staff(numbers) if numbers else "" + return phone, emails + + async def _staff_targets(self, channel: str) -> List[str]: + """Notify/contact targets from AccessControl ``HandoffAction.staff``. + + Members are classified with ``_contact_kind`` (phone → whatsapp, + address → email). + """ + staff = await self._aca_staff_members() + return [m for m in staff if _contact_kind(m) == channel] + + async def _is_staff(self) -> bool: + """True when the dispatch sender may save/update handoff answers. + + Sender must be listed in AccessControlAction ``HandoffAction.staff``. + """ + from jvagent.tooling.tool_executor import get_dispatch_context + + ctx = get_dispatch_context() + sender = (getattr(ctx, "user_id", "") or "").strip() + if not sender: + return False + staff = await self._aca_staff_members() + return sender in staff + + async def _send_whatsapp(self, recipient: str, message: str) -> None: + masked = _mask(recipient) + agent = await self.get_agent() + logger.warning( + "handoff whatsapp: start recipient=%s body_chars=%d agent=%s", + masked, + len(message or ""), + getattr(agent, "id", None), + ) + action = ( + await agent.get_action_by_type(self.handoff_notify_action_type) + if agent + else None + ) + if action is None: + logger.warning( + "handoff whatsapp: notify action %r NOT FOUND on agent %s", + self.handoff_notify_action_type, + getattr(agent, "id", None), + ) + raise RuntimeError( + f"notify action {self.handoff_notify_action_type} not found" + ) + configured = None + try: + configured = action.is_configured() + except Exception as exc: # pragma: no cover - defensive + logger.warning("handoff whatsapp: is_configured() raised: %s", exc) + issues: List[str] = [] + try: + issues = list(action._config_issues() or []) + except Exception as exc: # pragma: no cover - defensive + logger.warning("handoff whatsapp: _config_issues() raised: %s", exc) + logger.warning( + "handoff whatsapp: action=%s provider=%s configured=%s " + "phone_number_id=%s jvconnect_url=%s jvconnect_key=%s issues=%s", + action.get_class_name(), + getattr(action, "provider", None), + configured, + getattr(action, "phone_number_id", "") or "(unset)", + bool( + getattr(action, "jvconnect_url", "") or os.environ.get("JVCONNECT_URL") + ), + bool(os.environ.get("JVCONNECT_API_KEY")), + issues or "(none)", + ) + api = await action.api() + if api is None: + logger.warning("handoff whatsapp: action.api() returned None") + raise RuntimeError("whatsapp api() returned None") + logger.warning( + "handoff whatsapp: sending via %s to %s", + type(api).__name__, + masked, + ) + result = await api.send_message(phone=recipient, message=message or "") + logger.warning( + "handoff whatsapp: provider result ok=%s http_status=%s error=%s raw=%s", + (result or {}).get("ok") if isinstance(result, dict) else None, + (result or {}).get("http_status") if isinstance(result, dict) else None, + (result or {}).get("error") if isinstance(result, dict) else None, + _truncate(result), + ) + if not isinstance(result, dict): + logger.warning( + "handoff whatsapp: provider returned non-dict result: %r", result + ) + raise RuntimeError("whatsapp send returned a non-dict result") + if result.get("ok") is False: + raise RuntimeError(result.get("error") or "whatsapp send failed") + logger.warning("handoff whatsapp: sent to %s", masked) + + async def _send_email( + self, + recipients: List[str], + message: str, + subject: str = "Human handoff request", + ) -> None: + from jvagent.action.email_action.email_payload import ( + CanonicalSendMessage, + EmailRecipient, + ) + + agent = await self.get_agent() + action = ( + await agent.get_action_by_type(self.handoff_email_action_type) + if agent + else None + ) + if action is None: + raise RuntimeError( + f"email action {self.handoff_email_action_type} not found" + ) + sender_email, sender_name = await action.resolve_outbound_sender() + if not sender_email: + raise RuntimeError("email action has no resolvable sender address") + api = await action.api() + if api is None: + raise RuntimeError("email api() returned None") + to_email = recipients[0] + cc = [EmailRecipient(email=addr) for addr in recipients[1:]] + result = await api.send_canonical( + CanonicalSendMessage( + to_email=to_email, + subject=subject or "Human handoff request", + sender_email=sender_email, + sender_name=sender_name, + text_content=message or "", + cc=cc, + ) + ) + if isinstance(result, dict) and result.get("ok") is False: + raise RuntimeError(result.get("error") or "email send failed") + + async def _agent_id(self) -> str: + agent = await self.get_agent() + return str( + getattr(agent, "id", "") or getattr(self, "agent_id", "") or "" + ).strip() + + async def _evict_self_cache(self) -> None: + """Drop entity cache so the next Action.get hits durable storage.""" + aid = str(getattr(self, "id", "") or "").strip() + if not aid: + return + try: + from jvspatial.core.context import get_default_context + + ctx = get_default_context() + evict = getattr(ctx, "_evict_from_cache", None) + if evict is not None: + await evict(aid) + except Exception: + logger.warning( + "handoff pending cache evict failed action_id=%s", aid, exc_info=True + ) + + async def _invalidate_pending_caches(self) -> None: + await self._evict_self_cache() + agent_id = await self._agent_id() + if not agent_id: + return + try: + from jvagent.core.cache import invalidate_action_cache + + await invalidate_action_cache(agent_id) + except Exception: + logger.warning( + "handoff pending action-cache invalidate failed agent_id=%s", + agent_id, + exc_info=True, + ) + + async def _refresh_pending_state(self) -> None: + """Reload pending_questions from durable Action storage onto this instance.""" + aid = str(getattr(self, "id", "") or "").strip() + if not aid: + return + await self._evict_self_cache() + try: + fresh = await type(self).get(aid) + except Exception: + logger.warning( + "handoff pending refresh failed action_id=%s", aid, exc_info=True + ) + return + if fresh is None: + return + rows = getattr(fresh, "pending_questions", None) + self.pending_questions = list(rows) if isinstance(rows, list) else [] + + def _unanswered_rows(self) -> List[Dict[str, Any]]: + return [ + row + for row in (self.pending_questions or []) + if isinstance(row, dict) and not str(row.get("answer") or "").strip() + ] + + async def _list_pending(self) -> List[Dict[str, Any]]: + """Pending questions as dict rows for tool output.""" + await self._refresh_pending_state() + loaded_id = await self._agent_id() + rows = [_question_row(row) for row in self._unanswered_rows()] + logger.warning( + "handoff list_pending action_agent_id=%r loaded_agent_id=%r count=%s", + getattr(self, "agent_id", None), + loaded_id, + len(rows), + ) + return rows + + async def _get_pending_question(self, question_id: str) -> Any: + qid = (question_id or "").strip() + if not qid: + return None + await self._refresh_pending_state() + for row in self._unanswered_rows(): + if str(row.get("id") or "") == qid: + return row + return None + + async def _persist_pending_rows(self, rows: List[Dict[str, Any]]) -> None: + self.pending_questions = list(rows) + await self.save() + await self._invalidate_pending_caches() + + async def _add_pending(self, message: str, contact: str = "") -> Dict[str, Any]: + """Append a pending question on this Action and verify it is listable.""" + from jvagent.tooling.tool_executor import get_dispatch_context + + ctx = get_dispatch_context() + channel = (getattr(ctx, "channel", "") or "default").strip() or "default" + stored_contact = (contact or "").strip() + agent_id = await self._agent_id() + logger.warning( + "handoff add_pending action_agent_id=%r loaded_agent_id=%r contact=%r", + getattr(self, "agent_id", None), + agent_id, + stored_contact, + ) + try: + await self._refresh_pending_state() + entry = { + "id": f"q_{uuid.uuid4().hex}", + "question": message or "", + "user_channel": channel, + "user_contact": stored_contact, + "created_at": datetime.now(timezone.utc).isoformat(), + } + rows = self._unanswered_rows() + rows.append(entry) + await self._persist_pending_rows(rows) + await self._refresh_pending_state() + if not any( + str(row.get("id") or "") == entry["id"] + for row in self._unanswered_rows() + ): + raise RuntimeError( + f"handoff question {entry['id']} was created but is not listable" + ) + except Exception: + logger.warning( + "handoff add_pending failed action_agent_id=%r loaded_agent_id=%r", + getattr(self, "agent_id", None), + agent_id, + exc_info=True, + ) + raise + logger.warning( + "handoff add_pending created id=%s agent_id=%r", + entry["id"], + agent_id, + ) + return entry + + async def _update_pending( + self, + question_id: str, + *, + user_contact: str = "", + node: Any = None, + ) -> None: + """Update contact on one pending question.""" + qid = (question_id or "").strip() or _question_field(node, "id").strip() + if not qid: + logger.warning("handoff update_pending missing question_id") + return + await self._refresh_pending_state() + rows = self._unanswered_rows() + changed = False + for row in rows: + if str(row.get("id") or "") == qid: + row["user_contact"] = (user_contact or "").strip() + changed = True + break + if not changed: + logger.warning("handoff update_pending missing question_id=%r", qid) + return + await self._persist_pending_rows(rows) + + async def _update_pending_contact(self, prior: str, contact: str) -> None: + """Update user_contact on every pending question matching ``prior``.""" + prior = (prior or "").strip() + contact = (contact or "").strip() + if not prior or not contact: + return + await self._refresh_pending_state() + rows = self._unanswered_rows() + changed = False + for row in rows: + if str(row.get("user_contact") or "") == prior: + row["user_contact"] = contact + changed = True + if changed: + await self._persist_pending_rows(rows) + + async def _remove_pending( + self, question_id: str, *, answer: str = "", node: Any = None + ) -> None: + """Drop a pending question from the Action list after it is answered.""" + qid = (question_id or "").strip() or _question_field(node, "id").strip() + if not qid: + logger.warning("handoff remove_pending missing question_id") + return + await self._refresh_pending_state() + kept = [ + row + for row in (self.pending_questions or []) + if not (isinstance(row, dict) and str(row.get("id") or "") == qid) + ] + if len(kept) == len(self.pending_questions or []): + logger.warning("handoff remove_pending missing question_id=%r", qid) + return + await self._persist_pending_rows(kept) + + async def _conversation(self) -> Any: + """Current conversation from the tool visitor, when one is bound.""" + from jvagent.tooling.tool_executor import get_tool_visitor + + visitor = get_tool_visitor() + if visitor is None: + return None + conversation = getattr(visitor, "conversation", None) + if conversation is not None: + return conversation + interaction = getattr(visitor, "interaction", None) + getter = getattr(interaction, "get_conversation", None) + if not callable(getter): + return None + try: + return await getter() + except Exception: + logger.debug("handoff conversation load failed", exc_info=True) + return None + + async def _saved_contact(self) -> str: + conversation = await self._conversation() + context = getattr(conversation, "context", None) or {} + return str(context.get(_HANDOFF_CONTACT_KEY) or "").strip() + + async def _save_contact(self, contact: str) -> None: + contact = (contact or "").strip() + if not contact: + return + conversation = await self._conversation() + if conversation is None: + logger.warning("handoff contact not saved: no conversation") + return + update = getattr(conversation, "update_context", None) + if callable(update): + await update({_HANDOFF_CONTACT_KEY: contact}) + return + context = getattr(conversation, "context", None) + if isinstance(context, dict): + context[_HANDOFF_CONTACT_KEY] = contact + + async def _open_lookup(self, message: str = "") -> Any: + """Open pending question for this issue.""" + try: + agent_id = await self._agent_id() + keys = _issue_keys(message) + rows = await self._list_pending() + for question in rows: + stored = _question_field(question, "question").strip() + if keys and stored not in keys: + continue + logger.warning( + "handoff open lookup matched id=%s agent_id=%r", + _question_field(question, "id"), + agent_id, + ) + return question + logger.warning( + "handoff open lookup no match agent_id=%r scanned=%s", + agent_id, + len(rows), + ) + except Exception: + logger.warning("handoff open lookup failed", exc_info=True) + return None + + async def _pending_debug_ids(self) -> str: + from jvagent.tooling.tool_executor import get_dispatch_context + + ctx = get_dispatch_context() + agent = await self.get_agent() + session_id = (getattr(ctx, "session_id", "") or "").strip() + user_id = (getattr(ctx, "user_id", "") or "").strip() + return ( + f"action_agent_id={getattr(self, 'agent_id', None)!r} " + f"loaded_agent_id={getattr(agent, 'id', None)!r} " + f"session_id={session_id!r} user_id={user_id!r}" + ) + + async def _condense_qa(self, question: str, answer: str) -> tuple: + """One short customer question and the factual answer. + + A failed model call keeps the current heading and answer. + """ + from jvagent.action.utils.call_model import call_model + + fallback = ((question or "").strip(), (answer or "").strip()) + try: + result = await call_model( + self, + user_prompt=( + f"Staff summary:\n{fallback[0]}\n\nStaff answer:\n{fallback[1]}" + ), + system_prompt=_CONDENSE_SYSTEM, + json_response=True, + use_history=False, + ) + except Exception: + logger.debug("handoff condense failed", exc_info=True) + return fallback + if not isinstance(result, dict): + return fallback + short_q = str(result.get("question") or "").strip() + short_a = str(result.get("answer") or "").strip() + if not short_q or not short_a: + return fallback + return short_q, short_a + + async def _record_chunk_event(self, chunk_id: str, question: str) -> None: + """Store the chunk id on this interaction so a later turn can update it.""" + from jvagent.tooling.tool_executor import get_tool_visitor + + visitor = get_tool_visitor() + interaction = getattr(visitor, "interaction", None) if visitor else None + adder = getattr(interaction, "add_event", None) + if interaction is None or not callable(adder) or not chunk_id: + return + try: + added = adder(_chunk_event(chunk_id, question), "HandoffAction") + except Exception: + logger.debug("handoff chunk event failed", exc_info=True) + return + if added is False: + return + saver = getattr(interaction, "save", None) + if not callable(saver): + return + try: + result = saver() + if hasattr(result, "__await__"): + await result + except Exception: + logger.debug("handoff chunk event save failed", exc_info=True) + + async def _append_and_ingest(self, question: str, answer: str) -> tuple: + """Condense one Q&A, append its chunk, and re-import the handoff graph.""" + short_q, short_a = await self._condense_qa(question, answer) + agent = await self.get_agent() + collection = str(getattr(agent, "id", "") or "") or "default" + from jvagent.action.pageindex.documents import ( + delete_document, + export_documents, + import_documents, + ) + + try: + exported = await export_documents( + collection_name=collection, doc_name=HANDOFF_DOC_NAME + ) + except Exception: + logger.debug("handoff: export of handoff.md skipped", exc_info=True) + exported = {} + graph = _append_handoff_chunk(exported or {}, short_q, short_a, collection) + nodes = graph.get("nodes") or [] + chunk_id = str(nodes[-1].get("id") or "") if nodes else "" + _handoff_json_path().write_text( + json.dumps(graph, indent=2) + "\n", encoding="utf-8" + ) + + try: + await delete_document(HANDOFF_DOC_NAME, collection_name=collection) + except Exception: # document may not exist yet — import replaces it + logger.debug("handoff: prior handoff.md delete skipped", exc_info=True) + await import_documents(graph, purge=False, collection_name=collection) + return short_q, short_a, chunk_id + + async def healthcheck(self) -> Any: + return True + + # -- rendering helper (shared with tests) --------------------------------- + + async def render_direct_contact(self) -> str: + phone, emails = await self._public_contacts() + return _render_direct_contact( + self.direct_contact_prompt, + email=", ".join(emails), + phone=phone, + hours=self.handoff_hours, + ) + + +__all__ = [ + "AGENT_ESCALATION_PROMPT", + "DIRECT_CONTACT_PROMPT", + "HANDOFF_DOC_NAME", + "HANDOFF_PARAMETERS", + "HandoffAction", + "SCHEDULED_CALLBACK_PROMPT", + "STAFF_LOOKUP_PROMPT", +] diff --git a/jvagent/action/handoff_action/info.yaml b/jvagent/action/handoff_action/info.yaml new file mode 100644 index 00000000..78a0eb1f --- /dev/null +++ b/jvagent/action/handoff_action/info.yaml @@ -0,0 +1,23 @@ +package: + name: jvagent/handoff_action + author: Tharick Jairam + archetype: HandoffAction + version: 1.0.0 + meta: + title: Handoff Action + description: >- + Human-support capability tools, skill-gated: return contact details + (handoff__contact_details), notify staff for escalation + (handoff__agent_escalation), a callback (handoff__scheduled_callback), + or a staff lookup (handoff__staff_lookup), and save an answer to a + pending question (handoff__pending_questions / handoff__save_answer) into + a public PageIndex document (handoff.md). + group: jvagent + type: action + config: + order: + weight: 0 + dependencies: + jvagent: ~0.1.7 + actions: [] + pip: [] diff --git a/jvagent/action/handoff_action/store.py b/jvagent/action/handoff_action/store.py new file mode 100644 index 00000000..eef214ad --- /dev/null +++ b/jvagent/action/handoff_action/store.py @@ -0,0 +1,166 @@ +"""Pending staff questions for :class:`~jvagent.action.handoff_action.HandoffAction`. + +Agent-scoped (not conversation-scoped) so a staff member answering in their own +thread can find the question a customer asked elsewhere. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import AsyncIterator, List, Optional + +from jvspatial.core import Node +from jvspatial.core.annotations import attribute +from jvspatial.core.context import GraphContext + +logger = logging.getLogger(__name__) + +STATUS_PENDING = "pending" +STATUS_RESOLVED = "resolved" + + +@asynccontextmanager +async def _on_prime() -> AsyncIterator[Optional[GraphContext]]: + """Pin HandoffQuestion reads/writes to the prime graph database. + + A turn can swap the default context (PageIndex does this). Questions must + stay on the same database as the agent — same pattern as WhatsApp/Email. + """ + from jvspatial.core.context import get_default_context, scoped_default_context_async + from jvspatial.db import get_prime_database + + current = get_default_context() + try: + prime_db = get_prime_database() + except Exception: + logger.warning("HandoffQuestion: prime database unavailable", exc_info=True) + yield current + return + if getattr(current, "database", None) is prime_db: + yield current + return + prime_ctx = GraphContext(database=prime_db) + async with scoped_default_context_async(prime_ctx): + yield prime_ctx + + +class HandoffQuestion(Node): + """One customer question awaiting a staff answer.""" + + agent_id: str = attribute(indexed=True, default="") + question: str = attribute(default="") + notes: str = attribute(default="") + user_id: str = attribute(indexed=True, default="") + user_channel: str = attribute(default="default") + user_contact: str = attribute(default="") + session_id: str = attribute(default="") + status: str = attribute(indexed=True, default=STATUS_PENDING) + answer: str = attribute(default="") + created_at: str = attribute(default="") + resolved_at: str = attribute(default="") + + @classmethod + async def create_question( + cls, + *, + agent_id: str, + question: str, + notes: str = "", + user_id: str = "", + user_channel: str = "default", + user_contact: str = "", + session_id: str = "", + ) -> "HandoffQuestion": + async with _on_prime(): + created = await cls.create( + agent_id=agent_id, + question=question, + notes=notes, + user_id=user_id, + user_channel=user_channel, + user_contact=user_contact, + session_id=session_id, + status=STATUS_PENDING, + created_at=datetime.now(timezone.utc).isoformat(), + ) + stored = await cls.get(created.id) + if stored is None: + logger.warning( + "HandoffQuestion.create_question did not persist id=%s agent_id=%r", + getattr(created, "id", None), + agent_id, + ) + raise RuntimeError( + f"handoff question {getattr(created, 'id', None)} was not saved" + ) + return stored + + @classmethod + async def pending(cls, agent_id: str) -> List["HandoffQuestion"]: + """Pending questions for one agent (filter status in Python).""" + rows: List["HandoffQuestion"] = [] + loaded: List["HandoffQuestion"] = [] + any_status = 0 + all_questions = 0 + try: + async with _on_prime(): + rows = list(await cls.find(agent_id=agent_id) or []) + loaded = [ + row + for row in rows + if str(getattr(row, "status", "") or "") == STATUS_PENDING + ] + if not loaded: + any_status = len(rows) + all_questions = await cls.count() + except Exception: + logger.error( + "HandoffQuestion.pending failed for agent %s", agent_id, exc_info=True + ) + raise + logger.warning( + "HandoffQuestion.pending agent_id=%r status=%s loaded=%s scanned=%s", + agent_id, + STATUS_PENDING, + len(loaded), + len(rows), + ) + if not loaded: + logger.warning( + "HandoffQuestion.pending empty agent_id=%r " + "any_status=%s all_questions=%s", + agent_id, + any_status, + all_questions, + ) + return loaded + + @classmethod + async def get_question(cls, question_id: str) -> Optional["HandoffQuestion"]: + try: + async with _on_prime(): + return await cls.get(question_id) + except Exception as exc: # pragma: no cover - defensive + logger.debug("HandoffQuestion.get_question failed: %s", exc) + return None + + async def mark_resolved(self, answer: str) -> None: + async with _on_prime() as ctx: + if ctx is not None: + self._graph_context = ctx + self.status = STATUS_RESOLVED + self.answer = answer + self.resolved_at = datetime.now(timezone.utc).isoformat() + await self.save() + + async def update_user_contact(self, contact: str) -> None: + async with _on_prime() as ctx: + if ctx is not None: + self._graph_context = ctx + self.user_contact = (contact or "").strip() + await self.save() + + +__all__ = ["HandoffQuestion", "STATUS_PENDING", "STATUS_RESOLVED"] diff --git a/jvagent/skills/handoff/SKILL.md b/jvagent/skills/handoff/SKILL.md new file mode 100644 index 00000000..2957e957 --- /dev/null +++ b/jvagent/skills/handoff/SKILL.md @@ -0,0 +1,65 @@ +--- +name: handoff +description: >- + Reach a human teammate, give the team's contact details, or change the + phone or email replies are sent to. Use when the user asks for a person, + wants a callback, or you cannot answer from the knowledge base or catalog. +allowed-tools: + - handoff__contact_details + - handoff__update_contact + - handoff__staff_lookup + - handoff__agent_escalation + - handoff__scheduled_callback +requires-actions: + - HandoffAction +extends: action:jvagent/handoff_action +access-action: HandoffAction +denied-groups: + - staff +version: 1 +tags: + - handoff + - human + - support + - escalation + - callback +--- + +Call `use_skill` for this skill before any of its tools. + +## Tools + +- `handoff__contact_details` — user wants the team's details only; return contact number and office hours only. +- `handoff__update_contact` — the user wants to change the phone or email replies are sent to. Pass one `phone_number` or one `email`. +- `handoff__staff_lookup` — you cannot answer or the request is outside the assortment. Records a pending question and notifies staff. +- `handoff__agent_escalation` — the user wants a person now. +- `handoff__scheduled_callback` — the user wants to be reached later. + +## Channels (edit per agent) + +Each tool picks its own channel. Defaults are WhatsApp. Change them on the action if a mode should use email instead. Staff recipients come from AccessControlAction `HandoffAction.staff`. You never choose the channel or the recipient. + +## `handoff__staff_lookup` message format + +`message` is never shown to the customer. Write it as: + +1. **Sentence 1 — natural customer ask only** (this is what gets stored as the pending question). Phrasing must sound natural, e.g. `Customer asked for the company's location.` or `Customer asked if we sell car parts.` — not awkward doubles like "location address". +2. **Optional sentence 2 — what was already tried** (staff notify only; not stored). e.g. `No information found in the FAQ.` or `No information found in the FAQ or catalog.` + +Do not put handling notes in sentence 1. Do not mention WhatsApp or tell staff to reply. + +## Customer side + +1. **Detect intent.** The user explicitly asks for a human / agent / live + support, wants a callback, or you cannot answer from the knowledge base or + available tools (an FAQ / knowledge-base search returned nothing relevant, or + the request is outside what you can do). +2. **Call the matching tool.** For `handoff__staff_lookup`, follow the message + format above. Pass `phone_numbers` or `emails` only when the user already + gave one. +3. **Relay the tool's directive** as-is. If it says to ask for a phone or + email and call the same tool again, do that. If it does not, do not ask + again and do not call the tool a second time. + +If the user only wants the team's details (not a notification), use +`handoff__contact_details` and relay the block exactly as returned. diff --git a/jvagent/skills/handoff_staff/SKILL.md b/jvagent/skills/handoff_staff/SKILL.md new file mode 100644 index 00000000..e0afb71f --- /dev/null +++ b/jvagent/skills/handoff_staff/SKILL.md @@ -0,0 +1,50 @@ +--- +name: handoff_staff +description: >- + Save or correct a staff answer in the knowledge base. Use when the latest + message answers a pending customer question, or corrects a saved answer + whose chunk id is in an [EVENT] in history. +allowed-tools: + - handoff__pending_questions + - handoff__save_answer + - handoff__update_chunk +requires-actions: + - HandoffAction +extends: action:jvagent/handoff_action +access-action: HandoffAction +allowed-groups: + - staff +version: 1 +tags: + - handoff + - staff + - knowledge +--- + +Call `use_skill` for this skill before any of its tools. + +## Tools + +- `handoff__pending_questions` — if the user message and the history look like an answer rather than a new question, call this first. Check if the user message is an answer to a pending question, then call `handoff__save_answer` to save the full answer only. +- `handoff__save_answer` — if one pending question matches, pass its `question_id` and the full answer. Relay the thank-you. +- `handoff__update_chunk` — correct a saved answer. Take `chunk_id` from the `[EVENT]` in history and the new answer from the user's message. Relay the confirmation. + +## Saving an answer + +If the latest message and the history look like an answer rather than a new +question, you do not know what is pending until you list it: + +1. Call `handoff__pending_questions` first. +2. Check whether the user message answers one of the returned questions. +3. Call `handoff__save_answer` with that `question_id` and the full answer + only. The tool stores the cleaned answer on the knowledge base. +4. Relay the tool's confirmation as-is. Do not repeat the answer, and do not + try to deliver it to the customer from this thread. + +Do not save an answer for a question that is not pending. + +## Correcting a saved answer + +When history has an `[EVENT]` with a handoff chunk id and the user is +correcting that answer, call `handoff__update_chunk` with that `chunk_id` and +the new answer. Relay the confirmation as-is. diff --git a/tests/action/test_action_import_guard.py b/tests/action/test_action_import_guard.py index 3830bbfd..6afbda72 100644 --- a/tests/action/test_action_import_guard.py +++ b/tests/action/test_action_import_guard.py @@ -48,6 +48,8 @@ ("microsoft", "mcp_oauth"), ("pageindex", "google"), ("artifact_handler_interact_action", "pageindex"), + ("handoff_action", "email_action"), + ("handoff_action", "pageindex"), } ) diff --git a/tests/action/test_handoff_action.py b/tests/action/test_handoff_action.py new file mode 100644 index 00000000..8f22d635 --- /dev/null +++ b/tests/action/test_handoff_action.py @@ -0,0 +1,1223 @@ +"""HandoffAction — skill-gated capability tools (jvagent/handoff_action). + +Tools: handoff__contact_details / handoff__staff_lookup / handoff__agent_escalation / +handoff__scheduled_callback / handoff__pending_questions / +handoff__save_answer. Per-mode channel, staff allowlist + random target, staff Q&A +capture into PageIndex (handoff.md, public). Staff targets/write allowlist come from +AccessControlAction HandoffAction.staff only. +""" + +import json +from types import SimpleNamespace + +from jvagent.action.handoff_action import HandoffAction +from jvagent.action.handoff_action.handoff_action import ( + DIRECT_CONTACT_PROMPT, + _bold_whatsapp_ask, + _extract_saved_answer, + _pending_question_text, + _render_direct_contact, + _staff_outbound, +) +from jvagent.tooling.tool_executor import bind_dispatch_context + +# Digits must pass _contact_kind (>=6) so ACA members classify as whatsapp. +_STAFF_PHONE = "5921111111" +_STAFF_PHONE_B = "5922222222" + + +class _Ctx: + def __init__(self, user_id="", channel="whatsapp", conversation=None): + self.user_id = user_id + self.channel = channel + self.conversation = conversation + self.session_id = "" + + +class _Conversation: + def __init__(self, contact=""): + self.context = {"handoff_contact": contact} if contact else {} + + async def update_context(self, updates): + self.context.update(updates) + + +class _PendingStore: + """Shared durable pending_questions keyed by action id (simulates Action.get).""" + + by_action: dict = {} + ACTION_ID = "n.HandoffAction.test" + + @classmethod + def reset(cls): + cls.by_action = {} + + @classmethod + def rows_for(cls, action_id=None): + return list(cls.by_action.get(action_id or cls.ACTION_ID, [])) + + +def _install_aca_staff(monkeypatch, members=None): + """Stub AccessControlAction HandoffAction.staff membership.""" + staff = list(members if members is not None else [_STAFF_PHONE]) + + class _ACA: + def policy_applies(self): + return True + + def get_user_groups(self, action_label=None): + assert action_label == "HandoffAction" + return {"staff": list(staff)} + + async def _get_action(self, name, *args, **kwargs): + assert name == "AccessControlAction" + return _ACA() + + monkeypatch.setattr(HandoffAction, "get_action", _get_action) + return staff + + +def _install_pending_store(monkeypatch, action_id=_PendingStore.ACTION_ID): + """Stub Action save/get + cache so pending_questions is shared across instances.""" + _PendingStore.reset() + _PendingStore.ACTION_ID = action_id + + async def _save(self): + aid = str(getattr(self, "id", "") or action_id).strip() or action_id + object.__setattr__(self, "id", aid) + _PendingStore.by_action[aid] = [ + row for row in (self.pending_questions or []) if isinstance(row, dict) + ] + + async def _get(cls, eid): + aid = str(eid or "").strip() + rows = _PendingStore.by_action.get(aid, []) + fresh = SimpleNamespace( + id=aid, + pending_questions=list(rows), + ) + return fresh + + async def _noop_cache(self): + return None + + async def _noop_invalidate(self): + await self._evict_self_cache() + + monkeypatch.setattr(HandoffAction, "save", _save) + monkeypatch.setattr(HandoffAction, "get", classmethod(_get)) + monkeypatch.setattr(HandoffAction, "_evict_self_cache", _noop_cache) + monkeypatch.setattr(HandoffAction, "_invalidate_pending_caches", _noop_invalidate) + return _PendingStore + + +def _bind_action(action, action_id=_PendingStore.ACTION_ID): + object.__setattr__(action, "id", action_id) + action.pending_questions = list(_PendingStore.by_action.get(action_id, [])) + return action + + +def _seed_pending(action, **kwargs): + row = { + "id": kwargs.get("id") or f"q_{len(action.pending_questions or []) + 1}", + "question": kwargs.get("question", ""), + "user_channel": kwargs.get("user_channel", "default"), + "user_contact": kwargs.get("user_contact", ""), + "created_at": kwargs.get("created_at", ""), + } + rows = list(action.pending_questions or []) + rows.append(row) + action.pending_questions = rows + aid = str(getattr(action, "id", "") or _PendingStore.ACTION_ID) + object.__setattr__(action, "id", aid) + _PendingStore.by_action[aid] = list(rows) + return row + + +def test_direct_contact_prompt_uses_placeholders_not_literals(): + assert "support@company.com" not in DIRECT_CONTACT_PROMPT + assert "+592" not in DIRECT_CONTACT_PROMPT + assert "{handoff_email}" in DIRECT_CONTACT_PROMPT + assert "{handoff_phone}" in DIRECT_CONTACT_PROMPT + assert "{handoff_hours}" in DIRECT_CONTACT_PROMPT + + +def test_defaults(): + action = HandoffAction() + assert "9:00 AM" in action.handoff_hours + + +def test_render_direct_contact_drops_blank_field_lines(): + rendered = _render_direct_contact( + DIRECT_CONTACT_PROMPT, email="", phone="", hours="Mon-Fri" + ) + assert "Email:" not in rendered + assert "Phone" not in rendered + assert "Office Hours: Mon-Fri" in rendered + + +async def test_render_helper_matches_tool(monkeypatch): + _install_aca_staff(monkeypatch, ["1234567890", "a@b.c", "c@d.e"]) + action = HandoffAction() + rendered = await action.render_direct_contact() + assert "a@b.c" in rendered + assert "c@d.e" in rendered + assert "1234567890" in rendered + + +async def test_tools_are_published(): + action = HandoffAction() + names = {t.name for t in await action.get_tools()} + assert names == { + "handoff__contact_details", + "handoff__update_contact", + "handoff__staff_lookup", + "handoff__agent_escalation", + "handoff__scheduled_callback", + "handoff__pending_questions", + "handoff__save_answer", + "handoff__update_chunk", + } + + +async def test_direct_contact_tool_returns_rendered_block(monkeypatch): + _install_aca_staff(monkeypatch, ["1234567890", "a@b.c"]) + action = HandoffAction() + tools = {t.name: t for t in await action.get_tools()} + result = await tools["handoff__contact_details"].call() + assert "Email: a@b.c" in result.content + assert "Phone / WhatsApp: 1234567890" in result.content + + +async def test_update_contact_replaces_saved_reply_address(monkeypatch): + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + q1 = _seed_pending( + action, + id="q1", + question="Do you offer delivery?", + user_channel="default", + user_contact="592000", + ) + q2 = _seed_pending( + action, + id="q2", + question="Someone else", + user_channel="default", + user_contact="592111", + ) + conversation = _Conversation("592000") + + tools = {t.name: t for t in await action.get_tools()} + missing_conversation = await tools["handoff__update_contact"].call( + phone_number="5926431530" + ) + assert missing_conversation.is_error + assert conversation.context["handoff_contact"] == "592000" + + visitor = _Ctx("o.User.abc", channel="default", conversation=conversation) + with bind_dispatch_context(visitor): + missing = await tools["handoff__update_contact"].call() + assert missing.is_error + assert conversation.context["handoff_contact"] == "592000" + assert q1["user_contact"] == "592000" + + phone = await tools["handoff__update_contact"].call(phone_number="5926431530") + assert not phone.is_error + assert "5926431530" in phone.content + assert conversation.context["handoff_contact"] == "5926431530" + assert q1["user_contact"] == "5926431530" + assert q2["user_contact"] == "592111" + + mailed = await tools["handoff__update_contact"].call(email="customer@x.com") + assert not mailed.is_error + assert conversation.context["handoff_contact"] == "customer@x.com" + assert q1["user_contact"] == "customer@x.com" + assert q2["user_contact"] == "592111" + + +async def test_escalation_asks_when_no_contact(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + r = await tools["handoff__agent_escalation"].call(message="x") + assert not r.is_error + assert "Ask the user for a phone number or email" in r.content + assert "handoff__agent_escalation" in r.content + assert "to" not in sent + + +async def test_escalation_uses_whatsapp_sender(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("592000")): + pending = await tools["handoff__agent_escalation"].call(message="need a person") + assert "Confirm with the user" in pending.content + assert "592000" in pending.content + assert "need a person" in pending.content + assert "to" not in sent + r = await tools["handoff__agent_escalation"].call( + message="need a person", confirmed=True + ) + callback = await tools["handoff__scheduled_callback"].call( + message="call me later" + ) + assert not r.is_error + assert "staff member will reach out" in r.content + assert "phone number or email" not in r.content + assert sent["to"] == _STAFF_PHONE + assert "Contact: 592000" in sent["message"] + assert "need a person" in sent["message"] + assert "Confirm with the user" in callback.content + sent.clear() + with bind_dispatch_context(_Ctx("592000")): + confirmed_callback = await tools["handoff__scheduled_callback"].call( + message="call me later", confirmed=True + ) + assert "follow up" in confirmed_callback.content + assert "Contact: 592000" in sent["message"] + + +async def test_staff_lookup_holds_until_contact_or_decline(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + sent = {"count": 0} + open_q = {} + + class _Question: + def __init__(self, contact): + self.id = "q_open" + self.user_contact = contact + self.question = "" + + async def update_user_contact(self, contact): + self.user_contact = contact + + async def save(self): + open_q["q"] = self + + async def _send(self, recipient, message): + sent["count"] += 1 + sent["to"] = recipient + sent["message"] = message + + async def _record(self, message, contact=""): + sent["contact"] = contact + sent["question"] = message + open_q["q"] = _Question(contact) + + async def _open(self, message=""): + return open_q.get("q") + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + monkeypatch.setattr(HandoffAction, "_add_pending", _record) + monkeypatch.setattr(HandoffAction, "_open_lookup", _open) + tools = {t.name: t for t in await action.get_tools()} + + r = await tools["handoff__staff_lookup"].call(message="where do you deliver?") + assert not r.is_error + assert "phone number or email" in r.content + assert "contact_declined" in r.content + assert sent["count"] == 0 + assert "question" not in sent + assert open_q.get("q") is None + + r = await tools["handoff__staff_lookup"].call(message="where do you deliver?") + assert sent["count"] == 0 + + r = await tools["handoff__staff_lookup"].call( + message=( + "The customer asked where we deliver. FAQ search returned nothing. " + "Customer provided phone number: 592000." + ), + phone_numbers=["592000"], + ) + assert "don't have that information" in r.content + assert "check with the team" in r.content + assert "when they respond" in r.content + assert sent["count"] == 1 + assert open_q["q"].user_contact == "592000" + assert "592000" not in sent["message"] + assert "where we deliver" in sent["message"] + + r = await tools["handoff__staff_lookup"].call( + message="The customer asked where we deliver. FAQ search found nothing useful.", + phone_numbers=["592000"], + ) + assert sent["count"] == 1 + + open_q.clear() + sent["count"] = 0 + r = await tools["handoff__staff_lookup"].call( + message="where do you deliver?", contact_declined=True + ) + assert "don't have that information" in r.content + assert "check with the team" in r.content + assert "when they respond" in r.content + assert sent["count"] == 1 + assert open_q["q"].user_contact == "declined" + + with bind_dispatch_context(_Ctx("592111")): + open_q.clear() + r = await tools["handoff__staff_lookup"].call(message="what are your hours?") + assert "don't have that information" in r.content + assert "check with the team" in r.content + assert "when they respond" in r.content + assert "phone number or email" not in r.content + + +async def test_declined_lookup_does_not_reply(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = [] + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send_whatsapp(self, recipient, message): + sent.append(("whatsapp", recipient)) + + async def _send_email(self, recipients, message, subject="Human handoff request"): + sent.append(("email", recipients)) + + async def _append(self, question, answer): + return question, answer, "n.DocumentNode.test" + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send_whatsapp) + monkeypatch.setattr(HandoffAction, "_send_email", _send_email) + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("o.User.abc", channel="default")): + looked = await tools["handoff__staff_lookup"].call( + message="where do you deliver?", contact_declined=True + ) + assert "when they respond" in looked.content + assert sent == [("whatsapp", _STAFF_PHONE)] + assert len(action.pending_questions) == 1 + row = action.pending_questions[0] + assert row["user_contact"] == "declined" + question_id = row["id"] + sent.clear() + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + saved = await tools["handoff__save_answer"].call( + question_id=question_id, answer="Yes, we deliver." + ) + assert not saved.is_error + assert sent == [] + assert action.pending_questions == [] + + +async def test_staff_lookup_reuses_saved_contact(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + conversation = _Conversation("5926431530") + sent = {"count": 0, "messages": []} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + sent["count"] += 1 + sent["messages"].append(message) + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx("o.User.abc", channel="default", conversation=conversation) + with bind_dispatch_context(visitor): + reused = await tools["handoff__staff_lookup"].call( + message="Do you sell car parts" + ) + again = await tools["handoff__staff_lookup"].call( + message="Do you offer delivery" + ) + repeat = await tools["handoff__staff_lookup"].call( + message="Do you offer delivery" + ) + assert "phone number or email" not in reused.content + assert "when they respond" in reused.content + assert "when they respond" in again.content + assert sent["count"] == 2 + assert all("5926431530" not in message for message in sent["messages"]) + assert [row["question"] for row in action.pending_questions] == [ + "Do you sell car parts", + "Do you offer delivery", + ] + assert {row["user_contact"] for row in action.pending_questions} == {"5926431530"} + assert "when they respond" in repeat.content + assert sent["count"] == 2 + + +async def test_escalation_confirms_updated_contact(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + conversation = _Conversation("5926431530") + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx("o.User.abc", channel="default", conversation=conversation) + with bind_dispatch_context(visitor): + updated = await tools["handoff__agent_escalation"].call( + message="need a person", + phone_numbers=["592999"], + confirmed=True, + ) + assert "Confirm with the user" in updated.content + assert "592999" in updated.content + assert "need a person" in updated.content + assert "to" not in sent + assert conversation.context["handoff_contact"] == "592999" + sent_call = await tools["handoff__agent_escalation"].call( + message="need a person", confirmed=True + ) + assert "staff member will reach out" in sent_call.content + assert sent["to"] == _STAFF_PHONE + assert "Contact: 592999" in sent["message"] + + +def test_channel_for_defaults_and_override(): + action = HandoffAction() + assert action._channel_for("staff_lookup") == "whatsapp" + action.handoff_channels = {"staff_lookup": "email"} + assert action._channel_for("staff_lookup") == "email" + action.handoff_channels = {"staff_lookup": "nonsense"} + assert action._channel_for("staff_lookup") == "whatsapp" + + +async def test_staff_targets_and_allowlist(monkeypatch): + _install_aca_staff( + monkeypatch, [_STAFF_PHONE, _STAFF_PHONE_B, "a@x.com", "b@x.com"] + ) + action = HandoffAction() + assert await action._staff_targets("whatsapp") == [_STAFF_PHONE, _STAFF_PHONE_B] + assert await action._staff_targets("email") == ["a@x.com", "b@x.com"] + with bind_dispatch_context(_Ctx(_STAFF_PHONE_B)): + assert await action._is_staff() + with bind_dispatch_context(_Ctx("9999999999")): + assert not await action._is_staff() + with bind_dispatch_context(_Ctx("a@x.com", channel="email")): + assert await action._is_staff() + + +async def test_is_staff_uses_access_control_group(monkeypatch): + _install_aca_staff(monkeypatch, ["5929999999"]) + action = HandoffAction() + with bind_dispatch_context(_Ctx("5929999999")): + assert await action._is_staff() + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + assert not await action._is_staff() + + +async def test_staff_targets_classify_aca_members(monkeypatch): + _install_aca_staff(monkeypatch, ["5929999999", "aca@x.com"]) + action = HandoffAction() + assert await action._staff_targets("whatsapp") == ["5929999999"] + assert await action._staff_targets("email") == ["aca@x.com"] + phone, emails = await action._public_contacts() + assert phone == "5929999999" + assert emails == ["aca@x.com"] + + +async def test_staff_targets_empty_without_aca(monkeypatch): + action = HandoffAction() + + async def _no_aca(self, *args, **kwargs): + return None + + monkeypatch.setattr(HandoffAction, "get_action", _no_aca) + assert await action._staff_targets("whatsapp") == [] + assert await action._staff_targets("email") == [] + + +async def test_pending_list_is_shared_and_save_stays_staff_only(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + + class _Agent: + id = "n.Agent.shared" + + action = _bind_action(HandoffAction()) + other = _bind_action(HandoffAction()) + _seed_pending( + action, + id="q1", + question="Do you offer delivery?", + user_channel="whatsapp", + user_contact="5926431530", + ) + + async def _get_agent(self): + return _Agent() + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + listed = await action._list_pending() + assert any(row["id"] == "q1" for row in listed) + # Same action id: second instance refreshes and sees the shared pending list. + other_listed = await other._list_pending() + assert any(row["id"] == "q1" for row in other_listed) + + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("999")): + listed_tool = await tools["handoff__pending_questions"].call() + denied = await tools["handoff__save_answer"].call( + question_id="q1", answer="Yes, we deliver." + ) + assert not listed_tool.is_error + assert "Do you offer delivery?" in listed_tool.content + assert denied.is_error + assert "only authorized staff" in denied.content + + +async def test_pending_crud_add_list_update_remove(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + + class _Agent: + id = "n.Agent.shared" + + writer = _bind_action(HandoffAction()) + reader = _bind_action(HandoffAction()) + + async def _get_agent(self): + return _Agent() + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + with bind_dispatch_context(_Ctx("5926431530", channel="whatsapp")): + created = await writer._add_pending("Do you offer delivery?", "5926431530") + assert len(writer.pending_questions) == 1 + assert created["id"] == writer.pending_questions[0]["id"] + + listed = await reader._list_pending() + assert len(listed) == 1 + assert listed[0]["question"] == "Do you offer delivery?" + assert listed[0]["user_contact"] == "5926431530" + + await reader._update_pending(created["id"], user_contact="5920001111") + assert writer.pending_questions[0]["user_contact"] == "5920001111" + assert (await reader._list_pending())[0]["user_contact"] == "5920001111" + + tools = {t.name: t for t in await reader.get_tools()} + with bind_dispatch_context(_Ctx("999")): + denied = await tools["handoff__save_answer"].call( + question_id=created["id"], answer="Yes." + ) + assert denied.is_error + assert "only authorized staff" in denied.content + + async def _append(self, question, answer): + return question, answer, "n.DocumentNode.test" + + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + saved = await tools["handoff__save_answer"].call( + question_id=created["id"], answer="Yes, we deliver for 5000." + ) + assert not saved.is_error + assert await reader._list_pending() == [] + + +async def test_add_pending_raises_when_not_listable(monkeypatch): + _install_pending_store(monkeypatch) + + class _Agent: + id = "n.Agent.shared" + + action = _bind_action(HandoffAction()) + + async def _get_agent(self): + return _Agent() + + async def _save_no_persist(self): + # Simulate write that never becomes visible to Action.get. + aid = str(getattr(self, "id", "") or _PendingStore.ACTION_ID) + object.__setattr__(self, "id", aid) + _PendingStore.by_action[aid] = [] + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "save", _save_no_persist) + with bind_dispatch_context(_Ctx("5926431530", channel="whatsapp")): + try: + await action._add_pending("Do you offer delivery?", "5926431530") + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "not listable" in str(exc) + + +def test_action_contributes_orchestration_routing_parameter(): + from jvagent.action.parameters import orchestration_parameters, render_parameters + + action = HandoffAction() + params = orchestration_parameters(action.parameters) + assert params and params[0].get("key") == "handoff_routing" + rendered = render_parameters(params) + assert "handoff__staff_lookup" in rendered + assert "handoff__contact_details" in rendered + assert "use_skill" in rendered + assert params[1].get("key") == "handoff_staff_answer" + assert "handoff__pending_questions" in rendered + assert "handoff__save_answer" in rendered + + +def test_extract_saved_answer_strips_prefix_and_prefers_longer_utterance(): + assert ( + _extract_saved_answer("save answer: no we do not sell cars") + == "no we do not sell cars" + ) + assert _extract_saved_answer("Answer: we do not sell cars") == "we do not sell cars" + assert ( + _extract_saved_answer("no we do", "save answer: no we do not sell cars") + == "no we do not sell cars" + ) + assert ( + _extract_saved_answer( + "no we do not sell cars", "save answer: no we do not sell cars" + ) + == "no we do not sell cars" + ) + + +def test_pending_question_text_keeps_ask_drops_handling_notes(): + full = ( + "Customer asked if we sell car parts. " + "No information found in the FAQ or catalog." + ) + assert _pending_question_text(full) == "Customer asked if we sell car parts." + assert ( + _pending_question_text("Customer asked for the company's location.") + == "Customer asked for the company's location." + ) + + +def test_bold_whatsapp_ask_wraps_core(): + assert ( + _bold_whatsapp_ask("Customer asked if we sell car parts.") + == "Customer asked if *we sell car parts*." + ) + assert ( + _bold_whatsapp_ask("Customer asked for the company's location.") + == "Customer asked for *the company's location*." + ) + + +def test_staff_outbound_lookup_bolds_ask_and_keeps_notes(): + body = _staff_outbound( + "staff_lookup", + "Customer asked if we sell car parts. No information found in the FAQ.", + None, + None, + "", + ) + assert body.startswith("Customer asked if *we sell car parts*.") + assert "No information found in the FAQ." in body + + +async def test_staff_lookup_stores_question_only_sends_bold_notes(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("o.User.abc", channel="default")): + result = await tools["handoff__staff_lookup"].call( + message=( + "Customer asked for the company's location. " + "No information found in the knowledge base." + ), + phone_numbers=["5926431530"], + ) + assert not result.is_error + assert len(action.pending_questions) == 1 + assert ( + action.pending_questions[0]["question"] + == "Customer asked for the company's location." + ) + assert "No information found" not in action.pending_questions[0]["question"] + assert sent["to"] == _STAFF_PHONE + assert "Customer asked for *the company's location*." in sent["message"] + assert "No information found in the knowledge base." in sent["message"] + + +async def test_resolve_saves_cleaned_answer_and_thanks(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + _seed_pending( + action, + id="q1", + question="Do you sell cars?", + ) + saved = {} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _append(self, question, answer): + saved["ingested"] = (question, answer) + return question, answer, "n.DocumentNode.test" + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx(_STAFF_PHONE) + visitor.utterance = ( + "save answer: no we do not sell cars and we do not take trade-ins" + ) + with bind_dispatch_context(visitor): + result = await tools["handoff__save_answer"].call( + question_id="q1", + answer="save answer: no we do", + ) + assert not result.is_error + expected = "no we do not sell cars and we do not take trade-ins" + assert action.pending_questions == [] + assert await action._list_pending() == [] + assert saved["ingested"] == ("Do you sell cars?", expected) + assert "Your answer is saved and will be used in the future" in result.content + assert "Do you sell cars?" not in result.content + + +async def test_save_answer_whatsapp_replies_to_customer(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + _seed_pending( + action, + id="q1", + question="Customer asked if we sell cars.", + user_channel="whatsapp", + user_contact="592111", + ) + sent = {} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _append(self, question, answer): + return "Do you sell cars?", "No, we do not sell cars.", "n.DocumentNode.test" + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + async def _reply(self, question, answer): + return f"You asked: {question}\n\n{answer}" + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + monkeypatch.setattr(HandoffAction, "_customer_reply", _reply) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + result = await tools["handoff__save_answer"].call( + question_id="q1", answer="No, we do not sell cars." + ) + assert not result.is_error + assert sent["to"] == "592111" + assert "Do you sell cars?" in sent["message"] + assert "No, we do not sell cars." in sent["message"] + + +async def test_save_answer_replies_to_provided_contact(monkeypatch): + _install_aca_staff(monkeypatch) + + async def _append(self, question, answer): + return "Do you sell cars?", "No, we do not sell cars.", "n.DocumentNode.test" + + async def _reply(self, question, answer): + return f"You asked: {question}\n\n{answer}" + + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + monkeypatch.setattr(HandoffAction, "_customer_reply", _reply) + + async def _run(contact): + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + _seed_pending( + action, + id="q1", + question="Customer asked if we sell cars.", + user_channel="default", + user_contact=contact, + ) + sent = {} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send_whatsapp(self, recipient, message): + sent["whatsapp"] = (recipient, message) + + async def _send_email( + self, recipients, message, subject="Human handoff request" + ): + sent["email"] = (recipients, message, subject) + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send_whatsapp) + monkeypatch.setattr(HandoffAction, "_send_email", _send_email) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + result = await tools["handoff__save_answer"].call( + question_id="q1", answer="No, we do not sell cars." + ) + assert not result.is_error + return sent + + phone = await _run("592000") + assert phone["whatsapp"][0] == "592000" + assert "Do you sell cars?" in phone["whatsapp"][1] + assert "email" not in phone + + mailed = await _run("customer@x.com") + assert mailed["email"][0] == ["customer@x.com"] + assert mailed["email"][2] == "Do you sell cars?" + assert "No, we do not sell cars." in mailed["email"][1] + assert "whatsapp" not in mailed + + declined = await _run("declined") + assert declined == {} + + +async def test_append_and_ingest_writes_short_qa_and_passes_text(monkeypatch, tmp_path): + json_path = tmp_path / "handoff.json" + monkeypatch.setattr( + "jvagent.action.handoff_action.handoff_action._handoff_json_path", + lambda: json_path, + ) + + condense_calls = [] + + async def _condense(self, question, answer): + condense_calls.append((question, answer)) + return "Do you sell cars?", "No, we do not sell cars." + + captured = {} + + async def _export(collection_name="default", doc_name=None, root_id=None): + return { + "roots": [ + { + "id": "n.DocumentRootNode.existing", + "entity": "DocumentRootNode", + "type_code": "n", + "edge_ids": ["e.DocumentContentEdge.existing"], + "doc_name": "handoff.md", + "collection_name": collection_name, + "metadata": {"access": "public"}, + "chunks": 1, + } + ], + "nodes": [ + { + "id": "n.DocumentNode.existing", + "entity": "DocumentNode", + "title": "Where are you located?", + } + ], + "edges": [ + { + "id": "e.DocumentContentEdge.existing", + "entity": "DocumentContentEdge", + "source": "n.DocumentRootNode.existing", + "target": "n.DocumentNode.existing", + } + ], + } + + async def _delete(doc_name, collection_name="default"): + captured["deleted"] = doc_name + captured["collection"] = collection_name + return True + + async def _import(data, purge=False, collection_name=None): + captured["graph"] = data + captured["purge"] = purge + captured["import_collection"] = collection_name + + monkeypatch.setattr("jvagent.action.pageindex.documents.export_documents", _export) + monkeypatch.setattr("jvagent.action.pageindex.documents.delete_document", _delete) + monkeypatch.setattr("jvagent.action.pageindex.documents.import_documents", _import) + + class _Agent: + id = "n.Agent.test" + + action = HandoffAction() + + async def _get_agent(self): + return _Agent() + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_condense_qa", _condense) + + short_q, short_a, chunk_id = await action._append_and_ingest( + "Customer asked if we sell cars. Please confirm if we sell vehicles.", + "No, we do not sell cars.", + ) + assert condense_calls == [ + ( + "Customer asked if we sell cars. Please confirm if we sell vehicles.", + "No, we do not sell cars.", + ) + ] + assert short_q == "Do you sell cars?" + assert short_a == "No, we do not sell cars." + assert chunk_id.startswith("n.DocumentNode.") + assert chunk_id != "n.DocumentNode.existing" + assert not (tmp_path / "handoff.md").exists() + assert captured["deleted"] == "handoff.md" + assert captured["collection"] == "n.Agent.test" + assert captured["purge"] is False + graph = captured["graph"] + assert graph["roots"][0]["doc_name"] == "handoff.md" + assert graph["roots"][0]["metadata"]["access"] == "public" + assert graph["roots"][0]["collection_name"] == "n.Agent.test" + titles = {node["title"] for node in graph["nodes"]} + assert titles == {"Where are you located?", "Do you sell cars?"} + assert any(node["id"] == "n.DocumentNode.existing" for node in graph["nodes"]) + assert len(graph["edges"]) == 2 + saved = json.loads(json_path.read_text(encoding="utf-8")) + assert {node["title"] for node in saved["nodes"]} == titles + assert saved["roots"][0]["doc_name"] == "handoff.md" + + +class _Interaction: + def __init__(self): + self.events = [] + + def add_event(self, event, action_name): + self.events.append((event, action_name)) + return True + + async def save(self): + return self + + +async def test_save_answer_records_chunk_event(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + _seed_pending( + action, + id="q1", + question="Customer asked if we sell cars.", + user_channel="default", + user_contact="", + ) + interaction = _Interaction() + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _append(self, question, answer): + return "Do you sell cars?", "No, we do not sell cars.", "n.DocumentNode.abc" + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx(_STAFF_PHONE) + visitor.interaction = interaction + with bind_dispatch_context(visitor): + result = await tools["handoff__save_answer"].call( + question_id="q1", answer="No, we do not sell cars." + ) + assert not result.is_error + assert interaction.events == [ + ('Handoff chunk n.DocumentNode.abc "Do you sell cars?".', "HandoffAction") + ] + + +async def test_update_chunk_replaces_existing_answer(monkeypatch, tmp_path): + _install_aca_staff(monkeypatch) + json_path = tmp_path / "handoff.json" + json_path.write_text( + json.dumps( + { + "nodes": [ + { + "id": "n.DocumentNode.existing", + "title": "Do you sell cars?", + "text": "old", + "summary": "old", + } + ] + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + "jvagent.action.handoff_action.handoff_action._handoff_json_path", + lambda: json_path, + ) + captured = {} + interaction = _Interaction() + + async def _condense(self, question, answer): + captured["condense"] = (question, answer) + return "Do you sell cars?", "No, we do not sell cars." + + async def _update(chunk_id, doc_name, collection_name, updates): + captured["update"] = (chunk_id, doc_name, collection_name, updates) + return {"id": chunk_id} + + async def _get_agent(self): + class _Agent: + id = "n.Agent.test" + + return _Agent() + + monkeypatch.setattr(HandoffAction, "_condense_qa", _condense) + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr( + "jvagent.action.pageindex.documents.update_document_chunk", _update + ) + action = HandoffAction() + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx(_STAFF_PHONE) + visitor.interaction = interaction + with bind_dispatch_context(visitor): + result = await tools["handoff__update_chunk"].call( + chunk_id="n.DocumentNode.existing", + answer="No, we do not sell cars.", + ) + assert not result.is_error + assert "Your answer is updated" in result.content + assert captured["condense"] == ("Do you sell cars?", "No, we do not sell cars.") + chunk_id, doc_name, collection, updates = captured["update"] + assert chunk_id == "n.DocumentNode.existing" + assert doc_name == "handoff.md" + assert collection == "n.Agent.test" + assert updates["text"] == "## Do you sell cars?\n\nNo, we do not sell cars." + assert updates["summary"] == updates["text"] + saved = json.loads(json_path.read_text(encoding="utf-8")) + assert saved["nodes"][0]["text"] == updates["text"] + assert not (tmp_path / "handoff.md").exists() + assert interaction.events[0][0] == ( + 'Handoff chunk n.DocumentNode.existing "Do you sell cars?".' + ) + + +def test_handoff_skills_split_access_and_tools(): + from pathlib import Path + + from jvagent.action.orchestrator.orchestrator_interact_action import ( + OrchestratorInteractAction, + ) + from jvagent.action.orchestrator.skills import SkillDoc + from jvagent.scaffold.skill_resolve import parse_skill_bundle + + root = Path(__file__).resolve().parents[2] / "jvagent" / "skills" + customer = parse_skill_bundle(root / "handoff", source="builtin") + staff = parse_skill_bundle(root / "handoff_staff", source="builtin") + assert customer["access_action"] == "HandoffAction" + assert customer["denied_groups"] == ["staff"] + assert staff["access_action"] == "HandoffAction" + assert staff["allowed_groups"] == ["staff"] + assert "handoff__staff_lookup" in customer["allowed_tools"] + assert "handoff__save_answer" not in customer["allowed_tools"] + assert staff["allowed_tools"] == [ + "handoff__pending_questions", + "handoff__save_answer", + "handoff__update_chunk", + ] + assert "pending customer question" in staff["description"] + assert "cannot answer from the knowledge base" in customer["description"] + + class _ACA: + def policy_applies(self): + return True + + def get_user_groups(self, action_label=None): + return {"staff": ["111"]} + + customer_doc = SkillDoc( + name="handoff", + description="", + body="", + access_action="HandoffAction", + denied_groups=("staff",), + ) + staff_doc = SkillDoc( + name="handoff_staff", + description="", + body="", + access_action="HandoffAction", + allowed_groups=("staff",), + ) + aca = _ACA() + assert OrchestratorInteractAction._skill_access_control_allowed( + customer_doc, "999", aca + ) + assert not OrchestratorInteractAction._skill_access_control_allowed( + customer_doc, "111", aca + ) + assert OrchestratorInteractAction._skill_access_control_allowed( + staff_doc, "111", aca + ) + assert not OrchestratorInteractAction._skill_access_control_allowed( + staff_doc, "999", aca + ) + assert not OrchestratorInteractAction._skill_access_control_allowed( + staff_doc, "111", None + ) From 11cf551200d2192e93d31a312f3b5658d1dd5cb0 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 11:25:57 -0400 Subject: [PATCH 05/20] Update Dockerfile and workflows for dev-tj branch; bump jvagent version to 0.1.8rc20 --- .github/workflows/auto-tag.yaml | 2 +- Dockerfile.base | 2 +- jvagent/version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-tag.yaml b/.github/workflows/auto-tag.yaml index 916cd60d..1d16ee80 100644 --- a/.github/workflows/auto-tag.yaml +++ b/.github/workflows/auto-tag.yaml @@ -20,7 +20,7 @@ name: Auto-tag on version bump on: push: branches: - - main + - dev-tj paths: - "jvagent/version.py" diff --git a/Dockerfile.base b/Dockerfile.base index de6052ac..a7809cb4 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -16,7 +16,7 @@ WORKDIR /var/task RUN set -eux && \ dnf -y update && \ dnf install -y git file file-libs && \ - git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvagent && \ + git clone --depth 1 --single-branch --branch dev-tj https://github.com/TrueSelph/jvagent && \ git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvspatial # Stage 2: venv and Python deps (Lambda image provides python3.12 under /var/lang/bin) diff --git a/jvagent/version.py b/jvagent/version.py index 086f2037..5267cd08 100644 --- a/jvagent/version.py +++ b/jvagent/version.py @@ -1,3 +1,3 @@ """Version information for jvagent package.""" -__version__ = "0.1.8rc19" +__version__ = "0.1.8rc20" From ed6b4706c70f05cc6cc79dbfffacac4310b113a4 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 17:19:08 -0400 Subject: [PATCH 06/20] refactor(handoff): clarify usage of contact details and staff lookup tools Updated docstrings for `handoff__contact_details` and `handoff__staff_lookup` to specify their intended use cases. Enhanced the SKILL.md documentation to emphasize that contact details should only be provided upon explicit request, while any unanswerable questions should be directed to staff lookup. This improves clarity for developers and users regarding the functionality of the handoff tools. --- .../action/handoff_action/handoff_action.py | 4 ++-- jvagent/skills/handoff/SKILL.md | 18 +++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/jvagent/action/handoff_action/handoff_action.py b/jvagent/action/handoff_action/handoff_action.py index 7e0f83e3..226e9d68 100644 --- a/jvagent/action/handoff_action/handoff_action.py +++ b/jvagent/action/handoff_action/handoff_action.py @@ -653,7 +653,7 @@ def get_capabilities(self) -> List[str]: @tool(name="handoff__contact_details") async def contact_details(self) -> str: - """Return the team's email, phone, and office hours. Use when the user wants those details and no notification should be sent.""" + """Return the team's email, phone, and office hours. Use only when the user explicitly requests a contact channel or office hours. It returns no business information; do not use it for any other unanswerable question (use handoff__staff_lookup).""" phone, emails = await self._public_contacts() return _render_direct_contact( self.direct_contact_prompt, @@ -720,7 +720,7 @@ async def staff_lookup( "True if the user refused to share a phone or email.", ] = None, ) -> ToolResult: - """Record a question you cannot answer and notify staff. Use after a search returned nothing useful.""" + """Record a question you cannot answer and notify staff. Use for any request the knowledge base cannot answer, including questions outside the assortment and any business fact no document provides.""" return await self._dispatch_handoff( "staff_lookup", message, diff --git a/jvagent/skills/handoff/SKILL.md b/jvagent/skills/handoff/SKILL.md index 2957e957..384f78ad 100644 --- a/jvagent/skills/handoff/SKILL.md +++ b/jvagent/skills/handoff/SKILL.md @@ -1,9 +1,11 @@ --- name: handoff description: >- - Reach a human teammate, give the team's contact details, or change the - phone or email replies are sent to. Use when the user asks for a person, - wants a callback, or you cannot answer from the knowledge base or catalog. + Reach a human teammate, give the team's phone, email, or office hours on + request, or change the phone or email replies are sent to. Use when the user + asks for a person, wants a callback, or you cannot answer from the knowledge + base or catalog. A question the knowledge base cannot answer is never a + request for contact details. allowed-tools: - handoff__contact_details - handoff__update_contact @@ -29,9 +31,9 @@ Call `use_skill` for this skill before any of its tools. ## Tools -- `handoff__contact_details` — user wants the team's details only; return contact number and office hours only. +- `handoff__contact_details` — use only when the user explicitly requests a contact channel (phone or email) or office hours. It returns no business information; any question the knowledge base cannot answer is not a contact-details request and goes to `handoff__staff_lookup`. - `handoff__update_contact` — the user wants to change the phone or email replies are sent to. Pass one `phone_number` or one `email`. -- `handoff__staff_lookup` — you cannot answer or the request is outside the assortment. Records a pending question and notifies staff. +- `handoff__staff_lookup` — owns every request the knowledge base cannot answer: anything outside the assortment or any business fact no document provides. Records a pending question and notifies staff. - `handoff__agent_escalation` — the user wants a person now. - `handoff__scheduled_callback` — the user wants to be reached later. @@ -61,5 +63,7 @@ Do not put handling notes in sentence 1. Do not mention WhatsApp or tell staff t email and call the same tool again, do that. If it does not, do not ask again and do not call the tool a second time. -If the user only wants the team's details (not a notification), use -`handoff__contact_details` and relay the block exactly as returned. +Use `handoff__contact_details` only for an explicit request for a contact +channel (phone or email) or office hours. Any other question the knowledge base +cannot answer is not a contact-details request; use `handoff__staff_lookup`. +Relay the tool's returned block exactly as returned. From 7608fa53db9f6dd5fb9844ae50fa55e802270bcf Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 19:00:59 -0400 Subject: [PATCH 07/20] fix(mcp-oauth): keep a cleared service binding from coming back via scopes Co-authored-by: Cursor --- jvagent/action/mcp_oauth/mcp_oauth_action.py | 15 ++++---- .../action/mcp_oauth/test_mcp_oauth_action.py | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/jvagent/action/mcp_oauth/mcp_oauth_action.py b/jvagent/action/mcp_oauth/mcp_oauth_action.py index 78972a18..1bef350f 100644 --- a/jvagent/action/mcp_oauth/mcp_oauth_action.py +++ b/jvagent/action/mcp_oauth/mcp_oauth_action.py @@ -192,18 +192,21 @@ def _services_from_scopes(token: Dict[str, Any], server_name: str) -> List[str]: def token_services(token: Dict[str, Any], server_name: str) -> List[str]: - """Services this token owns. Prefer ``mcp_services``; else infer from scopes.""" - raw = token.get("mcp_services") - if isinstance(raw, list): + """Services this token owns. Prefer ``mcp_services``; else infer from scopes. + + An explicit empty ``mcp_services`` list means this token owns nothing. + A missing key still falls through to ``service_tokens`` and then scopes, + so a legacy flat token keeps working. + """ + if "mcp_services" in token and isinstance(token.get("mcp_services"), list): seen: set[str] = set() out: List[str] = [] - for item in raw: + for item in token["mcp_services"]: svc = str(item).strip() if svc and svc not in seen: seen.add(svc) out.append(svc) - if out: - return out + return out st = _service_tokens_map(token) if st: return list(st) diff --git a/tests/action/mcp_oauth/test_mcp_oauth_action.py b/tests/action/mcp_oauth/test_mcp_oauth_action.py index a56654fb..ae74ee0a 100644 --- a/tests/action/mcp_oauth/test_mcp_oauth_action.py +++ b/tests/action/mcp_oauth/test_mcp_oauth_action.py @@ -9,6 +9,8 @@ mcp_oauth_state_action_id, oauth_bindings_from_tokens, parse_mcp_oauth_state_action_id, + token_row_for_service, + token_services, ) from jvagent.action.mcp_oauth.scopes import google_services_from_scopes @@ -195,6 +197,38 @@ def test_mcp_oauth_state_action_id_roundtrip(): assert parse_mcp_oauth_state_action_id("") == ("integral", "") +def test_empty_mcp_services_does_not_reclaim_sheets_from_scopes(): + """A cleared binding must stay cleared even if the refresh scopes include Sheets. + + Otherwise the older account still matches ``token_row_for_service`` and a + later sync writes the Sheets binding back onto it. + """ + old = { + "email": "jtharick@gmail.com", + "refresh_token": "rt-old", + "scopes": SHEETS_SCOPES, + "mcp_services": [], + } + new = { + "email": "other@example.com", + "refresh_token": "rt-new", + "scopes": SHEETS_SCOPES, + "mcp_services": ["sheets"], + } + assert token_services(old, "google_workspace") == [] + assert token_services({"scopes": SHEETS_SCOPES}, "google_workspace") == ["sheets"] + account, payload, _node = token_row_for_service( + [ + {"account_name": "jtharick@gmail.com", "token": old}, + {"account_name": "other@example.com", "token": new}, + ], + "google_workspace", + "sheets", + ) + assert account == "other@example.com" + assert payload["refresh_token"] == "rt-new" + + def test_drive_mcp_services_does_not_bind_sheets(): bindings = oauth_bindings_from_tokens( "google_workspace", From a59777d3de42ce0cd50a929ae3e9657394d9abfe Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 29 Sep 2026 19:33:33 -0400 Subject: [PATCH 08/20] fix(mcp-oauth): send with the mailbox last authorized for that service Co-authored-by: Cursor --- jvagent/action/mcp_oauth/mcp_oauth_action.py | 72 ++++++++++++++++--- .../action/mcp_oauth/test_mcp_oauth_action.py | 39 ++++++++++ 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/jvagent/action/mcp_oauth/mcp_oauth_action.py b/jvagent/action/mcp_oauth/mcp_oauth_action.py index 1bef350f..818ac5fc 100644 --- a/jvagent/action/mcp_oauth/mcp_oauth_action.py +++ b/jvagent/action/mcp_oauth/mcp_oauth_action.py @@ -351,15 +351,33 @@ def _migrate_flat_to_service_tokens( return out +def _row_recency(index: int, row: Any) -> tuple: + """Sort key: newest ``node.updated`` wins; equal stamps keep the later row.""" + node = row.get("node") if isinstance(row, dict) else None + updated = getattr(node, "updated", None) if node is not None else None + if isinstance(updated, datetime): + if updated.tzinfo is None: + updated = updated.replace(tzinfo=timezone.utc) + stamp = updated + else: + stamp = datetime.min.replace(tzinfo=timezone.utc) + return (stamp, index) + + def oauth_bindings_from_tokens( server_name: str, rows: Sequence[Any], ) -> Dict[str, Dict[str, str]]: - """Map stored MCP tokens to ``{service: {email}}`` without leaking secrets.""" + """Map stored MCP tokens to ``{service: {email}}`` without leaking secrets. + + When more than one token still lists a service, the newest ``node.updated`` + is the connection the panel shows. That is the same row ``token_row_for_service`` + uses to send. + """ from .hydrate import account_email_from_token - bindings: Dict[str, Dict[str, str]] = {} - for row in rows or []: + chosen: Dict[str, tuple[tuple, str]] = {} + for index, row in enumerate(rows or []): if not isinstance(row, dict): continue token = row.get("token") @@ -369,9 +387,12 @@ def oauth_bindings_from_tokens( email = account_email_from_token(token, account) if not email: continue + rank = _row_recency(index, row) for svc in token_services(token, server_name): - bindings[svc] = {"email": email} - return bindings + prev = chosen.get(svc) + if prev is None or rank >= prev[0]: + chosen[svc] = (rank, email) + return {svc: {"email": email} for svc, (_rank, email) in chosen.items()} def apply_service_rebind( @@ -478,10 +499,15 @@ def token_row_for_service( service: str, fallback_account: str = "integral", ) -> tuple[Optional[str], Optional[Dict[str, Any]], Any]: - """Pick the token that owns ``service``. Never steal another service's row.""" + """Pick the token that owns ``service``. Never steal another service's row. + + If several rows still list ``service``, the newest ``node.updated`` wins so + a reconnect replaces the mailbox the next send uses. + """ svc = (service or "").strip() if svc: - for row in rows or []: + matches: List[tuple[tuple, Any, Dict[str, Any]]] = [] + for index, row in enumerate(rows or []): if not isinstance(row, dict): continue token = row.get("token") @@ -492,8 +518,11 @@ def token_row_for_service( payload = service_token_payload(token, svc, server_name) if payload is None: continue - return row.get("account_name"), payload, row.get("node") - return None, None, None + matches.append((_row_recency(index, row), row, payload)) + if not matches: + return None, None, None + _rank, row, payload = max(matches, key=lambda item: item[0]) + return row.get("account_name"), payload, row.get("node") for row in rows or []: if not isinstance(row, dict): continue @@ -670,6 +699,31 @@ async def save_oauth_token_for_service( ) for acc, tok in updates: await self.save_oauth_token(server_name, acc, tok) + await self._clear_cached_google_clients(service) + + async def _clear_cached_google_clients(self, service: Optional[str]) -> None: + """Drop in-process Google API clients so the next call loads the new token.""" + from .scopes import GOOGLE_ACTION_SERVICES + + wanted = (service or "").strip() + try: + agent = await self.get_agent() + except Exception as exc: + logger.debug("No agent while clearing Google client cache: %s", exc) + return + if agent is None: + return + for type_name, svc_name in GOOGLE_ACTION_SERVICES: + if wanted and svc_name != wanted: + continue + try: + action = await agent.get_action_by_type(type_name) + except Exception as exc: + logger.debug("Google action lookup failed for %s: %s", type_name, exc) + continue + clear = getattr(action, "_clear_cached_services", None) + if callable(clear): + clear() async def get_oauth_token( self, diff --git a/tests/action/mcp_oauth/test_mcp_oauth_action.py b/tests/action/mcp_oauth/test_mcp_oauth_action.py index ae74ee0a..298033fc 100644 --- a/tests/action/mcp_oauth/test_mcp_oauth_action.py +++ b/tests/action/mcp_oauth/test_mcp_oauth_action.py @@ -229,6 +229,45 @@ def test_empty_mcp_services_does_not_reclaim_sheets_from_scopes(): assert payload["refresh_token"] == "rt-new" +def test_reconnected_gmail_uses_newest_mailbox(): + """Panel and send both follow the mailbox authorized last for that service.""" + from datetime import datetime, timezone + + gmail_scope = "https://www.googleapis.com/auth/gmail.send" + old_node = SimpleNamespace(updated=datetime(2026, 1, 1, tzinfo=timezone.utc)) + new_node = SimpleNamespace(updated=datetime(2026, 9, 29, tzinfo=timezone.utc)) + + def _gmail_token(email: str, refresh: str) -> dict: + return { + "email": email, + "refresh_token": refresh, + "scopes": [gmail_scope], + "mcp_services": ["gmail"], + "service_tokens": { + "gmail": {"refresh_token": refresh, "scopes": [gmail_scope]}, + }, + } + + older = { + "account_name": "jtharick@gmail.com", + "token": _gmail_token("jtharick@gmail.com", "rt-old"), + "node": old_node, + } + newer = { + "account_name": "rickdeghost25@gmail.com", + "token": _gmail_token("rickdeghost25@gmail.com", "rt-new"), + "node": new_node, + } + for rows in ([older, newer], [newer, older]): + account, payload, _node = token_row_for_service( + rows, "google_workspace", "gmail" + ) + assert account == "rickdeghost25@gmail.com" + assert payload["refresh_token"] == "rt-new" + bindings = oauth_bindings_from_tokens("google_workspace", rows) + assert bindings["gmail"]["email"] == "rickdeghost25@gmail.com" + + def test_drive_mcp_services_does_not_bind_sheets(): bindings = oauth_bindings_from_tokens( "google_workspace", From 808435eca44b1ee455663a9d3fbc6be4bc780792 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Wed, 30 Sep 2026 09:42:42 -0400 Subject: [PATCH 09/20] chore(version): update jvagent version to 0.1.8rc20-dev1 for development --- jvagent/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jvagent/version.py b/jvagent/version.py index 5267cd08..8e0257cf 100644 --- a/jvagent/version.py +++ b/jvagent/version.py @@ -1,3 +1,3 @@ """Version information for jvagent package.""" -__version__ = "0.1.8rc20" +__version__ = "0.1.8rc20-dev1" From e30a33b8549b385383cd61e70a0927364b3ff93e Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Wed, 30 Sep 2026 15:33:55 -0400 Subject: [PATCH 10/20] docs: update documentation for leadgen and handoff skills Clarified usage instructions in `leadgen` and `handoff` skills documentation. Updated `leadgen__retrieve` to specify it should be called mid-conversation and not at the start of a turn. Removed unnecessary references to `extends` in handoff skill files. Adjusted logging level for skill loading in the registry to debug for better traceability. --- jvagent/action/handoff_action/README.md | 7 +++---- jvagent/action/leadgen/SKILL.md | 12 +++++++++--- jvagent/action/leadgen/tools.py | 12 +++++++----- jvagent/action/skill_spec/registry.py | 2 +- jvagent/skills/handoff/SKILL.md | 1 - jvagent/skills/handoff_staff/SKILL.md | 1 - jvagent/version.py | 2 +- 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/jvagent/action/handoff_action/README.md b/jvagent/action/handoff_action/README.md index f73a454d..33293416 100644 --- a/jvagent/action/handoff_action/README.md +++ b/jvagent/action/handoff_action/README.md @@ -67,10 +67,9 @@ matrix. ## Skill The SOP lives in [`jvagent/skills/handoff/SKILL.md`](../../skills/handoff/SKILL.md) -(`allowed-tools` = these four, `requires-actions: [HandoffAction]`, -`extends: action:jvagent/handoff_action`). The action also contributes an -always-on orchestration parameter (`key: handoff_routing`) that tells the loop -when to hand off. +(`allowed-tools` = these four, `requires-actions: [HandoffAction]`). The action +also contributes an always-on orchestration parameter (`key: handoff_routing`) +that tells the loop when to hand off. ## Orchestrator wiring (agent.yaml) diff --git a/jvagent/action/leadgen/SKILL.md b/jvagent/action/leadgen/SKILL.md index cd223679..5ebc3fed 100644 --- a/jvagent/action/leadgen/SKILL.md +++ b/jvagent/action/leadgen/SKILL.md @@ -27,12 +27,18 @@ Do **not** call with empty arguments or when nothing changed. ## When to call `leadgen__retrieve` -Call when you need profile context but the user did not provide new data this turn: +Call mid-conversation, once a request cycle is already underway, when you need +profile context and the visitor supplied no new lead data this turn: -- Start of conversation (after greeting) -- Before gap-fill questions +- Before a gap-fill question, after the new request cycle has already been opened with `leadgen__prepare_request` - After `leadgen__capture` in the same turn (retrieve is optional — capture response includes `missing_fields`) +Do **not** call `leadgen__retrieve` to open a turn. When the visitor returns +after a sync — including a bare greeting like "hey" with no product named — the +next request cycle starts with `leadgen__prepare_request`, not retrieve. +Retrieve loads the now-cleared live record and would re-ask for contact details +the visitor already gave. + ## Gap-fill — ask on every turn until captured Contact capture is a staple: **after answering the visitor's question or request, proactively ask for the next missing field** from `gap_fill_priority` — do not wait for them to volunteer it. On every turn where required fields are still missing, close your reply with a concrete ask for the next one (**name** first, then **email or phone**). diff --git a/jvagent/action/leadgen/tools.py b/jvagent/action/leadgen/tools.py index cf325cef..5419b652 100644 --- a/jvagent/action/leadgen/tools.py +++ b/jvagent/action/leadgen/tools.py @@ -109,11 +109,13 @@ async def _sync(skill: Optional[str] = None, visitor: Any = None, **_: Any) -> s name="leadgen__retrieve", description=( "Load the current lead profile, missing_fields, field_reference, and a " - "next_ask hint. Call at the start of a turn when the visitor did not provide " - "new lead data, to plan the standing gap-fill ask — collecting the visitor's " - "name and email/phone is a staple, so keep moving toward the next missing " - "contact field on every turn (tie it to value; stop asking a field only once " - "it is captured or explicitly declined)." + "next_ask hint. Use mid-conversation, once a request cycle is already " + "underway, when you need profile context and the visitor supplied no new " + "lead data. NEVER the first call on a post-sync turn: when the visitor " + 'returns after a sync — including a bare greeting like "hey" with no ' + "product named — leadgen__prepare_request runs first. Do not use retrieve to " + "restore the profile after a sync or to open a turn; it reads the cleared " + "record and re-asks for details the visitor already gave." ), parameters_schema=optional_skill, execute=_retrieve, diff --git a/jvagent/action/skill_spec/registry.py b/jvagent/action/skill_spec/registry.py index 1776bdb5..051ed4c6 100644 --- a/jvagent/action/skill_spec/registry.py +++ b/jvagent/action/skill_spec/registry.py @@ -50,7 +50,7 @@ def discover(self, skills_dirs: List[str]) -> Dict[str, TSpec]: if spec is None or not name: continue self._specs[name] = spec - logger.info("Loaded %s spec: %s from %s", self._label, name, skill_dir) + logger.debug("Loaded %s spec: %s from %s", self._label, name, skill_dir) return self._specs def get(self, name: str) -> Optional[TSpec]: diff --git a/jvagent/skills/handoff/SKILL.md b/jvagent/skills/handoff/SKILL.md index 384f78ad..bdb9ddd9 100644 --- a/jvagent/skills/handoff/SKILL.md +++ b/jvagent/skills/handoff/SKILL.md @@ -14,7 +14,6 @@ allowed-tools: - handoff__scheduled_callback requires-actions: - HandoffAction -extends: action:jvagent/handoff_action access-action: HandoffAction denied-groups: - staff diff --git a/jvagent/skills/handoff_staff/SKILL.md b/jvagent/skills/handoff_staff/SKILL.md index e0afb71f..bc467676 100644 --- a/jvagent/skills/handoff_staff/SKILL.md +++ b/jvagent/skills/handoff_staff/SKILL.md @@ -10,7 +10,6 @@ allowed-tools: - handoff__update_chunk requires-actions: - HandoffAction -extends: action:jvagent/handoff_action access-action: HandoffAction allowed-groups: - staff diff --git a/jvagent/version.py b/jvagent/version.py index 8e0257cf..98611162 100644 --- a/jvagent/version.py +++ b/jvagent/version.py @@ -1,3 +1,3 @@ """Version information for jvagent package.""" -__version__ = "0.1.8rc20-dev1" +__version__ = "0.1.8rc20-dev2" From 565e1785b0f8c7436c747007128b774aa795341c Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Wed, 30 Sep 2026 16:38:58 -0400 Subject: [PATCH 11/20] chore: update Dockerfile and workflows to use main branch; bump jvagent version to 0.1.8rc19 --- .github/workflows/auto-tag.yaml | 2 +- Dockerfile.base | 2 +- jvagent/version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-tag.yaml b/.github/workflows/auto-tag.yaml index 1d16ee80..916cd60d 100644 --- a/.github/workflows/auto-tag.yaml +++ b/.github/workflows/auto-tag.yaml @@ -20,7 +20,7 @@ name: Auto-tag on version bump on: push: branches: - - dev-tj + - main paths: - "jvagent/version.py" diff --git a/Dockerfile.base b/Dockerfile.base index a7809cb4..de6052ac 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -16,7 +16,7 @@ WORKDIR /var/task RUN set -eux && \ dnf -y update && \ dnf install -y git file file-libs && \ - git clone --depth 1 --single-branch --branch dev-tj https://github.com/TrueSelph/jvagent && \ + git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvagent && \ git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvspatial # Stage 2: venv and Python deps (Lambda image provides python3.12 under /var/lang/bin) diff --git a/jvagent/version.py b/jvagent/version.py index 98611162..086f2037 100644 --- a/jvagent/version.py +++ b/jvagent/version.py @@ -1,3 +1,3 @@ """Version information for jvagent package.""" -__version__ = "0.1.8rc20-dev2" +__version__ = "0.1.8rc19" From 573aa749e8929f0c41576dbd80a111875e4202dc Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Tue, 6 Oct 2026 13:30:13 -0400 Subject: [PATCH 12/20] refactor(handoff): consolidate handoff action functionality and remove interact action This commit refactors the handoff action by consolidating its functionality into a single action mode, allowing for clearer management of human support interactions. The `handoff_interact_action` has been removed, and its features have been integrated into the main `handoff_action`. Additionally, new methods for tool access control have been introduced, enhancing the permission handling for tools based on user roles. Documentation has been updated to reflect these changes and clarify the usage of the handoff tools. --- .../access_control/access_control_action.py | 93 ++ jvagent/action/handoff_action/README.md | 502 +++++- .../action/handoff_action/handoff_action.py | 1382 +++++++++++------ jvagent/action/handoff_action/info.yaml | 12 +- .../action/handoff_interact_action/README.md | 13 - .../handoff_interact_action/__init__.py | 5 - .../handoff_interact_action.py | 339 ---- .../action/handoff_interact_action/info.yaml | 37 - jvagent/action/orchestrator/access.py | 66 +- jvagent/action/orchestrator/loop.py | 2 +- .../orchestrator_interact_action.py | 39 +- jvagent/action/orchestrator/surface_policy.py | 8 +- jvagent/action/orchestrator/tools.py | 13 + jvagent/action/parameters.py | 14 +- jvagent/action/reply/reply_action.py | 19 +- .../action/whatsapp/utils/endpoint_helpers.py | 40 + jvagent/skills/handoff/SKILL.md | 68 - jvagent/skills/handoff_staff/SKILL.md | 49 - jvagent/tooling/tool.py | 2 + jvagent/tooling/tool_decorator.py | 9 + tests/action/reply/test_reply_action.py | 20 + tests/action/test_handoff_action.py | 1334 +++++++++++++--- tests/action/test_handoff_contact_template.py | 31 - 23 files changed, 2723 insertions(+), 1374 deletions(-) delete mode 100644 jvagent/action/handoff_interact_action/README.md delete mode 100644 jvagent/action/handoff_interact_action/__init__.py delete mode 100644 jvagent/action/handoff_interact_action/handoff_interact_action.py delete mode 100644 jvagent/action/handoff_interact_action/info.yaml delete mode 100644 jvagent/skills/handoff/SKILL.md delete mode 100644 jvagent/skills/handoff_staff/SKILL.md delete mode 100644 tests/action/test_handoff_contact_template.py diff --git a/jvagent/action/access_control/access_control_action.py b/jvagent/action/access_control/access_control_action.py index 367a46b5..cc060ccd 100644 --- a/jvagent/action/access_control/access_control_action.py +++ b/jvagent/action/access_control/access_control_action.py @@ -119,6 +119,99 @@ async def has_action_access( logger.error(f"Error checking access for user {user_id}: {e}") return False + async def has_tool_access( + self, + user_id: str, + tool_name: str, + channel: str = "default", + ) -> bool: + """Check ``permissions[channel].tools[tool_name]`` allow/deny. + + Does not fall through to the channel ``any`` rule or ``default_deny``. + A missing ``tools`` entry denies. ``group`` matches that group under + every ``user_groups`` label unless the rule sets ``action``, which + limits the lookup to that label. + """ + try: + channel = normalize_channel(channel) + if not self.policy_applies(): + return False + uid = (user_id or "").strip() + tool_name = (tool_name or "").strip() + if not uid or not tool_name: + return False + channel_perms = self.permissions.get(channel) + if not isinstance(channel_perms, dict): + channel_perms = self.permissions.get("default", {}) + if not isinstance(channel_perms, dict): + return False + tools = channel_perms.get("tools") + if not isinstance(tools, dict): + return False + entry = tools.get(tool_name) + if not isinstance(entry, dict): + return False + for deny_rule in entry.get("deny") or []: + rule = self._normalize_rule(deny_rule) + if rule.get("enabled", True) and self._matches_tool_rule(uid, rule): + return False + for allow_rule in entry.get("allow") or []: + rule = self._normalize_rule(allow_rule) + if rule.get("enabled", True) and self._matches_tool_rule(uid, rule): + return True + return False + except Exception as e: + logger.error(f"Error checking tool access for user {user_id}: {e}") + return False + + def tool_permission_names(self, channel: str = "default") -> set: + """Tool names with an allow/deny entry on this channel.""" + channel = normalize_channel(channel) + channel_perms = self.permissions.get(channel) + if not isinstance(channel_perms, dict): + channel_perms = self.permissions.get("default", {}) + tools = channel_perms.get("tools") if isinstance(channel_perms, dict) else None + if not isinstance(tools, dict): + return set() + return {str(name) for name in tools} + + def _user_in_named_group( + self, user_id: str, group_name: str, label: str = "" + ) -> bool: + """True when ``user_id`` is in ``group_name``. + + A label limits the lookup to that ``user_groups`` key. An empty label + searches every key, so ``staff`` matches ``HandoffAction.staff``. + """ + if label: + groups = self._resolve_user_groups(label) + members = groups.get(group_name) + return isinstance(members, list) and user_id in members + for groups in (self.user_groups or {}).values(): + if not isinstance(groups, dict): + continue + members = groups.get(group_name) + if isinstance(members, list) and user_id in members: + return True + return False + + def _matches_tool_rule(self, user_id: str, rule: Dict) -> bool: + """Match a tool allow/deny rule.""" + rule_user = rule.get("user") + if rule_user is not None: + if rule_user in ["all", "any"]: + return True + if rule_user == user_id: + return True + rule_group = rule.get("group") + if rule_group: + if rule_group in ["all", "any"]: + return True + label = str(rule.get("action") or "").strip() + if self._user_in_named_group(user_id, rule_group, label): + return True + return False + def _check_access(self, user_id: str, channel: str, resource: str) -> bool: """Check access using permissions structure.""" channel_perms = self.permissions.get( diff --git a/jvagent/action/handoff_action/README.md b/jvagent/action/handoff_action/README.md index 33293416..085e6c7e 100644 --- a/jvagent/action/handoff_action/README.md +++ b/jvagent/action/handoff_action/README.md @@ -1,88 +1,432 @@ # Handoff Action (`jvagent/handoff_action`) -Human-support capability tools, **skill-gated** by the `handoff` library skill. -The tools stay on the orchestrator surface but refuse to run until a skill -declaring them in `allowed-tools` is active — so configure the orchestrator with -`skill_only_tools: ["handoff__*"]` and enable the `handoff` skill. - -## Tools - -| Tool | Purpose | -|---|---| -| `handoff__contact_details()` | Return office hours plus a random staff number and every staff email from `HandoffAction.staff`. No notification. | -| `handoff__staff_lookup(message, phone_numbers?, emails?)` | Notify staff about a question you cannot answer and record it as pending. Contact is optional. Non-retryable. | -| `handoff__agent_escalation(message, phone_numbers?, emails?)` | Notify staff that the customer wants a person now. Needs a contact. Non-retryable. | -| `handoff__scheduled_callback(message, phone_numbers?, emails?)` | Notify staff to reach the customer later. Needs a contact. Non-retryable. | -| `handoff__pending_questions()` | List customer questions waiting for an answer. | -| `handoff__save_answer(question_id, answer)` | Save the answer for one pending question into `handoff.md` and return a thank-you. Non-retryable. | - -`handoff__staff_lookup` records the question on `HandoffAction.pending_questions` -(reloaded from the Action row before list/get, caches invalidated after write) -on the first call, before staff are notified. Staff are notified once the -customer shares a phone or email, or declines. A later call with the same -contact does not send again. When a later message looks like an answer, call -`handoff__pending_questions`, then `handoff__save_answer`. Pending questions are -shared across conversations for the same Action so staff answering in their own -thread can see customer questions. - -## Channels - -Each tool chooses its channel from `handoff_channels` (`whatsapp` via `handoff_notify_action_type`, -default `WhatsAppAction`) or `"email"` (via `handoff_email_action_type`, default -`EmailAction`). Set the per-mode default in the skill body; the recipient is -**one staff target chosen at random** from the configured list. - -## Configuration - -AccessControlAction `user_groups.HandoffAction.staff` in agent.yaml — a mixed -list of WhatsApp numbers and email addresses. That list is the staff identity -allowlist and the notify/contact target source. Members are classified as -phone or email; notify picks one matching target at random for the channel. - -| Source | Role | -|---|---| -| `user_groups.HandoffAction.staff` | Staff WhatsApp numbers and emails. Any listed sender may save answers. Notify picks one matching target at random. | - -PageIndex target is fixed: `doc_name="handoff.md"`, -`metadata={"access": "public"}`, written to `/handoff.md` (default -`./.files/handoff.md`) — the file is created on first resolve and appended -cumulatively thereafter. - -### Email channel setup - -To use `channel="email"` you must wire outbound **and** inbound email: - -1. Add `jvagent/mcp_oauth`, `jvagent/mcp` (a `google_workspace` server), and - `jvagent/google_gmail_action` (Gmail) — or the SendGrid/Outlook equivalents. -2. Add `jvagent/email_action` with `provider: gmail` (or `sendgrid` / `outlook`). -3. Authorize: `/api/mcp/google_workspace/auth?service=gmail`. -4. Create the **email webhook** (`GET /api/actions/{action_id}/email/webhook-url`) - and point your provider's inbound parse / poll at it (SendGrid Inbound Parse - requires SPF+DKIM pass). -5. Set `EMAIL_DEFAULT_SENDER` (or the provider's from-address). - -See [`../email_action/README.md`](../email_action/README.md) for the provider -matrix. - -## Skill - -The SOP lives in [`jvagent/skills/handoff/SKILL.md`](../../skills/handoff/SKILL.md) -(`allowed-tools` = these four, `requires-actions: [HandoffAction]`). The action -also contributes an always-on orchestration parameter (`key: handoff_routing`) -that tells the loop when to hand off. - -## Orchestrator wiring (agent.yaml) +One mode at a time, selected by `HandoffAction.mode`. That mode is the only +tool set published. Pin those same tools on the orchestrator. Put allow and +deny rules only for that mode's tools. There is no handoff skill. + +| Mode | Pin these tools | What it does | +|---|---|---| +| `consult` (default) | `handoff__consult`, `handoff__pending_questions`, `handoff__save_answer`, `handoff__update_chunk` | Ask staff, tell the user you will return, save the answer, and reply to the user. | +| `transfer` | `handoff__transfer` | Send staff a summary and tell the user a staff member will follow up; later messages run normally. | +| `observe` | `handoff__observe` | In a WhatsApp group, store a useful fact in PageIndex, add the other numbers to staff, and send nothing. | + +## When FAQ / KB cannot answer + +After a skill (for example `faq` or `product_search`) follows its search +protocol and still has no grounded answer, escalate using **the handoff tool +for the agent's configured mode**: + +- **consult** → `handoff__consult` (customer's words in `message` sentence 1) +- **transfer** → `handoff__transfer` (short staff summary in `message`) + +The orchestrator **auto routing** rule (below) uses the same trigger for +consult and transfer customers. Agent skills must **not** contradict that +(for example "do not hand off on empty FAQ" on a transfer agent). Prefer +**mode-neutral** skill wording ([Skill templates](#skill-templates)) so you +can switch modes in yaml only. + +```mermaid +flowchart LR + mode[HandoffAction.mode] + mode -->|consult| consultTool[handoff__consult] + mode -->|transfer| transferTool[handoff__transfer] + kbGap[KB or tools cannot answer] + kbGap --> consultTool + kbGap --> transferTool +``` + +## Mode switch checklist + +Change **all** of these when switching mode (pins and permissions do not +follow `mode` by themselves): + +1. `HandoffAction.mode` → `consult` | `transfer` | `observe` +2. Orchestrator `pinned_tools` → that mode's tool list only (remove stale pins) +3. Remove the **previous** mode's `permissions..tools` entries +4. Add the **new** mode's permissions map (consult matrix, transfer none, observe staff allow) +5. `handoff_channels` → use key `consult` or `transfer` matching mode (observe typically omits notify) +6. Align agent **skills** — see [Updating agent skills when HandoffAction mode changes](#updating-agent-skills-when-handoffaction-mode-changes) (mode-neutral text needs no edits) +7. **Smoke test** — empty FAQ, B2B escalation, staff answer (consult only) + +## Common setup (all modes) + +1. Add `jvagent/handoff_action` with `enabled: true`. +2. Populate `user_groups.HandoffAction.staff` on `jvagent/access_control_action`. +3. For **consult** or **transfer**, set `customer_contact: phone` | `email` for web/default. +4. Omit `parameters` on HandoffAction unless you intentionally override auto routing. +5. Pin **only** the active mode's tools on `jvagent/orchestrator`. + +## Auto routing (no `parameters` block) + +Leave `parameters` empty on the handoff action. Each turn the orchestrator +pools action parameters; HandoffAction supplies orchestration rule(s) from +**mode** and whether the **sender** is in `HandoffAction.staff`. + +Handoff tools are **not** behind a skill. When an auto rule's condition +matches, it **overrides** the active skill and any skill procedure. + +| Mode | Sender | Key | Condition (verbatim) | Response (summary) | +|---|---|---|---|---| +| consult | customer | `handoff_consult` | you cannot answer a customer question, or you cannot complete the request from the knowledge base and tools, or the request is outside what the store sells (a "do you sell X" question the knowledge base cannot answer) — a quote, stock confirmation, bulk or B2B order, an upset customer, or the user wants a person or wants something reported | Call `handoff__consult` now; relay only tool line; sentence 1 = customer's words; contact rules per channel | +| consult | staff | `handoff_consult_staff` | the sender is staff and the latest message answers a customer question or corrects a saved answer | `handoff__pending_questions` → `handoff__save_answer`; id rules for `pend_` and chunks | +| transfer | customer | `handoff_transfer` | *(same condition as consult customer)* | Call `handoff__transfer` with short summary; relay only tool line; keep helping on later turns | +| transfer | staff | `handoff_transfer_staff` | the sender is staff | Do not call `handoff__transfer` for customer escalation; reply in thread | +| observe | any | `handoff_observe` | the latest WhatsApp group message contains a fact, policy, or answer worth keeping | Call `handoff__observe`; no group message; no consult/transfer/pending tools | + +**Override:** a non-empty `parameters` list on the action in agent.yaml +**replaces** auto rules entirely. Use for custom agents or narrow transfer +behavior — see [Parameters override](#parameters-override). + +The staff consult rule applies when the latest message **answers a customer +question** or **corrects a saved answer**. A staff location statement with no +matching pending question does not match until the rule text in code is +widened. + +## Skills vs handoff (rules of thumb) + +- The orchestrator loop prefers **skills first** (`use_skill` before ad-hoc tools). +- FAQ is for customer **questions** answered from PageIndex, not staff **statements** or saves. +- Do **not** put `handoff__pending_questions`, `handoff__save_answer`, or + "call handoff in FAQ description" in FAQ skills — that makes FAQ match staff + turns and the model opens with `use_skill` before handoff routing. +- Use FAQ **Do not use when** / description so staff answers do **not** activate faq. +- When KB/tools cannot answer, call the mode's customer handoff tool; do not + promise staff follow-up in prose instead of the tool when the auto rule applies. + +## Updating agent skills when HandoffAction mode changes + +**Goal:** Use **mode-neutral** skill text so switching consult ↔ transfer +requires **agent.yaml only**. Use mode-specific snippets only if the agent +will never change mode. + +### What to change in skills + +| Location | consult | transfer | observe (customer-facing skills) | +|----------|---------|----------|----------------------------------| +| Tool in procedures | `handoff__consult` | `handoff__transfer` | Do not reference customer handoff tools | +| `message` semantics | Customer's words, short (sentence 1) | Short staff summary | N/A | +| Empty KB / weak catalog | Call consult; relay tool line only | Call transfer; relay tool line only | N/A | +| After handoff | User waits for staff save + async reply | Continue helping on later messages | N/A | +| `allowed-tools` in skill frontmatter | Orchestrator pins handoff; listing optional | Same | Same | + +### Skills that typically need handoff lines + +| Skill | Where to patch | +|-------|----------------| +| `faq` | Step **EMPTY RESULT** / unscoped search empty | +| `product_search` | **Weak or empty results** | +| `salesmanship` | **Escalation triggers** / B2B row | +| `fab` | FAB doc gap / out of scope | +| `pageindex_with_links` | **Fallback response** | + +### Mode-specific find/replace (if not using mode-neutral text) + +**Permanently on consult:** replace `handoff__transfer` → `handoff__consult`; +remove "continue helping after handoff"; ensure empty KB calls consult, not prose. + +**Permanently on transfer:** replace `handoff__consult` → `handoff__transfer`; +add "after transfer, keep helping when you can"; ensure empty KB calls transfer. + +### Skill mistakes to avoid + +- Hardcoding one tool name while yaml sets another mode. +- "Do not auto handoff on empty FAQ" on an agent whose mode expects KB-gap escalation. +- Staff tools in FAQ skill text → wrong routing on staff messages. +- `reply` with "a team member can confirm…" when the orchestration rule requires the handoff tool. +- Stale tool names in skill **description** / frontmatter. + +### After skill edits + +```bash +# Example: wrong tool names for current mode +rg 'handoff__consult|handoff__transfer' agents//skills/ +``` + +Re-run smoke: empty FAQ → correct tool; B2B → handoff; staff save (consult only). + +## Skill templates + +Copy into agent `SKILL.md` files (mode-neutral — works with consult or transfer yaml). + +**FAQ — EMPTY RESULT (after search protocol, no usable hits):** + +```markdown +Do not invent an answer. Call `handoff__consult` or `handoff__transfer` +according to this agent's HandoffAction mode (see OPERATING RULES for +`message`, contact, and relay). Relay only the tool return line; omit +`contact` on WhatsApp. Do not substitute prose (for example "a team member +can confirm") when the handoff orchestration rule applies. +``` + +**product_search — weak or empty (after optional `faq` if applicable):** + +```markdown +If you still cannot answer from catalog and FAQ, do not invent products. +Call `handoff__consult` or `handoff__transfer` per HandoffAction mode and +OPERATING RULES; relay only the tool line. +``` + +**salesmanship — escalation:** + +```markdown +When escalation applies, call `handoff__consult` or `handoff__transfer` per +HandoffAction mode with the appropriate `message` (customer words vs staff +summary). Relay only the tool line. After transfer, keep helping on later +messages when you can (transfer mode only). +``` + +**fab / pageindex — knowledge gap:** + +```markdown +If search cannot ground an answer, do not invent detail. Call +`handoff__consult` or `handoff__transfer` per HandoffAction mode when the +orchestration rule applies. +``` + +Reference implementation: **Silvie** (`agents/silvie-ai/agents/silvies/silvie_ai`) +uses **transfer** mode with mode-neutral skill wording — see that agent's yaml +and `skills/*/SKILL.md`. + +## Parameters override + +A non-empty `parameters` list on `jvagent/handoff_action` **replaces** all +auto rules from mode. Use when an agent needs custom escalation logic without +changing jvagent code. + +**Example — transfer with explicit-escalation only (no KB-gap auto transfer):** ```yaml - - action: jvagent/orchestrator - context: - skill_only_tools: ["handoff__*"] # gated until the handoff skill is active - skills: [..., handoff] - - action: jvagent/handoff_action - context: - enabled: true +- action: jvagent/handoff_action + context: + enabled: true + mode: transfer + handoff_channels: + transfer: whatsapp + parameters: + - scope: orchestration + key: handoff_transfer_explicit + condition: >- + the user wants a person or wants something reported, and the + conversation should move to staff + response: >- + Call handoff__transfer with a short summary. Do not reply in text. + Relay only the tool line. Omit contact on WhatsApp. On later + messages keep helping when you can. ``` +Skills for such an agent should **not** require handoff on empty FAQ alone. + +## Staff numbers and emails + +Put every staff WhatsApp number and email in one list: +`user_groups.HandoffAction.staff` on `jvagent/access_control_action`. + +```yaml +- action: jvagent/access_control_action + context: + user_groups: + HandoffAction: + staff: + - "5926178650" # WhatsApp number + - "team@example.com" # email +``` + +- **WhatsApp** (`handoff_channels` value `whatsapp`) — one staff number from + the list is chosen at random per notification. +- **Email** (`handoff_channels` value `email`) — the first email is the + recipient; the rest are CC'd. + +**Customer contact** (`customer_contact`: `phone` | `email`) — which single +type to collect on web/default. On web, the first handoff call with no +resolved contact relays intro plus contact ask and does **not** notify staff; +the user's reply is passed as `contact` on the next tool call. WhatsApp +customers use the sender id automatically. + +## Playbook: consult + +**Use when** staff must answer into PageIndex and the customer gets an async +reply when staff saves. + +**Not for** notify-and-continue-only workflows (use **transfer**). + +### Setup + +- `mode: consult`, `handoff_channels.consult: whatsapp` | `email` +- Pin four tools on orchestrator +- Permissions on **every channel** (`default`, `whatsapp`, …): customers → + `handoff__consult` only; staff → pending/save/update only + +```yaml +- action: jvagent/orchestrator + context: + pinned_tools: + - handoff__consult + - handoff__pending_questions + - handoff__save_answer + - handoff__update_chunk +``` + +```yaml +- action: jvagent/handoff_action + context: + enabled: true + mode: consult + customer_contact: phone + handoff_channels: + consult: whatsapp +``` + +Permissions (repeat under each channel): + +```yaml +permissions: + default: + tools: + handoff__consult: + deny: [{ group: staff, enabled: true }] + allow: [{ group: all, enabled: true }] + handoff__pending_questions: + deny: [] + allow: [{ group: staff, enabled: true }] + handoff__save_answer: + deny: [] + allow: [{ group: staff, enabled: true }] + handoff__update_chunk: + deny: [] + allow: [{ group: staff, enabled: true }] + whatsapp: + tools: + # same four-tool matrix +``` + +### Smoke tests + +- Customer: policy question with empty PageIndex → `handoff__consult`, relay only +- Staff: answer matching pending → `handoff__pending_questions` + `handoff__save_answer` +- Customer receives async reply after save + +Question ids start with `pend_` from `handoff__pending_questions`. Chunk ids +start with `n.DocumentNode.` from `[EVENT]` Handoff chunk lines. A `corr-` id +is neither. + +Customer relay: intro (`handoff_intro`), contact ask (web when unresolved), +close (`consult_close`). Each consult creates a new pending row and notifies +staff. `handoff__consult` records pending + notifies; staff use save tools only. + +## Playbook: transfer + +**Use when** staff should be notified per issue and the bot keeps helping on +later customer messages. + +**Not for** staff-ingest + auto-reply to customer (use **consult**). + +### Setup + +- `mode: transfer`, `handoff_channels.transfer: whatsapp` | `email` +- Pin **only** `handoff__transfer` +- **No** `permissions.*.tools` entries for handoff (staff list still used for notify targets) + +```yaml +- action: jvagent/orchestrator + context: + pinned_tools: + - handoff__transfer +``` + +```yaml +- action: jvagent/handoff_action + context: + enabled: true + mode: transfer + customer_contact: phone + handoff_channels: + transfer: whatsapp +``` + +### Smoke tests + +- Customer: "Do you offer delivery?" with empty FAQ → `handoff__transfer`, relay only +- Customer: later message → bot can still help (catalog, etc.) +- Staff sender → no spurious `handoff__transfer` for staff messages + +`handoff__transfer` uses intro (`handoff_intro`), optional contact ask, close +(`transfer_close`). Optional yaml: `handoff_intro`, `transfer_close`, +`consult_close`. Relay only what the tool returns. + +**Contact resolution:** provided `contact`, then saved `handoff_contact`, then +`user_id` when it matches `customer_contact` kind. On WhatsApp omit `contact`. + +## Playbook: observe + +**Use in** a WhatsApp group to capture facts silently. + +**Not for** customer FAQ/catalog agents (unless a separate agent instance handles customers). + +### Setup + +- `mode: observe` (no customer handoff notify channel required) +- Pin **only** `handoff__observe` +- Allow `handoff__observe` for **staff** on `whatsapp` + +```yaml +- action: jvagent/orchestrator + context: + pinned_tools: + - handoff__observe +``` + +```yaml +- action: jvagent/handoff_action + context: + enabled: true + mode: observe +``` + +```yaml +permissions: + whatsapp: + tools: + handoff__observe: + deny: [] + allow: [{ group: staff, enabled: true }] +``` + +Group messages are admitted via `whatsapp_direct_all_group_messages`. Model +sends **nothing** to the group; facts append to `handoff.md`. + +### Smoke tests + +- Group message with useful policy fact → `handoff__observe`, no group reply +- No `handoff__consult` / `handoff__transfer` on customer skills for this agent + +## Tool visibility and permissions + +Deny is checked before allow. Repeat the `tools` block under every channel +you use. List only the **active mode's** tools. + +After pins, each name under `permissions[channel].tools` is dropped when +`has_tool_access` is false. **Consult:** customers see `handoff__consult` only; +staff see save/list tools. **Transfer:** no handoff `tools` entries. +**Observe:** staff allow on `handoff__observe` only. + +## Staff save confirmation and customer reply + +After `handoff__save_answer` or `handoff__update_chunk`, the tool returns a +terminal `Tell the user:` directive. The orchestrator sends that sentence to +the staff sender. The model must not rewrite the confirmation. + +When the pending question has a customer contact, the action also sends a +separate WhatsApp or email message: thank them for their patience, remind them +of the question, then give the answer. + +## PageIndex + +Saved answers and observed facts go to `doc_name="handoff.md"`, +`metadata={"access": "public"}`. + +Notify delivery uses `handoff_notify_action_type` (default `WhatsAppAction`) +or `handoff_email_action_type` (default `EmailAction`). + ## License See the application-level [LICENSE](../../../../LICENSE). diff --git a/jvagent/action/handoff_action/handoff_action.py b/jvagent/action/handoff_action/handoff_action.py index 226e9d68..84abd257 100644 --- a/jvagent/action/handoff_action/handoff_action.py +++ b/jvagent/action/handoff_action/handoff_action.py @@ -1,20 +1,20 @@ """Human handoff capability action. -Model-callable tools, split across the ``handoff`` and ``handoff_staff`` skills: - -- ``handoff__contact_details()`` — return the team's email, phone, and hours. -- ``handoff__update_contact(phone_number?, email?)`` — replace the reply address. -- ``handoff__staff_lookup(message, phone_numbers?, emails?)`` — cannot answer. -- ``handoff__agent_escalation(message, phone_numbers?, emails?)`` — person now. -- ``handoff__scheduled_callback(message, phone_numbers?, emails?)`` — later. -- ``handoff__pending_questions()`` — list questions waiting for an answer. -- ``handoff__save_answer(question_id, answer)`` — save the answer for one - pending question and return a thank-you. -- ``handoff__update_chunk(chunk_id, answer)`` — replace one saved chunk. - -Staff identity is the dispatch sender. Prefer AccessControlAction -``HandoffAction.staff`` (phones and emails in one list). One WhatsApp or email -target is chosen at random per notification. +One mode at a time (``HandoffAction.mode``). That mode is the only tool set +``get_tools()`` publishes. Pin those tools on the orchestrator — there is no +skill SOP. + +- ``consult`` — ``handoff__consult``, ``handoff__pending_questions``, + ``handoff__save_answer``, ``handoff__update_chunk``. Ask staff, tell the user + you will return, then save the answer and reply to the user. +- ``transfer`` — ``handoff__transfer``. Summarize for staff, tell the user a + staff member will follow up; the conversation continues on later messages. +- ``observe`` — ``handoff__observe``. In a WhatsApp group, store a useful fact + and add the other numbers to ``HandoffAction.staff``. Send nothing. + +On channel ``web`` or ``default``, consult and transfer ask for one WhatsApp +number or email. On WhatsApp they use the sender. Staff targets are +AccessControlAction ``HandoffAction.staff``. """ import json @@ -36,74 +36,207 @@ logger = logging.getLogger(__name__) -DIRECT_CONTACT_PROMPT = """You can reach a human representative directly using the contact details below: -Email: {handoff_email} -Phone / WhatsApp: {handoff_phone} -Office Hours: {handoff_hours} - -A team member will assist you as soon as possible.""" +def _register_orchestrator_vocabulary() -> None: + """Declare ``handoff__`` a trusted directive source. + The handoff tools return ``response_directive`` guidance (the confirmation + prompt, the relay lines). The orchestrator honors directives only from a + registered first-party ``ns__`` namespace, so this must run before the first + turn — mirroring ``InterviewAction``. Idempotent. + """ + try: + from jvagent.action.orchestrator.constants import ( + register_trusted_directive_prefix, + ) + except Exception: # pragma: no cover - orchestrator optional at load + return + register_trusted_directive_prefix("handoff__") -AGENT_ESCALATION_PROMPT = """A staff member will reach out to you shortly.""" +_register_orchestrator_vocabulary() -SCHEDULED_CALLBACK_PROMPT = """I'll arrange for a human representative to follow up with you. -You can expect a response within the next 24 hours (or the next business day). If your request is urgent, please use the direct contact option for faster assistance.""" +HANDOFF_INTRO = "I don't have that information yet." +TRANSFER_CLOSE = ( + "I've passed it to the team and a staff member will reach out to you shortly." +) -STAFF_LOOKUP_PROMPT = """I don't have that information yet, so I'll check with the team and get back to you when they respond.""" +CONSULT_CLOSE = "I'll check with the team and get back to you when they respond." -STAFF_LOOKUP_ASK_PROMPT = """I don't have that right now. May I have your phone number or email so I can get back to you?""" +HANDOFF_FOLLOWUP_THANKS = "Thanks." HANDOFF_DOC_NAME = "handoff.md" HANDOFF_DOC_ACCESS = "public" +#: Model-only. A denied save must not be relayed to the user. +SAVE_DENIED_LINE = ( + "This user cannot save answers. Do not tell the user. " + "Send no message about permissions or saving." +) + _CHANNELS = ("whatsapp", "email") +_MODES = ("consult", "transfer", "observe") +_WEB_CHANNELS = frozenset({"", "web", "default"}) +_MODE_TOOLS = { + "consult": frozenset( + { + "handoff__consult", + "handoff__pending_questions", + "handoff__save_answer", + "handoff__update_chunk", + } + ), + "transfer": frozenset({"handoff__transfer"}), + "observe": frozenset({"handoff__observe"}), +} -#: Orchestration-scoped routing rule. Accumulated onto the interaction by the -#: orchestrator and rendered into the loop prompt, so the model activates the -#: handoff skill and uses the tools when the user asks for a human or the answer -#: is out of reach. Keyed so an agent can override it. -HANDOFF_PARAMETERS: List[Dict[str, Any]] = [ - { - "scope": "orchestration", - "key": "handoff_routing", - "condition": ( - "the user asks for a human / agent / live support, wants a callback, " - "OR you cannot answer their question from the knowledge base or the " - "tools available this turn (a search returned nothing relevant, or " - "the request is outside what you can do)" - ), - "response": ( - "Search the FAQ and available skills first, including a 'do you " - "sell' question. Do not guess or drop the request. Call use_skill " - "with name 'handoff' only after that search returned nothing useful " - "(its tools are gated), then: if the user only wants the team's " - "contact details call handoff__contact_details; otherwise call " - "handoff__agent_escalation (a staff member will reach out), " - "handoff__scheduled_callback (reach out later), or " - "handoff__staff_lookup (you cannot answer). For staff_lookup, " - "message sentence 1 is the natural customer ask only; optional " - "sentence 2 is what was already tried (staff notify only). Do not " - "mention WhatsApp or tell staff to reply. Relay the returned line " - "and do not add that summary." - ), - }, - { - "scope": "orchestration", - "key": "handoff_staff_answer", - "condition": ( - "the latest message, with the conversation so far, looks like an " - "answer rather than a new question" - ), - "response": ( - "Call handoff__pending_questions first (pending questions are " - "unknown until it returns), then handoff__save_answer for the match." - ), - }, -] + +_CUSTOMER_CONTACT_KINDS = frozenset({"phone", "email"}) + + +def _normalized_customer_contact_kind(value: str) -> str: + kind = (value or "phone").strip().lower() + return kind if kind in _CUSTOMER_CONTACT_KINDS else "phone" + + +def _contact_label(kind: str) -> str: + return "email address" if kind == "email" else "WhatsApp number" + + +# Shared consult + transfer customer escalation trigger (KB/tools gap and explicit ask). +_CUSTOMER_CANNOT_ANSWER_CONDITION = ( + "you cannot answer a customer question, or you cannot " + "complete the request from the knowledge base and tools, or the " + 'request is outside what the store sells (a "do you sell X" ' + "question the knowledge base cannot answer) — a quote, stock " + "confirmation, bulk or B2B order, an upset customer, or the user " + "wants a person or wants something reported" +) + + +def _parameters_for( + mode: str, + staff: bool = False, + *, + customer_contact_kind: str = "phone", +) -> List[Dict[str, Any]]: + """Orchestration parameter(s) for this mode and sender.""" + mode = mode if mode in _MODE_TOOLS else "consult" + kind = _normalized_customer_contact_kind(customer_contact_kind) + contact_phrase = _contact_label(kind) + if mode == "consult" and staff: + return [ + { + "scope": "orchestration", + "key": "handoff_consult_staff", + "condition": ( + "the sender is staff and the latest message answers a " + "customer question or corrects a saved answer" + ), + "response": ( + "This rule overrides the active skill and any skill " + "procedure. Call handoff__pending_questions and read the " + "full list of pend_ ids and questions. Choose every pend_ " + "id the staff message answers (same issue, multiple " + "customers). Call handoff__save_answer with question_ids " + "and the full answer text. Do not call handoff__consult. " + "Call handoff__update_chunk only when an [EVENT] line says " + "Handoff chunk and the id starts with n.DocumentNode. A " + "corr- correlation id is not a question id or a chunk id. " + "If the pending list is empty, do not save. Reply in the " + "conversation. If a save tool says this user cannot save " + "answers, send nothing about permissions or saving." + ), + } + ] + if mode == "transfer" and staff: + return [ + { + "scope": "orchestration", + "key": "handoff_transfer_staff", + "condition": "the sender is staff", + "response": ( + "Do not call handoff__transfer to escalate a customer issue " + "from a staff message. Reply in the conversation when " + "appropriate. Transfer mode notifies staff only when the " + "customer (non-staff sender) triggers handoff__transfer." + ), + } + ] + if mode == "transfer": + return [ + { + "scope": "orchestration", + "key": "handoff_transfer", + "condition": _CUSTOMER_CANNOT_ANSWER_CONDITION, + "response": ( + "This rule overrides the active skill and any skill " + "procedure. Call handoff__transfer with a short summary of " + "the issue in message (what the customer needs handled — " + "not Customer wants… placeholders). Do not reply in text or " + "ask for contact yourself — relay only the line the tool " + "returns. The tool resolves contact " + f"from WhatsApp identity, saved context, or user_id when it " + f"is a {contact_phrase}. Omit contact on WhatsApp. On web or " + "default, omit contact on the first call; when the user " + f"replies with their {contact_phrase}, call again with the " + "same message and contact. Never pass placeholders. On later " + "messages keep helping when you can; call handoff__transfer " + "again when another issue needs staff." + ), + } + ] + if mode == "observe": + return [ + { + "scope": "orchestration", + "key": "handoff_observe", + "condition": ( + "the latest WhatsApp group message contains a fact, policy, " + "or answer worth keeping" + ), + "response": ( + "Observe mode only — not customer consult or transfer. Call " + "handoff__observe with that fact. Do not call " + "handoff__consult, handoff__transfer, or " + "handoff__pending_questions. Never send a message to the " + "group. If nothing is worth keeping, send nothing. If the " + "tool says this user cannot save answers, send nothing " + "about permissions or saving." + ), + } + ] + return [ + { + "scope": "orchestration", + "key": "handoff_consult", + "condition": _CUSTOMER_CANNOT_ANSWER_CONDITION, + "response": ( + "This rule overrides the active skill and any skill " + "procedure. If the latest message is a question you cannot " + "answer, or a request you cannot complete, call " + "handoff__consult now. Do not reply in text. Do not ask " + "permission. Relay only the line the tool returns. Sentence 1 " + "must be the customer's words, kept short (drop fillers like " + "can u check or again yourself) — e.g. where r u located or what " + "is your address? Never Customer wants… or Customer asked… " + "summaries or extra detail they did not say. Optional sentence " + "2 is what was already tried. On contact follow-up repeat the " + "same sentence 1 as the first consult; only contact changes. " + "The tool resolves contact from channel identity and saved " + "context; omit contact on WhatsApp. " + f"On web or default, pass contact only after the tool asks and " + f"the user gives their {contact_phrase} — never placeholders." + ), + } + ] + + +#: Default (consult) parameter. Reads of ``HandoffAction.parameters`` follow +#: the active mode; see ``HandoffAction.__getattribute__``. +HANDOFF_PARAMETERS: List[Dict[str, Any]] = _parameters_for("consult") def _question_field(question: Any, name: str) -> str: @@ -131,26 +264,6 @@ def _question_row(question: Any) -> Dict[str, Any]: } -def _render_direct_contact(template: str, email: str, phone: str, hours: str) -> str: - """Render the direct-contact block; drop lines whose field is blank.""" - try: - rendered = template.format( - handoff_email=email, handoff_phone=phone, handoff_hours=hours - ) - except (KeyError, IndexError): - return template - cleaned_lines = [] - for line in rendered.split("\n"): - stripped = line.strip() - if stripped.endswith(":") and ( - stripped.lower().startswith("email:") - or stripped.lower().startswith("phone") - ): - continue - cleaned_lines.append(line) - return "\n".join(cleaned_lines) - - def _lookup_staff_message(message: str, tokens: List[str]) -> str: """Full staff text with the customer's contact removed (no bold).""" text = message or "" @@ -213,6 +326,45 @@ def _pending_question_text(message: str) -> str: return sentences[0] +def _looks_like_staff_summary(message: str) -> bool: + """Third-person consult summary from the model (routing only, not rewriting).""" + return (message or "").strip().lower().startswith("customer ") + + +def _is_contact_only_message( + message: str, + contact: str, + *, + customer_contact_kind: str = "phone", +) -> bool: + """True when message is only a phone/email (contact follow-up turn).""" + msg = (message or "").strip() + token = (contact or "").strip() + if not msg: + return bool(token) + if token and msg == token: + return True + clean_msg = _sanitize_contact(msg, customer_contact_kind=customer_contact_kind) + if token and clean_msg and clean_msg == token: + return True + if clean_msg and _contact_kind(clean_msg) and not token: + return True + stripped = _lookup_staff_message(msg, [token] if token else []) + residual = re.sub(r"[^A-Za-z0-9]+", "", stripped or "") + return not residual + + +def _consult_issue_text(message: str, contact: str = "") -> str: + """Pending/staff question line from one consult message.""" + tokens = [contact] if (contact or "").strip() else [] + cleaned = _lookup_staff_message(message, tokens) + return ( + _pending_question_text(cleaned) + or _pending_question_text(message) + or (message or "").strip() + ) + + def _staff_notes_text(message: str, question: str) -> str: """Handling notes after the stored question (contact-stripped later).""" sentences = _split_message_sentences(message) @@ -237,42 +389,40 @@ def _bold_whatsapp_ask(question: str) -> str: return f"{prefix}*{core}*{punct}" -def _staff_outbound( - mode: str, - message: str, - phone_numbers: Optional[List[str]], - emails: Optional[List[str]], - contact: str, -) -> str: - """Staff text: lookup omits the contact; escalation and callback include it.""" - tokens = [ - str(t).strip() for t in (phone_numbers or []) + (emails or []) if str(t).strip() - ] - if mode == "staff_lookup": +def _staff_outbound(mode: str, message: str, contact: str = "") -> str: + """Staff text: consult question + optional contact; transfer includes contact.""" + token = (contact or "").strip() + tokens = [token] if token and token != "declined" else [] + if mode == "consult": cleaned = _lookup_staff_message(message, tokens) question = _pending_question_text(cleaned) notes = _staff_notes_text(cleaned, question) bolded = _bold_whatsapp_ask(question) - if notes: - return f"{bolded} {notes}".strip() - return bolded + body = f"{bolded} {notes}".strip() if notes else bolded + if token and token != "declined" and token not in body: + body = f"{body}\nContact: {token}".strip() + return body text = (message or "").strip() - follow_up = (contact or "").strip() - if follow_up and follow_up not in text: - text = f"{text}\nContact: {follow_up}".strip() + if token and token not in text: + text = f"{text}\nContact: {token}".strip() return text -def _first_contact( - phone_numbers: Optional[List[str]], emails: Optional[List[str]] -) -> str: - for n in phone_numbers or []: - if str(n).strip(): - return str(n).strip() - for e in emails or []: - if str(e).strip(): - return str(e).strip() - return "" +def _one_contact(contact: Optional[str]) -> str: + """Normalize one customer contact (phone or email) to a stripped string. + + Tolerates a list for backward compatibility, but the customer provides a + single contact; only the first non-empty entry is kept. + """ + if contact is None: + return "" + if isinstance(contact, (list, tuple)): + for item in contact: + value = str(item).strip() + if value: + return value + return "" + return str(contact).strip() def _dispatch_user_id() -> str: @@ -308,39 +458,178 @@ def _sender_contact(channel: str) -> str: return "" -_HANDOFF_CONTACT_KEY = "handoff_contact" -_CONFIRM_MODES = ("agent_escalation", "scheduled_callback") +def _dispatch_channel() -> str: + """Visitor channel for this tool call (``""`` when unbound).""" + from jvagent.tooling.tool_executor import get_dispatch_context + + ctx = get_dispatch_context() + return (getattr(ctx, "channel", "") or "").strip().lower() -def _issue_keys(message: str) -> set: - """Raw and contact-stripped forms of one staff issue (question side only).""" - raw = (message or "").strip() - keys = {raw} if raw else set() - stripped = _lookup_staff_message(raw, []) - if stripped: - keys.add(stripped) - question = _pending_question_text(stripped or raw) - if question: - keys.add(question) - return keys +def _asks_for_contact(channel: str) -> bool: + """Web and default have no sender phone, so the user must give one.""" + return (channel or "").strip().lower() in _WEB_CHANNELS -def _confirm_directive(mode: str, message: str, contact: str) -> ToolResult: - """Ask the user to confirm the issue and contact before staff are notified.""" - tool_name = f"handoff__{mode}" +_CONTACT_PLACEHOLDERS = frozenset( + { + "not provided", + "unknown", + "n/a", + "na", + "none", + "no contact", + "unavailable", + "not available", + "missing", + } +) + + +def _contact_matches_kind(value: str, kind: str) -> bool: + ck = _contact_kind((value or "").strip()) + normalized = _normalized_customer_contact_kind(kind) + if normalized == "email": + return ck == "email" + return ck == "whatsapp" + + +def _sanitize_contact( + raw: Optional[str], *, customer_contact_kind: str = "phone" +) -> str: + """Normalize tool or saved contact; drop placeholders and wrong kind.""" + text = _one_contact(raw) + if not text: + return "" + if text.strip().lower() in _CONTACT_PLACEHOLDERS: + return "" + if not _contact_matches_kind(text, customer_contact_kind): + return "" + return text + + +def _identity_contact( + visitor_channel: str, *, customer_contact_kind: str = "phone" +) -> str: + """Phone or email from dispatch user_id for this visitor channel.""" + kind = _normalized_customer_contact_kind(customer_contact_kind) + ch = (visitor_channel or "").strip().lower() + if ch == "email": + return _sender_contact("email") if kind == "email" else "" + if _asks_for_contact(ch): + if kind == "email": + return _sender_contact("email") + return _sender_contact("whatsapp") + return _sender_contact("whatsapp") + + +def _resolve_customer_contact( + *, + provided: Optional[str], + saved: Optional[str], + visitor_channel: str, + customer_contact_kind: str = "phone", +) -> str: + """Provided, then saved, then identity — all sanitized for the configured kind.""" + kind = _normalized_customer_contact_kind(customer_contact_kind) + for candidate in ( + _sanitize_contact(provided, customer_contact_kind=kind), + _sanitize_contact(saved, customer_contact_kind=kind), + _identity_contact(visitor_channel, customer_contact_kind=kind), + ): + if candidate: + return candidate + return "" + + +def _join_handoff_parts(*parts: str) -> str: + """Join non-empty relay fragments into one customer-facing paragraph.""" + cleaned = [(p or "").strip() for p in parts if (p or "").strip()] + if not cleaned: + return "" + out = cleaned[0] + for piece in cleaned[1:]: + if not out.endswith((".", "!", "?")): + out += "." + out += " " + piece + return out + + +def _handoff_contact_ask(mode: str, customer_contact_kind: str) -> str: + """Ask for phone or email; no closing promise to staff (that is part 3).""" + kind = _normalized_customer_contact_kind(customer_contact_kind) + if kind == "email": + need = "your email address" + question = "What's the best email to reach you?" + else: + need = "your WhatsApp number" + question = "What's the best number to reach you?" + if mode == "consult": + return f"I'll need {need} so I can get back to you. {question}" + return ( + f"To pass this to our team, I need {need} so a staff member can follow up. " + f"{question}" + ) + + +def _compose_handoff_relay( + mode: str, + customer_contact_kind: str, + *, + include_intro: bool, + include_contact_ask: bool, + include_close: bool, + intro: Optional[str] = None, + close: Optional[str] = None, + followup_thanks: bool = False, +) -> str: + """Build user relay: intro, optional contact ask, mode-specific close.""" + parts: List[str] = [] + if followup_thanks: + parts.append(HANDOFF_FOLLOWUP_THANKS) + if include_intro: + parts.append((intro or HANDOFF_INTRO).strip()) + if include_contact_ask: + parts.append(_handoff_contact_ask(mode, customer_contact_kind)) + if include_close: + default_close = CONSULT_CLOSE if mode == "consult" else TRANSFER_CLOSE + parts.append((close or default_close).strip()) + return _join_handoff_parts(*parts) + + +def _missing_contact_handoff_result( + mode: str, + customer_contact_kind: str, + tool_name: str, + *, + intro: Optional[str] = None, +) -> ToolResult: + """Relay natural ask line; internal note for the executive to recall the tool.""" + user_line = _compose_handoff_relay( + mode, + customer_contact_kind, + include_intro=True, + include_contact_ask=True, + include_close=False, + intro=intro, + ) + label = _contact_label(_normalized_customer_contact_kind(customer_contact_kind)) return ToolResult( content=( - "Confirm with the user before notifying staff. " - f"The issue is: {message}. The contact is: {contact}. " - "Ask if that issue and contact are correct. If they agree, call " - f"{tool_name} again with the same message and confirmed true. " - "If they give a different phone or email, call " - f"{tool_name} again with the same message and pass it in " - "phone_numbers or emails. Do not say staff were notified." + "Relay this to the user and do not add the staff summary:\n" + f"{user_line}\n\n" + "INTERNAL (do not say to the user): After they reply, call " + f"{tool_name} again with the same message and contact set to their " + f"{label}. Do not notify staff until then." ) ) +_HANDOFF_CONTACT_KEY = "handoff_contact" +_HANDOFF_ACTIVE_MODE_KEY = "handoff_active_mode" +_HANDOFF_ACTIVE_ISSUE_KEY = "handoff_active_issue" + + def _relay(line: str) -> ToolResult: return ToolResult( content=("Relay this to the user and do not add the staff summary:\n" f"{line}") @@ -546,6 +835,14 @@ def _chunk_event(chunk_id: str, question: str) -> str: return f'Handoff chunk {chunk_id} "{question}".' +def _handoff_event(kind: str, detail: str = "", status: str = "completed") -> str: + """One-line interaction event for a handoff lifecycle step.""" + detail = (detail or "").strip() + if detail: + return f'Handoff {status}: {kind} "{detail}".' + return f"Handoff {status}: {kind}." + + def _chunk_title(chunk_id: str) -> str: path = _handoff_json_path() if not chunk_id or not path.exists(): @@ -575,39 +872,52 @@ def _patch_handoff_json(chunk_id: str, title: str, section: str) -> None: class HandoffAction(Action): - """Human handoff tools: direct contact, staff notification, staff Q&A.""" + """One handoff mode: consult, transfer, or observe.""" tool_namespace: ClassVar[str] = "handoff" description: str = attribute( default=( - "Human handoff tools — show direct contact details, notify staff " - "(WhatsApp or email) for escalation/callback, look up a pending " - "customer question, or resolve it into the knowledge base." + "Human handoff. mode selects one tool set: consult (ask staff and " + "reply later), transfer (escalate to staff per message), or " + "observe (store WhatsApp group facts silently)." ), description="Action description", ) + mode: str = attribute( + default="consult", + description="Active handoff mode: consult | transfer | observe. Only one.", + ) + parameters: List[Dict[str, Any]] = attribute( - default_factory=lambda: [dict(p) for p in HANDOFF_PARAMETERS], + default_factory=list, description=( - "Scoped behavioural parameters this action contributes to the common " - "subsystem. The orchestration-scoped rule tells the loop when to " - "hand off (explicit human ask, or an unanswerable question)." + "Optional orchestration parameters from agent.yaml. A non-empty " + "list is published as-is. An empty list uses the active mode's " + "built-in parameter." ), ) - direct_contact_prompt: str = attribute( - default=DIRECT_CONTACT_PROMPT, description="Prompt for direct contact" + handoff_intro: str = attribute( + default=HANDOFF_INTRO, + description="Opening line when the customer has not yet heard this handoff.", + ) + consult_close: str = attribute( + default=CONSULT_CLOSE, + description="Closing line after consult staff notify (part 3).", ) - agent_escalation_prompt: str = attribute( - default=AGENT_ESCALATION_PROMPT, description="Prompt for agent escalation" + transfer_close: str = attribute( + default=TRANSFER_CLOSE, + description="Closing line after transfer staff notify (part 3).", ) - scheduled_callback_prompt: str = attribute( - default=SCHEDULED_CALLBACK_PROMPT, description="Prompt for scheduled callback" + consult_prompt: str = attribute( + default="", + description="Deprecated yaml alias for consult_close when set.", ) - staff_lookup_prompt: str = attribute( - default=STAFF_LOOKUP_PROMPT, description="Prompt for staff lookup" + transfer_prompt: str = attribute( + default="", + description="Deprecated yaml alias for transfer_close when set.", ) handoff_hours: str = attribute( @@ -626,13 +936,20 @@ class HandoffAction(Action): handoff_channels: Dict[str, str] = attribute( default_factory=lambda: { - "agent_escalation": "whatsapp", - "scheduled_callback": "whatsapp", - "staff_lookup": "whatsapp", + "consult": "whatsapp", + "transfer": "whatsapp", }, description="Per-mode notify channel: whatsapp | email.", ) + customer_contact: str = attribute( + default="phone", + description=( + "Which contact type to collect on web/default: phone (WhatsApp " + "number) or email. Independent of handoff_channels (staff notify)." + ), + ) + pending_questions: List[Dict[str, Any]] = attribute( default_factory=list, description=( @@ -643,242 +960,249 @@ class HandoffAction(Action): # -- tools ----------------------------------------------------------------- - def get_capabilities(self) -> List[str]: - if not self.enabled: - return [] - return [ - "Reach a human teammate (contact details, escalation, callback, or " - "checking a pending question with staff)", - ] + def __getattribute__(self, name: str) -> Any: + """Non-empty stored ``parameters`` win; otherwise they follow ``mode``.""" + if name == "parameters": + stored = super().__getattribute__("parameters") + if stored: + return stored + try: + mode = super().__getattribute__("mode") + except Exception: + mode = "consult" + kind = _normalized_customer_contact_kind( + str(super().__getattribute__("customer_contact") or "phone") + ) + return _parameters_for(str(mode or "consult"), customer_contact_kind=kind) + return super().__getattribute__(name) + + async def contributed_parameters(self, visitor: Any) -> List[Dict[str, Any]]: + """Mode and staff rule for this sender. A yaml list replaces it.""" + stored = super().__getattribute__("parameters") + if stored: + return list(stored) + user_id = str(getattr(visitor, "user_id", "") or "").strip() + members = await self._aca_staff_members() + return _parameters_for( + self._normalized_mode(), + staff=bool(user_id) and user_id in members, + customer_contact_kind=self._normalized_customer_contact_kind(), + ) - @tool(name="handoff__contact_details") - async def contact_details(self) -> str: - """Return the team's email, phone, and office hours. Use only when the user explicitly requests a contact channel or office hours. It returns no business information; do not use it for any other unanswerable question (use handoff__staff_lookup).""" - phone, emails = await self._public_contacts() - return _render_direct_contact( - self.direct_contact_prompt, - email=", ".join(emails), - phone=phone, - hours=self.handoff_hours, + def _normalized_customer_contact_kind(self) -> str: + return _normalized_customer_contact_kind( + str(getattr(self, "customer_contact", None) or "phone") ) - @tool(name="handoff__update_contact") - async def update_contact( - self, - phone_number: Annotated[ - Optional[str], - "One phone number. Omit when none.", - ] = None, - email: Annotated[ - Optional[str], - "One email address. Omit when none.", - ] = None, - ) -> ToolResult: - """Replace the saved phone or email replies are sent to. Use when the user wants to change where the team reaches them.""" - contact = (phone_number or "").strip() or (email or "").strip() - if not contact: - return ToolResult( - content="handoff failed: pass a phone number or email.", - is_error=True, - ) - prior = await self._saved_contact() - if await self._conversation() is None: - return ToolResult( - content="handoff failed: the contact could not be saved.", - is_error=True, - ) - await self._save_contact(contact) - if await self._saved_contact() != contact: - return ToolResult( - content="handoff failed: the contact could not be saved.", - is_error=True, - ) - if prior: - await self._update_pending_contact(prior, contact) - return _relay(f"Your contact is updated to {contact}.") + def _normalized_mode(self) -> str: + mode = str(self.mode or "consult").strip().lower() + return mode if mode in _MODE_TOOLS else "consult" - @tool( - name="handoff__staff_lookup", idempotency_class=IdempotencyClass.NON_RETRYABLE - ) - async def staff_lookup( + def whatsapp_direct_all_group_messages(self) -> bool: + """WhatsApp ingress hook: in observe mode, treat every group message as directed.""" + return self._normalized_mode() == "observe" + + async def get_tools(self) -> List[Any]: + """Publish only the active mode's tools.""" + from jvagent.tooling.tool_decorator import collect_tools + + allowed = _MODE_TOOLS[self._normalized_mode()] + return [tool for tool in collect_tools(self) if tool.name in allowed] + + def get_capabilities(self) -> List[str]: + if not self.enabled: + return [] + mode = self._normalized_mode() + if mode == "transfer": + return ["Notify staff and tell the user a staff member will follow up"] + if mode == "observe": + return ["Store useful WhatsApp group facts without replying"] + return ["Ask staff for an answer and reply to the user when it arrives"] + + @tool(name="handoff__consult", idempotency_class=IdempotencyClass.NON_RETRYABLE) + async def consult( self, message: Annotated[ str, - "Sentence 1: natural customer ask only. Optional sentence 2: what " - "was already tried (staff notify only). Never shown to the user.", + "Sentence 1: the customer's question or request in their words, " + "kept short (you trim fillers; do not use Customer wants… or " + "Customer asked…). Optional sentence 2: what was already tried " + "(staff only). Never shown to the user.", ], - phone_numbers: Annotated[ - Optional[List[str]], - "Phone numbers the user provided. Omit when none.", - ] = None, - emails: Annotated[ - Optional[List[str]], - "Email addresses the user provided. Omit when none.", + contact: Annotated[ + Optional[str], + "Customer phone or email per agent customer_contact setting. " + "Omit on WhatsApp. Pass only after the user gives it when the tool " + "asked. No placeholders.", ] = None, contact_declined: Annotated[ Optional[bool], - "True if the user refused to share a phone or email.", + "True if the user refused to share the configured contact type.", ] = None, ) -> ToolResult: - """Record a question you cannot answer and notify staff. Use for any request the knowledge base cannot answer, including questions outside the assortment and any business fact no document provides.""" + """Call when you cannot answer from KB/tools (consult mode). Relay only the tool line; do not reply in prose. Contact is resolved in code (see customer_contact on the action).""" return await self._dispatch_handoff( - "staff_lookup", + "consult", message, - phone_numbers, - emails, + contact, contact_declined=bool(contact_declined), ) - @tool( - name="handoff__agent_escalation", - idempotency_class=IdempotencyClass.NON_RETRYABLE, - ) - async def agent_escalation( + @tool(name="handoff__transfer", idempotency_class=IdempotencyClass.NON_RETRYABLE) + async def transfer( self, message: Annotated[ str, - "The customer's question and what was already tried. Never shown to the user.", + "Short summary of the issue for staff: what the customer wants " + "handled, built from the conversation. Never a placeholder. Never " + "shown to the user.", ], - phone_numbers: Annotated[ - Optional[List[str]], - "Phone numbers the user provided. Omit when none.", - ] = None, - emails: Annotated[ - Optional[List[str]], - "Email addresses the user provided. Omit when none.", - ] = None, - confirmed: Annotated[ - Optional[bool], - "True after the user confirms this issue and contact.", + contact: Annotated[ + Optional[str], + "Customer phone or email per agent customer_contact setting. " + "Omit on WhatsApp. Pass only after the user gives it when the tool " + "asked. No placeholders.", ] = None, ) -> ToolResult: - """Notify staff that the customer wants a person now. Use when the user asks for a human or live support.""" - return await self._dispatch_handoff( - "agent_escalation", - message, - phone_numbers, - emails, - confirmed=bool(confirmed), - ) + """Call when you cannot answer from KB/tools or the issue should move to staff (transfer mode). Relay only the tool line; keep helping on later turns. Contact is resolved in code (see customer_contact on the action).""" + return await self._dispatch_handoff("transfer", message, contact) @tool( - name="handoff__scheduled_callback", + name="handoff__observe", idempotency_class=IdempotencyClass.NON_RETRYABLE, + requires_tool_permission=True, + permission_denied_message=SAVE_DENIED_LINE, ) - async def scheduled_callback( + async def observe( self, - message: Annotated[ + fact: Annotated[ str, - "The customer's question and what was already tried. Never shown to the user.", + "The useful fact, policy, or answer from the group message. Not " + "sent back to the group.", ], - phone_numbers: Annotated[ - Optional[List[str]], - "Phone numbers the user provided. Omit when none.", - ] = None, - emails: Annotated[ - Optional[List[str]], - "Email addresses the user provided. Omit when none.", - ] = None, - confirmed: Annotated[ - Optional[bool], - "True after the user confirms this issue and contact.", - ] = None, ) -> ToolResult: - """Notify staff to reach the customer later. Use when the user wants a callback.""" - return await self._dispatch_handoff( - "scheduled_callback", - message, - phone_numbers, - emails, - confirmed=bool(confirmed), + """Store one useful fact from this WhatsApp group in the knowledge base and add the other group numbers as staff. Call this directly with the fact. Do not call handoff__pending_questions. Call only when the message contains something worth keeping. Send no message.""" + fact = (fact or "").strip() + if not fact: + return ToolResult( + content="handoff failed: no fact to store.", + is_error=True, + ) + try: + await self._enroll_group_staff() + except Exception: + logger.warning("handoff observe enroll failed", exc_info=True) + try: + await self._append_and_ingest(fact, fact) + except Exception as exc: + logger.error("handoff__observe ingest failed: %s", exc, exc_info=True) + return ToolResult( + content="handoff failed: could not save the fact to the knowledge base.", + is_error=True, + ) + await self._record_event(_handoff_event("observe")) + return ToolResult( + content=("The fact is stored. Do not send any message to the group.") + ) + + def _effective_consult_close(self) -> str: + legacy = (self.consult_prompt or "").strip() + return legacy or (self.consult_close or CONSULT_CLOSE).strip() + + def _effective_transfer_close(self) -> str: + legacy = (self.transfer_prompt or "").strip() + return legacy or (self.transfer_close or TRANSFER_CLOSE).strip() + + def _handoff_relay_line( + self, mode: str, customer_contact_kind: str, *, continuing_handoff: bool + ) -> str: + close = ( + self._effective_consult_close() + if mode == "consult" + else self._effective_transfer_close() + ) + followup_thanks = continuing_handoff + if followup_thanks and close.strip().lower().startswith("thanks"): + followup_thanks = False + return _compose_handoff_relay( + mode, + customer_contact_kind, + include_intro=not continuing_handoff, + include_contact_ask=False, + include_close=True, + intro=self.handoff_intro, + close=close, + followup_thanks=followup_thanks, ) async def _dispatch_handoff( self, mode: str, message: str, - phone_numbers: Optional[List[str]], - emails: Optional[List[str]], + contact: Optional[str], contact_declined: bool = False, - confirmed: bool = False, ) -> ToolResult: channel = self._channel_for(mode) - prior = await self._saved_contact() - provided = _first_contact(phone_numbers, emails) - contact_replaced = bool(provided) and provided != prior - if provided: - await self._save_contact(provided) - contact = provided or prior or _sender_contact(channel) - if mode in _CONFIRM_MODES: - if not contact: - tool_name = f"handoff__{mode}" - return ToolResult( - content=( - "Ask the user for a phone number or email. When they reply, " - f"call {tool_name} again with the same message and pass that " - "contact in phone_numbers or emails." - ) + kind = self._normalized_customer_contact_kind() + active = await self._active_mode() + continuing_handoff = bool(active) + if not active: + from jvagent.tooling.tool_executor import get_tool_visitor + + visitor = get_tool_visitor() + utterance = str(getattr(visitor, "utterance", "") or "").strip() + issue_src = message + used_utterance = bool( + utterance + and not _is_contact_only_message( + utterance, "", customer_contact_kind=kind ) - if contact_replaced or not confirmed: - return _confirm_directive(mode, message, contact) - user_facing = ( - self.agent_escalation_prompt - if mode == "agent_escalation" - else self.scheduled_callback_prompt - ) - elif mode == "staff_lookup" and not contact and not contact_declined: - logger.warning( - "handoff staff_lookup ask path no contact declined=%s %s", - contact_declined, - await self._pending_debug_ids(), ) - return ToolResult( - content=( - "Ask the user, in a natural line, that you don't have that " - "right now and may they share a phone number or email so you " - "can get back to them. If they share one, call " - "handoff__staff_lookup again with the same message and pass " - "it in phone_numbers or emails. If they refuse, call " - "handoff__staff_lookup again with the same message and " - "contact_declined true." + if used_utterance: + issue_src = utterance + if used_utterance: + issue = utterance + else: + issue = _consult_issue_text(issue_src) or (issue_src or "").strip() + await self._set_active(mode, issue) + await self._record_event(_handoff_event(mode, status="started")) + saved = await self._saved_contact() + visitor_channel = _dispatch_channel() + provided_clean = _sanitize_contact(contact, customer_contact_kind=kind) + if continuing_handoff and not provided_clean: + from jvagent.tooling.tool_executor import get_tool_visitor + + visitor = get_tool_visitor() + utterance = str(getattr(visitor, "utterance", "") or "").strip() + if _is_contact_only_message(utterance, "", customer_contact_kind=kind): + provided_clean = _sanitize_contact( + utterance, customer_contact_kind=kind ) - ) - updating = False - if mode == "staff_lookup": - user_facing = self.staff_lookup_prompt - ids = await self._pending_debug_ids() - open_q = await self._open_lookup(message) - if open_q is not None: - stored = _question_field(open_q, "user_contact").strip() - effective = contact or ("declined" if contact_declined else "") - contact_changed = bool(contact) and stored != contact - question_id = _question_field(open_q, "id") - if stored and not contact_changed: - logger.warning( - "handoff staff_lookup send skipped same contact " - "question_id=%s stored=%r contact=%r declined=%s %s", - question_id, - stored, - contact, - contact_declined, - ids, - ) - return _relay(user_facing) + elif _is_contact_only_message(message, "", customer_contact_kind=kind): + provided_clean = _sanitize_contact(message, customer_contact_kind=kind) + if provided_clean: + await self._save_contact(provided_clean) + effective_provided = provided_clean if provided_clean else contact + contact = _resolve_customer_contact( + provided=effective_provided, + saved=saved, + visitor_channel=visitor_channel, + customer_contact_kind=kind, + ) + if not contact and not (mode == "consult" and contact_declined): + tool_name = f"handoff__{mode}" + if mode == "consult": logger.warning( - "handoff staff_lookup updating question_id=%s " - "stored=%r effective=%r contact=%r declined=%s %s", - question_id, - stored, - effective, - contact, + "handoff consult ask path no contact declined=%s %s", contact_declined, - ids, - ) - new_contact = contact or effective - await self._update_pending( - question_id, user_contact=new_contact, node=open_q + await self._pending_debug_ids(), ) - updating = True + return _missing_contact_handoff_result( + mode, kind, tool_name, intro=self.handoff_intro + ) + user_facing = self._handoff_relay_line( + mode, kind, continuing_handoff=continuing_handoff + ) targets = await self._staff_targets(channel) if not targets: @@ -897,28 +1221,36 @@ async def _dispatch_handoff( is_error=True, ) recipient = _pick_staff(targets) - outbound = _staff_outbound(mode, message, phone_numbers, emails, contact) - if mode == "staff_lookup" and not updating: + staff_message = message + if mode == "consult": logger.warning( - "handoff staff_lookup send path creating pending question " + "handoff consult send path creating pending question " "contact=%r declined=%s %s", contact, contact_declined, await self._pending_debug_ids(), ) - recorded = "declined" if contact_declined and not contact else contact - pending_q = _pending_question_text( - _lookup_staff_message( + recorded = ( + "declined" if contact_declined and not contact else (contact or "") + ) + stored_issue = (await self._active_issue()).strip() + issue_source = message + if continuing_handoff and stored_issue: + contact_only = _is_contact_only_message( message, - [ - str(t).strip() - for t in (phone_numbers or []) + (emails or []) - if str(t).strip() - ], + contact or provided_clean, + customer_contact_kind=kind, ) - or message - ) + if contact_only or _looks_like_staff_summary(message): + issue_source = stored_issue + staff_message = stored_issue + pending_q = _consult_issue_text(issue_source, contact or "") await self._add_pending(pending_q, recorded) + outbound = _staff_outbound( + mode, + staff_message if mode == "consult" else message, + contact or "", + ) try: if channel == "whatsapp": @@ -930,8 +1262,7 @@ async def _dispatch_handoff( return ToolResult( content=( "handoff failed: the notification could not be sent. " - "Apologize briefly and offer the direct contact option via " - "handoff__contact_details." + "Apologize briefly." ), is_error=True, ) @@ -939,11 +1270,13 @@ async def _dispatch_handoff( logger.info( "handoff %s summary dispatched via %s to %s", mode, channel, recipient ) + await self._record_event(_handoff_event(mode)) + await self._clear_active() return _relay(user_facing) @tool(name="handoff__pending_questions") async def list_pending_questions(self) -> ToolResult: - """List customer questions waiting for an answer. If the latest message and the conversation so far look like an answer rather than a new question, call this first. Then call handoff__save_answer with the question_id it matches.""" + """List all unanswered customer pending questions. Call this first whenever the latest message is a statement, answer, or supplied business fact (not a question). Returns every pend_ id and question text — you choose which ids the staff message answers, then call handoff__save_answer with question_ids. If the list is empty, do not call handoff__consult and do not save the message.""" try: pending = await self._list_pending() except Exception: @@ -958,45 +1291,50 @@ async def list_pending_questions(self) -> ToolResult: ) if not pending: logger.warning( - "handoff pending_questions returning empty action_agent_id=%r", + "handoff pending_questions empty action_agent_id=%r", getattr(self, "agent_id", None), ) - return ToolResult(content="(no pending questions)") + return ToolResult( + content=( + "No pending questions. Do not call handoff__consult and do " + "not save the message. Reply in the conversation." + ) + ) lines = [ - "- id={id} | contact={contact} | Q: {question}".format( + "- {id} | {question}".format( id=_question_field(q, "id") or "unknown", - contact=_question_field(q, "user_contact") or "unknown", question=_question_field(q, "question")[:300], ) for q in pending ] return ToolResult( content=( - "Pending questions:\n" + "Pending questions (choose question_ids for handoff__save_answer):\n" + "\n".join(lines) - + "\n\nCall handoff__save_answer with the question_id that this " - "answer matches and the full answer text. Do not reply to the " - "user yet." + + "\n\nQuestion ids start with pend_. Pick every id this staff " + "answer resolves (same issue, multiple customers). Call " + "handoff__save_answer with question_ids and the full answer " + "text. Do not reply to the user yet." ) ) - @tool(name="handoff__save_answer", idempotency_class=IdempotencyClass.NON_RETRYABLE) + @tool( + name="handoff__save_answer", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + requires_tool_permission=True, + permission_denied_message=SAVE_DENIED_LINE, + ) async def save_answer( self, - question_id: Annotated[str, "id of the pending question being answered."], + question_ids: Annotated[ + List[str], + "One or more pend_ ids from handoff__pending_questions that this answer resolves.", + ], answer: Annotated[str, "Full answer to store."], ) -> ToolResult: - """Save the full answer for one pending question and return a confirmation. Use after handoff__pending_questions, with the matching question_id.""" + """Save the full answer for the chosen pending questions and return a confirmation. The only save tool in consult. question_ids must come only from handoff__pending_questions. A corr- correlation id is not a question id. This tool is the only source of the saved confirmation; never write it yourself.""" from jvagent.tooling.tool_executor import get_tool_visitor - if not await self._is_staff(): - return ToolResult( - content=( - "handoff failed: only authorized staff may resolve a pending " - "question." - ), - is_error=True, - ) visitor = get_tool_visitor() utterance = str(getattr(visitor, "utterance", "") or "") cleaned = _extract_saved_answer(answer, utterance) @@ -1005,15 +1343,32 @@ async def save_answer( content="handoff failed: no answer text to save.", is_error=True, ) - question = await self._get_pending_question(question_id) - if question is None: + ordered_ids: List[str] = [] + seen: set[str] = set() + for item in question_ids: + qid = str(item or "").strip() + if not qid or qid in seen: + continue + seen.add(qid) + ordered_ids.append(qid) + if not ordered_ids: return ToolResult( - content=f"handoff failed: no pending question with id {question_id!r}", + content="handoff failed: question_ids must include at least one pend_ id.", is_error=True, ) + group: List[Any] = [] + for qid in ordered_ids: + row = await self._get_pending_question(qid) + if row is None: + return ToolResult( + content=f"handoff failed: no pending question with id {qid!r}", + is_error=True, + ) + group.append(row) + anchor = group[0] try: short_q, short_a, chunk_id = await self._append_and_ingest( - _question_field(question, "question"), cleaned + _question_field(anchor, "question"), cleaned ) except Exception as exc: logger.error("handoff__save_answer ingest failed: %s", exc, exc_info=True) @@ -1024,35 +1379,50 @@ async def save_answer( ), is_error=True, ) - await self._remove_pending(question_id, answer=cleaned, node=question) - await self._reply_to_customer(question, short_q, short_a) + for row in group: + rid = _question_field(row, "id") + if rid: + await self._remove_pending(rid, answer=cleaned, node=row) + replied_contacts: set[str] = set() + for row in group: + contact = str(_question_field(row, "user_contact") or "").strip() + if not contact or contact.lower() == "declined": + continue + if contact in replied_contacts: + continue + replied_contacts.add(contact) + await self._reply_to_customer(row, short_q, short_a) await self._record_chunk_event(chunk_id, short_q) + await self._record_event(_handoff_event("save_answer", short_q)) + await self._clear_active() return ToolResult( - content=( - "Relay this to the staff member as-is: Your answer is saved " - "and will be used in the future to answer this question." + content=json.dumps( + { + "response_directive": ( + "Tell the user: Your answer is saved and will be " + "used in the future to answer this question." + ) + } ) ) @tool( - name="handoff__update_chunk", idempotency_class=IdempotencyClass.NON_RETRYABLE + name="handoff__update_chunk", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + requires_tool_permission=True, + permission_denied_message=SAVE_DENIED_LINE, ) async def update_chunk( self, - chunk_id: Annotated[str, "id of the handoff chunk to update."], + chunk_id: Annotated[ + str, + "Chunk id from an event line that says Handoff chunk. Starts with n.DocumentNode. A corr- id is not a chunk id.", + ], answer: Annotated[str, "Full answer to store."], ) -> ToolResult: - """Update the saved answer for one existing chunk and return a confirmation. Use when the user is correcting a saved answer, taking chunk_id from the [EVENT] in history and the answer from their message.""" + """Update the saved answer for one existing chunk and return a confirmation. chunk_id starts with n.DocumentNode and comes only from an event line that says Handoff chunk. A corr- correlation id is not a chunk id.""" from jvagent.tooling.tool_executor import get_tool_visitor - if not await self._is_staff(): - return ToolResult( - content=( - "handoff failed: only authorized staff may resolve a pending " - "question." - ), - is_error=True, - ) visitor = get_tool_visitor() utterance = str(getattr(visitor, "utterance", "") or "") cleaned = _extract_saved_answer(answer, utterance) @@ -1097,10 +1467,16 @@ async def update_chunk( ) _patch_handoff_json(chunk_id, title, section) await self._record_chunk_event(chunk_id, title) + await self._record_event(_handoff_event("update_chunk", title)) + await self._clear_active() return ToolResult( - content=( - "Relay this to the staff member as-is: Your answer is updated " - "and will be used in the future to answer this question." + content=json.dumps( + { + "response_directive": ( + "Tell the user: Your answer is updated and will be " + "used in the future to answer this question." + ) + } ) ) @@ -1116,9 +1492,10 @@ async def _customer_reply(self, question: str, answer: str) -> str: self, user_prompt=f"Question: {question}\nAnswer: {answer}", system_prompt=( - "Write a short message to the customer. Remind them of " - "the question they asked, then give the answer. Plain text " - "only. No mention of staff, tools, or a knowledge base." + "Write a short message to the customer. Thank them for " + "their patience, remind them of the question they asked, " + "then give the answer. Plain text only. No mention of " + "staff, tools, or a knowledge base." ), json_response=False, use_history=False, @@ -1132,11 +1509,13 @@ async def _customer_reply(self, question: str, answer: str) -> str: async def _reply_to_customer( self, question: Any, short_q: str, short_a: str ) -> None: - contact = _question_field(question, "user_contact").strip() + contact = str(_question_field(question, "user_contact") or "").strip() + if not contact or contact.lower() == "declined": + return + text = await self._customer_reply(short_q, short_a) kind = _contact_kind(contact) if not kind: return - text = await self._customer_reply(short_q, short_a) try: if kind == "email": await self._send_email( @@ -1145,7 +1524,13 @@ async def _reply_to_customer( else: await self._send_whatsapp(contact, text) except Exception: - logger.error("handoff reply to customer failed", exc_info=True) + logger.error( + "handoff reply to customer failed contact=%r", + contact, + exc_info=True, + ) + return + await self._record_event("Handoff delivered the saved answer to the customer.") def _channel_for(self, mode: str) -> str: channel = ( @@ -1171,13 +1556,6 @@ async def _aca_staff_members(self) -> List[str]: return [] return [str(m).strip() for m in staff if str(m).strip()] - async def _public_contacts(self) -> tuple: - """Phone is one random staff number; email is every staff address.""" - numbers = await self._staff_targets("whatsapp") - emails = await self._staff_targets("email") - phone = _pick_staff(numbers) if numbers else "" - return phone, emails - async def _staff_targets(self, channel: str) -> List[str]: """Notify/contact targets from AccessControl ``HandoffAction.staff``. @@ -1424,7 +1802,7 @@ async def _add_pending(self, message: str, contact: str = "") -> Dict[str, Any]: try: await self._refresh_pending_state() entry = { - "id": f"q_{uuid.uuid4().hex}", + "id": f"pend_{uuid.uuid4().hex}", "question": message or "", "user_channel": channel, "user_contact": stored_contact, @@ -1463,7 +1841,7 @@ async def _update_pending( user_contact: str = "", node: Any = None, ) -> None: - """Update contact on one pending question.""" + """Update contact on one pending question (replaces with single contact).""" qid = (question_id or "").strip() or _question_field(node, "id").strip() if not qid: logger.warning("handoff update_pending missing question_id") @@ -1471,9 +1849,10 @@ async def _update_pending( await self._refresh_pending_state() rows = self._unanswered_rows() changed = False + stored = (user_contact or "").strip() for row in rows: if str(row.get("id") or "") == qid: - row["user_contact"] = (user_contact or "").strip() + row["user_contact"] = stored changed = True break if not changed: @@ -1481,22 +1860,6 @@ async def _update_pending( return await self._persist_pending_rows(rows) - async def _update_pending_contact(self, prior: str, contact: str) -> None: - """Update user_contact on every pending question matching ``prior``.""" - prior = (prior or "").strip() - contact = (contact or "").strip() - if not prior or not contact: - return - await self._refresh_pending_state() - rows = self._unanswered_rows() - changed = False - for row in rows: - if str(row.get("user_contact") or "") == prior: - row["user_contact"] = contact - changed = True - if changed: - await self._persist_pending_rows(rows) - async def _remove_pending( self, question_id: str, *, answer: str = "", node: Any = None ) -> None: @@ -1557,30 +1920,93 @@ async def _save_contact(self, contact: str) -> None: if isinstance(context, dict): context[_HANDOFF_CONTACT_KEY] = contact - async def _open_lookup(self, message: str = "") -> Any: - """Open pending question for this issue.""" + async def _update_context(self, updates: Dict[str, Any]) -> None: + """Persist handoff state keys on the current conversation, best-effort.""" + if not updates: + return + conversation = await self._conversation() + if conversation is None: + logger.warning("handoff context not saved: no conversation") + return + update = getattr(conversation, "update_context", None) + if callable(update): + await update(updates) + return + context = getattr(conversation, "context", None) + if isinstance(context, dict): + context.update(updates) + + async def _active_mode(self) -> str: + """Mode of the handoff currently in progress (``""`` when none).""" + conversation = await self._conversation() + context = getattr(conversation, "context", None) or {} + return str(context.get(_HANDOFF_ACTIVE_MODE_KEY) or "").strip() + + async def _active_issue(self) -> str: + """Customer ask stored for an in-progress handoff.""" + conversation = await self._conversation() + context = getattr(conversation, "context", None) or {} + return str(context.get(_HANDOFF_ACTIVE_ISSUE_KEY) or "").strip() + + async def _set_active(self, mode: str, issue: str = "") -> None: + await self._update_context( + { + _HANDOFF_ACTIVE_MODE_KEY: (mode or "").strip(), + _HANDOFF_ACTIVE_ISSUE_KEY: (issue or "").strip(), + } + ) + + async def _clear_active(self) -> None: + await self._update_context( + {_HANDOFF_ACTIVE_MODE_KEY: "", _HANDOFF_ACTIVE_ISSUE_KEY: ""} + ) + + async def _group_payload(self) -> Dict[str, Any]: + """WhatsApp payload on the current visitor, or ``{}``.""" + from jvagent.tooling.tool_executor import get_tool_visitor + + visitor = get_tool_visitor() + data = getattr(visitor, "data", None) if visitor else None + if not isinstance(data, dict): + return {} + payload = data.get("whatsapp_payload") or {} + return payload if isinstance(payload, dict) else {} + + async def _enroll_group_staff(self) -> List[str]: + """Add every other WhatsApp group number to ``HandoffAction.staff``.""" + payload = await self._group_payload() + if not payload.get("isGroup"): + return [] + group_id = str(payload.get("sender") or "").strip() + if not group_id: + return [] + agent = await self.get_agent() + wa = ( + await agent.get_action_by_type(self.handoff_notify_action_type) + if agent + else None + ) + if wa is None: + return [] + api = await wa.api() + result = await api.group_members(group_id) + numbers: List[str] = [] + for item in (result or {}).get("response") or []: + if not isinstance(item, dict) or item.get("formattedName") == "You": + continue + user = str((item.get("id") or {}).get("user") or "").strip() + if user: + numbers.append(user) + if not numbers: + return [] try: - agent_id = await self._agent_id() - keys = _issue_keys(message) - rows = await self._list_pending() - for question in rows: - stored = _question_field(question, "question").strip() - if keys and stored not in keys: - continue - logger.warning( - "handoff open lookup matched id=%s agent_id=%r", - _question_field(question, "id"), - agent_id, - ) - return question - logger.warning( - "handoff open lookup no match agent_id=%r scanned=%s", - agent_id, - len(rows), - ) + aca: Any = await self.get_action("AccessControlAction") except Exception: - logger.warning("handoff open lookup failed", exc_info=True) - return None + return [] + if aca is None: + return [] + await aca.add_users_to_group("staff", numbers, action_label="HandoffAction") + return numbers async def _pending_debug_ids(self) -> str: from jvagent.tooling.tool_executor import get_dispatch_context @@ -1624,19 +2050,22 @@ async def _condense_qa(self, question: str, answer: str) -> tuple: return fallback return short_q, short_a - async def _record_chunk_event(self, chunk_id: str, question: str) -> None: - """Store the chunk id on this interaction so a later turn can update it.""" + async def _record_event(self, event: str) -> None: + """Store one handoff event on this interaction and save it.""" from jvagent.tooling.tool_executor import get_tool_visitor + text = (event or "").strip() + if not text: + return visitor = get_tool_visitor() interaction = getattr(visitor, "interaction", None) if visitor else None adder = getattr(interaction, "add_event", None) - if interaction is None or not callable(adder) or not chunk_id: + if interaction is None or not callable(adder): return try: - added = adder(_chunk_event(chunk_id, question), "HandoffAction") + added = adder(text, "HandoffAction") except Exception: - logger.debug("handoff chunk event failed", exc_info=True) + logger.debug("handoff event failed", exc_info=True) return if added is False: return @@ -1648,7 +2077,12 @@ async def _record_chunk_event(self, chunk_id: str, question: str) -> None: if hasattr(result, "__await__"): await result except Exception: - logger.debug("handoff chunk event save failed", exc_info=True) + logger.debug("handoff event save failed", exc_info=True) + + async def _record_chunk_event(self, chunk_id: str, question: str) -> None: + """Store the chunk id on this interaction so a later turn can update it.""" + if chunk_id: + await self._record_event(_chunk_event(chunk_id, question)) async def _append_and_ingest(self, question: str, answer: str) -> tuple: """Condense one Q&A, append its chunk, and re-import the handoff graph.""" @@ -1685,24 +2119,12 @@ async def _append_and_ingest(self, question: str, answer: str) -> tuple: async def healthcheck(self) -> Any: return True - # -- rendering helper (shared with tests) --------------------------------- - - async def render_direct_contact(self) -> str: - phone, emails = await self._public_contacts() - return _render_direct_contact( - self.direct_contact_prompt, - email=", ".join(emails), - phone=phone, - hours=self.handoff_hours, - ) - __all__ = [ - "AGENT_ESCALATION_PROMPT", - "DIRECT_CONTACT_PROMPT", + "CONSULT_CLOSE", "HANDOFF_DOC_NAME", + "HANDOFF_INTRO", "HANDOFF_PARAMETERS", "HandoffAction", - "SCHEDULED_CALLBACK_PROMPT", - "STAFF_LOOKUP_PROMPT", + "TRANSFER_CLOSE", ] diff --git a/jvagent/action/handoff_action/info.yaml b/jvagent/action/handoff_action/info.yaml index 78a0eb1f..d81b6643 100644 --- a/jvagent/action/handoff_action/info.yaml +++ b/jvagent/action/handoff_action/info.yaml @@ -6,12 +6,12 @@ package: meta: title: Handoff Action description: >- - Human-support capability tools, skill-gated: return contact details - (handoff__contact_details), notify staff for escalation - (handoff__agent_escalation), a callback (handoff__scheduled_callback), - or a staff lookup (handoff__staff_lookup), and save an answer to a - pending question (handoff__pending_questions / handoff__save_answer) into - a public PageIndex document (handoff.md). + One handoff mode at a time. consult asks staff and replies when they + answer (handoff__consult, handoff__pending_questions, + handoff__save_answer, handoff__update_chunk). transfer leaves the + conversation (handoff__transfer). observe stores WhatsApp group facts + in PageIndex and adds group numbers to AccessControl, without replying + (handoff__observe). Pin the active mode's tools on the orchestrator. group: jvagent type: action config: diff --git a/jvagent/action/handoff_interact_action/README.md b/jvagent/action/handoff_interact_action/README.md deleted file mode 100644 index 1249a15d..00000000 --- a/jvagent/action/handoff_interact_action/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Handoff Interact Action (`jvagent/handoff_interact_action`) - -Detects when the user wants a human and uses a language model to choose how to help: **direct_contact** (show support email/phone), **agent_escalation** (notify a human on WhatsApp with a structured internal summary), or **scheduled_callback** (confirm follow-up and notify when contact details are present). Contact information is read from the thread where possible; if escalation or callback needs details and none are present, the user is asked to provide them. - -Registered package id (namespace/action): `jvagent/handoff_interact_action`. - -## License - -See the application-level [LICENSE](../../../../../../LICENSE). - -## Author - -**Tharick Jairam** · jvagent/handoff_interact_action / V75 Inc. diff --git a/jvagent/action/handoff_interact_action/__init__.py b/jvagent/action/handoff_interact_action/__init__.py deleted file mode 100644 index 6e0df340..00000000 --- a/jvagent/action/handoff_interact_action/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Handoff interact action.""" - -from .handoff_interact_action import HandoffInteractAction - -__all__ = ["HandoffInteractAction"] diff --git a/jvagent/action/handoff_interact_action/handoff_interact_action.py b/jvagent/action/handoff_interact_action/handoff_interact_action.py deleted file mode 100644 index dad17c4e..00000000 --- a/jvagent/action/handoff_interact_action/handoff_interact_action.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Handoff interact action.""" - -import logging -from typing import List - -from jvspatial.core.annotations import attribute - -from jvagent.action.interact.base import InteractAction -from jvagent.action.interact.interact_walker import InteractWalker -from jvagent.action.utils.call_model import call_model - -logger = logging.getLogger(__name__) - -DIRECT_CONTACT_PROMPT = """You can reach a human representative directly using the contact details below: - -Email: {handoff_email} -Phone / WhatsApp: {handoff_phone} -Office Hours: {handoff_hours} - -A team member will assist you as soon as possible.""" - - -AGENT_ESCALATION_PROMPT = """I'll escalate your request to a human representative and share your conversation details with them. - -A team member will review your case and reach out to you shortly using the contact information you provided.""" - - -SCHEDULED_CALLBACK_PROMPT = """I'll arrange for a human representative to follow up with you. - -You can expect a response within the next 24 hours (or the next business day). If your request is urgent, please use the direct contact option for faster assistance.""" - -HANDOFF_SYSTEM_PROMPT = """ -You are responsible for selecting the correct human handoff mode and generating a structured handoff message for a human agent. - -Your output is NOT a message to the user. -It is an internal message intended for a human representative to understand the situation and take action. - ---- - -Step 1: Determine the user's intent and select ONE mode: - -1. direct_contact -- Use when the user prefers to contact a human themselves. -- No handoff is required. -- Message should be minimal or empty. - -2. agent_escalation -- Use when the user wants immediate human assistance or escalation. -- The message should summarize the user's request and the **reason** for escalation based on conversation history. - -3. scheduled_callback -- Use when the user wants a human to reach out later OR when a callback is required. -- Include extracted contact details if available. -- If contact info is missing, clearly indicate what is missing in the message. - ---- - -Step 2: Extract Contact Information - -- Extract any phone number(s) mentioned by the user. -- Extract any email address(es) mentioned by the user. -- Preserve the original format exactly as provided. -- If multiple numbers or emails are present, include all of them. -- If no contact details are provided, explicitly list them as missing. -- Do NOT ask the user for missing details — only report their absence. - ---- - -Message Guidelines: - -- The message must be written for a HUMAN AGENT, not the user. -- Be concise, structured, and informative. -- Include: - - User intent - - Key issue or request (reason for handoff) - - Relevant context from conversation history - - Extracted contact details (if any) - - Missing information (if any) - - Username (if available) -- If the user did not specify a reason for requesting a human, default to: - "User requests to speak with a human." -- Do NOT mention that the user wants to connect to a human as the reason; instead, summarize the underlying context. -- Do NOT include greetings, conversational filler, or system explanations. - ---- - -Username: {username} - ---- - -Output Format (strict JSON): - -{{ - "mode": "direct_contact | agent_escalation | scheduled_callback", - "message": "", - "contact": {{ - "phone_numbers": [""], - "emails": [""], - "missing": ["phone_number" | "email" | null] - }} -}} -""" - - -class HandoffInteractAction(InteractAction): - """Interact action that hands users to human support with LLM-chosen handling. - - Modes: - - direct_contact — user sees support contact details; no outbound notification. - - agent_escalation — user sees an escalation message; internal summary sent via WhatsApp when contact info exists. - - scheduled_callback — user sees a callback confirmation; internal summary sent via WhatsApp when contact info exists. - """ - - description: str = ( - "Detects human-support intent and routes to direct contact, WhatsApp escalation, " - "or scheduled callback using conversation context and extracted contact details." - ) - - anchors: List[str] = attribute( - default_factory=lambda: [ - "User wants to speak to a human.", - "User requests a human agent.", - "User asks to be connected to support.", - "User does not want to continue with the AI.", - "User expresses frustration and asks for a real person.", - "User asks for contact information for support.", - "User wants to escalate the conversation.", - "User requests a callback from a human.", - "User asks for phone, email, or WhatsApp contact.", - "User says they need further assistance from a human.", - "User indicates the issue is not resolved and wants escalation.", - ], - description="Anchor statements for Orchestrator tool surfacing (handoff intent).", - ) - - model_action_type: str = attribute( - default="OpenAILanguageModelAction", - description="Model action type", - ) - model: str = attribute(default="gpt-4o-mini", description="Model name") - model_temperature: float = attribute( - default=0.1, description="Sampling temperature" - ) - model_max_tokens: int = attribute(default=8192, description="Max tokens") - use_history: bool = attribute(default=True, description="Use history") - history_limit: int = attribute(default=6, description="History limit") - max_statement_length: int = attribute( - default=400, description="Max statement length" - ) - - direct_contact_prompt: str = attribute( - default=DIRECT_CONTACT_PROMPT, - description="Prompt for direct contact", - ) - - agent_escalation_prompt: str = attribute( - default=AGENT_ESCALATION_PROMPT, - description="Prompt for agent escalation", - ) - - scheduled_callback_prompt: str = attribute( - default=SCHEDULED_CALLBACK_PROMPT, - description="Prompt for scheduled callback", - ) - - handoff_system_prompt: str = attribute( - default=HANDOFF_SYSTEM_PROMPT, - description="Prompt for handoff", - ) - - handoff_mode: str = attribute( - default="direct_contact", - description="direct_contact, agent_escalation, scheduled_callback", - ) - - handoff_number: str = attribute( - default="", - description=( - "Phone / WhatsApp number for direct contact. Configure via " - "agent.yaml ``context.handoff_number`` (or pin to an env var " - "with ``${HANDOFF_NUMBER}``). Empty string disables the phone " - "line in the DIRECT_CONTACT_PROMPT template." - ), - ) - - handoff_email: str = attribute( - default="", - description=( - "Email for direct contact. Configure via agent.yaml " - "``context.handoff_email`` or ``${HANDOFF_EMAIL}``. AUDIT-actions" - " (Wave D removed the previous hardcoded ``support@company.com``)." - ), - ) - - handoff_hours: str = attribute( - default="Mon-Fri, 9:00 AM - 5:00 PM", - description="Office hours phrase rendered into DIRECT_CONTACT_PROMPT.", - ) - - handoff_notify_action_type: str = attribute( - default="WhatsAppAction", - description=( - "Action class name used to notify staff on escalation/callback " - "(must expose ``api()`` with ``send_message``)." - ), - ) - - ######################################################################################## - # CORE FUNCTIONS - ######################################################################################## - - async def execute(self, visitor: InteractWalker) -> None: - """Execute the handoff action.""" - - # selecting the handoff mode and their message - user = await visitor.interaction.get_user() - username = "Unknown" - if user: - username = user.get_display_name() - user_prompt = visitor.utterance - system_prompt = self.handoff_system_prompt.format(username=username) - - handoff_result = await call_model( - self, - user_prompt=user_prompt, - system_prompt=system_prompt, - json_response=True, - use_history=self.use_history, - history_limit=self.history_limit, - interaction=visitor.interaction, - with_utterance=True, - with_response=True, - with_interpretation=False, - with_event=True, - max_statement_length=self.max_statement_length, - model=self.model, - temperature=self.model_temperature, - max_tokens=self.model_max_tokens, - ) - - if not isinstance(handoff_result, dict): - logger.error( - "Handoff model call failed or returned non-JSON: %r", handoff_result - ) - return - - message = handoff_result.get("message", "") - handoff_mode = handoff_result.get("mode", "") - contact_info = handoff_result.get("contact", {}) - phone_numbers = contact_info.get("phone_numbers", []) - emails = contact_info.get("emails", []) - phone = self.handoff_number - - agent = await self.get_agent() - notify_action = ( - await agent.get_action_by_type(self.handoff_notify_action_type) - if agent - else None - ) - notify_api = await notify_action.api() if notify_action is not None else None - - # Handle the handoff mode - if handoff_mode == "direct_contact": - # Render contact placeholders into the operator-configurable - # template. When a field is blank, drop the line so we never - # show ``Email: `` / ``Phone: ``. Falls back to the literal - # template when there are no `{...}` placeholders (legacy - # override). AUDIT-actions D.1 (Wave D). - try: - rendered = self.direct_contact_prompt.format( - handoff_email=self.handoff_email, - handoff_phone=self.handoff_number, - handoff_hours=self.handoff_hours, - ) - except (KeyError, IndexError): - rendered = self.direct_contact_prompt - # Trim blank-field lines so an empty handoff_email/number does - # not render as ``Email: ``. - cleaned_lines = [] - for line in rendered.split("\n"): - stripped = line.strip() - if stripped.endswith(":") and ( - stripped.lower().startswith("email:") - or stripped.lower().startswith("phone") - ): - continue - cleaned_lines.append(line) - rendered = "\n".join(cleaned_lines) - visitor.interaction.directives = [ - { - "action_name": self.get_class_name(), - "content": rendered, - "executed": False, - } - ] - elif handoff_mode == "agent_escalation": - - if not phone_numbers and not emails: - # ask for contact info if missing - visitor.interaction.directives = [ - { - "action_name": self.get_class_name(), - "content": f"Please provide your contact information if you would like a callback.", - "executed": False, - } - ] - else: - visitor.interaction.directives = [ - { - "action_name": self.get_class_name(), - "content": self.agent_escalation_prompt, - "executed": False, - } - ] - if notify_api: - await notify_api.send_message(phone=phone, message=message) - elif handoff_mode == "scheduled_callback": - if not phone_numbers and not emails: - # ask for contact info if missing - visitor.interaction.directives = [ - { - "action_name": self.get_class_name(), - "content": f"Please provide your contact information if you would like a callback.", - "executed": False, - } - ] - else: - visitor.interaction.directives = [ - { - "action_name": self.get_class_name(), - "content": self.scheduled_callback_prompt, - "executed": False, - } - ] - - if notify_api: - await notify_api.send_message(phone=phone, message=message) - - return diff --git a/jvagent/action/handoff_interact_action/info.yaml b/jvagent/action/handoff_interact_action/info.yaml deleted file mode 100644 index b8cf648f..00000000 --- a/jvagent/action/handoff_interact_action/info.yaml +++ /dev/null @@ -1,37 +0,0 @@ -package: - name: jvagent/handoff_interact_action - author: Tharick Jairam - archetype: HandoffInteractAction - version: 1.0.0 - meta: - title: Handoff Interact Action - description: >- - Routes human-support intent with a language model into three modes — - direct contact details for the user, immediate escalation with an internal - summary to operations (WhatsApp), or a scheduled callback — including - extraction of phone and email from the conversation when relevant. - group: jvagent - type: action - config: - order: - weight: 0 - # Pattern-agnostic manifest (ADR-0010). Consumed - # by pattern orchestrators and schedulers for routing hints; ignored by patterns that - # don't read it. - manifest: - purpose: >- - Route the user to a human operator via direct contact details, - WhatsApp escalation, or scheduled callback. - activates_on: - - "user explicitly asks to speak to a person / human / agent" - - "user requests escalation, callback, or live support" - terminates_when: - - "contact details delivered to user" - - "escalation message dispatched" - - "callback scheduled" - latency_class: quick - expected_duration_seconds: 3.0 - dependencies: - jvagent: ~0.0.1 - actions: [] - pip: [] diff --git a/jvagent/action/orchestrator/access.py b/jvagent/action/orchestrator/access.py index c8cda2d3..098f9984 100644 --- a/jvagent/action/orchestrator/access.py +++ b/jvagent/action/orchestrator/access.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging -from typing import Any, Optional +from typing import Any, Dict, Optional logger = logging.getLogger(__name__) @@ -35,6 +35,68 @@ async def _resolve_access_control(agent: Any) -> Optional[Any]: return ac +async def drop_unpermitted_tools( + ac: Any, + *, + user_id: Optional[str], + channel: str, + tools: Dict[str, Any], + visible: set, + longtail: Optional[set] = None, +) -> None: + """Remove tools listed in ``permissions[channel].tools`` the sender cannot call. + + A pin cannot keep them. They leave the callable map, so discovery cannot + load them either. Names that are not in that map stay. + """ + names = ac.tool_permission_names(channel) + for name in names: + if name not in tools: + continue + try: + allowed = bool(await ac.has_tool_access(user_id or "", name, channel)) + except Exception as exc: + logger.warning( + "orchestrator.access: has_tool_access raised for %s — hiding: %s", + name, + exc, + ) + allowed = False + if allowed: + continue + tools.pop(name, None) + visible.discard(name) + if longtail is not None: + longtail.discard(name) + + +async def is_named_tool_allowed( + agent: Any, *, tool_name: str, user_id: Optional[str], channel: str +) -> bool: + """True if ``permissions[channel].tools[tool_name]`` allows this sender. + + Opted-in tools fail closed: no enforcing AccessControl, a missing ``tools`` + entry, or no matching allow rule denies the call. This does not use the + action-label gate. + """ + ac = await _resolve_access_control(agent) + if ac is None: + return False + check = getattr(ac, "has_tool_access", None) + if not callable(check): + return False + try: + return bool(await check(user_id or "", tool_name, channel)) + except Exception as exc: + logger.warning( + "orchestrator.access: has_tool_access raised for %s — " + "failing closed: %s", + tool_name, + exc, + ) + return False + + async def is_tool_allowed( agent: Any, *, label: str, user_id: Optional[str], channel: str ) -> bool: @@ -62,5 +124,7 @@ async def is_tool_allowed( __all__ = [ "delegate_resource_label", + "drop_unpermitted_tools", + "is_named_tool_allowed", "is_tool_allowed", ] diff --git a/jvagent/action/orchestrator/loop.py b/jvagent/action/orchestrator/loop.py index dd879d18..e7937940 100644 --- a/jvagent/action/orchestrator/loop.py +++ b/jvagent/action/orchestrator/loop.py @@ -414,7 +414,7 @@ async def _prepare_turn(self, visitor: "InteractWalker") -> Optional[TurnState]: # flow through the subsystem of record (observable + deduped) and each # injection site renders its scope. interaction = getattr(visitor, "interaction", None) - await self._accumulate_parameters(interaction) + await self._accumulate_parameters(interaction, visitor) # Conversation cost ceiling (ADR-0046): a turn that starts over it makes # no model call at all — the user is told plainly, and the activation diff --git a/jvagent/action/orchestrator/orchestrator_interact_action.py b/jvagent/action/orchestrator/orchestrator_interact_action.py index eb969fa9..1d634070 100644 --- a/jvagent/action/orchestrator/orchestrator_interact_action.py +++ b/jvagent/action/orchestrator/orchestrator_interact_action.py @@ -1290,7 +1290,13 @@ async def _assemble_tools( visible.add(name) else: wrap_visitor = visitor if bind_visitor else None - tools[name] = wrap_action_tool(tool, visitor=wrap_visitor) + tools[name] = wrap_action_tool( + tool, + visitor=wrap_visitor, + agent=agent, + user_id=getattr(visitor, "user_id", None), + channel=getattr(visitor, "channel", "default") or "default", + ) longtail.add(name) for name, tool in cached_surface.mcp_tools.items(): tools[name] = policy.wrap_mcp(tool) @@ -1367,7 +1373,13 @@ async def _assemble_tools( # decides visibility after assembly). Actions that set # ``binds_tools_to_visitor`` receive the live visitor at wrap. wrap_visitor = visitor if bind_visitor else None - tools[name] = wrap_action_tool(tool, visitor=wrap_visitor) + tools[name] = wrap_action_tool( + tool, + visitor=wrap_visitor, + agent=agent, + user_id=getattr(visitor, "user_id", None), + channel=getattr(visitor, "channel", "default") or "default", + ) longtail.add(name) # MCP tool servers (via jvagent/mcp MCPAction; ADR-0015). Tools surface @@ -1705,6 +1717,23 @@ async def _egress_exec( for d in docs: if getattr(d, "always_active", False): visible |= {t for t in getattr(d, "requires_tools", ()) if t in tools} + # Tool permissions win over pins: a save tool the sender cannot call + # leaves the prompt and the callable map. + from jvagent.action.orchestrator.access import ( + _resolve_access_control, + drop_unpermitted_tools, + ) + + ac = await _resolve_access_control(agent) + if ac is not None: + await drop_unpermitted_tools( + ac, + user_id=getattr(visitor, "user_id", None), + channel=getattr(visitor, "channel", "default") or "default", + tools=tools, + visible=visible, + longtail=longtail, + ) # Hard exclude (wins over lean + pins): drop from tools so find_tool / # load_tool / dispatch cannot reach them. Applied last intentionally. if policy.denied_patterns: @@ -2640,7 +2669,9 @@ async def _finalize_proactive_task(self, visitor: Any) -> None: # Loop # ------------------------------------------------------------------ - async def _accumulate_parameters(self, interaction: Any) -> None: + async def _accumulate_parameters( + self, interaction: Any, visitor: Any = None + ) -> None: """Pool every enabled action's scoped parameters onto ``interaction.parameters`` — the accumulation step of the common subsystem. Params are queued like directives (observable, persisted, @@ -2654,7 +2685,7 @@ async def _accumulate_parameters(self, interaction: Any) -> None: agent = await self._safe_agent() actions = await self._enabled_actions(agent) if agent else [self] try: - if await accumulate_action_parameters(interaction, actions): + if await accumulate_action_parameters(interaction, actions, visitor): await interaction.save() except Exception as exc: logger.debug("orchestrator: accumulating parameters failed: %s", exc) diff --git a/jvagent/action/orchestrator/surface_policy.py b/jvagent/action/orchestrator/surface_policy.py index 202250c8..ab966f53 100644 --- a/jvagent/action/orchestrator/surface_policy.py +++ b/jvagent/action/orchestrator/surface_policy.py @@ -125,7 +125,13 @@ def wrap_mcp(self, tool: Any) -> SkillTool: def wrap_action(self, tool: Any, *, visitor: Any = None) -> SkillTool: """Wrap a plain capability tool (visitor bound only when requested).""" - return wrap_action_tool(tool, visitor=visitor) + return wrap_action_tool( + tool, + visitor=visitor, + agent=self.agent, + user_id=self.user_id, + channel=self.channel, + ) def materialize( self, tool: Any, *, action: Any, visitor: Any = None diff --git a/jvagent/action/orchestrator/tools.py b/jvagent/action/orchestrator/tools.py index e576fbf5..a453a73f 100644 --- a/jvagent/action/orchestrator/tools.py +++ b/jvagent/action/orchestrator/tools.py @@ -96,6 +96,19 @@ async def _run(args: Dict[str, Any], _tool: Any = tool) -> str: call_args = dict(args or {}) reject_model_authority_fields(call_args) + if getattr(tool, "requires_tool_permission", False): + from jvagent.action.orchestrator.access import is_named_tool_allowed + + if not await is_named_tool_allowed( + agent, + tool_name=name, + user_id=user_id, + channel=channel, + ): + return ( + getattr(tool, "permission_denied_message", None) + or "(access denied)" + ) if effective_access_label is not None and not await is_tool_allowed( agent, label=effective_access_label, user_id=user_id, channel=channel ): diff --git a/jvagent/action/parameters.py b/jvagent/action/parameters.py index b22340b4..c51d5295 100644 --- a/jvagent/action/parameters.py +++ b/jvagent/action/parameters.py @@ -415,7 +415,9 @@ def orchestration_parameters(parameters: Optional[List[Any]]) -> List[Dict[str, return in_scope(parameters, SCOPE_ORCHESTRATION) -async def accumulate_action_parameters(interaction: Any, actions: List[Any]) -> bool: +async def accumulate_action_parameters( + interaction: Any, actions: List[Any], visitor: Any = None +) -> bool: """Queue every action's scoped parameters onto ``interaction.parameters``. The accumulation step of the common subsystem: each action contributes its @@ -432,8 +434,16 @@ async def accumulate_action_parameters(interaction: Any, actions: List[Any]) -> # Stamp the resolved scope onto each param (unspecified → response) so # the pooled, persisted, observable entries always carry an explicit # scope — no read-time guessing downstream. + contributor = getattr(action, "contributed_parameters", None) + if visitor is not None and callable(contributor): + try: + raw = await contributor(visitor) + except Exception: + raw = getattr(action, "parameters", None) or [] + else: + raw = getattr(action, "parameters", None) or [] scoped: List[Dict[str, Any]] = [] - for p in getattr(action, "parameters", None) or []: + for p in raw or []: if not isinstance(p, dict): continue entry = dict(p) diff --git a/jvagent/action/reply/reply_action.py b/jvagent/action/reply/reply_action.py index ee1de6cf..1cd310f5 100644 --- a/jvagent/action/reply/reply_action.py +++ b/jvagent/action/reply/reply_action.py @@ -439,10 +439,10 @@ async def reply(self, text: str, visitor: Optional[Any] = None) -> bool: self.get_channel_format(channel) ) - # Any queued shaping (directives, parameters, or channel format) routes - # through respond(), which enqueues the message as a directive so the - # whole queue composes together and the message is never overridden. - if directive_items or has_params or has_format: + # Queued directives/parameters (or explicit text + channel format) route + # through respond(). Channel format alone never forces compose on empty + # reply() — ReplyAction must not answer the utterance without content. + if directive_items or has_params or (has_format and text): return bool(await self.respond(interaction, visitor=visitor, text=text)) if not text: return False @@ -466,7 +466,7 @@ async def gather(self, visitor: Optional[Any] = None) -> bool: self.get_channel_format(channel) ) if not directive_items: - if has_params or has_format: + if has_params: return bool(await self.respond(interaction, visitor=visitor)) return False first = directive_items[0] @@ -583,17 +583,14 @@ async def respond( if content else f"User message: {utterance}" ) - # Parameters (and channel format) shape HOW to phrase. When no explicit + # Contributed response parameters shape HOW to phrase. When no explicit # message or directive is queued, fall back to the user's utterance so - # intro-style *contributed* parameters still reach compose. + # intro-style parameters still reach compose. Channel format alone does + # not trigger that fallback — it is not permission to answer the utterance. if not content and not directive_contents: has_shaping = bool( self._collect_parameters(parameters, interaction).strip() ) - if not has_shaping: - channel_pre = getattr(visitor, "channel", "default") or "default" - if self.apply_channel_format and self.get_channel_format(channel_pre): - has_shaping = True if has_shaping and interaction is not None: content = (getattr(interaction, "utterance", "") or "").strip() if not content and not directive_contents and not has_shaping: diff --git a/jvagent/action/whatsapp/utils/endpoint_helpers.py b/jvagent/action/whatsapp/utils/endpoint_helpers.py index 992f6105..4582d248 100644 --- a/jvagent/action/whatsapp/utils/endpoint_helpers.py +++ b/jvagent/action/whatsapp/utils/endpoint_helpers.py @@ -6,6 +6,7 @@ import asyncio import base64 +import inspect import logging import re from typing import Any, Dict, Optional, Tuple @@ -703,6 +704,41 @@ async def handle_whatsapp_interact_deferred_event( ) +async def _whatsapp_direct_all_group_messages(action_node: WhatsAppAction) -> bool: + """True when any enabled action opts in via ``whatsapp_direct_all_group_messages``.""" + try: + agent = await action_node.get_agent() + if agent is None: + return False + mgr = await agent.get_actions_manager() + if mgr is None: + return False + actions = await mgr.get_all_actions(enabled_only=True) + except Exception: + logger.debug( + "whatsapp: could not enumerate actions for group-directed hook", + exc_info=True, + ) + return False + for action in actions or []: + hook = getattr(action, "whatsapp_direct_all_group_messages", None) + if not callable(hook): + continue + try: + result = hook() + if inspect.isawaitable(result): + result = await result + if result: + return True + except Exception: + logger.debug( + "whatsapp: whatsapp_direct_all_group_messages hook failed on %s", + type(action).__name__, + exc_info=True, + ) + return False + + async def is_directed_message(action_node: WhatsAppAction, data: Any) -> bool: """Determine if message is directed at the bot. @@ -717,6 +753,10 @@ async def is_directed_message(action_node: WhatsAppAction, data: Any) -> bool: if not data.isGroup: return True + # An enabled action may opt in (e.g. silent group observer) — not only @mentions. + if await _whatsapp_direct_all_group_messages(action_node): + return True + # Extract body from message or caption body = data.body or data.caption or "" matches = re.findall(r"@(\d+)", body) diff --git a/jvagent/skills/handoff/SKILL.md b/jvagent/skills/handoff/SKILL.md deleted file mode 100644 index bdb9ddd9..00000000 --- a/jvagent/skills/handoff/SKILL.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: handoff -description: >- - Reach a human teammate, give the team's phone, email, or office hours on - request, or change the phone or email replies are sent to. Use when the user - asks for a person, wants a callback, or you cannot answer from the knowledge - base or catalog. A question the knowledge base cannot answer is never a - request for contact details. -allowed-tools: - - handoff__contact_details - - handoff__update_contact - - handoff__staff_lookup - - handoff__agent_escalation - - handoff__scheduled_callback -requires-actions: - - HandoffAction -access-action: HandoffAction -denied-groups: - - staff -version: 1 -tags: - - handoff - - human - - support - - escalation - - callback ---- - -Call `use_skill` for this skill before any of its tools. - -## Tools - -- `handoff__contact_details` — use only when the user explicitly requests a contact channel (phone or email) or office hours. It returns no business information; any question the knowledge base cannot answer is not a contact-details request and goes to `handoff__staff_lookup`. -- `handoff__update_contact` — the user wants to change the phone or email replies are sent to. Pass one `phone_number` or one `email`. -- `handoff__staff_lookup` — owns every request the knowledge base cannot answer: anything outside the assortment or any business fact no document provides. Records a pending question and notifies staff. -- `handoff__agent_escalation` — the user wants a person now. -- `handoff__scheduled_callback` — the user wants to be reached later. - -## Channels (edit per agent) - -Each tool picks its own channel. Defaults are WhatsApp. Change them on the action if a mode should use email instead. Staff recipients come from AccessControlAction `HandoffAction.staff`. You never choose the channel or the recipient. - -## `handoff__staff_lookup` message format - -`message` is never shown to the customer. Write it as: - -1. **Sentence 1 — natural customer ask only** (this is what gets stored as the pending question). Phrasing must sound natural, e.g. `Customer asked for the company's location.` or `Customer asked if we sell car parts.` — not awkward doubles like "location address". -2. **Optional sentence 2 — what was already tried** (staff notify only; not stored). e.g. `No information found in the FAQ.` or `No information found in the FAQ or catalog.` - -Do not put handling notes in sentence 1. Do not mention WhatsApp or tell staff to reply. - -## Customer side - -1. **Detect intent.** The user explicitly asks for a human / agent / live - support, wants a callback, or you cannot answer from the knowledge base or - available tools (an FAQ / knowledge-base search returned nothing relevant, or - the request is outside what you can do). -2. **Call the matching tool.** For `handoff__staff_lookup`, follow the message - format above. Pass `phone_numbers` or `emails` only when the user already - gave one. -3. **Relay the tool's directive** as-is. If it says to ask for a phone or - email and call the same tool again, do that. If it does not, do not ask - again and do not call the tool a second time. - -Use `handoff__contact_details` only for an explicit request for a contact -channel (phone or email) or office hours. Any other question the knowledge base -cannot answer is not a contact-details request; use `handoff__staff_lookup`. -Relay the tool's returned block exactly as returned. diff --git a/jvagent/skills/handoff_staff/SKILL.md b/jvagent/skills/handoff_staff/SKILL.md deleted file mode 100644 index bc467676..00000000 --- a/jvagent/skills/handoff_staff/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: handoff_staff -description: >- - Save or correct a staff answer in the knowledge base. Use when the latest - message answers a pending customer question, or corrects a saved answer - whose chunk id is in an [EVENT] in history. -allowed-tools: - - handoff__pending_questions - - handoff__save_answer - - handoff__update_chunk -requires-actions: - - HandoffAction -access-action: HandoffAction -allowed-groups: - - staff -version: 1 -tags: - - handoff - - staff - - knowledge ---- - -Call `use_skill` for this skill before any of its tools. - -## Tools - -- `handoff__pending_questions` — if the user message and the history look like an answer rather than a new question, call this first. Check if the user message is an answer to a pending question, then call `handoff__save_answer` to save the full answer only. -- `handoff__save_answer` — if one pending question matches, pass its `question_id` and the full answer. Relay the thank-you. -- `handoff__update_chunk` — correct a saved answer. Take `chunk_id` from the `[EVENT]` in history and the new answer from the user's message. Relay the confirmation. - -## Saving an answer - -If the latest message and the history look like an answer rather than a new -question, you do not know what is pending until you list it: - -1. Call `handoff__pending_questions` first. -2. Check whether the user message answers one of the returned questions. -3. Call `handoff__save_answer` with that `question_id` and the full answer - only. The tool stores the cleaned answer on the knowledge base. -4. Relay the tool's confirmation as-is. Do not repeat the answer, and do not - try to deliver it to the customer from this thread. - -Do not save an answer for a question that is not pending. - -## Correcting a saved answer - -When history has an `[EVENT]` with a handoff chunk id and the user is -correcting that answer, call `handoff__update_chunk` with that `chunk_id` and -the new answer. Relay the confirmation as-is. diff --git a/jvagent/tooling/tool.py b/jvagent/tooling/tool.py index 69b89619..722b4304 100644 --- a/jvagent/tooling/tool.py +++ b/jvagent/tooling/tool.py @@ -29,6 +29,8 @@ class Tool: terminal: Optional[bool] = None binds_visitor: Optional[bool] = None idempotency_class: Optional[Any] = None + requires_tool_permission: bool = False + permission_denied_message: Optional[str] = None def __post_init__(self) -> None: if not self.parameters_schema: diff --git a/jvagent/tooling/tool_decorator.py b/jvagent/tooling/tool_decorator.py index 4a06ac59..483f4234 100644 --- a/jvagent/tooling/tool_decorator.py +++ b/jvagent/tooling/tool_decorator.py @@ -63,6 +63,8 @@ class ToolSpec: terminal: Optional[bool] = None binds_visitor: Optional[bool] = None idempotency_class: Optional[IdempotencyClass] = None + requires_tool_permission: bool = False + permission_denied_message: Optional[str] = None def tool( @@ -74,6 +76,8 @@ def tool( terminal: Optional[bool] = None, binds_visitor: Optional[bool] = None, idempotency_class: Optional[IdempotencyClass] = None, + requires_tool_permission: bool = False, + permission_denied_message: Optional[str] = None, ) -> Callable[..., Any]: """Mark a method as an agent tool. Usable as ``@tool`` or ``@tool(name=...)``.""" @@ -84,6 +88,8 @@ def tool( terminal=terminal, binds_visitor=binds_visitor, idempotency_class=idempotency_class, + requires_tool_permission=requires_tool_permission, + permission_denied_message=permission_denied_message, ) def decorate(fn: Callable[..., Any]) -> Callable[..., Any]: @@ -215,6 +221,9 @@ def collect_tools(instance: Any) -> List[Tool]: built.binds_visitor = spec.binds_visitor if spec.idempotency_class is not None: built.idempotency_class = spec.idempotency_class + built.requires_tool_permission = bool(spec.requires_tool_permission) + if spec.permission_denied_message: + built.permission_denied_message = spec.permission_denied_message tools.append(built) tools.sort(key=lambda t: t.name) diff --git a/tests/action/reply/test_reply_action.py b/tests/action/reply/test_reply_action.py index 0b51d0cd..d974ec72 100644 --- a/tests/action/reply/test_reply_action.py +++ b/tests/action/reply/test_reply_action.py @@ -474,6 +474,26 @@ async def test_fast_path_strips_invitation_closer(): assert v.interaction.response == "Classes begin Monday at 9 AM." +async def test_gather_does_not_compose_on_channel_format_only(monkeypatch): + """WhatsApp (or other) channel format alone must not answer the utterance.""" + ra = ReplyAction() + ra.apply_channel_format = True + _patch_agent(monkeypatch) + model = MagicMock() + model.generate = AsyncMock(return_value="Should not run.") + + async def _ma(self, required=False): + return model + + monkeypatch.setattr(ReplyAction, "get_model_action", _ma) + monkeypatch.setattr(ReplyAction, "_compose_model_action", _ma) + v = _visitor_with() + v.channel = "whatsapp" + v.interaction.utterance = "Do you sell cars?" + assert await ra.gather(v) is False + model.generate.assert_not_called() + + async def test_gather_compose_on_parameters_only(monkeypatch): """Intro-style parameters with no directives must still compose via gather().""" ra = ReplyAction() diff --git a/tests/action/test_handoff_action.py b/tests/action/test_handoff_action.py index 8f22d635..c12e34a7 100644 --- a/tests/action/test_handoff_action.py +++ b/tests/action/test_handoff_action.py @@ -1,10 +1,8 @@ -"""HandoffAction — skill-gated capability tools (jvagent/handoff_action). +"""HandoffAction — one mode at a time (consult, transfer, or observe). -Tools: handoff__contact_details / handoff__staff_lookup / handoff__agent_escalation / -handoff__scheduled_callback / handoff__pending_questions / -handoff__save_answer. Per-mode channel, staff allowlist + random target, staff Q&A -capture into PageIndex (handoff.md, public). Staff targets/write allowlist come from -AccessControlAction HandoffAction.staff only. +consult asks staff and replies later. transfer leaves the conversation. +observe stores WhatsApp group facts and enrolls group numbers. Staff +targets come from AccessControlAction HandoffAction.staff. """ import json @@ -12,11 +10,9 @@ from jvagent.action.handoff_action import HandoffAction from jvagent.action.handoff_action.handoff_action import ( - DIRECT_CONTACT_PROMPT, _bold_whatsapp_ask, _extract_saved_answer, _pending_question_text, - _render_direct_contact, _staff_outbound, ) from jvagent.tooling.tool_executor import bind_dispatch_context @@ -69,6 +65,9 @@ def get_user_groups(self, action_label=None): assert action_label == "HandoffAction" return {"staff": list(staff)} + async def has_tool_access(self, user_id, tool_name, channel="default"): + return (user_id or "").strip() in staff + async def _get_action(self, name, *args, **kwargs): assert name == "AccessControlAction" return _ACA() @@ -134,64 +133,171 @@ def _seed_pending(action, **kwargs): return row -def test_direct_contact_prompt_uses_placeholders_not_literals(): - assert "support@company.com" not in DIRECT_CONTACT_PROMPT - assert "+592" not in DIRECT_CONTACT_PROMPT - assert "{handoff_email}" in DIRECT_CONTACT_PROMPT - assert "{handoff_phone}" in DIRECT_CONTACT_PROMPT - assert "{handoff_hours}" in DIRECT_CONTACT_PROMPT - - def test_defaults(): action = HandoffAction() + assert action.mode == "consult" assert "9:00 AM" in action.handoff_hours -def test_render_direct_contact_drops_blank_field_lines(): - rendered = _render_direct_contact( - DIRECT_CONTACT_PROMPT, email="", phone="", hours="Mon-Fri" - ) - assert "Email:" not in rendered - assert "Phone" not in rendered - assert "Office Hours: Mon-Fri" in rendered +async def test_tools_follow_mode(): + consult = HandoffAction() + consult_tools = await consult.get_tools() + assert {t.name for t in consult_tools} == { + "handoff__consult", + "handoff__pending_questions", + "handoff__save_answer", + "handoff__update_chunk", + } + gated = {t.name for t in consult_tools if t.requires_tool_permission} + assert gated == {"handoff__save_answer", "handoff__update_chunk"} + transfer = HandoffAction() + transfer.mode = "transfer" + assert {t.name for t in await transfer.get_tools()} == {"handoff__transfer"} + observe = HandoffAction() + observe.mode = "observe" + observe_tools = await observe.get_tools() + assert {t.name for t in observe_tools} == {"handoff__observe"} + assert observe_tools[0].requires_tool_permission is True + unknown = HandoffAction() + unknown.mode = "nope" + assert "handoff__consult" in {t.name for t in await unknown.get_tools()} + + +async def test_transfer_asks_on_web_when_no_contact(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + action.mode = "transfer" + action.customer_contact = "phone" + sent = {} + async def _send(self, recipient, message): + sent["to"] = recipient -async def test_render_helper_matches_tool(monkeypatch): - _install_aca_staff(monkeypatch, ["1234567890", "a@b.c", "c@d.e"]) - action = HandoffAction() - rendered = await action.render_direct_contact() - assert "a@b.c" in rendered - assert "c@d.e" in rendered - assert "1234567890" in rendered + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("o.User.abc", channel="web")): + r = await tools["handoff__transfer"].call(message="need a person") + assert not r.is_error + assert "Relay this to the user" in r.content + assert "don't have that information" in r.content.lower() + assert "WhatsApp number" in r.content + assert "email" not in r.content.lower().split("internal")[0] + assert "handoff__transfer" in r.content + assert "to" not in sent -async def test_tools_are_published(): +async def test_transfer_web_two_step_uses_confirm_not_repeat_limitation( + monkeypatch, +): + _install_aca_staff(monkeypatch) action = HandoffAction() - names = {t.name for t in await action.get_tools()} - assert names == { - "handoff__contact_details", - "handoff__update_contact", - "handoff__staff_lookup", - "handoff__agent_escalation", - "handoff__scheduled_callback", - "handoff__pending_questions", - "handoff__save_answer", - "handoff__update_chunk", - } + action.mode = "transfer" + action.customer_contact = "phone" + conversation = _Conversation() + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + ctx = _Ctx("o.User.abc", channel="web", conversation=conversation) + with bind_dispatch_context(ctx): + ask = await tools["handoff__transfer"].call(message="need a person") + assert not ask.is_error + assert "don't have that information" in ask.content.lower() + assert "to" not in sent + assert conversation.context.get("handoff_active_mode") == "transfer" + + with bind_dispatch_context(ctx): + confirm = await tools["handoff__transfer"].call( + message="need a person", + contact="5926431530", + ) + assert not confirm.is_error + assert "staff member will reach out" in confirm.content.lower() + assert "don't have that information" not in confirm.content.lower() + assert sent["to"] == _STAFF_PHONE + assert "Contact: 5926431530" in sent["message"] -async def test_direct_contact_tool_returns_rendered_block(monkeypatch): - _install_aca_staff(monkeypatch, ["1234567890", "a@b.c"]) +async def test_transfer_web_infers_contact_from_utterance_on_active_handoff( + monkeypatch, +): + _install_aca_staff(monkeypatch) action = HandoffAction() + action.mode = "transfer" + action.customer_contact = "phone" + conversation = _Conversation() + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) tools = {t.name: t for t in await action.get_tools()} - result = await tools["handoff__contact_details"].call() - assert "Email: a@b.c" in result.content - assert "Phone / WhatsApp: 1234567890" in result.content + visitor1 = _Ctx("o.User.abc", channel="web", conversation=conversation) + with bind_dispatch_context(visitor1): + ask = await tools["handoff__transfer"].call(message="need office location") + assert not ask.is_error + assert "WhatsApp number" in ask.content + assert "to" not in sent + + visitor2 = _Ctx("o.User.abc", channel="web", conversation=conversation) + visitor2.utterance = "5927371531" + with bind_dispatch_context(visitor2): + confirm = await tools["handoff__transfer"].call( + message=( + "Customer wants the office location to visit tomorrow " + "and needs staff follow-up. User provided WhatsApp number " + "5927371531." + ), + ) + assert not confirm.is_error + assert "staff member will reach out" in confirm.content.lower() + assert "WhatsApp number" not in confirm.content.split("INTERNAL")[0] + assert sent["to"] == _STAFF_PHONE + assert "5927371531" in sent["message"] + +async def test_transfer_web_infers_contact_from_contact_only_message( + monkeypatch, +): + _install_aca_staff(monkeypatch) + action = HandoffAction() + action.mode = "transfer" + action.customer_contact = "phone" + conversation = _Conversation() + sent = {} -async def test_update_contact_replaces_saved_reply_address(monkeypatch): + async def _send(self, recipient, message): + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor1 = _Ctx("o.User.abc", channel="web", conversation=conversation) + with bind_dispatch_context(visitor1): + await tools["handoff__transfer"].call(message="need a person") + + visitor2 = _Ctx("o.User.abc", channel="web", conversation=conversation) + with bind_dispatch_context(visitor2): + confirm = await tools["handoff__transfer"].call(message="5927371531") + assert not confirm.is_error + assert "staff member will reach out" in confirm.content.lower() + assert sent["message"] == "5927371531" + assert conversation.context.get("handoff_contact") == "5927371531" + + +async def test_consult_web_infers_contact_from_utterance_on_active_handoff( + monkeypatch, +): + _install_aca_staff(monkeypatch) _install_pending_store(monkeypatch) action = _bind_action(HandoffAction()) + conversation = _Conversation() + sent = {"count": 0, "message": ""} class _Agent: id = "n.Agent.test" @@ -199,105 +305,269 @@ class _Agent: async def _get_agent(self): return _Agent() - monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) - q1 = _seed_pending( - action, - id="q1", - question="Do you offer delivery?", - user_channel="default", - user_contact="592000", - ) - q2 = _seed_pending( - action, - id="q2", - question="Someone else", - user_channel="default", - user_contact="592111", - ) - conversation = _Conversation("592000") + async def _send(self, recipient, message): + sent["count"] += 1 + sent["message"] = message + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) tools = {t.name: t for t in await action.get_tools()} - missing_conversation = await tools["handoff__update_contact"].call( - phone_number="5926431530" - ) - assert missing_conversation.is_error - assert conversation.context["handoff_contact"] == "592000" + visitor1 = _Ctx("o.User.abc", channel="web", conversation=conversation) + visitor1.utterance = "where is your office?" + with bind_dispatch_context(visitor1): + first = await tools["handoff__consult"].call(message="where is your office?") + assert not first.is_error + assert sent["count"] == 0 - visitor = _Ctx("o.User.abc", channel="default", conversation=conversation) - with bind_dispatch_context(visitor): - missing = await tools["handoff__update_contact"].call() - assert missing.is_error - assert conversation.context["handoff_contact"] == "592000" - assert q1["user_contact"] == "592000" - - phone = await tools["handoff__update_contact"].call(phone_number="5926431530") - assert not phone.is_error - assert "5926431530" in phone.content - assert conversation.context["handoff_contact"] == "5926431530" - assert q1["user_contact"] == "5926431530" - assert q2["user_contact"] == "592111" - - mailed = await tools["handoff__update_contact"].call(email="customer@x.com") - assert not mailed.is_error - assert conversation.context["handoff_contact"] == "customer@x.com" - assert q1["user_contact"] == "customer@x.com" - assert q2["user_contact"] == "592111" - - -async def test_escalation_asks_when_no_contact(monkeypatch): + visitor2 = _Ctx("o.User.abc", channel="web", conversation=conversation) + visitor2.utterance = "5927371531" + with bind_dispatch_context(visitor2): + second = await tools["handoff__consult"].call( + message="Customer wants the office location.", + ) + assert not second.is_error + assert sent["count"] == 1 + assert action.pending_questions[0]["question"] == "where is your office?" + assert "Contact: 5927371531" in sent["message"] + + +async def test_transfer_notifies_staff_and_relays(monkeypatch): _install_aca_staff(monkeypatch) action = HandoffAction() + action.mode = "transfer" + conversation = _Conversation() sent = {} async def _send(self, recipient, message): sent["to"] = recipient + sent["message"] = message monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) tools = {t.name: t for t in await action.get_tools()} - r = await tools["handoff__agent_escalation"].call(message="x") + with bind_dispatch_context(_Ctx("592000", conversation=conversation)): + r = await tools["handoff__transfer"].call(message="need a person") assert not r.is_error - assert "Ask the user for a phone number or email" in r.content - assert "handoff__agent_escalation" in r.content - assert "to" not in sent + assert "staff member will reach out" in r.content + assert "WhatsApp number or email" not in r.content + assert sent["to"] == _STAFF_PHONE + assert "Contact: 592000" in sent["message"] + assert "need a person" in sent["message"] + assert not conversation.context.get("handoff_transferred") -async def test_escalation_uses_whatsapp_sender(monkeypatch): +async def test_transfer_ignores_placeholder_contact_uses_whatsapp_user_id( + monkeypatch, +): _install_aca_staff(monkeypatch) action = HandoffAction() + action.mode = "transfer" + conversation = _Conversation() sent = {} async def _send(self, recipient, message): - sent["to"] = recipient sent["message"] = message monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) tools = {t.name: t for t in await action.get_tools()} - with bind_dispatch_context(_Ctx("592000")): - pending = await tools["handoff__agent_escalation"].call(message="need a person") - assert "Confirm with the user" in pending.content - assert "592000" in pending.content - assert "need a person" in pending.content - assert "to" not in sent - r = await tools["handoff__agent_escalation"].call( - message="need a person", confirmed=True - ) - callback = await tools["handoff__scheduled_callback"].call( - message="call me later" + with bind_dispatch_context(_Ctx("5926431530", conversation=conversation)): + r = await tools["handoff__transfer"].call( + message="Customer asked for location.", + contact="not provided", ) assert not r.is_error + assert "Contact: 5926431530" in sent["message"] + assert conversation.context.get("handoff_contact") != "not provided" + + +async def test_transfer_web_resolves_contact_from_email_user_id(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + action.mode = "transfer" + action.customer_contact = "email" + sent = {} + + async def _send(self, recipient, message): + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("john@example.com", channel="web")): + r = await tools["handoff__transfer"].call(message="need help") + assert not r.is_error assert "staff member will reach out" in r.content - assert "phone number or email" not in r.content - assert sent["to"] == _STAFF_PHONE + assert "Contact: john@example.com" in sent["message"] + + +async def test_transfer_web_ignores_saved_email_when_customer_contact_phone( + monkeypatch, +): + _install_aca_staff(monkeypatch) + action = HandoffAction() + action.mode = "transfer" + action.customer_contact = "phone" + conversation = _Conversation("not-an-email@example.com") + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context( + _Ctx("o.User.abc", channel="web", conversation=conversation) + ): + r = await tools["handoff__transfer"].call(message="need help") + assert not r.is_error + assert "Relay this to the user" in r.content + assert "to" not in sent + + +async def test_transfer_web_uses_saved_contact_when_user_id_opaque(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + action.mode = "transfer" + action.customer_contact = "phone" + conversation = _Conversation("592999") + sent = {} + + async def _send(self, recipient, message): + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context( + _Ctx("o.User.abc", channel="web", conversation=conversation) + ): + r = await tools["handoff__transfer"].call(message="need help") + assert not r.is_error + assert "Contact: 592999" in sent["message"] + + +async def test_transfer_ignores_poisoned_saved_contact_on_whatsapp(monkeypatch): + _install_aca_staff(monkeypatch) + action = HandoffAction() + action.mode = "transfer" + conversation = _Conversation("not provided") + sent = {} + + async def _send(self, recipient, message): + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("592000", conversation=conversation)): + r = await tools["handoff__transfer"].call(message="need a person") + assert not r.is_error assert "Contact: 592000" in sent["message"] - assert "need a person" in sent["message"] - assert "Confirm with the user" in callback.content - sent.clear() - with bind_dispatch_context(_Ctx("592000")): - confirmed_callback = await tools["handoff__scheduled_callback"].call( - message="call me later", confirmed=True + + +async def test_consult_stores_message_without_code_refinement(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + pass + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("5926431530", channel="whatsapp")): + result = await tools["handoff__consult"].call( + message="can u check where u are located again" ) - assert "follow up" in confirmed_callback.content - assert "Contact: 592000" in sent["message"] + assert not result.is_error + assert len(action.pending_questions) == 1 + assert ( + action.pending_questions[0]["question"] + == "can u check where u are located again" + ) + + +async def test_consult_preserves_customer_ask_on_contact_follow_up(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + conversation = _Conversation() + sent = {"count": 0, "message": ""} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + sent["count"] += 1 + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor1 = _Ctx("o.User.abc", channel="default", conversation=conversation) + visitor1.utterance = "what is your address?" + with bind_dispatch_context(visitor1): + first = await tools["handoff__consult"].call(message="what is your address?") + assert not first.is_error + assert sent["count"] == 0 + assert conversation.context.get("handoff_active_issue") == "what is your address?" + + visitor2 = _Ctx("o.User.abc", channel="default", conversation=conversation) + visitor2.utterance = "5927371531" + with bind_dispatch_context(visitor2): + second = await tools["handoff__consult"].call( + message="Customer wants the store address.", + contact="5927371531", + ) + assert not second.is_error + assert sent["count"] == 1 + assert action.pending_questions[0]["question"] == "what is your address?" + assert "Customer wants" not in action.pending_questions[0]["question"] + assert "what is your address?" in sent["message"] + + +async def test_consult_prefers_utterance_for_stored_location_ask(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + conversation = _Conversation() + sent = {"count": 0} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + sent["count"] += 1 + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor1 = _Ctx("o.User.abc", channel="default", conversation=conversation) + visitor1.utterance = "where r u located" + with bind_dispatch_context(visitor1): + await tools["handoff__consult"].call( + message="Customer wants the store address." + ) + assert conversation.context.get("handoff_active_issue") == "where r u located" + + visitor2 = _Ctx("o.User.abc", channel="default", conversation=conversation) + visitor2.utterance = "5927371531" + with bind_dispatch_context(visitor2): + await tools["handoff__consult"].call( + message="5927371531", + contact="5927371531", + ) + assert action.pending_questions[0]["question"] == "where r u located" + assert "store address" not in action.pending_questions[0]["question"].lower() async def test_staff_lookup_holds_until_contact_or_decline(monkeypatch): @@ -328,49 +598,47 @@ async def _record(self, message, contact=""): sent["question"] = message open_q["q"] = _Question(contact) - async def _open(self, message=""): - return open_q.get("q") - monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) monkeypatch.setattr(HandoffAction, "_add_pending", _record) - monkeypatch.setattr(HandoffAction, "_open_lookup", _open) tools = {t.name: t for t in await action.get_tools()} - r = await tools["handoff__staff_lookup"].call(message="where do you deliver?") + r = await tools["handoff__consult"].call(message="where do you deliver?") assert not r.is_error - assert "phone number or email" in r.content - assert "contact_declined" in r.content + assert "Relay this to the user" in r.content + assert "don't have that information" in r.content.lower() + assert "WhatsApp number" in r.content + assert "handoff__consult" in r.content assert sent["count"] == 0 assert "question" not in sent assert open_q.get("q") is None - r = await tools["handoff__staff_lookup"].call(message="where do you deliver?") + r = await tools["handoff__consult"].call(message="where do you deliver?") assert sent["count"] == 0 - r = await tools["handoff__staff_lookup"].call( + r = await tools["handoff__consult"].call( message=( "The customer asked where we deliver. FAQ search returned nothing. " "Customer provided phone number: 592000." ), - phone_numbers=["592000"], + contact="592000", ) assert "don't have that information" in r.content assert "check with the team" in r.content assert "when they respond" in r.content assert sent["count"] == 1 assert open_q["q"].user_contact == "592000" - assert "592000" not in sent["message"] + assert "Contact: 592000" in sent["message"] assert "where we deliver" in sent["message"] - r = await tools["handoff__staff_lookup"].call( + r = await tools["handoff__consult"].call( message="The customer asked where we deliver. FAQ search found nothing useful.", - phone_numbers=["592000"], + contact="592000", ) - assert sent["count"] == 1 + assert sent["count"] == 2 open_q.clear() sent["count"] = 0 - r = await tools["handoff__staff_lookup"].call( + r = await tools["handoff__consult"].call( message="where do you deliver?", contact_declined=True ) assert "don't have that information" in r.content @@ -381,11 +649,11 @@ async def _open(self, message=""): with bind_dispatch_context(_Ctx("592111")): open_q.clear() - r = await tools["handoff__staff_lookup"].call(message="what are your hours?") + r = await tools["handoff__consult"].call(message="what are your hours?") assert "don't have that information" in r.content assert "check with the team" in r.content assert "when they respond" in r.content - assert "phone number or email" not in r.content + assert "WhatsApp number or email" not in r.content async def test_declined_lookup_does_not_reply(monkeypatch): @@ -415,7 +683,7 @@ async def _append(self, question, answer): monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) tools = {t.name: t for t in await action.get_tools()} with bind_dispatch_context(_Ctx("o.User.abc", channel="default")): - looked = await tools["handoff__staff_lookup"].call( + looked = await tools["handoff__consult"].call( message="where do you deliver?", contact_declined=True ) assert "when they respond" in looked.content @@ -427,7 +695,7 @@ async def _append(self, question, answer): sent.clear() with bind_dispatch_context(_Ctx(_STAFF_PHONE)): saved = await tools["handoff__save_answer"].call( - question_id=question_id, answer="Yes, we deliver." + question_ids=[question_id], answer="Yes, we deliver." ) assert not saved.is_error assert sent == [] @@ -456,33 +724,171 @@ async def _send(self, recipient, message): tools = {t.name: t for t in await action.get_tools()} visitor = _Ctx("o.User.abc", channel="default", conversation=conversation) with bind_dispatch_context(visitor): - reused = await tools["handoff__staff_lookup"].call( - message="Do you sell car parts" - ) - again = await tools["handoff__staff_lookup"].call( - message="Do you offer delivery" - ) - repeat = await tools["handoff__staff_lookup"].call( - message="Do you offer delivery" - ) - assert "phone number or email" not in reused.content + reused = await tools["handoff__consult"].call(message="Do you sell car parts") + again = await tools["handoff__consult"].call(message="Do you offer delivery") + repeat = await tools["handoff__consult"].call(message="Do you offer delivery") + assert "WhatsApp number or email" not in reused.content assert "when they respond" in reused.content assert "when they respond" in again.content - assert sent["count"] == 2 - assert all("5926431530" not in message for message in sent["messages"]) + assert sent["count"] == 3 + assert all("Contact: 5926431530" in message for message in sent["messages"]) assert [row["question"] for row in action.pending_questions] == [ "Do you sell car parts", "Do you offer delivery", + "Do you offer delivery", ] assert {row["user_contact"] for row in action.pending_questions} == {"5926431530"} assert "when they respond" in repeat.content + + +async def test_consult_always_appends_separate_rows(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {"count": 0, "messages": []} + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + sent["count"] += 1 + sent["messages"].append(message) + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + question = "Where are you located?" + with bind_dispatch_context(_Ctx("5923333333", channel="whatsapp")): + first = await tools["handoff__consult"].call(message=question) + assert not first.is_error + assert sent["count"] == 1 + assert len(action.pending_questions) == 1 + assert action.pending_questions[0]["user_contact"] == "5923333333" + with bind_dispatch_context(_Ctx("5924444444", channel="whatsapp")): + second = await tools["handoff__consult"].call( + message=question, contact="5924444444" + ) + assert not second.is_error assert sent["count"] == 2 + assert "Contact: 5924444444" in sent["messages"][-1] + assert len(action.pending_questions) == 2 + contacts = {row["user_contact"] for row in action.pending_questions} + assert contacts == {"5923333333", "5924444444"} + with bind_dispatch_context(_Ctx("5923333333", channel="whatsapp")): + third = await tools["handoff__consult"].call( + message=question, contact="5923333333" + ) + assert not third.is_error + assert sent["count"] == 3 + assert len(action.pending_questions) == 3 + + +async def test_pending_questions_lists_all_id_and_question_only(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + _seed_pending( + action, + id="pend_a", + question="Customer asks where the store is located.", + user_contact="5921111111", + ) + _seed_pending( + action, + id="pend_b", + question="What are your hours?", + user_contact="5922222222", + ) + _seed_pending( + action, + id="pend_c", + question="Customer wants the store location/address.", + user_contact="5923333333", + ) + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx(_STAFF_PHONE) + visitor.utterance = "we are located in Guyana on water street" + with bind_dispatch_context(visitor): + listed = await tools["handoff__pending_questions"].call() + assert not listed.is_error + assert "contact=" not in listed.content.lower() + assert "pend_a" in listed.content + assert "pend_b" in listed.content + assert "pend_c" in listed.content + assert "choose question_ids" in listed.content.lower() + + +async def test_pending_questions_empty_queue(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + listed = await tools["handoff__pending_questions"].call() + assert not listed.is_error + assert "No pending questions" in listed.content + + +async def test_save_answer_group_one_ingest_multi_reply(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + customer_sends = [] + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send_whatsapp(self, recipient, message): + if recipient != _STAFF_PHONE: + customer_sends.append(recipient) + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send_whatsapp) + + async def _customer_reply(self, q, a): + return f"Answer: {a}" + + monkeypatch.setattr(HandoffAction, "_customer_reply", _customer_reply) + ingest_calls = {"count": 0} + + async def _append_once(self, question, answer): + ingest_calls["count"] += 1 + return question, answer, "n.DocumentNode.test" + + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append_once) + tools = {t.name: t for t in await action.get_tools()} + question = "Where are you located?" + with bind_dispatch_context(_Ctx("5923333333", channel="whatsapp")): + await tools["handoff__consult"].call(message=question) + with bind_dispatch_context(_Ctx("5924444444", channel="whatsapp")): + await tools["handoff__consult"].call( + message="Where are you located? I need the store location.", + contact="5924444444", + ) + assert len(action.pending_questions) == 2 + id_a = action.pending_questions[0]["id"] + id_b = action.pending_questions[1]["id"] + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + saved = await tools["handoff__save_answer"].call( + question_ids=[id_a, id_b], answer="123 Main St" + ) + assert not saved.is_error + assert ingest_calls["count"] == 1 + assert sorted(customer_sends) == ["5923333333", "5924444444"] + assert action.pending_questions == [] -async def test_escalation_confirms_updated_contact(monkeypatch): +async def test_transfer_sends_web_contact(monkeypatch): _install_aca_staff(monkeypatch) action = HandoffAction() - conversation = _Conversation("5926431530") + action.mode = "transfer" + conversation = _Conversation() sent = {} async def _send(self, recipient, message): @@ -493,31 +899,23 @@ async def _send(self, recipient, message): tools = {t.name: t for t in await action.get_tools()} visitor = _Ctx("o.User.abc", channel="default", conversation=conversation) with bind_dispatch_context(visitor): - updated = await tools["handoff__agent_escalation"].call( - message="need a person", - phone_numbers=["592999"], - confirmed=True, - ) - assert "Confirm with the user" in updated.content - assert "592999" in updated.content - assert "need a person" in updated.content - assert "to" not in sent - assert conversation.context["handoff_contact"] == "592999" - sent_call = await tools["handoff__agent_escalation"].call( - message="need a person", confirmed=True + sent_call = await tools["handoff__transfer"].call( + message="need a person", contact="592999" ) assert "staff member will reach out" in sent_call.content assert sent["to"] == _STAFF_PHONE assert "Contact: 592999" in sent["message"] + assert conversation.context["handoff_contact"] == "592999" + assert not conversation.context.get("handoff_transferred") def test_channel_for_defaults_and_override(): action = HandoffAction() - assert action._channel_for("staff_lookup") == "whatsapp" - action.handoff_channels = {"staff_lookup": "email"} - assert action._channel_for("staff_lookup") == "email" - action.handoff_channels = {"staff_lookup": "nonsense"} - assert action._channel_for("staff_lookup") == "whatsapp" + assert action._channel_for("consult") == "whatsapp" + action.handoff_channels = {"consult": "email"} + assert action._channel_for("consult") == "email" + action.handoff_channels = {"consult": "nonsense"} + assert action._channel_for("consult") == "whatsapp" async def test_staff_targets_and_allowlist(monkeypatch): @@ -549,9 +947,6 @@ async def test_staff_targets_classify_aca_members(monkeypatch): action = HandoffAction() assert await action._staff_targets("whatsapp") == ["5929999999"] assert await action._staff_targets("email") == ["aca@x.com"] - phone, emails = await action._public_contacts() - assert phone == "5929999999" - assert emails == ["aca@x.com"] async def test_staff_targets_empty_without_aca(monkeypatch): @@ -592,16 +987,22 @@ async def _get_agent(self): other_listed = await other._list_pending() assert any(row["id"] == "q1" for row in other_listed) + aca = _aca_for_tools() tools = {t.name: t for t in await action.get_tools()} with bind_dispatch_context(_Ctx("999")): listed_tool = await tools["handoff__pending_questions"].call() - denied = await tools["handoff__save_answer"].call( - question_id="q1", answer="Yes, we deliver." + denied = await _run_gated_tool( + tools["handoff__save_answer"], + aca=aca, + user_id="999", + question_ids=["q1"], + answer="Yes, we deliver.", ) assert not listed_tool.is_error assert "Do you offer delivery?" in listed_tool.content - assert denied.is_error - assert "only authorized staff" in denied.content + assert not denied.is_error + assert "cannot save answers" in denied.content + assert "Do not tell the user" in denied.content async def test_pending_crud_add_list_update_remove(monkeypatch): @@ -621,6 +1022,7 @@ async def _get_agent(self): with bind_dispatch_context(_Ctx("5926431530", channel="whatsapp")): created = await writer._add_pending("Do you offer delivery?", "5926431530") assert len(writer.pending_questions) == 1 + assert created["id"].startswith("pend_") assert created["id"] == writer.pending_questions[0]["id"] listed = await reader._list_pending() @@ -632,13 +1034,18 @@ async def _get_agent(self): assert writer.pending_questions[0]["user_contact"] == "5920001111" assert (await reader._list_pending())[0]["user_contact"] == "5920001111" + aca = _aca_for_tools() tools = {t.name: t for t in await reader.get_tools()} with bind_dispatch_context(_Ctx("999")): - denied = await tools["handoff__save_answer"].call( - question_id=created["id"], answer="Yes." + denied = await _run_gated_tool( + tools["handoff__save_answer"], + aca=aca, + user_id="999", + question_ids=[created["id"]], + answer="Yes.", ) - assert denied.is_error - assert "only authorized staff" in denied.content + assert not denied.is_error + assert "cannot save answers" in denied.content async def _append(self, question, answer): return question, answer, "n.DocumentNode.test" @@ -646,7 +1053,7 @@ async def _append(self, question, answer): monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) with bind_dispatch_context(_Ctx(_STAFF_PHONE)): saved = await tools["handoff__save_answer"].call( - question_id=created["id"], answer="Yes, we deliver for 5000." + question_ids=[created["id"]], answer="Yes, we deliver for 5000." ) assert not saved.is_error assert await reader._list_pending() == [] @@ -682,16 +1089,107 @@ async def _save_no_persist(self): def test_action_contributes_orchestration_routing_parameter(): from jvagent.action.parameters import orchestration_parameters, render_parameters - action = HandoffAction() - params = orchestration_parameters(action.parameters) - assert params and params[0].get("key") == "handoff_routing" + consult = HandoffAction() + params = orchestration_parameters(consult.parameters) + assert len(params) == 1 + assert params[0].get("key") == "handoff_consult" rendered = render_parameters(params) - assert "handoff__staff_lookup" in rendered - assert "handoff__contact_details" in rendered - assert "use_skill" in rendered - assert params[1].get("key") == "handoff_staff_answer" + assert "handoff__consult" in rendered + assert "handoff__update_chunk" not in rendered + assert "overrides the active skill" in rendered.lower() + assert "call handoff__consult now" in rendered.lower() + assert "do not reply in text" in rendered.lower() + assert "do not ask permission" in rendered.lower() + assert "use_skill" not in rendered + + transfer = HandoffAction() + transfer.mode = "transfer" + transfer_params = orchestration_parameters(transfer.parameters) + assert transfer_params[0].get("key") == "handoff_transfer" + transfer_rendered = render_parameters(transfer_params) + assert "handoff__transfer" in transfer_rendered + assert "knowledge base" in transfer_rendered.lower() + assert "overrides the active skill" in transfer_rendered.lower() + assert transfer_params[0].get("condition") == params[0].get("condition") + + observe = HandoffAction() + observe.mode = "observe" + observe_params = orchestration_parameters(observe.parameters) + assert observe_params[0].get("key") == "handoff_observe" + rendered_observe = render_parameters(observe_params).lower() + assert "never send a message" in rendered_observe + assert "handoff__observe" in rendered_observe + assert "handoff__pending_questions" in rendered_observe + assert "handoff__consult" in rendered_observe + + +def test_yaml_parameters_override_mode_defaults(): + action = HandoffAction() + action.mode = "transfer" + custom = [ + { + "scope": "orchestration", + "key": "custom_handoff", + "condition": "the conversation should move to staff", + "response": "Call handoff__transfer.", + } + ] + action.parameters = custom + assert action.parameters == custom + action.parameters = [] + assert action.parameters[0]["key"] == "handoff_transfer" + + +async def test_contributed_parameters_follow_staff_access(monkeypatch): + from jvagent.action.parameters import render_parameters + + action = HandoffAction() + + async def _members(self): + return [_STAFF_PHONE] + + monkeypatch.setattr(HandoffAction, "_aca_staff_members", _members) + staff = await action.contributed_parameters(SimpleNamespace(user_id=_STAFF_PHONE)) + rendered = render_parameters(staff) + assert "pend_" in rendered + assert "corr-" in rendered + assert "not a question id or a chunk id" in rendered assert "handoff__pending_questions" in rendered - assert "handoff__save_answer" in rendered + + customer = await action.contributed_parameters(SimpleNamespace(user_id="999")) + customer_text = render_parameters(customer) + assert "call handoff__consult now" in customer_text.lower() + assert "handoff__update_chunk" not in customer_text + + custom = [ + { + "scope": "orchestration", + "key": "custom_handoff", + "condition": "the conversation should move to staff", + "response": "Call handoff__transfer.", + } + ] + action.parameters = custom + assert ( + await action.contributed_parameters(SimpleNamespace(user_id=_STAFF_PHONE)) + == custom + ) + + action.parameters = [] + action.mode = "transfer" + transfer_staff = await action.contributed_parameters( + SimpleNamespace(user_id=_STAFF_PHONE) + ) + transfer_staff_text = render_parameters(transfer_staff) + assert transfer_staff[0].get("key") == "handoff_transfer_staff" + assert "do not call handoff__transfer" in transfer_staff_text.lower() + + transfer_customer = await action.contributed_parameters( + SimpleNamespace(user_id="999") + ) + transfer_customer_text = render_parameters(transfer_customer) + assert "handoff__transfer" in transfer_customer_text + assert "knowledge base" in transfer_customer_text.lower() def test_extract_saved_answer_strips_prefix_and_prefers_longer_utterance(): @@ -737,10 +1235,8 @@ def test_bold_whatsapp_ask_wraps_core(): def test_staff_outbound_lookup_bolds_ask_and_keeps_notes(): body = _staff_outbound( - "staff_lookup", + "consult", "Customer asked if we sell car parts. No information found in the FAQ.", - None, - None, "", ) assert body.startswith("Customer asked if *we sell car parts*.") @@ -767,12 +1263,12 @@ async def _send(self, recipient, message): monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) tools = {t.name: t for t in await action.get_tools()} with bind_dispatch_context(_Ctx("o.User.abc", channel="default")): - result = await tools["handoff__staff_lookup"].call( + result = await tools["handoff__consult"].call( message=( "Customer asked for the company's location. " "No information found in the knowledge base." ), - phone_numbers=["5926431530"], + contact="5926431530", ) assert not result.is_error assert len(action.pending_questions) == 1 @@ -817,7 +1313,7 @@ async def _append(self, question, answer): ) with bind_dispatch_context(visitor): result = await tools["handoff__save_answer"].call( - question_id="q1", + question_ids=["q1"], answer="save answer: no we do", ) assert not result.is_error @@ -825,6 +1321,7 @@ async def _append(self, question, answer): assert action.pending_questions == [] assert await action._list_pending() == [] assert saved["ingested"] == ("Do you sell cars?", expected) + assert "Tell the user:" in result.content assert "Your answer is saved and will be used in the future" in result.content assert "Do you sell cars?" not in result.content @@ -865,7 +1362,7 @@ async def _reply(self, question, answer): tools = {t.name: t for t in await action.get_tools()} with bind_dispatch_context(_Ctx(_STAFF_PHONE)): result = await tools["handoff__save_answer"].call( - question_id="q1", answer="No, we do not sell cars." + question_ids=["q1"], answer="No, we do not sell cars." ) assert not result.is_error assert sent["to"] == "592111" @@ -917,7 +1414,7 @@ async def _send_email( tools = {t.name: t for t in await action.get_tools()} with bind_dispatch_context(_Ctx(_STAFF_PHONE)): result = await tools["handoff__save_answer"].call( - question_id="q1", answer="No, we do not sell cars." + question_ids=["q1"], answer="No, we do not sell cars." ) assert not result.is_error return sent @@ -1080,11 +1577,12 @@ async def _append(self, question, answer): visitor.interaction = interaction with bind_dispatch_context(visitor): result = await tools["handoff__save_answer"].call( - question_id="q1", answer="No, we do not sell cars." + question_ids=["q1"], answer="No, we do not sell cars." ) assert not result.is_error assert interaction.events == [ - ('Handoff chunk n.DocumentNode.abc "Do you sell cars?".', "HandoffAction") + ('Handoff chunk n.DocumentNode.abc "Do you sell cars?".', "HandoffAction"), + ('Handoff completed: save_answer "Do you sell cars?".', "HandoffAction"), ] @@ -1142,6 +1640,7 @@ class _Agent: answer="No, we do not sell cars.", ) assert not result.is_error + assert "Tell the user:" in result.content assert "Your answer is updated" in result.content assert captured["condense"] == ("Do you sell cars?", "No, we do not sell cars.") chunk_id, doc_name, collection, updates = captured["update"] @@ -1156,68 +1655,409 @@ class _Agent: assert interaction.events[0][0] == ( 'Handoff chunk n.DocumentNode.existing "Do you sell cars?".' ) + assert interaction.events[1][0] == ( + 'Handoff completed: update_chunk "Do you sell cars?".' + ) + + +def test_handoff_namespace_is_registered_as_trusted(): + from jvagent.action.orchestrator import constants + from jvagent.action.orchestrator.constants import is_untrusted_directive_source + + constants._TRUSTED_DIRECTIVE_PREFIXES_DYNAMIC.add("handoff__") + assert not is_untrusted_directive_source("handoff__consult") + assert not is_untrusted_directive_source("handoff__save_answer") + assert is_untrusted_directive_source("somepkg__tool") -def test_handoff_skills_split_access_and_tools(): - from pathlib import Path +async def test_consult_records_completed_event(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + interaction = _Interaction() + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + return None + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx("o.User.abc", channel="default", conversation=_Conversation()) + visitor.interaction = interaction + with bind_dispatch_context(visitor): + looked = await tools["handoff__consult"].call( + message="where do you deliver?", contact_declined=True + ) + assert "when they respond" in looked.content + assert interaction.events == [ + ("Handoff started: consult.", "HandoffAction"), + ("Handoff completed: consult.", "HandoffAction"), + ] + - from jvagent.action.orchestrator.orchestrator_interact_action import ( - OrchestratorInteractAction, +async def test_observe_enrolls_group_numbers_and_does_not_reply(monkeypatch): + enrolled = {} + + class _ACA: + async def add_users_to_group(self, group, user_ids, action_label="default"): + enrolled["group"] = group + enrolled["users"] = list(user_ids) + enrolled["label"] = action_label + + async def has_tool_access(self, user_id, tool_name, channel="default"): + return True + + class _API: + async def group_members(self, group_id): + assert group_id == "120363@g.us" + return { + "status": "success", + "response": [ + {"id": {"user": "5921111111"}, "formattedName": "Ada"}, + {"id": {"user": "5922222222"}, "formattedName": "Ben"}, + {"id": {"user": "5920000000"}, "formattedName": "You"}, + ], + } + + class _WA: + async def api(self): + return _API() + + class _Agent: + id = "n.Agent.test" + + async def get_action_by_type(self, name): + assert name == "WhatsAppAction" + return _WA() + + action = HandoffAction() + action.mode = "observe" + sent = [] + + async def _get_agent(self): + return _Agent() + + async def _get_action(self, name, *args, **kwargs): + assert name == "AccessControlAction" + return _ACA() + + async def _append(self, question, answer): + sent.append((question, answer)) + return question, answer, "n.DocumentNode.fact" + + async def _send(self, recipient, message): + sent.append(("whatsapp", recipient, message)) + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "get_action", _get_action) + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx("120363@g.us", channel="whatsapp") + visitor.data = {"whatsapp_payload": {"isGroup": True, "sender": "120363@g.us"}} + with bind_dispatch_context(visitor): + result = await tools["handoff__observe"].call( + fact="The shop closes at 5pm on Fridays." + ) + assert not result.is_error + assert "Do not send any message" in result.content + assert enrolled == { + "group": "staff", + "users": ["5921111111", "5922222222"], + "label": "HandoffAction", + } + assert sent == [ + ("The shop closes at 5pm on Fridays.", "The shop closes at 5pm on Fridays.") + ] + + +async def test_observe_mode_admits_group_messages_without_a_mention(): + from jvagent.action.whatsapp.utils.endpoint_helpers import is_directed_message + + class _HookAction: + def whatsapp_direct_all_group_messages(self): + return True + + class _ActionsMgr: + async def get_all_actions(self, enabled_only=True): + return [_HookAction()] + + class _Agent: + async def get_actions_manager(self): + return _ActionsMgr() + + class _WA: + async def get_agent(self): + return _Agent() + + data = SimpleNamespace(isGroup=True, body="the shop closes at 5", caption="") + assert await is_directed_message(_WA(), data) is True + + +def _tool_permissions(extra_tools=None): + tools = { + "handoff__save_answer": { + "deny": [], + "allow": [{"group": "staff", "enabled": True}], + }, + "handoff__update_chunk": { + "deny": [], + "allow": [{"group": "staff", "enabled": True}], + }, + "handoff__observe": { + "deny": [], + "allow": [{"group": "staff", "enabled": True}], + }, + } + if extra_tools: + tools.update(extra_tools) + return { + "whatsapp": { + "any": {"deny": [], "allow": [{"group": "all", "enabled": True}]}, + "tools": tools, + } + } + + +def _aca_for_tools(members=None, permissions=None): + from jvagent.action.access_control.access_control_action import ( + AccessControlAction, ) - from jvagent.action.orchestrator.skills import SkillDoc - from jvagent.scaffold.skill_resolve import parse_skill_bundle - - root = Path(__file__).resolve().parents[2] / "jvagent" / "skills" - customer = parse_skill_bundle(root / "handoff", source="builtin") - staff = parse_skill_bundle(root / "handoff_staff", source="builtin") - assert customer["access_action"] == "HandoffAction" - assert customer["denied_groups"] == ["staff"] - assert staff["access_action"] == "HandoffAction" - assert staff["allowed_groups"] == ["staff"] - assert "handoff__staff_lookup" in customer["allowed_tools"] - assert "handoff__save_answer" not in customer["allowed_tools"] - assert staff["allowed_tools"] == [ + + aca = AccessControlAction( + permissions=permissions if permissions is not None else _tool_permissions(), + user_groups={"HandoffAction": {"staff": list(members or [_STAFF_PHONE])}}, + enforce=True, + default_deny=False, + ) + aca.enabled = True + return aca + + +def _agent_for_aca(aca): + class _Agent: + async def get_access_control_action(self): + return aca + + return _Agent() + + +async def _run_gated_tool(tool, *, aca, user_id, channel="whatsapp", **call_kwargs): + """Invoke a tool through orchestrator permission wrap (production path).""" + from jvagent.action.orchestrator.tools import wrap_action_tool + from jvagent.tooling.tool_result import ToolResult + + wrapped = wrap_action_tool( + tool, + agent=_agent_for_aca(aca), + user_id=user_id, + channel=channel, + ) + return ToolResult(content=await wrapped.run(call_kwargs)) + + +async def test_has_tool_access_allow_and_deny(): + aca = _aca_for_tools() + assert await aca.has_tool_access(_STAFF_PHONE, "handoff__save_answer", "whatsapp") + assert await aca.has_tool_access(_STAFF_PHONE, "handoff__update_chunk", "whatsapp") + assert await aca.has_tool_access(_STAFF_PHONE, "handoff__observe", "whatsapp") + assert not await aca.has_tool_access( + "9999999999", "handoff__save_answer", "whatsapp" + ) + assert not await aca.has_tool_access( + _STAFF_PHONE, "handoff__save_answer", "default" + ) + + +async def test_gated_tools_leave_the_visible_set_for_a_non_staff_sender(): + from jvagent.action.orchestrator.access import drop_unpermitted_tools + + aca = _aca_for_tools() + names = [ + "handoff__consult", "handoff__pending_questions", "handoff__save_answer", "handoff__update_chunk", ] - assert "pending customer question" in staff["description"] - assert "cannot answer from the knowledge base" in customer["description"] - class _ACA: - def policy_applies(self): - return True - - def get_user_groups(self, action_label=None): - return {"staff": ["111"]} - - customer_doc = SkillDoc( - name="handoff", - description="", - body="", - access_action="HandoffAction", - denied_groups=("staff",), + customer_tools = {name: object() for name in names} + customer_visible = set(names) + await drop_unpermitted_tools( + aca, + user_id="9999999999", + channel="whatsapp", + tools=customer_tools, + visible=customer_visible, ) - staff_doc = SkillDoc( - name="handoff_staff", - description="", - body="", - access_action="HandoffAction", - allowed_groups=("staff",), + assert "handoff__save_answer" not in customer_tools + assert "handoff__save_answer" not in customer_visible + assert "handoff__update_chunk" not in customer_visible + assert "handoff__consult" in customer_visible + assert "handoff__pending_questions" in customer_tools + + staff_tools = {name: object() for name in names} + staff_visible = set(names) + await drop_unpermitted_tools( + aca, + user_id=_STAFF_PHONE, + channel="whatsapp", + tools=staff_tools, + visible=staff_visible, ) - aca = _ACA() - assert OrchestratorInteractAction._skill_access_control_allowed( - customer_doc, "999", aca + assert "handoff__save_answer" in staff_visible + assert "handoff__update_chunk" in staff_tools + + +def _consult_matrix(): + staff_only = {"deny": [], "allow": [{"group": "staff", "enabled": True}]} + tools = { + "handoff__consult": { + "deny": [{"group": "staff", "enabled": True}], + "allow": [{"group": "all", "enabled": True}], + }, + "handoff__pending_questions": staff_only, + "handoff__save_answer": staff_only, + "handoff__update_chunk": staff_only, + } + return { + "whatsapp": { + "any": {"deny": [], "allow": [{"group": "all", "enabled": True}]}, + "tools": tools, + } + } + + +async def test_consult_tools_split_by_sender(): + from jvagent.action.orchestrator.access import drop_unpermitted_tools + + aca = _aca_for_tools(permissions=_consult_matrix()) + names = [ + "handoff__consult", + "handoff__pending_questions", + "handoff__save_answer", + "handoff__update_chunk", + "pageindex__search", + ] + staff_only = { + "handoff__pending_questions", + "handoff__save_answer", + "handoff__update_chunk", + } + + customer_tools = {name: object() for name in names} + customer_visible = set(names) + await drop_unpermitted_tools( + aca, + user_id="9999999999", + channel="whatsapp", + tools=customer_tools, + visible=customer_visible, + ) + assert customer_visible & staff_only == set() + assert "handoff__consult" in customer_visible + assert "pageindex__search" in customer_tools + + staff_tools = {name: object() for name in names} + staff_visible = set(names) + await drop_unpermitted_tools( + aca, + user_id=_STAFF_PHONE, + channel="whatsapp", + tools=staff_tools, + visible=staff_visible, ) - assert not OrchestratorInteractAction._skill_access_control_allowed( - customer_doc, "111", aca + assert ( + staff_visible + & { + "handoff__pending_questions", + "handoff__save_answer", + "handoff__update_chunk", + } + == staff_only ) - assert OrchestratorInteractAction._skill_access_control_allowed( - staff_doc, "111", aca + assert "handoff__consult" not in staff_tools + assert "handoff__consult" not in staff_visible + assert "pageindex__search" in staff_visible + + +async def test_has_tool_access_missing_entry_denies_even_when_any_allows(): + aca = _aca_for_tools( + permissions={ + "whatsapp": { + "any": {"deny": [], "allow": [{"group": "all", "enabled": True}]}, + } + } ) - assert not OrchestratorInteractAction._skill_access_control_allowed( - staff_doc, "999", aca + assert not await aca.has_tool_access( + _STAFF_PHONE, "handoff__save_answer", "whatsapp" ) - assert not OrchestratorInteractAction._skill_access_control_allowed( - staff_doc, "111", None + + +async def test_denied_save_is_silent_and_writes_nothing(monkeypatch): + from jvagent.action.handoff_action.handoff_action import SAVE_DENIED_LINE + + _install_pending_store(monkeypatch) + aca = _aca_for_tools() + action = _bind_action(HandoffAction()) + _seed_pending(action, id="q1", question="Do you offer delivery?") + ingested = {} + + async def _append(self, question, answer): + ingested["hit"] = True + return question, answer, "n.DocumentNode.test" + + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("9999999999", channel="whatsapp")): + denied = await _run_gated_tool( + tools["handoff__save_answer"], + aca=aca, + user_id="9999999999", + question_ids=["q1"], + answer="Yes, we deliver.", + ) + assert denied.content == SAVE_DENIED_LINE + assert not denied.is_error + assert "hit" not in ingested + assert action.pending_questions[0]["id"] == "q1" + + action.mode = "observe" + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("9999999999", channel="whatsapp")): + observed = await _run_gated_tool( + tools["handoff__observe"], + aca=aca, + user_id="9999999999", + fact="We are on Water Street.", + ) + assert observed.content == SAVE_DENIED_LINE + assert "hit" not in ingested + + +async def test_wrap_denied_save_does_not_run_the_tool(): + from jvagent.action.handoff_action.handoff_action import SAVE_DENIED_LINE + from jvagent.action.orchestrator.tools import wrap_action_tool + from jvagent.tooling.tool import Tool + + called = {} + + async def _exec(**kwargs): + called["yes"] = True + return "saved" + + tool = Tool( + name="handoff__save_answer", + description="save", + execute=_exec, + requires_tool_permission=True, + permission_denied_message=SAVE_DENIED_LINE, ) + wrapped = wrap_action_tool(tool, agent=None, user_id="999", channel="whatsapp") + assert await wrapped.run({}) == SAVE_DENIED_LINE + assert "yes" not in called diff --git a/tests/action/test_handoff_contact_template.py b/tests/action/test_handoff_contact_template.py deleted file mode 100644 index cea6c778..00000000 --- a/tests/action/test_handoff_contact_template.py +++ /dev/null @@ -1,31 +0,0 @@ -"""HandoffInteractAction: contact info is configurable, not hardcoded. - -Pre-fix: ``DIRECT_CONTACT_PROMPT`` contained the literal strings -``support@company.com`` and ``+592 XXX XXXX``, and ``handoff_number`` -defaulted to a real-looking Guyana phone number. -AUDIT-actions Wave D. -""" - -from jvagent.action.handoff_interact_action.handoff_interact_action import ( - DIRECT_CONTACT_PROMPT, - HandoffInteractAction, -) - - -def test_direct_contact_prompt_uses_placeholders_not_literals(): - assert "support@company.com" not in DIRECT_CONTACT_PROMPT - assert "+592" not in DIRECT_CONTACT_PROMPT - assert "{handoff_email}" in DIRECT_CONTACT_PROMPT - assert "{handoff_phone}" in DIRECT_CONTACT_PROMPT - assert "{handoff_hours}" in DIRECT_CONTACT_PROMPT - - -def test_handoff_number_default_is_empty(): - action = HandoffInteractAction() - assert action.handoff_number == "" - assert action.handoff_email == "" - - -def test_handoff_hours_default_is_generic(): - action = HandoffInteractAction() - assert "9:00 AM" in action.handoff_hours From 2c7dab5c7c6a0bbc65d9de7337d0440ebec9c9b3 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Thu, 8 Oct 2026 12:27:51 -0400 Subject: [PATCH 13/20] refactor(handoff): enhance handoff action and group handling This commit updates the handoff action to improve the handling of group interactions and streamline the messaging process. The `handoff_intro` and `consult_close` messages have been revised for clarity. New utility functions for managing WhatsApp group IDs and participant phone numbers have been added, enhancing the ability to process group messages. Additionally, the documentation has been updated to reflect these changes, ensuring better guidance for developers on the usage of the handoff tools. --- jvagent/action/handoff_action/README.md | 25 +- .../action/handoff_action/handoff_action.py | 764 ++++++++++++++++-- jvagent/action/whatsapp/endpoints.py | 28 + jvagent/action/whatsapp/modules/wwebjs_api.py | 42 + jvagent/action/whatsapp/utils/chat_ids.py | 94 +++ .../whatsapp/utils/group_inbound_log.py | 57 ++ jvagent/action/whatsapp/whatsapp_action.py | 42 + tests/action/test_action_import_guard.py | 2 + tests/action/test_handoff_action.py | 505 +++++++++++- .../action/test_whatsapp_group_inbound_log.py | 72 ++ tests/action/test_whatsapp_group_users.py | 107 +++ tests/action/whatsapp/test_chat_ids.py | 69 ++ 12 files changed, 1705 insertions(+), 102 deletions(-) create mode 100644 jvagent/action/whatsapp/utils/chat_ids.py create mode 100644 jvagent/action/whatsapp/utils/group_inbound_log.py create mode 100644 tests/action/test_whatsapp_group_inbound_log.py create mode 100644 tests/action/test_whatsapp_group_users.py create mode 100644 tests/action/whatsapp/test_chat_ids.py diff --git a/jvagent/action/handoff_action/README.md b/jvagent/action/handoff_action/README.md index 085e6c7e..6c187c57 100644 --- a/jvagent/action/handoff_action/README.md +++ b/jvagent/action/handoff_action/README.md @@ -309,9 +309,12 @@ Question ids start with `pend_` from `handoff__pending_questions`. Chunk ids start with `n.DocumentNode.` from `[EVENT]` Handoff chunk lines. A `corr-` id is neither. -Customer relay: intro (`handoff_intro`), contact ask (web when unresolved), -close (`consult_close`). Each consult creates a new pending row and notifies -staff. `handoff__consult` records pending + notifies; staff use save tools only. +Customer relay: the completion reply is **generated** from model-facing steering +(topic echo + check-with-team + reply-on-response), so the orchestrator voices a +fresh acknowledgment per request. Each consult creates a new pending row and +notifies staff. `handoff__consult` records pending + notifies; staff use save +tools only. Setting `handoff_intro` and/or `consult_close` in `agent.yaml` forces +a deterministic literal relay instead (the old `intro + close` behavior). ## Playbook: transfer @@ -349,12 +352,22 @@ later customer messages. - Customer: later message → bot can still help (catalog, etc.) - Staff sender → no spurious `handoff__transfer` for staff messages -`handoff__transfer` uses intro (`handoff_intro`), optional contact ask, close -(`transfer_close`). Optional yaml: `handoff_intro`, `transfer_close`, -`consult_close`. Relay only what the tool returns. +`handoff__transfer` generates its completion reply the same way (steering, not a +canned sentence); an optional contact ask is added on web when unresolved. +Optional yaml: `handoff_intro`, `transfer_close`, `consult_close` — setting any of +them reverts to a fixed literal relay. Relay only what the tool returns. **Contact resolution:** provided `contact`, then saved `handoff_contact`, then `user_id` when it matches `customer_contact` kind. On WhatsApp omit `contact`. +For **group** consults or transfers, Handoff prefers the participant phone from +`whatsapp_payload` (deep scan of nested JIDs, wwebjs `get_message_by_id` when +needed, LID→phone), then saved `handoff_contact` / `handoff_whatsapp_author`. +When no participant phone is available (after payload scan, optional +`get_message_by_id`, and saved-context fallbacks), it stores the **group chat id** +(dispatch `user_id`, e.g. `120363…`) as `user_contact` so consult can create +pending rows without asking for a personal number. Staff notify stays a DM to staff; saved +answers go back to the **group thread** (`send_message` with `is_group=True`). +Group ids are never used as a staff DM target. ## Playbook: observe diff --git a/jvagent/action/handoff_action/handoff_action.py b/jvagent/action/handoff_action/handoff_action.py index 84abd257..9fcd9fd3 100644 --- a/jvagent/action/handoff_action/handoff_action.py +++ b/jvagent/action/handoff_action/handoff_action.py @@ -57,13 +57,13 @@ def _register_orchestrator_vocabulary() -> None: _register_orchestrator_vocabulary() -HANDOFF_INTRO = "I don't have that information yet." +HANDOFF_INTRO = "Let me sort that out with our team." TRANSFER_CLOSE = ( "I've passed it to the team and a staff member will reach out to you shortly." ) -CONSULT_CLOSE = "I'll check with the team and get back to you when they respond." +CONSULT_CLOSE = "I'll get back to you as soon as they respond." HANDOFF_FOLLOWUP_THANKS = "Thanks." @@ -199,12 +199,7 @@ def _parameters_for( ), "response": ( "Observe mode only — not customer consult or transfer. Call " - "handoff__observe with that fact. Do not call " - "handoff__consult, handoff__transfer, or " - "handoff__pending_questions. Never send a message to the " - "group. If nothing is worth keeping, send nothing. If the " - "tool says this user cannot save answers, send nothing " - "about permissions or saving." + "handoff__observe with that fact." ), } ] @@ -389,6 +384,13 @@ def _bold_whatsapp_ask(question: str) -> str: return f"{prefix}*{core}*{punct}" +def _staff_contact_suffix(token: str) -> str: + """Staff notify line for customer contact or group thread id.""" + if _contact_kind(token) == "whatsapp_group": + return f"Group: {token}" + return f"Contact: {token}" + + def _staff_outbound(mode: str, message: str, contact: str = "") -> str: """Staff text: consult question + optional contact; transfer includes contact.""" token = (contact or "").strip() @@ -400,11 +402,13 @@ def _staff_outbound(mode: str, message: str, contact: str = "") -> str: bolded = _bold_whatsapp_ask(question) body = f"{bolded} {notes}".strip() if notes else bolded if token and token != "declined" and token not in body: - body = f"{body}\nContact: {token}".strip() + body = f"{body}\n{_staff_contact_suffix(token)}".strip() return body text = (message or "").strip() if token and token not in text: - text = f"{text}\nContact: {token}".strip() + suffix = _staff_contact_suffix(token) + if suffix not in text: + text = f"{text}\n{suffix}".strip() return text @@ -433,27 +437,53 @@ def _dispatch_user_id() -> str: def _contact_kind(value: str) -> str: - """``whatsapp`` for a phone, ``email`` for an address, else empty.""" + """``whatsapp`` / ``whatsapp_group`` / ``email``, else empty.""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_valid_whatsapp_phone, + is_whatsapp_group_chat_id, + ) + text = (value or "").strip() if not text or text.lower() == "declined": return "" - if "@" in text and " " not in text: + if ( + "@" in text + and " " not in text + and not text.endswith(("@g.us", "@c.us", "@lid")) + and not is_whatsapp_group_chat_id(text) + ): return "email" - digits = re.sub(r"\D", "", text) - if digits and len(digits) >= 6 and re.fullmatch(r"[+\d][\d\s()-]*", text): + if is_whatsapp_group_chat_id(text): + return "whatsapp_group" + if is_valid_whatsapp_phone(text): return "whatsapp" return "" def _sender_contact(channel: str) -> str: """Dispatch user id when it is already a phone or email for this channel.""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_valid_whatsapp_phone, + is_whatsapp_group_chat_id, + participant_phone_from_payload, + whatsapp_payload_from_visitor_data, + ) + from jvagent.tooling.tool_executor import get_tool_visitor + user_id = _dispatch_user_id() if not user_id: return "" if channel == "email": return user_id if "@" in user_id and " " not in user_id else "" - digits = re.sub(r"\D", "", user_id) - if digits and len(digits) >= 6 and re.fullmatch(r"[+\d][\d\s()-]*", user_id): + visitor = get_tool_visitor() + data = getattr(visitor, "data", None) if visitor else None + payload = whatsapp_payload_from_visitor_data(data) + author = participant_phone_from_payload(payload, user_id) + if author: + return author + if is_whatsapp_group_chat_id(user_id): + return "" + if is_valid_whatsapp_phone(user_id): return user_id return "" @@ -491,13 +521,34 @@ def _contact_matches_kind(value: str, kind: str) -> bool: normalized = _normalized_customer_contact_kind(kind) if normalized == "email": return ck == "email" - return ck == "whatsapp" + return ck in ("whatsapp", "whatsapp_group") + + +def _group_dispatch_contact(payload: Optional[Dict[str, Any]], user_id: str) -> str: + """Group chat id from dispatch user_id when this turn is a group thread.""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_group_whatsapp_turn, + is_whatsapp_group_chat_id, + strip_whatsapp_suffix, + ) + + uid = str(user_id or "").strip() + if not uid or not is_whatsapp_group_chat_id(uid): + return "" + if not is_group_whatsapp_turn(payload, uid): + return "" + normalized = strip_whatsapp_suffix(uid) + if _contact_kind(normalized) != "whatsapp_group": + return "" + return normalized def _sanitize_contact( raw: Optional[str], *, customer_contact_kind: str = "phone" ) -> str: """Normalize tool or saved contact; drop placeholders and wrong kind.""" + from jvagent.action.whatsapp.utils.chat_ids import strip_whatsapp_suffix + text = _one_contact(raw) if not text: return "" @@ -505,6 +556,8 @@ def _sanitize_contact( return "" if not _contact_matches_kind(text, customer_contact_kind): return "" + if _contact_kind(text) == "whatsapp_group": + return strip_whatsapp_suffix(text) return text @@ -542,6 +595,39 @@ def _resolve_customer_contact( return "" +def _handoff_relay_steering( + mode: str, + topic: str, + *, + continuing_handoff: bool, +) -> str: + """Model-facing steering for the completion relay (no finished sentence). + + The orchestrator/reply model voices this into a fresh, natural + acknowledgment per request; the tool never hands back a canned line that + would be echoed verbatim. + """ + subject = (topic or "").strip() or "their request" + if mode == "transfer": + lead = ( + "Acknowledge what they asked in one natural sentence" + f' (their request: "{subject}"), then say you have passed it to the ' + "team and a staff member will reach out." + ) + else: + lead = ( + "Acknowledge what they asked in one natural sentence" + f' (their request: "{subject}"), then say or paraphrase: you are checking with the ' + "team on how to answer or move forward with your request and will get back to them once staff respond." + ) + if continuing_handoff: + lead = "Thank them briefly, then " + lead[0].lower() + lead[1:] + return ( + f"{lead} Keep it to 1-2 short sentences; do not promise a phone call; " + "do not add extra detail." + ) + + def _join_handoff_parts(*parts: str) -> str: """Join non-empty relay fragments into one customer-facing paragraph.""" cleaned = [(p or "").strip() for p in parts if (p or "").strip()] @@ -616,7 +702,8 @@ def _missing_contact_handoff_result( label = _contact_label(_normalized_customer_contact_kind(customer_contact_kind)) return ToolResult( content=( - "Relay this to the user and do not add the staff summary:\n" + "Tell the user this in your own short, natural wording — do not add " + "the staff summary:\n" f"{user_line}\n\n" "INTERNAL (do not say to the user): After they reply, call " f"{tool_name} again with the same message and contact set to their " @@ -626,13 +713,18 @@ def _missing_contact_handoff_result( _HANDOFF_CONTACT_KEY = "handoff_contact" +_HANDOFF_WHATSAPP_AUTHOR_KEY = "handoff_whatsapp_author" _HANDOFF_ACTIVE_MODE_KEY = "handoff_active_mode" _HANDOFF_ACTIVE_ISSUE_KEY = "handoff_active_issue" def _relay(line: str) -> ToolResult: return ToolResult( - content=("Relay this to the user and do not add the staff summary:\n" f"{line}") + content=( + "Tell the user this in your own short, natural wording — do not add " + "the staff summary:\n" + f"{line}" + ) ) @@ -821,6 +913,137 @@ def _truncate(value: Any, limit: int = 300) -> str: return text if len(text) <= limit else text[:limit] + "…" +def _jid_string_from_nested(value: Any) -> str: + """Coerce webhook message id / author fields to a JID string.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + for key in ("_serialized", "user", "participant", "author"): + part = value.get(key) + if isinstance(part, str) and part.strip(): + return part.strip() + return "" + + +def _participant_jids_from_whatsapp_payload(payload: Dict[str, Any]) -> List[str]: + """Candidate participant JIDs from whatsapp_payload (handoff-local scan).""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_whatsapp_group_chat_id, + strip_whatsapp_suffix, + ) + + if not isinstance(payload, dict): + return [] + seen: set[str] = set() + out: List[str] = [] + + def add(raw: Any, *, allow_long_id: bool = False) -> None: + jid = _jid_string_from_nested(raw) + if not jid or jid in seen: + return + cleaned = strip_whatsapp_suffix(jid) + if "@g.us" in jid: + return + if not allow_long_id and is_whatsapp_group_chat_id(cleaned): + return + seen.add(jid) + out.append(jid) + + add(payload.get("author"), allow_long_id=True) + for key in ("participant", "authorId", "participantId"): + add(payload.get(key)) + quoted = payload.get("quoted_message") or {} + if isinstance(quoted, dict): + add(quoted.get("author")) + add(quoted.get("participant")) + sender = str(payload.get("sender") or "").strip() + if sender and not is_whatsapp_group_chat_id(sender): + add(sender) + for mid in payload.get("mentionedIds") or []: + if isinstance(mid, str) and mid.strip(): + token = mid.split("@", 1)[0].strip() if "@" in mid else mid.strip() + add(token) + return out + + +def _author_from_get_message_response(result: Any) -> str: + """Extract group participant JID from get_message_by_id API response.""" + if not isinstance(result, dict): + return "" + roots: List[Any] = [result] + for key in ("message", "data", "response"): + nested = result.get(key) + if isinstance(nested, dict): + roots.append(nested) + for root in roots: + if not isinstance(root, dict): + continue + msg = root.get("message") if isinstance(root.get("message"), dict) else root + if not isinstance(msg, dict): + continue + for source in (msg.get("_data"), msg): + if not isinstance(source, dict): + continue + jid = _jid_string_from_nested(source.get("author")) + if jid: + return jid + msg_id = source.get("id") + if isinstance(msg_id, dict): + jid = _jid_string_from_nested(msg_id.get("participant")) + if jid: + return jid + return "" + + +def _handoff_whatsapp_context_snapshot( + *, + mode: str = "", + visitor_channel: str = "", + provided_clean: str = "", + contact_after_sync: str = "", + saved_contact: str = "", + saved_whatsapp_author: str = "", +) -> str: + """Compact masked snapshot for group WhatsApp handoff diagnostics.""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_group_whatsapp_turn, + participant_phone_from_payload, + raw_author_from_payload, + whatsapp_payload_from_visitor_data, + ) + from jvagent.tooling.tool_executor import get_tool_visitor + + user_id = _dispatch_user_id() + channel = (visitor_channel or _dispatch_channel() or "").strip() + visitor = get_tool_visitor() + data = getattr(visitor, "data", None) if visitor else None + payload = whatsapp_payload_from_visitor_data(data) + payload_present = bool( + isinstance(data, dict) and isinstance(data.get("whatsapp_payload"), dict) + ) + raw_author = raw_author_from_payload(payload) + participant = participant_phone_from_payload(payload, user_id) + message_id = str(payload.get("message_id") or "").strip() + payload_keys = ",".join(sorted(payload.keys())) if isinstance(payload, dict) else "" + return ( + f"mode={mode or '?'} channel={channel or '?'} " + f"user_id={_mask(user_id)} is_group_turn={is_group_whatsapp_turn(payload, user_id)} " + f"payload_present={payload_present} isGroup={bool(payload.get('isGroup'))} " + f"sender={_mask(str(payload.get('sender') or ''))} " + f"author={_mask(raw_author)} author_len={len(raw_author)} " + f"author_present={bool(raw_author)} " + f"message_id_present={bool(message_id)} " + f"payload_keys={payload_keys} " + f"participant_from_payload={_mask(participant)} " + f"provided_clean={_mask(provided_clean)} " + f"contact_after_sync={_mask(contact_after_sync)} " + f"saved_contact={_mask(saved_contact)} " + f"saved_whatsapp_author={_mask(saved_whatsapp_author)}" + ) + + def _handoff_json_path() -> Path: """``/handoff.json`` (defaults to ``./.files/handoff.json``).""" from jvagent.core.sandbox import resolve_sandbox_root @@ -840,6 +1063,23 @@ def _handoff_event(kind: str, detail: str = "", status: str = "completed") -> st detail = (detail or "").strip() if detail: return f'Handoff {status}: {kind} "{detail}".' + if status == "started" and kind == "consult": + return ( + "Handoff started: sending the customer's query to staff for consultation." + ) + if status == "completed" and kind == "consult": + return ( + "Handoff completed: the customer's query was sent to staff for " + "consultation. That request is closed — do not re-handle or look " + "up the previous request again unless user reask it again. Always run the matching skills/tools first to check for fresh data before falling back to handoff__consult; focus on the current request." + ) + if status == "started" and kind == "transfer": + return "Handoff started: forwarding the issue summary to staff." + if status == "completed" and kind == "transfer": + return ( + "Handoff completed: the issue summary was forwarded to staff. " + "This request is closed — keep helping on later messages only." + ) return f"Handoff {status}: {kind}." @@ -900,16 +1140,25 @@ class HandoffAction(Action): ) handoff_intro: str = attribute( - default=HANDOFF_INTRO, - description="Opening line when the customer has not yet heard this handoff.", + default="", + description=( + "Optional fixed opening line for the handoff relay. When unset, the " + "relay is generated from steering (see _handoff_relay_line)." + ), ) consult_close: str = attribute( - default=CONSULT_CLOSE, - description="Closing line after consult staff notify (part 3).", + default="", + description=( + "Optional fixed consult close. Set to force a deterministic literal " + "relay; when unset, the reply is generated." + ), ) transfer_close: str = attribute( - default=TRANSFER_CLOSE, - description="Closing line after transfer staff notify (part 3).", + default="", + description=( + "Optional fixed transfer close. Set to force a deterministic literal " + "relay; when unset, the reply is generated." + ), ) consult_prompt: str = attribute( default="", @@ -1024,23 +1273,28 @@ async def consult( self, message: Annotated[ str, - "Sentence 1: the customer's question or request in their words, " - "kept short (you trim fillers; do not use Customer wants… or " - "Customer asked…). Optional sentence 2: what was already tried " - "(staff only). Never shown to the user.", + "Sentence 1: What the customer needs, written as a complete, " + "grammatically full sentence so staff grasp it immediately without " + "reading the chat. Always name the specific item, matter, or " + "question with full details (for a product, include its full name " + "and code; for a question, state the full ask). Do NOT start with " + "truncated action phrases or shorthand fragments. When the latest " + "message is only a continuation (such as a phone number, " + "acknowledgment, or confirmation), synthesize the full request from " + "recent turns into a complete sentence. Optional sentence 2: What " + "was already tried (staff only; never shown to the user).", ], contact: Annotated[ Optional[str], - "Customer phone or email per agent customer_contact setting. " - "Omit on WhatsApp. Pass only after the user gives it when the tool " - "asked. No placeholders.", + "Customer phone or email. Omit on WhatsApp. Pass only when the " + "user gives it after the tool asks. No placeholders.", ] = None, contact_declined: Annotated[ Optional[bool], "True if the user refused to share the configured contact type.", ] = None, ) -> ToolResult: - """Call when you cannot answer from KB/tools (consult mode). Relay only the tool line; do not reply in prose. Contact is resolved in code (see customer_contact on the action).""" + """Call when you cannot answer a query or handle a request.""" return await self._dispatch_handoff( "consult", message, @@ -1053,18 +1307,24 @@ async def transfer( self, message: Annotated[ str, - "Short summary of the issue for staff: what the customer wants " - "handled, built from the conversation. Never a placeholder. Never " - "shown to the user.", + "Sentence 1: What the customer needs handled, written as a " + "complete, grammatically full sentence so staff grasp it " + "immediately without reading the chat. Always name the specific " + "item or matter and the desired outcome with full details. Do NOT " + "start with truncated action phrases or shorthand fragments. When " + "the latest message is only a continuation (such as a phone " + "number, acknowledgment, or confirmation), synthesize the full " + "request from recent turns into a complete sentence. Optional " + "sentence 2: What was already tried (staff only; never shown to " + "the user).", ], contact: Annotated[ Optional[str], - "Customer phone or email per agent customer_contact setting. " - "Omit on WhatsApp. Pass only after the user gives it when the tool " - "asked. No placeholders.", + "Customer phone or email. Omit on WhatsApp. Pass only when the " + "user gives it after the tool asks. No placeholders.", ] = None, ) -> ToolResult: - """Call when you cannot answer from KB/tools or the issue should move to staff (transfer mode). Relay only the tool line; keep helping on later turns. Contact is resolved in code (see customer_contact on the action).""" + """Call when a customer's issue should move to staff.""" return await self._dispatch_handoff("transfer", message, contact) @tool( @@ -1077,11 +1337,10 @@ async def observe( self, fact: Annotated[ str, - "The useful fact, policy, or answer from the group message. Not " - "sent back to the group.", + "The useful fact, policy, or answer from the group message.", ], ) -> ToolResult: - """Store one useful fact from this WhatsApp group in the knowledge base and add the other group numbers as staff. Call this directly with the fact. Do not call handoff__pending_questions. Call only when the message contains something worth keeping. Send no message.""" + """Store one useful fact from this WhatsApp group in the knowledge base. Call only when the group message contains something worth keeping.""" fact = (fact or "").strip() if not fact: return ToolResult( @@ -1101,38 +1360,54 @@ async def observe( is_error=True, ) await self._record_event(_handoff_event("observe")) - return ToolResult( - content=("The fact is stored. Do not send any message to the group.") - ) + return ToolResult(content=("The fact is stored. Inform them in a simple note.")) def _effective_consult_close(self) -> str: legacy = (self.consult_prompt or "").strip() - return legacy or (self.consult_close or CONSULT_CLOSE).strip() + return legacy or (self.consult_close or "").strip() def _effective_transfer_close(self) -> str: legacy = (self.transfer_prompt or "").strip() - return legacy or (self.transfer_close or TRANSFER_CLOSE).strip() + return legacy or (self.transfer_close or "").strip() def _handoff_relay_line( - self, mode: str, customer_contact_kind: str, *, continuing_handoff: bool + self, + mode: str, + customer_contact_kind: str, + *, + continuing_handoff: bool, + topic: str = "", ) -> str: + intro = (self.handoff_intro or "").strip() close = ( self._effective_consult_close() if mode == "consult" else self._effective_transfer_close() ) - followup_thanks = continuing_handoff - if followup_thanks and close.strip().lower().startswith("thanks"): - followup_thanks = False - return _compose_handoff_relay( + # Deterministic escape hatch: an operator set handoff_intro / *_close in + # agent.yaml, so relay the fixed literal line (old behavior). + if intro or close: + default_close = CONSULT_CLOSE if mode == "consult" else TRANSFER_CLOSE + resolved_close = close or default_close + followup_thanks = continuing_handoff + if followup_thanks and resolved_close.strip().lower().startswith("thanks"): + followup_thanks = False + return _compose_handoff_relay( + mode, + customer_contact_kind, + include_intro=not continuing_handoff, + include_contact_ask=False, + include_close=True, + intro=intro or HANDOFF_INTRO, + close=resolved_close, + followup_thanks=followup_thanks, + ) + # Dynamic path: hand back steering; the orchestrator model voices a fresh + # acknowledgment for this request instead of echoing a canned sentence. + return _handoff_relay_steering( mode, - customer_contact_kind, - include_intro=not continuing_handoff, - include_contact_ask=False, - include_close=True, - intro=self.handoff_intro, - close=close, - followup_thanks=followup_thanks, + topic, + continuing_handoff=continuing_handoff, ) async def _dispatch_handoff( @@ -1167,7 +1442,20 @@ async def _dispatch_handoff( await self._set_active(mode, issue) await self._record_event(_handoff_event(mode, status="started")) saved = await self._saved_contact() + saved_author = await self._saved_whatsapp_author() visitor_channel = _dispatch_channel() + logger.warning( + "handoff dispatch start mode=%s channel=%s continuing=%s snapshot=%s", + mode, + channel, + continuing_handoff, + _handoff_whatsapp_context_snapshot( + mode=mode, + visitor_channel=visitor_channel, + saved_contact=saved, + saved_whatsapp_author=saved_author, + ), + ) provided_clean = _sanitize_contact(contact, customer_contact_kind=kind) if continuing_handoff and not provided_clean: from jvagent.tooling.tool_executor import get_tool_visitor @@ -1189,22 +1477,95 @@ async def _dispatch_handoff( visitor_channel=visitor_channel, customer_contact_kind=kind, ) + logger.warning( + "handoff contact after sync resolve contact=%s provided=%s saved=%s " + "channel=%s snapshot=%s", + _mask(contact or ""), + _mask(str(effective_provided or "")), + _mask(saved), + visitor_channel, + _handoff_whatsapp_context_snapshot( + mode=mode, + visitor_channel=visitor_channel, + provided_clean=provided_clean, + contact_after_sync=contact or "", + saved_contact=saved, + saved_whatsapp_author=saved_author, + ), + ) + if ( + contact + and mode in ("consult", "transfer") + and not provided_clean + and not saved + ): + from jvagent.action.whatsapp.utils.chat_ids import ( + participant_phone_from_payload, + whatsapp_payload_from_visitor_data, + ) + from jvagent.tooling.tool_executor import get_tool_visitor + + visitor = get_tool_visitor() + payload = whatsapp_payload_from_visitor_data( + getattr(visitor, "data", None) if visitor else None + ) + if participant_phone_from_payload(payload, _dispatch_user_id()) == contact: + await self._save_contact(contact) + await self._update_context({_HANDOFF_WHATSAPP_AUTHOR_KEY: contact}) + if not contact and visitor_channel == "whatsapp": + logger.warning( + "handoff group participant resolve attempt snapshot=%s", + _handoff_whatsapp_context_snapshot( + mode=mode, + visitor_channel=visitor_channel, + provided_clean=provided_clean, + contact_after_sync="", + saved_contact=saved, + saved_whatsapp_author=saved_author, + ), + ) + participant = await self._resolve_whatsapp_participant_contact() + logger.warning( + "handoff group participant resolve result contact=%s", + _mask(participant or ""), + ) + if participant: + contact = participant + await self._save_contact(contact) + await self._update_context({_HANDOFF_WHATSAPP_AUTHOR_KEY: contact}) if not contact and not (mode == "consult" and contact_declined): tool_name = f"handoff__{mode}" if mode == "consult": logger.warning( - "handoff consult ask path no contact declined=%s %s", + "handoff consult ask path no contact declined=%s %s snapshot=%s", contact_declined, await self._pending_debug_ids(), + _handoff_whatsapp_context_snapshot( + mode=mode, + visitor_channel=visitor_channel, + provided_clean=provided_clean, + contact_after_sync="", + saved_contact=saved, + saved_whatsapp_author=saved_author, + ), ) return _missing_contact_handoff_result( mode, kind, tool_name, intro=self.handoff_intro ) + relay_topic = (await self._active_issue()).strip() or ( + _consult_issue_text(message, contact or "") or (message or "").strip() + ) user_facing = self._handoff_relay_line( - mode, kind, continuing_handoff=continuing_handoff + mode, kind, continuing_handoff=continuing_handoff, topic=relay_topic ) targets = await self._staff_targets(channel) + logger.warning( + "handoff staff targets channel=%s count=%d targets=%s", + channel, + len(targets), + [_mask(t) for t in targets], + ) if not targets: logger.error( "handoff %s: no staff targets for %s (AccessControlAction " @@ -1246,6 +1607,13 @@ async def _dispatch_handoff( staff_message = stored_issue pending_q = _consult_issue_text(issue_source, contact or "") await self._add_pending(pending_q, recorded) + if not (recorded or "").strip(): + logger.warning( + "handoff consult pending recorded contact empty on send path " + "contact=%s declined=%s", + _mask(contact or ""), + contact_declined, + ) outbound = _staff_outbound( mode, staff_message if mode == "consult" else message, @@ -1270,13 +1638,19 @@ async def _dispatch_handoff( logger.info( "handoff %s summary dispatched via %s to %s", mode, channel, recipient ) + if mode == "consult": + logger.warning( + "handoff consult dispatched staff recipient=%s pending_contact=%s", + _mask(recipient), + _mask(contact or ""), + ) await self._record_event(_handoff_event(mode)) await self._clear_active() return _relay(user_facing) @tool(name="handoff__pending_questions") async def list_pending_questions(self) -> ToolResult: - """List all unanswered customer pending questions. Call this first whenever the latest message is a statement, answer, or supplied business fact (not a question). Returns every pend_ id and question text — you choose which ids the staff message answers, then call handoff__save_answer with question_ids. If the list is empty, do not call handoff__consult and do not save the message.""" + """List every unanswered customer pending question. Call when the latest message is a statement, answer, or business fact that may resolve a waiting question. Returns each pend_ id and question text.""" try: pending = await self._list_pending() except Exception: @@ -1332,7 +1706,7 @@ async def save_answer( ], answer: Annotated[str, "Full answer to store."], ) -> ToolResult: - """Save the full answer for the chosen pending questions and return a confirmation. The only save tool in consult. question_ids must come only from handoff__pending_questions. A corr- correlation id is not a question id. This tool is the only source of the saved confirmation; never write it yourself.""" + """Save the full answer for the chosen pending questions.""" from jvagent.tooling.tool_executor import get_tool_visitor visitor = get_tool_visitor() @@ -1420,7 +1794,7 @@ async def update_chunk( ], answer: Annotated[str, "Full answer to store."], ) -> ToolResult: - """Update the saved answer for one existing chunk and return a confirmation. chunk_id starts with n.DocumentNode and comes only from an event line that says Handoff chunk. A corr- correlation id is not a chunk id.""" + """Update the saved answer for one existing chunk.""" from jvagent.tooling.tool_executor import get_tool_visitor visitor = get_tool_visitor() @@ -1521,8 +1895,16 @@ async def _reply_to_customer( await self._send_email( [contact], text, subject=short_q or "Your question" ) + elif kind in ("whatsapp", "whatsapp_group"): + await self._send_whatsapp_customer(contact, text) else: - await self._send_whatsapp(contact, text) + logger.warning( + "handoff reply to customer skipped unknown contact kind " + "contact=%r kind=%r", + contact, + kind, + ) + return except Exception: logger.error( "handoff reply to customer failed contact=%r", @@ -1579,12 +1961,248 @@ async def _is_staff(self) -> bool: staff = await self._aca_staff_members() return sender in staff + async def _participant_phone_from_jid(self, jid: str, api: Any) -> str: + """Resolve a participant JID to a dialable phone (incl. LID conversion).""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_valid_whatsapp_phone, + lid_jid_for_conversion, + strip_whatsapp_suffix, + ) + + raw = str(jid or "").strip() + if not raw or "@g.us" in raw: + return "" + cleaned = strip_whatsapp_suffix(raw) + if is_valid_whatsapp_phone(cleaned): + return cleaned + convert = getattr(api, "convert_lid_to_phone_number", None) if api else None + if not callable(convert): + return "" + lid = lid_jid_for_conversion(raw if "@" in raw else cleaned) + try: + resolved = await convert(lid) + resolved = strip_whatsapp_suffix(str(resolved or "")) + if is_valid_whatsapp_phone(resolved): + return resolved + except Exception: + logger.warning( + "handoff participant: LID conversion failed for jid=%s", + _mask(raw), + exc_info=True, + ) + return "" + + async def _resolve_whatsapp_participant_contact(self) -> str: + """Group participant phone from payload (LID→phone) or saved context.""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_group_whatsapp_turn, + participant_phone_from_payload, + raw_author_from_payload, + whatsapp_payload_from_visitor_data, + ) + from jvagent.tooling.tool_executor import get_tool_visitor + + user_id = _dispatch_user_id() + visitor = get_tool_visitor() + payload = whatsapp_payload_from_visitor_data( + getattr(visitor, "data", None) if visitor else None + ) + if not is_group_whatsapp_turn(payload, user_id): + logger.warning( + "handoff participant: not a group turn user_id=%s isGroup=%s", + _mask(user_id), + bool(payload.get("isGroup")) if payload else False, + ) + return "" + + agent = await self.get_agent() + wa_action = None + if agent is not None: + get_by_type = getattr(agent, "get_action_by_type", None) + if callable(get_by_type): + wa_action = await get_by_type(self.handoff_notify_action_type) + api = await wa_action.api() if wa_action else None + + phone = participant_phone_from_payload(payload, user_id) + if phone: + logger.warning( + "handoff participant: from payload author=%s", + _mask(phone), + ) + return phone + + for jid in _participant_jids_from_whatsapp_payload(payload): + phone = await self._participant_phone_from_jid(jid, api) + if phone: + logger.warning( + "handoff participant: from payload scan jid=%s phone=%s", + _mask(jid), + _mask(phone), + ) + return phone + + raw_author = raw_author_from_payload(payload) + if raw_author: + phone = await self._participant_phone_from_jid(raw_author, api) + if phone: + logger.warning( + "handoff participant: from raw author phone=%s", + _mask(phone), + ) + return phone + + message_id = str(payload.get("message_id") or "").strip() + fetch = getattr(api, "get_message_by_id", None) if api else None + if message_id and callable(fetch): + try: + result = await fetch(message_id) + fetched_jid = _author_from_get_message_response(result) + if fetched_jid: + phone = await self._participant_phone_from_jid(fetched_jid, api) + if phone: + logger.warning( + "handoff participant: fetched message_id=%s author_len=%d phone=%s", + _mask(message_id), + len(fetched_jid), + _mask(phone), + ) + return phone + logger.warning( + "handoff participant: get_message_by_id had no author message_id=%s", + _mask(message_id), + ) + except Exception: + logger.warning( + "handoff participant: get_message_by_id failed message_id=%s", + _mask(message_id), + exc_info=True, + ) + elif message_id: + logger.warning( + "handoff participant: no get_message_by_id on API message_id=%s", + _mask(message_id), + ) + + saved = _sanitize_contact( + await self._saved_contact(), customer_contact_kind="phone" + ) + if saved: + logger.warning( + "handoff participant: fallback handoff_contact=%s", + _mask(saved), + ) + return saved + author_saved = _sanitize_contact( + await self._saved_whatsapp_author(), customer_contact_kind="phone" + ) + if author_saved: + logger.warning( + "handoff participant: fallback handoff_whatsapp_author=%s", + _mask(author_saved), + ) + return author_saved + group_contact = _group_dispatch_contact(payload, user_id) + if group_contact: + logger.warning( + "handoff participant: fallback group user_id=%s", + _mask(group_contact), + ) + return group_contact + raw_author = raw_author_from_payload(payload) + logger.warning( + "handoff participant: unresolved empty author=%s snapshot=%s", + _mask(raw_author), + _handoff_whatsapp_context_snapshot( + visitor_channel="whatsapp", + saved_contact=await self._saved_contact(), + saved_whatsapp_author=await self._saved_whatsapp_author(), + ), + ) + return "" + + async def _resolve_whatsapp_dm_recipient(self, recipient: str) -> str: + """Normalize a DM target; reject group chat ids and resolve LIDs when possible.""" + from jvagent.action.whatsapp.utils.chat_ids import ( + is_valid_whatsapp_phone, + is_whatsapp_group_chat_id, + strip_whatsapp_suffix, + ) + + raw = str(recipient or "").strip() + if not raw: + return "" + if is_whatsapp_group_chat_id(raw): + logger.warning( + "handoff whatsapp: refusing group chat id as DM recipient %s", + _mask(raw), + ) + return "" + cleaned = strip_whatsapp_suffix(raw) + if not is_valid_whatsapp_phone(cleaned): + return "" + agent = await self.get_agent() + action = None + if agent is not None: + get_by_type = getattr(agent, "get_action_by_type", None) + if callable(get_by_type): + action = await get_by_type(self.handoff_notify_action_type) + if action is None: + return cleaned + api = await action.api() + if api is None: + return cleaned + if "@lid" in raw or raw.endswith("@lid"): + convert = getattr(api, "convert_lid_to_phone_number", None) + if callable(convert): + try: + resolved = await convert(raw if "@" in raw else f"{cleaned}@lid") + resolved = strip_whatsapp_suffix(str(resolved or "")) + if is_valid_whatsapp_phone(resolved): + return resolved + except Exception: + logger.debug( + "handoff whatsapp: LID conversion failed for %s", + _mask(raw), + exc_info=True, + ) + return cleaned + + async def _send_whatsapp_customer(self, recipient: str, message: str) -> None: + """Deliver saved answer to customer DM or WhatsApp group thread.""" + from jvagent.action.whatsapp.utils.chat_ids import strip_whatsapp_suffix + + kind = _contact_kind(recipient) + if kind == "whatsapp_group": + group_id = strip_whatsapp_suffix(str(recipient or "").strip()) + logger.warning( + "handoff whatsapp: sending group message to %s", + _mask(group_id), + ) + await self._execute_whatsapp_send(group_id, message or "", is_group=True) + return + if kind != "whatsapp": + raise RuntimeError( + f"whatsapp customer send refused invalid recipient {_mask(recipient)}" + ) + await self._send_whatsapp(recipient, message) + async def _send_whatsapp(self, recipient: str, message: str) -> None: + dm_recipient = await self._resolve_whatsapp_dm_recipient(recipient) + if not dm_recipient: + raise RuntimeError( + f"whatsapp send refused invalid recipient {_mask(recipient)}" + ) + await self._execute_whatsapp_send(dm_recipient, message or "", is_group=False) + + async def _execute_whatsapp_send( + self, recipient: str, message: str, *, is_group: bool + ) -> None: masked = _mask(recipient) agent = await self.get_agent() logger.warning( - "handoff whatsapp: start recipient=%s body_chars=%d agent=%s", + "handoff whatsapp: start recipient=%s is_group=%s body_chars=%d agent=%s", masked, + is_group, len(message or ""), getattr(agent, "id", None), ) @@ -1630,11 +2248,14 @@ async def _send_whatsapp(self, recipient: str, message: str) -> None: logger.warning("handoff whatsapp: action.api() returned None") raise RuntimeError("whatsapp api() returned None") logger.warning( - "handoff whatsapp: sending via %s to %s", + "handoff whatsapp: sending via %s to %s is_group=%s", type(api).__name__, masked, + is_group, + ) + result = await api.send_message( + phone=recipient, message=message or "", is_group=is_group ) - result = await api.send_message(phone=recipient, message=message or "") logger.warning( "handoff whatsapp: provider result ok=%s http_status=%s error=%s raw=%s", (result or {}).get("ok") if isinstance(result, dict) else None, @@ -1904,6 +2525,11 @@ async def _saved_contact(self) -> str: context = getattr(conversation, "context", None) or {} return str(context.get(_HANDOFF_CONTACT_KEY) or "").strip() + async def _saved_whatsapp_author(self) -> str: + conversation = await self._conversation() + context = getattr(conversation, "context", None) or {} + return str(context.get(_HANDOFF_WHATSAPP_AUTHOR_KEY) or "").strip() + async def _save_contact(self, contact: str) -> None: contact = (contact or "").strip() if not contact: diff --git a/jvagent/action/whatsapp/endpoints.py b/jvagent/action/whatsapp/endpoints.py index f859e07d..62bc4fa3 100644 --- a/jvagent/action/whatsapp/endpoints.py +++ b/jvagent/action/whatsapp/endpoints.py @@ -662,6 +662,34 @@ async def whatsapp_interact(request: Request, agent_id: str) -> Dict[str, Any]: f"Webhook: convert_lid done in {int((time.perf_counter() - t0) * 1000)}ms" ) + _group_inbound = bool(getattr(data, "isGroup", False)) or ( + data.author and data.sender and data.author != data.sender + ) + if not whatsapp_action.is_meta_provider() and _group_inbound and data.author: + from jvagent.action.whatsapp.utils.chat_ids import ( + is_valid_whatsapp_phone, + lid_jid_for_conversion, + strip_whatsapp_suffix, + ) + + author_clean = strip_whatsapp_suffix(str(data.author)) + if not is_valid_whatsapp_phone(author_clean): + lid = ( + data.author + if "@lid" in str(data.author) + else lid_jid_for_conversion(author_clean) + ) + converted = await wa.convert_lid_to_phone_number(lid) + data.author = strip_whatsapp_suffix(str(converted or "")) + + if _group_inbound: + from jvagent.action.whatsapp.utils.group_inbound_log import ( + build_group_inbound_context, + ) + + group_ctx = build_group_inbound_context(data) + await whatsapp_action.record_group_user_from_inbound(group_ctx) + sender = data.sender sender_name = data.sender_name diff --git a/jvagent/action/whatsapp/modules/wwebjs_api.py b/jvagent/action/whatsapp/modules/wwebjs_api.py index ac33d25b..e3ab5845 100644 --- a/jvagent/action/whatsapp/modules/wwebjs_api.py +++ b/jvagent/action/whatsapp/modules/wwebjs_api.py @@ -101,6 +101,8 @@ async def translate_wwebjs_to_wppconnect(wwebjs_data: dict) -> dict: if isinstance(msg_id, str): msg_id = {"_serialized": msg_id, "fromMe": False} + author = WWebJSAPI._group_message_author(msg_data, msg_id) + # Build standard format wppconnect_data = { "event": "onmessage", @@ -126,6 +128,7 @@ async def translate_wwebjs_to_wppconnect(wwebjs_data: dict) -> dict: "pushname": msg_data.get("notifyName", ""), }, "quotedMsg": msg_data.get("quotedMsg", {}), + "author": author, } # Handle special message types @@ -147,6 +150,37 @@ async def translate_wwebjs_to_wppconnect(wwebjs_data: dict) -> dict: return wppconnect_data + @staticmethod + def _jid_string_from_nested(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + for key in ("_serialized", "user", "participant", "author"): + part = value.get(key) + if isinstance(part, str) and part.strip(): + return part.strip() + return "" + + @classmethod + def _group_message_author(cls, msg_data: dict, msg_id: dict) -> str: + """Participant JID/phone for group inbound (wwebjs often omits top-level author).""" + author = cls._jid_string_from_nested(msg_data.get("author")) + if author: + return author + if isinstance(msg_id, dict): + for key in ("participant", "author"): + author = cls._jid_string_from_nested(msg_id.get(key)) + if author: + return author + nested_id = msg_data.get("id") + if isinstance(nested_id, dict): + author = cls._jid_string_from_nested(nested_id.get("participant")) + if author: + return author + return "" + # ======================================================================== # SESSION MANAGEMENT # ======================================================================== @@ -631,6 +665,14 @@ async def get_chat_by_id(self, phone: str) -> dict: f"client/getChatById/{self.session}", data=data ) + async def get_group_chat_by_id(self, group_id: str) -> dict: + """GET /client/getChatById/{sessionId} for a group JID.""" + chat_id = self._format_chat_id(group_id, True) + data = {"chatId": chat_id} + return await self.send_rest_request( + f"client/getChatById/{self.session}", data=data + ) + async def read_chat(self, chatid: str) -> dict: """POST /chat/sendSeen/{sessionId}""" return await self.send_rest_request( diff --git a/jvagent/action/whatsapp/utils/chat_ids.py b/jvagent/action/whatsapp/utils/chat_ids.py new file mode 100644 index 00000000..675b5e1d --- /dev/null +++ b/jvagent/action/whatsapp/utils/chat_ids.py @@ -0,0 +1,94 @@ +"""WhatsApp chat id helpers (group JIDs vs participant phones).""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Mapping, Optional + +_E164_MAX_DIGITS = 15 +_PHONE_PATTERN = re.compile(r"[+\d][\d\s()-]*") + + +def strip_whatsapp_suffix(chat_id: str) -> str: + """Remove common WhatsApp JID suffixes.""" + s = str(chat_id or "").strip() + for suffix in ("@g.us", "@c.us", "@lid"): + s = s.replace(suffix, "") + return s + + +def is_whatsapp_group_chat_id(chat_id: str) -> bool: + """True when *chat_id* is a group thread id, not a participant phone.""" + raw = str(chat_id or "").strip() + if not raw: + return False + if "@g.us" in raw: + return True + cleaned = strip_whatsapp_suffix(raw) + digits = re.sub(r"\D", "", cleaned) + if not digits: + return False + if len(digits) > _E164_MAX_DIGITS: + return True + return False + + +def is_valid_whatsapp_phone(value: str) -> bool: + """True for E.164-like phones; false for group ids and non-phones.""" + text = str(value or "").strip() + if not text or text.lower() == "declined": + return False + if is_whatsapp_group_chat_id(text): + return False + digits = re.sub(r"\D", "", text) + if not digits or len(digits) < 6 or len(digits) > _E164_MAX_DIGITS: + return False + return bool(_PHONE_PATTERN.fullmatch(text)) + + +def is_group_whatsapp_turn( + payload: Optional[Mapping[str, Any]], + user_id: Optional[str] = None, +) -> bool: + """True when the inbound turn is a group chat (flag or group JID user_id).""" + if payload and payload.get("isGroup"): + return True + return is_whatsapp_group_chat_id(str(user_id or "")) + + +def raw_author_from_payload(payload: Optional[Mapping[str, Any]]) -> str: + """Stripped ``author`` from payload, even when not yet a valid phone.""" + if not payload: + return "" + return strip_whatsapp_suffix(str(payload.get("author") or "")) + + +def lid_jid_for_conversion(raw_author: str) -> str: + """Format an author id for ``convert_lid_to_phone_number``.""" + text = str(raw_author or "").strip() + if not text: + return "" + if "@" in text: + return text + return f"{strip_whatsapp_suffix(text)}@lid" + + +def participant_phone_from_payload( + payload: Optional[Mapping[str, Any]], + user_id: Optional[str] = None, +) -> str: + """Participant phone for a group inbound message (``author``), or ``""``.""" + if not is_group_whatsapp_turn(payload, user_id): + return "" + author = raw_author_from_payload(payload) + if is_valid_whatsapp_phone(author): + return author + return "" + + +def whatsapp_payload_from_visitor_data(data: Any) -> Dict[str, Any]: + """Read ``whatsapp_payload`` from tool visitor ``data``, or ``{}``.""" + if not isinstance(data, dict): + return {} + payload = data.get("whatsapp_payload") or {} + return payload if isinstance(payload, dict) else {} diff --git a/jvagent/action/whatsapp/utils/group_inbound_log.py b/jvagent/action/whatsapp/utils/group_inbound_log.py new file mode 100644 index 00000000..6162684a --- /dev/null +++ b/jvagent/action/whatsapp/utils/group_inbound_log.py @@ -0,0 +1,57 @@ +"""Group inbound context for WhatsApp ``group_users`` persistence.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class GroupInboundContext: + group_id: str + group_name: str + sender_phone: str + sender_name: str + author_raw: str + + +def group_title_from_chat_api_response(result: Any) -> str: + """Best-effort group display name from wwebjs ``getChatById`` / similar.""" + if not isinstance(result, dict): + return "" + roots: list[Any] = [result] + for key in ("chat", "data", "response"): + nested = result.get(key) + if isinstance(nested, dict): + roots.append(nested) + for root in roots: + if not isinstance(root, dict): + continue + for key in ("name", "formattedTitle", "subject"): + title = root.get(key) + if isinstance(title, str) and title.strip(): + return title.strip() + meta = root.get("groupMetadata") + if isinstance(meta, dict): + subject = meta.get("subject") + if isinstance(subject, str) and subject.strip(): + return subject.strip() + return "" + + +def build_group_inbound_context(data: Any) -> GroupInboundContext: + """Group conversation id and sender display name from the inbound payload.""" + return GroupInboundContext( + group_id=str(getattr(data, "sender", "") or "").strip(), + group_name="", + sender_phone="", + sender_name=str(getattr(data, "sender_name", "") or "").strip(), + author_raw=str(getattr(data, "author", "") or "").strip(), + ) + + +__all__ = [ + "GroupInboundContext", + "build_group_inbound_context", + "group_title_from_chat_api_response", +] diff --git a/jvagent/action/whatsapp/whatsapp_action.py b/jvagent/action/whatsapp/whatsapp_action.py index 8ab87163..4891d79d 100644 --- a/jvagent/action/whatsapp/whatsapp_action.py +++ b/jvagent/action/whatsapp/whatsapp_action.py @@ -227,9 +227,51 @@ class WhatsAppAction(Action): ), ) + save_group_users: bool = attribute( + default=False, + description=( + "When true, upsert group chats into group_users " + "(conversation user_id → sender display name) on each inbound group turn." + ), + ) + + group_users: Dict[str, str] = attribute( + default_factory=dict, + description=( + "Map of group conversation user_id (the id access control checks) " + "to the latest sender display name (notifyName)." + ), + ) + # Internal state tracking (not persisted) _session_registered: bool = False + async def record_group_user_from_inbound(self, ctx: Any) -> None: + """Persist conversation user_id → sender_name when save_group_users is enabled. + + On group turns, access control checks ``visitor.user_id``, which is the + group chat id (``ctx.group_id``), not the participant phone. + """ + if not self.save_group_users: + return + user_id = str(getattr(ctx, "group_id", "") or "").strip() + name = str(getattr(ctx, "sender_name", "") or "").strip() + if not user_id: + return + current = dict(self.group_users or {}) + if current.get(user_id) == name: + return + is_new = user_id not in current + current[user_id] = name + self.group_users = current + await self.save() + if is_new: + logger.info( + "WhatsApp group_users: added user_id=%s sender_name=%s", + user_id, + name, + ) + def is_meta_provider(self) -> bool: return (self.provider or "").strip() == "meta" diff --git a/tests/action/test_action_import_guard.py b/tests/action/test_action_import_guard.py index 6afbda72..f476f59c 100644 --- a/tests/action/test_action_import_guard.py +++ b/tests/action/test_action_import_guard.py @@ -50,6 +50,8 @@ ("artifact_handler_interact_action", "pageindex"), ("handoff_action", "email_action"), ("handoff_action", "pageindex"), + # Group consult/transfer reuses WhatsApp JID parsing (chat_ids). + ("handoff_action", "whatsapp"), } ) diff --git a/tests/action/test_handoff_action.py b/tests/action/test_handoff_action.py index c12e34a7..6927c7d8 100644 --- a/tests/action/test_handoff_action.py +++ b/tests/action/test_handoff_action.py @@ -11,6 +11,7 @@ from jvagent.action.handoff_action import HandoffAction from jvagent.action.handoff_action.handoff_action import ( _bold_whatsapp_ask, + _contact_kind, _extract_saved_answer, _pending_question_text, _staff_outbound, @@ -163,6 +164,16 @@ async def test_tools_follow_mode(): assert "handoff__consult" in {t.name for t in await unknown.get_tools()} +async def test_consult_message_rule_tells_model_to_synthesize(): + action = HandoffAction() + tools = {t.name: t for t in await action.get_tools()} + props = tools["handoff__consult"].parameters_schema["properties"] + desc = props["message"]["description"] + assert "complete, grammatically full sentence" in desc + assert "continuation" in desc + assert "shorthand fragments" in desc + + async def test_transfer_asks_on_web_when_no_contact(monkeypatch): _install_aca_staff(monkeypatch) action = HandoffAction() @@ -178,8 +189,8 @@ async def _send(self, recipient, message): with bind_dispatch_context(_Ctx("o.User.abc", channel="web")): r = await tools["handoff__transfer"].call(message="need a person") assert not r.is_error - assert "Relay this to the user" in r.content - assert "don't have that information" in r.content.lower() + assert "Tell the user this" in r.content + assert "sort that out with our team" in r.content.lower() assert "WhatsApp number" in r.content assert "email" not in r.content.lower().split("internal")[0] assert "handoff__transfer" in r.content @@ -206,7 +217,7 @@ async def _send(self, recipient, message): with bind_dispatch_context(ctx): ask = await tools["handoff__transfer"].call(message="need a person") assert not ask.is_error - assert "don't have that information" in ask.content.lower() + assert "sort that out with our team" in ask.content.lower() assert "to" not in sent assert conversation.context.get("handoff_active_mode") == "transfer" @@ -217,7 +228,7 @@ async def _send(self, recipient, message): ) assert not confirm.is_error assert "staff member will reach out" in confirm.content.lower() - assert "don't have that information" not in confirm.content.lower() + assert "sort that out with our team" not in confirm.content.lower() assert sent["to"] == _STAFF_PHONE assert "Contact: 5926431530" in sent["message"] @@ -418,7 +429,7 @@ async def _send(self, recipient, message): ): r = await tools["handoff__transfer"].call(message="need help") assert not r.is_error - assert "Relay this to the user" in r.content + assert "Tell the user this" in r.content assert "to" not in sent @@ -604,8 +615,8 @@ async def _record(self, message, contact=""): r = await tools["handoff__consult"].call(message="where do you deliver?") assert not r.is_error - assert "Relay this to the user" in r.content - assert "don't have that information" in r.content.lower() + assert "Tell the user this" in r.content + assert "sort that out with our team" in r.content.lower() assert "WhatsApp number" in r.content assert "handoff__consult" in r.content assert sent["count"] == 0 @@ -622,9 +633,7 @@ async def _record(self, message, contact=""): ), contact="592000", ) - assert "don't have that information" in r.content - assert "check with the team" in r.content - assert "when they respond" in r.content + assert "checking with the team" in r.content assert sent["count"] == 1 assert open_q["q"].user_contact == "592000" assert "Contact: 592000" in sent["message"] @@ -641,18 +650,14 @@ async def _record(self, message, contact=""): r = await tools["handoff__consult"].call( message="where do you deliver?", contact_declined=True ) - assert "don't have that information" in r.content - assert "check with the team" in r.content - assert "when they respond" in r.content + assert "checking with the team" in r.content assert sent["count"] == 1 assert open_q["q"].user_contact == "declined" with bind_dispatch_context(_Ctx("592111")): open_q.clear() r = await tools["handoff__consult"].call(message="what are your hours?") - assert "don't have that information" in r.content - assert "check with the team" in r.content - assert "when they respond" in r.content + assert "checking with the team" in r.content assert "WhatsApp number or email" not in r.content @@ -686,7 +691,7 @@ async def _append(self, question, answer): looked = await tools["handoff__consult"].call( message="where do you deliver?", contact_declined=True ) - assert "when they respond" in looked.content + assert "checking with the team" in looked.content assert sent == [("whatsapp", _STAFF_PHONE)] assert len(action.pending_questions) == 1 row = action.pending_questions[0] @@ -728,8 +733,8 @@ async def _send(self, recipient, message): again = await tools["handoff__consult"].call(message="Do you offer delivery") repeat = await tools["handoff__consult"].call(message="Do you offer delivery") assert "WhatsApp number or email" not in reused.content - assert "when they respond" in reused.content - assert "when they respond" in again.content + assert "checking with the team" in reused.content + assert "checking with the team" in again.content assert sent["count"] == 3 assert all("Contact: 5926431530" in message for message in sent["messages"]) assert [row["question"] for row in action.pending_questions] == [ @@ -738,7 +743,7 @@ async def _send(self, recipient, message): "Do you offer delivery", ] assert {row["user_contact"] for row in action.pending_questions} == {"5926431530"} - assert "when they respond" in repeat.content + assert "checking with the team" in repeat.content async def test_consult_always_appends_separate_rows(monkeypatch): @@ -1117,10 +1122,8 @@ def test_action_contributes_orchestration_routing_parameter(): observe_params = orchestration_parameters(observe.parameters) assert observe_params[0].get("key") == "handoff_observe" rendered_observe = render_parameters(observe_params).lower() - assert "never send a message" in rendered_observe assert "handoff__observe" in rendered_observe - assert "handoff__pending_questions" in rendered_observe - assert "handoff__consult" in rendered_observe + assert "never send a message" not in rendered_observe def test_yaml_parameters_override_mode_defaults(): @@ -1694,13 +1697,461 @@ async def _send(self, recipient, message): looked = await tools["handoff__consult"].call( message="where do you deliver?", contact_declined=True ) - assert "when they respond" in looked.content + assert "checking with the team" in looked.content + assert interaction.events == [ + ( + "Handoff started: sending the customer's query to staff for " + "consultation.", + "HandoffAction", + ), + ( + "Handoff completed: the customer's query was sent to staff for " + "consultation. That request is closed — do not re-handle or look " + "up the previous request again unless user reask it again. Always run the matching skills/tools first to check for fresh data before falling back to handoff__consult; focus on the current request.", + "HandoffAction", + ), + ] + + +async def test_transfer_records_completed_event(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + action.mode = "transfer" + interaction = _Interaction() + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + return None + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + visitor = _Ctx("5926431530", channel="whatsapp", conversation=_Conversation()) + visitor.interaction = interaction + with bind_dispatch_context(visitor): + looked = await tools["handoff__transfer"].call( + message="customer needs a person" + ) + assert not looked.is_error assert interaction.events == [ - ("Handoff started: consult.", "HandoffAction"), - ("Handoff completed: consult.", "HandoffAction"), + ("Handoff started: forwarding the issue summary to staff.", "HandoffAction"), + ( + "Handoff completed: the issue summary was forwarded to staff. " + "This request is closed — keep helping on later messages only.", + "HandoffAction", + ), ] +def test_contact_kind_whatsapp_group_chat_id(): + assert _contact_kind("120363428616636917") == "whatsapp_group" + assert _contact_kind("5926431530") == "whatsapp" + + +async def test_consult_relay_carries_topic_not_canned_sentence(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + return None + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + + with bind_dispatch_context( + _Ctx("592000", channel="whatsapp", conversation=_Conversation()) + ): + first = await tools["handoff__consult"].call(message="where do you deliver?") + with bind_dispatch_context( + _Ctx("592111", channel="whatsapp", conversation=_Conversation()) + ): + second = await tools["handoff__consult"].call(message="what are your hours?") + # Steering, not a canned sentence: each relay names this request's topic. + assert "where do you deliver?" in first.content + assert "what are your hours?" in second.content + assert first.content != second.content + assert "sort that out with our team" not in first.content + assert "as soon as they respond" not in first.content + assert "INTERNAL" not in first.content + + +async def test_consult_relay_literal_when_close_configured(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + action.consult_close = "I'll follow up shortly." + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + return None + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context( + _Ctx("592000", channel="whatsapp", conversation=_Conversation()) + ): + result = await tools["handoff__consult"].call(message="where do you deliver?") + # Explicit yaml close forces the deterministic literal path. + assert "I'll follow up shortly." in result.content + assert "checking with the team" not in result.content + + +async def test_consult_from_whatsapp_group_uses_author_not_group_id(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + author = "5923333333" + visitor = _Ctx(group_id, channel="whatsapp") + visitor.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": author, + } + } + with bind_dispatch_context(visitor): + result = await tools["handoff__consult"].call( + message="Do you deliver to Bartica?" + ) + assert not result.is_error + assert action.pending_questions[0]["user_contact"] == author + assert sent["to"] == _STAFF_PHONE + + +async def test_consult_from_group_participant_field_without_author(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + author = "5927777777" + visitor = _Ctx(group_id, channel="whatsapp") + visitor.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": "", + "participant": f"{author}@c.us", + } + } + with bind_dispatch_context(visitor): + result = await tools["handoff__consult"].call(message="Need delivery info?") + assert not result.is_error + assert action.pending_questions[0]["user_contact"] == author + assert sent["to"] == _STAFF_PHONE + + +async def test_consult_from_group_empty_author_fetches_message_by_id(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {} + fetch_calls = [] + + class _API: + async def get_message_by_id(self, message_id): + fetch_calls.append(message_id) + return { + "message": { + "_data": {"author": "5928888888@c.us"}, + } + } + + class _WA: + async def api(self): + return _API() + + class _Agent: + id = "n.Agent.test" + + async def get_action_by_type(self, name): + assert name == "WhatsAppAction" + return _WA() + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + sent["to"] = recipient + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + msg_id = "true_120363428616636917@g.us_ABC123" + visitor = _Ctx(group_id, channel="whatsapp") + visitor.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": "", + "message_id": msg_id, + } + } + with bind_dispatch_context(visitor): + result = await tools["handoff__consult"].call(message="Need delivery info?") + assert not result.is_error + assert fetch_calls == [msg_id] + assert action.pending_questions[0]["user_contact"] == "5928888888" + assert sent["to"] == _STAFF_PHONE + + +async def test_consult_from_group_empty_author_uses_group_id_fallback(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + sent["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + visitor = _Ctx(group_id, channel="whatsapp") + visitor.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": "", + } + } + with bind_dispatch_context(visitor): + result = await tools["handoff__consult"].call(message="Do you offer delivery?") + assert not result.is_error + assert "WhatsApp number" not in result.content + assert action.pending_questions[0]["user_contact"] == group_id + assert sent["to"] == _STAFF_PHONE + assert f"Group: {group_id}" in sent["message"] + + +async def test_save_answer_replies_in_group_thread(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + group_id = "120363428616636917" + group_sends = [] + + class _API: + async def send_message(self, phone, message, is_group=False): + group_sends.append( + {"phone": phone, "message": message, "is_group": is_group} + ) + return {"ok": True} + + class _WA: + def is_configured(self): + return True + + def _config_issues(self): + return [] + + def get_class_name(self): + return "WhatsAppAction" + + async def api(self): + return _API() + + class _Agent: + id = "n.Agent.test" + + async def get_action_by_type(self, name): + assert name == "WhatsAppAction" + return _WA() + + async def _get_agent(self): + return _Agent() + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + + async def _customer_reply(self, q, a): + return f"Reply: {a}" + + monkeypatch.setattr(HandoffAction, "_customer_reply", _customer_reply) + + async def _append_once(self, question, answer): + return question, answer, "n.DocumentNode.test" + + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append_once) + + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx(group_id, channel="whatsapp")): + await tools["handoff__consult"].call(message="Do you deliver?") + qid = action.pending_questions[0]["id"] + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + saved = await tools["handoff__save_answer"].call( + question_ids=[qid], answer="Yes, we deliver." + ) + assert not saved.is_error + customer_sends = [s for s in group_sends if s["is_group"]] + assert len(customer_sends) == 1 + assert customer_sends[0]["phone"] == group_id + + +async def test_consult_from_group_without_isGroup_flag_uses_author(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {} + + async def _send(self, recipient, message): + sent["to"] = recipient + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + author = "5925555555" + visitor = _Ctx(group_id, channel="whatsapp") + visitor.data = { + "whatsapp_payload": { + "isGroup": False, + "sender": group_id, + "author": author, + } + } + with bind_dispatch_context(visitor): + result = await tools["handoff__consult"].call(message="Stock on item X?") + assert not result.is_error + assert action.pending_questions[0]["user_contact"] == author + assert sent["to"] == _STAFF_PHONE + + +async def test_consult_from_group_lid_author_resolves_via_api(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + sent = {} + + class _API: + async def convert_lid_to_phone_number(self, lid): + assert lid.endswith("@lid") + return "5926666666" + + class _WA: + async def api(self): + return _API() + + class _Agent: + id = "n.Agent.test" + + async def get_action_by_type(self, name): + assert name == "WhatsAppAction" + return _WA() + + async def _get_agent(self): + return _Agent() + + async def _send(self, recipient, message): + sent["to"] = recipient + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + visitor = _Ctx(group_id, channel="whatsapp") + visitor.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": "1234567890123456", + } + } + with bind_dispatch_context(visitor): + result = await tools["handoff__consult"].call(message="Need a quote.") + assert not result.is_error + assert action.pending_questions[0]["user_contact"] == "5926666666" + assert sent["to"] == _STAFF_PHONE + + +async def test_save_answer_dms_group_author_not_group_chat_id(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + customer_sends = [] + + class _Agent: + id = "n.Agent.test" + + async def get_action_by_type(self, name): + return None + + async def _get_agent(self): + return _Agent() + + async def _send_whatsapp(self, recipient, message): + if recipient != _STAFF_PHONE: + customer_sends.append(recipient) + + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send_whatsapp) + + async def _customer_reply(self, q, a): + return f"Answer: {a}" + + monkeypatch.setattr(HandoffAction, "_customer_reply", _customer_reply) + + async def _append_and_ingest_stub(self, question, answer): + return question, answer, "n.DocumentNode.test" + + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append_and_ingest_stub) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + author = "5923333333" + visitor = _Ctx(group_id, channel="whatsapp") + visitor.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": author, + } + } + with bind_dispatch_context(visitor): + await tools["handoff__consult"].call(message="Do you deliver to Bartica?") + qid = action.pending_questions[0]["id"] + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + saved = await tools["handoff__save_answer"].call( + question_ids=[qid], answer="Yes, we deliver there." + ) + assert not saved.is_error + assert customer_sends == [author] + + async def test_observe_enrolls_group_numbers_and_does_not_reply(monkeypatch): enrolled = {} @@ -1766,7 +2217,7 @@ async def _send(self, recipient, message): fact="The shop closes at 5pm on Fridays." ) assert not result.is_error - assert "Do not send any message" in result.content + assert "Inform them in a simple note" in result.content assert enrolled == { "group": "staff", "users": ["5921111111", "5922222222"], diff --git a/tests/action/test_whatsapp_group_inbound_log.py b/tests/action/test_whatsapp_group_inbound_log.py new file mode 100644 index 00000000..a1599cea --- /dev/null +++ b/tests/action/test_whatsapp_group_inbound_log.py @@ -0,0 +1,72 @@ +"""WhatsApp group inbound context (conversation id + sender name).""" + +from types import SimpleNamespace + +import pytest + +from jvagent.action.whatsapp.modules.wwebjs_api import WWebJSAPI +from jvagent.action.whatsapp.utils.group_inbound_log import ( + build_group_inbound_context, + group_title_from_chat_api_response, +) + + +@pytest.mark.asyncio +async def test_translate_wwebjs_sets_group_author_from_participant(): + wwebjs_data = { + "dataType": "message", + "sessionId": "test", + "data": { + "message": { + "_data": { + "from": "120363428616636917@g.us", + "body": "hi", + "type": "chat", + "notifyName": "Staff User", + "id": { + "_serialized": "abc@g.us", + "participant": "5926178650@c.us", + "fromMe": False, + }, + } + } + }, + } + wpp = await WWebJSAPI.translate_wwebjs_to_wppconnect(wwebjs_data) + assert wpp["author"] == "5926178650@c.us" + + payload = await WWebJSAPI("http://test", "sess", "token").parse_inbound_message( + wwebjs_data + ) + assert payload is not None + assert payload.author == "5926178650" + assert payload.sender_name == "Staff User" + assert payload.isGroup is True + + +def test_group_title_from_chat_api_response_subject(): + result = { + "chat": { + "name": "Silvie Team", + "groupMetadata": {"subject": "Fallback Subject"}, + } + } + assert group_title_from_chat_api_response(result) == "Silvie Team" + + +def test_group_title_from_chat_api_response_metadata_only(): + result = {"data": {"groupMetadata": {"subject": "Ops Group"}}} + assert group_title_from_chat_api_response(result) == "Ops Group" + + +def test_build_group_inbound_context_uses_sender_and_name(): + data = SimpleNamespace( + sender="120363428616636917", + author="5926178650", + sender_name="Staff User", + ) + ctx = build_group_inbound_context(data) + assert ctx.group_id == "120363428616636917" + assert ctx.sender_name == "Staff User" + assert ctx.sender_phone == "" + assert ctx.group_name == "" diff --git a/tests/action/test_whatsapp_group_users.py b/tests/action/test_whatsapp_group_users.py new file mode 100644 index 00000000..bfd70ae6 --- /dev/null +++ b/tests/action/test_whatsapp_group_users.py @@ -0,0 +1,107 @@ +"""WhatsAppAction save_group_users / group_users persistence.""" + +from unittest.mock import AsyncMock + +import pytest + +from jvagent.action.whatsapp.utils.group_inbound_log import GroupInboundContext + +_GROUP_ID = "120363428616636917" + + +class _StubWhatsAppAction: + """Minimal stand-in for record_group_user_from_inbound behavior.""" + + save_group_users = False + group_users: dict = None + save = AsyncMock() + + def __init__(self): + self.group_users = {} + self.save = AsyncMock() + + async def record_group_user_from_inbound(self, ctx): + from jvagent.action.whatsapp.whatsapp_action import WhatsAppAction + + await WhatsAppAction.record_group_user_from_inbound(self, ctx) + + +@pytest.mark.asyncio +async def test_record_group_user_upserts_user_id_to_name(): + action = _StubWhatsAppAction() + action.save_group_users = True + ctx = GroupInboundContext( + group_id=_GROUP_ID, + group_name="Team", + sender_phone="158025151201418", + sender_name="Staff User", + author_raw="158025151201418", + ) + await action.record_group_user_from_inbound(ctx) + assert action.group_users == {_GROUP_ID: "Staff User"} + action.save.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_record_group_user_updates_name_same_user_id(): + action = _StubWhatsAppAction() + action.save_group_users = True + action.group_users = {_GROUP_ID: "Old Name"} + ctx = GroupInboundContext( + group_id=_GROUP_ID, + group_name="Team", + sender_phone="158025151201418", + sender_name="New Name", + author_raw="158025151201418", + ) + await action.record_group_user_from_inbound(ctx) + assert action.group_users[_GROUP_ID] == "New Name" + action.save.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_record_group_user_skips_when_disabled(): + action = _StubWhatsAppAction() + action.save_group_users = False + ctx = GroupInboundContext( + group_id=_GROUP_ID, + group_name="", + sender_phone="5926178650", + sender_name="Staff", + author_raw="", + ) + await action.record_group_user_from_inbound(ctx) + assert action.group_users == {} + action.save.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_record_group_user_skips_when_user_id_empty(): + action = _StubWhatsAppAction() + action.save_group_users = True + ctx = GroupInboundContext( + group_id="", + group_name="", + sender_phone="158025151201418", + sender_name="Staff", + author_raw="123@lid", + ) + await action.record_group_user_from_inbound(ctx) + assert action.group_users == {} + action.save.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_record_group_user_no_save_when_unchanged(): + action = _StubWhatsAppAction() + action.save_group_users = True + action.group_users = {_GROUP_ID: "Staff User"} + ctx = GroupInboundContext( + group_id=_GROUP_ID, + group_name="", + sender_phone="5926178650", + sender_name="Staff User", + author_raw="5926178650", + ) + await action.record_group_user_from_inbound(ctx) + action.save.assert_not_awaited() diff --git a/tests/action/whatsapp/test_chat_ids.py b/tests/action/whatsapp/test_chat_ids.py new file mode 100644 index 00000000..a62733e8 --- /dev/null +++ b/tests/action/whatsapp/test_chat_ids.py @@ -0,0 +1,69 @@ +"""Tests for WhatsApp chat id helpers.""" + +from jvagent.action.whatsapp.utils.chat_ids import ( + is_group_whatsapp_turn, + is_valid_whatsapp_phone, + is_whatsapp_group_chat_id, + lid_jid_for_conversion, + participant_phone_from_payload, + raw_author_from_payload, + strip_whatsapp_suffix, +) + + +def test_strip_whatsapp_suffix(): + assert strip_whatsapp_suffix("5926431530@c.us") == "5926431530" + assert strip_whatsapp_suffix("120363428616636917@g.us") == "120363428616636917" + + +def test_is_whatsapp_group_chat_id(): + assert is_whatsapp_group_chat_id("120363428616636917@g.us") + assert is_whatsapp_group_chat_id("120363428616636917") + assert not is_whatsapp_group_chat_id("5926431530") + assert not is_whatsapp_group_chat_id("+5926431530") + + +def test_is_valid_whatsapp_phone_e164_boundary(): + assert is_valid_whatsapp_phone("5926431530") + assert not is_valid_whatsapp_phone("120363428616636917") + assert not is_valid_whatsapp_phone("12345") + + +def test_is_group_whatsapp_turn(): + group_id = "120363428616636917" + assert is_group_whatsapp_turn({"isGroup": True}, "592111") + assert is_group_whatsapp_turn({}, group_id) + assert not is_group_whatsapp_turn({}, "5926431530") + + +def test_raw_author_and_lid_jid(): + payload = {"author": "999888777@lid"} + assert raw_author_from_payload(payload) == "999888777" + assert lid_jid_for_conversion("999888777") == "999888777@lid" + assert lid_jid_for_conversion("999888777@lid") == "999888777@lid" + + +def test_participant_phone_from_payload(): + payload = { + "isGroup": True, + "sender": "120363428616636917", + "author": "5923333333", + } + assert participant_phone_from_payload(payload) == "5923333333" + assert ( + participant_phone_from_payload({"isGroup": False, "author": "5923333333"}) == "" + ) + assert ( + participant_phone_from_payload( + {"isGroup": True, "author": "120363428616636917"} + ) + == "" + ) + + assert ( + participant_phone_from_payload( + {"isGroup": False, "author": "5924444444"}, + user_id="120363428616636917", + ) + == "5924444444" + ) From b1048b09c4206bc16c17fafb8ea564ee1df2e732 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Fri, 9 Oct 2026 10:22:10 -0400 Subject: [PATCH 14/20] refactor(handoff): streamline consult mode and enhance staff interaction This commit refines the `handoff` action by removing the `handoff__pending_questions` tool and integrating its functionality into the staff turn parameter. The `consult` mode now directly injects current unanswered questions into the orchestration prompt, simplifying the staff's response process. Documentation has been updated to reflect these changes, ensuring clarity on the new parameter structure and its usage in staff interactions. --- jvagent/action/handoff_action/README.md | 37 +++-- .../action/handoff_action/handoff_action.py | 135 ++++++++---------- jvagent/action/handoff_action/info.yaml | 4 +- jvagent/action/reply/reply_action.py | 19 +-- jvagent/action/skill_spec/registry.py | 2 +- tests/action/reply/test_reply_action.py | 20 --- tests/action/test_handoff_action.py | 57 +++----- 7 files changed, 109 insertions(+), 165 deletions(-) diff --git a/jvagent/action/handoff_action/README.md b/jvagent/action/handoff_action/README.md index 6c187c57..565bc50d 100644 --- a/jvagent/action/handoff_action/README.md +++ b/jvagent/action/handoff_action/README.md @@ -6,7 +6,7 @@ deny rules only for that mode's tools. There is no handoff skill. | Mode | Pin these tools | What it does | |---|---|---| -| `consult` (default) | `handoff__consult`, `handoff__pending_questions`, `handoff__save_answer`, `handoff__update_chunk` | Ask staff, tell the user you will return, save the answer, and reply to the user. | +| `consult` (default) | `handoff__consult`, `handoff__save_answer`, `handoff__update_chunk` | Ask staff, tell the user you will return, save the answer, and reply to the user. The current pending questions are injected into the staff-turn prompt as a parameter (id + question only) — no read tool. | | `transfer` | `handoff__transfer` | Send staff a summary and tell the user a staff member will follow up; later messages run normally. | | `observe` | `handoff__observe` | In a WhatsApp group, store a useful fact in PageIndex, add the other numbers to staff, and send nothing. | @@ -68,25 +68,24 @@ matches, it **overrides** the active skill and any skill procedure. | Mode | Sender | Key | Condition (verbatim) | Response (summary) | |---|---|---|---|---| | consult | customer | `handoff_consult` | you cannot answer a customer question, or you cannot complete the request from the knowledge base and tools, or the request is outside what the store sells (a "do you sell X" question the knowledge base cannot answer) — a quote, stock confirmation, bulk or B2B order, an upset customer, or the user wants a person or wants something reported | Call `handoff__consult` now; relay only tool line; sentence 1 = customer's words; contact rules per channel | -| consult | staff | `handoff_consult_staff` | the sender is staff and the latest message answers a customer question or corrects a saved answer | `handoff__pending_questions` → `handoff__save_answer`; id rules for `pend_` and chunks | +| consult | staff | `handoff_consult_staff` | (unconditional; injected only for staff) | Staff-turn parameter carries `PENDING QUESTIONS: [{"id","question"}]`; choose matching ids and call `handoff__save_answer`; id rules for `pend_` and chunks | | transfer | customer | `handoff_transfer` | *(same condition as consult customer)* | Call `handoff__transfer` with short summary; relay only tool line; keep helping on later turns | -| transfer | staff | `handoff_transfer_staff` | the sender is staff | Do not call `handoff__transfer` for customer escalation; reply in thread | -| observe | any | `handoff_observe` | the latest WhatsApp group message contains a fact, policy, or answer worth keeping | Call `handoff__observe`; no group message; no consult/transfer/pending tools | +| transfer | staff | `handoff_transfer_staff` | (unconditional; injected only for staff) | Do not call `handoff__transfer` for a message needing no escalation; reply in thread | +| observe | any | `handoff_observe` | the latest WhatsApp group message contains a fact, policy, or answer worth keeping | Call `handoff__observe`; no group message; no consult/transfer tools | **Override:** a non-empty `parameters` list on the action in agent.yaml **replaces** auto rules entirely. Use for custom agents or narrow transfer behavior — see [Parameters override](#parameters-override). -The staff consult rule applies when the latest message **answers a customer -question** or **corrects a saved answer**. A staff location statement with no -matching pending question does not match until the rule text in code is -widened. +The staff consult rule applies on every staff turn and lists the current +unanswered questions in the parameter; the model matches the staff message to +those ids. ## Skills vs handoff (rules of thumb) - The orchestrator loop prefers **skills first** (`use_skill` before ad-hoc tools). - FAQ is for customer **questions** answered from PageIndex, not staff **statements** or saves. -- Do **not** put `handoff__pending_questions`, `handoff__save_answer`, or +- Do **not** put `handoff__save_answer`, `handoff__update_chunk`, or "call handoff in FAQ description" in FAQ skills — that makes FAQ match staff turns and the model opens with `use_skill` before handoff routing. - Use FAQ **Do not use when** / description so staff answers do **not** activate faq. @@ -252,16 +251,15 @@ reply when staff saves. ### Setup - `mode: consult`, `handoff_channels.consult: whatsapp` | `email` -- Pin four tools on orchestrator +- Pin three tools on orchestrator - Permissions on **every channel** (`default`, `whatsapp`, …): customers → - `handoff__consult` only; staff → pending/save/update only + `handoff__consult` only; staff → save/update only ```yaml - action: jvagent/orchestrator context: pinned_tools: - handoff__consult - - handoff__pending_questions - handoff__save_answer - handoff__update_chunk ``` @@ -285,9 +283,6 @@ permissions: handoff__consult: deny: [{ group: staff, enabled: true }] allow: [{ group: all, enabled: true }] - handoff__pending_questions: - deny: [] - allow: [{ group: staff, enabled: true }] handoff__save_answer: deny: [] allow: [{ group: staff, enabled: true }] @@ -296,18 +291,18 @@ permissions: allow: [{ group: staff, enabled: true }] whatsapp: tools: - # same four-tool matrix + # same three-tool matrix ``` ### Smoke tests - Customer: policy question with empty PageIndex → `handoff__consult`, relay only -- Staff: answer matching pending → `handoff__pending_questions` + `handoff__save_answer` +- Staff: answer matching pending → param ids + `handoff__save_answer` - Customer receives async reply after save -Question ids start with `pend_` from `handoff__pending_questions`. Chunk ids -start with `n.DocumentNode.` from `[EVENT]` Handoff chunk lines. A `corr-` id -is neither. +Question ids start with `pend_` in the staff-turn `PENDING QUESTIONS` parameter. +Chunk ids start with `n.DocumentNode.` from `[EVENT]` Handoff chunk lines. A +`corr-` id is neither. Customer relay: the completion reply is **generated** from model-facing steering (topic echo + check-with-team + reply-on-response), so the orchestrator voices a @@ -419,7 +414,7 @@ you use. List only the **active mode's** tools. After pins, each name under `permissions[channel].tools` is dropped when `has_tool_access` is false. **Consult:** customers see `handoff__consult` only; -staff see save/list tools. **Transfer:** no handoff `tools` entries. +staff see save/update tools. **Transfer:** no handoff `tools` entries. **Observe:** staff allow on `handoff__observe` only. ## Staff save confirmation and customer reply diff --git a/jvagent/action/handoff_action/handoff_action.py b/jvagent/action/handoff_action/handoff_action.py index 9fcd9fd3..7489ac93 100644 --- a/jvagent/action/handoff_action/handoff_action.py +++ b/jvagent/action/handoff_action/handoff_action.py @@ -4,9 +4,11 @@ ``get_tools()`` publishes. Pin those tools on the orchestrator — there is no skill SOP. -- ``consult`` — ``handoff__consult``, ``handoff__pending_questions``, - ``handoff__save_answer``, ``handoff__update_chunk``. Ask staff, tell the user - you will return, then save the answer and reply to the user. +- ``consult`` — ``handoff__consult``, ``handoff__save_answer``, + ``handoff__update_chunk``. Ask staff, tell the user you will return, then save + the answer and reply to the user. On a staff turn the current unanswered + questions are injected into the orchestration prompt as a parameter (id and + question only), so the staff sender needs no read tool to pick the ids. - ``transfer`` — ``handoff__transfer``. Summarize for staff, tell the user a staff member will follow up; the conversation continues on later messages. - ``observe`` — ``handoff__observe``. In a WhatsApp group, store a useful fact @@ -83,7 +85,6 @@ def _register_orchestrator_vocabulary() -> None: "consult": frozenset( { "handoff__consult", - "handoff__pending_questions", "handoff__save_answer", "handoff__update_chunk", } @@ -116,11 +117,25 @@ def _contact_label(kind: str) -> str: ) +def _pending_questions_block(rows: Optional[List[Dict[str, Any]]]) -> str: + """Compact JSON ``[{"id","question"}]`` for the staff-turn parameter.""" + compact = [ + { + "id": _question_field(row, "id"), + "question": _question_field(row, "question")[:300], + } + for row in (rows or []) + if _question_field(row, "id") + ] + return json.dumps(compact, ensure_ascii=False) + + def _parameters_for( mode: str, staff: bool = False, *, customer_contact_kind: str = "phone", + pending_rows: Optional[List[Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: """Orchestration parameter(s) for this mode and sender.""" mode = mode if mode in _MODE_TOOLS else "consult" @@ -131,23 +146,19 @@ def _parameters_for( { "scope": "orchestration", "key": "handoff_consult_staff", - "condition": ( - "the sender is staff and the latest message answers a " - "customer question or corrects a saved answer" - ), "response": ( - "This rule overrides the active skill and any skill " - "procedure. Call handoff__pending_questions and read the " - "full list of pend_ ids and questions. Choose every pend_ " - "id the staff message answers (same issue, multiple " - "customers). Call handoff__save_answer with question_ids " - "and the full answer text. Do not call handoff__consult. " + "The questions awaiting an answer are listed below. " + "Choose every id whose question this message answers " + "(several questions may share one answer) and call " + "handoff__save_answer with question_ids and the full answer " + "text. Do not call handoff__consult. " "Call handoff__update_chunk only when an [EVENT] line says " "Handoff chunk and the id starts with n.DocumentNode. A " "corr- correlation id is not a question id or a chunk id. " - "If the pending list is empty, do not save. Reply in the " + "If the list is empty, do not save. Reply in the " "conversation. If a save tool says this user cannot save " - "answers, send nothing about permissions or saving." + "answers, send nothing about permissions or saving. " + f"PENDING QUESTIONS: {_pending_questions_block(pending_rows)}" ), } ] @@ -156,12 +167,10 @@ def _parameters_for( { "scope": "orchestration", "key": "handoff_transfer_staff", - "condition": "the sender is staff", "response": ( - "Do not call handoff__transfer to escalate a customer issue " - "from a staff message. Reply in the conversation when " - "appropriate. Transfer mode notifies staff only when the " - "customer (non-staff sender) triggers handoff__transfer." + "Do not call handoff__transfer to escalate a message that " + "needs no escalation; reply in the conversation when " + "appropriate." ), } ] @@ -174,7 +183,7 @@ def _parameters_for( "response": ( "This rule overrides the active skill and any skill " "procedure. Call handoff__transfer with a short summary of " - "the issue in message (what the customer needs handled — " + "the issue in message (what needs handling — " "not Customer wants… placeholders). Do not reply in text or " "ask for contact yourself — relay only the line the tool " "returns. The tool resolves contact " @@ -184,7 +193,7 @@ def _parameters_for( f"replies with their {contact_phrase}, call again with the " "same message and contact. Never pass placeholders. On later " "messages keep helping when you can; call handoff__transfer " - "again when another issue needs staff." + "again when another issue needs escalation." ), } ] @@ -198,7 +207,7 @@ def _parameters_for( "or answer worth keeping" ), "response": ( - "Observe mode only — not customer consult or transfer. Call " + "Observe mode only — not consult or transfer. Call " "handoff__observe with that fact." ), } @@ -210,7 +219,10 @@ def _parameters_for( "condition": _CUSTOMER_CANNOT_ANSWER_CONDITION, "response": ( "This rule overrides the active skill and any skill " - "procedure. If the latest message is a question you cannot " + "procedure. First route anything the other skills do not own " + "to the faq fallback; escalate here only while it still cannot " + "answer the question or complete the request. If the latest " + "message is a question you cannot " "answer, or a request you cannot complete, call " "handoff__consult now. Do not reply in text. Do not ask " "permission. Relay only the line the tool returns. Sentence 1 " @@ -241,7 +253,7 @@ def _question_field(question: Any, name: str) -> str: def _question_row(question: Any) -> Dict[str, Any]: - """Normalize a pending question (dict or object) for tool output.""" + """Normalize a pending question (dict or object) for the save flow.""" if isinstance(question, dict): return { "id": str(question.get("id") or ""), @@ -1203,7 +1215,8 @@ class HandoffAction(Action): default_factory=list, description=( "Customer questions waiting for a staff answer. Source of truth for " - "handoff__pending_questions / save_answer (reloaded from DB on read)." + "the staff-turn parameter and handoff__save_answer (reloaded from DB " + "on read)." ), ) @@ -1232,10 +1245,22 @@ async def contributed_parameters(self, visitor: Any) -> List[Dict[str, Any]]: return list(stored) user_id = str(getattr(visitor, "user_id", "") or "").strip() members = await self._aca_staff_members() + is_staff = bool(user_id) and user_id in members + pending_rows: Optional[List[Dict[str, Any]]] = None + if is_staff and self._normalized_mode() == "consult": + try: + pending_rows = await self._list_pending() + except Exception: + logger.warning( + "handoff contributed_parameters: pending list unavailable", + exc_info=True, + ) + pending_rows = [] return _parameters_for( self._normalized_mode(), - staff=bool(user_id) and user_id in members, + staff=is_staff, customer_contact_kind=self._normalized_customer_contact_kind(), + pending_rows=pending_rows, ) def _normalized_customer_contact_kind(self) -> str: @@ -1294,7 +1319,7 @@ async def consult( "True if the user refused to share the configured contact type.", ] = None, ) -> ToolResult: - """Call when you cannot answer a query or handle a request.""" + """Escalate one question or request you cannot answer or complete — including a request you have no tool to perform — to the team. Returns the single line to send.""" return await self._dispatch_handoff( "consult", message, @@ -1324,7 +1349,7 @@ async def transfer( "user gives it after the tool asks. No placeholders.", ] = None, ) -> ToolResult: - """Call when a customer's issue should move to staff.""" + """Escalate an issue the assistant cannot resolve to the team.""" return await self._dispatch_handoff("transfer", message, contact) @tool( @@ -1648,50 +1673,6 @@ async def _dispatch_handoff( await self._clear_active() return _relay(user_facing) - @tool(name="handoff__pending_questions") - async def list_pending_questions(self) -> ToolResult: - """List every unanswered customer pending question. Call when the latest message is a statement, answer, or business fact that may resolve a waiting question. Returns each pend_ id and question text.""" - try: - pending = await self._list_pending() - except Exception: - logger.warning( - "handoff pending_questions failed action_agent_id=%r", - getattr(self, "agent_id", None), - exc_info=True, - ) - return ToolResult( - content="handoff failed: could not list pending questions.", - is_error=True, - ) - if not pending: - logger.warning( - "handoff pending_questions empty action_agent_id=%r", - getattr(self, "agent_id", None), - ) - return ToolResult( - content=( - "No pending questions. Do not call handoff__consult and do " - "not save the message. Reply in the conversation." - ) - ) - lines = [ - "- {id} | {question}".format( - id=_question_field(q, "id") or "unknown", - question=_question_field(q, "question")[:300], - ) - for q in pending - ] - return ToolResult( - content=( - "Pending questions (choose question_ids for handoff__save_answer):\n" - + "\n".join(lines) - + "\n\nQuestion ids start with pend_. Pick every id this staff " - "answer resolves (same issue, multiple customers). Call " - "handoff__save_answer with question_ids and the full answer " - "text. Do not reply to the user yet." - ) - ) - @tool( name="handoff__save_answer", idempotency_class=IdempotencyClass.NON_RETRYABLE, @@ -1702,11 +1683,11 @@ async def save_answer( self, question_ids: Annotated[ List[str], - "One or more pend_ ids from handoff__pending_questions that this answer resolves.", + "One or more pend_ ids from the pending questions list that this answer resolves.", ], answer: Annotated[str, "Full answer to store."], ) -> ToolResult: - """Save the full answer for the chosen pending questions.""" + """Save the full answer for the chosen pend_ ids from the pending questions list, reply to them, and store it.""" from jvagent.tooling.tool_executor import get_tool_visitor visitor = get_tool_visitor() @@ -1794,7 +1775,7 @@ async def update_chunk( ], answer: Annotated[str, "Full answer to store."], ) -> ToolResult: - """Update the saved answer for one existing chunk.""" + """Update the stored answer for one existing handoff chunk, using the chunk id from a Handoff chunk event line.""" from jvagent.tooling.tool_executor import get_tool_visitor visitor = get_tool_visitor() diff --git a/jvagent/action/handoff_action/info.yaml b/jvagent/action/handoff_action/info.yaml index d81b6643..806d5155 100644 --- a/jvagent/action/handoff_action/info.yaml +++ b/jvagent/action/handoff_action/info.yaml @@ -7,8 +7,8 @@ package: title: Handoff Action description: >- One handoff mode at a time. consult asks staff and replies when they - answer (handoff__consult, handoff__pending_questions, - handoff__save_answer, handoff__update_chunk). transfer leaves the + answer (handoff__consult, handoff__save_answer, handoff__update_chunk; + the pending queue rides the staff-turn parameter). transfer leaves the conversation (handoff__transfer). observe stores WhatsApp group facts in PageIndex and adds group numbers to AccessControl, without replying (handoff__observe). Pin the active mode's tools on the orchestrator. diff --git a/jvagent/action/reply/reply_action.py b/jvagent/action/reply/reply_action.py index 1cd310f5..ee1de6cf 100644 --- a/jvagent/action/reply/reply_action.py +++ b/jvagent/action/reply/reply_action.py @@ -439,10 +439,10 @@ async def reply(self, text: str, visitor: Optional[Any] = None) -> bool: self.get_channel_format(channel) ) - # Queued directives/parameters (or explicit text + channel format) route - # through respond(). Channel format alone never forces compose on empty - # reply() — ReplyAction must not answer the utterance without content. - if directive_items or has_params or (has_format and text): + # Any queued shaping (directives, parameters, or channel format) routes + # through respond(), which enqueues the message as a directive so the + # whole queue composes together and the message is never overridden. + if directive_items or has_params or has_format: return bool(await self.respond(interaction, visitor=visitor, text=text)) if not text: return False @@ -466,7 +466,7 @@ async def gather(self, visitor: Optional[Any] = None) -> bool: self.get_channel_format(channel) ) if not directive_items: - if has_params: + if has_params or has_format: return bool(await self.respond(interaction, visitor=visitor)) return False first = directive_items[0] @@ -583,14 +583,17 @@ async def respond( if content else f"User message: {utterance}" ) - # Contributed response parameters shape HOW to phrase. When no explicit + # Parameters (and channel format) shape HOW to phrase. When no explicit # message or directive is queued, fall back to the user's utterance so - # intro-style parameters still reach compose. Channel format alone does - # not trigger that fallback — it is not permission to answer the utterance. + # intro-style *contributed* parameters still reach compose. if not content and not directive_contents: has_shaping = bool( self._collect_parameters(parameters, interaction).strip() ) + if not has_shaping: + channel_pre = getattr(visitor, "channel", "default") or "default" + if self.apply_channel_format and self.get_channel_format(channel_pre): + has_shaping = True if has_shaping and interaction is not None: content = (getattr(interaction, "utterance", "") or "").strip() if not content and not directive_contents and not has_shaping: diff --git a/jvagent/action/skill_spec/registry.py b/jvagent/action/skill_spec/registry.py index 051ed4c6..1776bdb5 100644 --- a/jvagent/action/skill_spec/registry.py +++ b/jvagent/action/skill_spec/registry.py @@ -50,7 +50,7 @@ def discover(self, skills_dirs: List[str]) -> Dict[str, TSpec]: if spec is None or not name: continue self._specs[name] = spec - logger.debug("Loaded %s spec: %s from %s", self._label, name, skill_dir) + logger.info("Loaded %s spec: %s from %s", self._label, name, skill_dir) return self._specs def get(self, name: str) -> Optional[TSpec]: diff --git a/tests/action/reply/test_reply_action.py b/tests/action/reply/test_reply_action.py index d974ec72..0b51d0cd 100644 --- a/tests/action/reply/test_reply_action.py +++ b/tests/action/reply/test_reply_action.py @@ -474,26 +474,6 @@ async def test_fast_path_strips_invitation_closer(): assert v.interaction.response == "Classes begin Monday at 9 AM." -async def test_gather_does_not_compose_on_channel_format_only(monkeypatch): - """WhatsApp (or other) channel format alone must not answer the utterance.""" - ra = ReplyAction() - ra.apply_channel_format = True - _patch_agent(monkeypatch) - model = MagicMock() - model.generate = AsyncMock(return_value="Should not run.") - - async def _ma(self, required=False): - return model - - monkeypatch.setattr(ReplyAction, "get_model_action", _ma) - monkeypatch.setattr(ReplyAction, "_compose_model_action", _ma) - v = _visitor_with() - v.channel = "whatsapp" - v.interaction.utterance = "Do you sell cars?" - assert await ra.gather(v) is False - model.generate.assert_not_called() - - async def test_gather_compose_on_parameters_only(monkeypatch): """Intro-style parameters with no directives must still compose via gather().""" ra = ReplyAction() diff --git a/tests/action/test_handoff_action.py b/tests/action/test_handoff_action.py index 6927c7d8..10137d92 100644 --- a/tests/action/test_handoff_action.py +++ b/tests/action/test_handoff_action.py @@ -145,7 +145,6 @@ async def test_tools_follow_mode(): consult_tools = await consult.get_tools() assert {t.name for t in consult_tools} == { "handoff__consult", - "handoff__pending_questions", "handoff__save_answer", "handoff__update_chunk", } @@ -791,7 +790,9 @@ async def _send(self, recipient, message): assert len(action.pending_questions) == 3 -async def test_pending_questions_lists_all_id_and_question_only(monkeypatch): +async def test_staff_turn_parameter_lists_id_and_question_only(monkeypatch): + from jvagent.action.parameters import render_parameters + _install_aca_staff(monkeypatch) _install_pending_store(monkeypatch) action = _bind_action(HandoffAction()) @@ -807,34 +808,27 @@ async def test_pending_questions_lists_all_id_and_question_only(monkeypatch): question="What are your hours?", user_contact="5922222222", ) - _seed_pending( - action, - id="pend_c", - question="Customer wants the store location/address.", - user_contact="5923333333", - ) - tools = {t.name: t for t in await action.get_tools()} - visitor = _Ctx(_STAFF_PHONE) - visitor.utterance = "we are located in Guyana on water street" - with bind_dispatch_context(visitor): - listed = await tools["handoff__pending_questions"].call() - assert not listed.is_error - assert "contact=" not in listed.content.lower() - assert "pend_a" in listed.content - assert "pend_b" in listed.content - assert "pend_c" in listed.content - assert "choose question_ids" in listed.content.lower() + params = await action.contributed_parameters(SimpleNamespace(user_id=_STAFF_PHONE)) + rendered = render_parameters(params) + assert "PENDING QUESTIONS:" in rendered + assert "pend_a" in rendered + assert "pend_b" in rendered + assert "5921111111" not in rendered + assert "user_contact" not in rendered + assert "created_at" not in rendered + assert "handoff__pending_questions" not in rendered -async def test_pending_questions_empty_queue(monkeypatch): +async def test_staff_turn_parameter_empty_queue(monkeypatch): + from jvagent.action.parameters import render_parameters + _install_aca_staff(monkeypatch) _install_pending_store(monkeypatch) action = _bind_action(HandoffAction()) - tools = {t.name: t for t in await action.get_tools()} - with bind_dispatch_context(_Ctx(_STAFF_PHONE)): - listed = await tools["handoff__pending_questions"].call() - assert not listed.is_error - assert "No pending questions" in listed.content + params = await action.contributed_parameters(SimpleNamespace(user_id=_STAFF_PHONE)) + rendered = render_parameters(params) + assert "PENDING QUESTIONS: []" in rendered + assert "do not save" in rendered.lower() async def test_save_answer_group_one_ingest_multi_reply(monkeypatch): @@ -995,7 +989,6 @@ async def _get_agent(self): aca = _aca_for_tools() tools = {t.name: t for t in await action.get_tools()} with bind_dispatch_context(_Ctx("999")): - listed_tool = await tools["handoff__pending_questions"].call() denied = await _run_gated_tool( tools["handoff__save_answer"], aca=aca, @@ -1003,8 +996,6 @@ async def _get_agent(self): question_ids=["q1"], answer="Yes, we deliver.", ) - assert not listed_tool.is_error - assert "Do you offer delivery?" in listed_tool.content assert not denied.is_error assert "cannot save answers" in denied.content assert "Do not tell the user" in denied.content @@ -1154,10 +1145,10 @@ async def _members(self): monkeypatch.setattr(HandoffAction, "_aca_staff_members", _members) staff = await action.contributed_parameters(SimpleNamespace(user_id=_STAFF_PHONE)) rendered = render_parameters(staff) - assert "pend_" in rendered + assert "PENDING QUESTIONS:" in rendered assert "corr-" in rendered assert "not a question id or a chunk id" in rendered - assert "handoff__pending_questions" in rendered + assert "handoff__pending_questions" not in rendered customer = await action.contributed_parameters(SimpleNamespace(user_id="999")) customer_text = render_parameters(customer) @@ -2332,7 +2323,6 @@ async def test_gated_tools_leave_the_visible_set_for_a_non_staff_sender(): aca = _aca_for_tools() names = [ "handoff__consult", - "handoff__pending_questions", "handoff__save_answer", "handoff__update_chunk", ] @@ -2350,7 +2340,6 @@ async def test_gated_tools_leave_the_visible_set_for_a_non_staff_sender(): assert "handoff__save_answer" not in customer_visible assert "handoff__update_chunk" not in customer_visible assert "handoff__consult" in customer_visible - assert "handoff__pending_questions" in customer_tools staff_tools = {name: object() for name in names} staff_visible = set(names) @@ -2372,7 +2361,6 @@ def _consult_matrix(): "deny": [{"group": "staff", "enabled": True}], "allow": [{"group": "all", "enabled": True}], }, - "handoff__pending_questions": staff_only, "handoff__save_answer": staff_only, "handoff__update_chunk": staff_only, } @@ -2390,13 +2378,11 @@ async def test_consult_tools_split_by_sender(): aca = _aca_for_tools(permissions=_consult_matrix()) names = [ "handoff__consult", - "handoff__pending_questions", "handoff__save_answer", "handoff__update_chunk", "pageindex__search", ] staff_only = { - "handoff__pending_questions", "handoff__save_answer", "handoff__update_chunk", } @@ -2426,7 +2412,6 @@ async def test_consult_tools_split_by_sender(): assert ( staff_visible & { - "handoff__pending_questions", "handoff__save_answer", "handoff__update_chunk", } From 2b0e923bc1076e010ebafa5df5918ff7964a26a9 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Mon, 12 Oct 2026 09:10:27 -0400 Subject: [PATCH 15/20] chore(version): bump jvagent version to 0.1.8rc20-dev3 This commit updates the version of the jvagent package to 0.1.8rc20-dev3. Additionally, it enhances the `handoff_action` by refining the customer contact resolution logic for WhatsApp group interactions, ensuring that the current participant is prioritized over saved contacts. The `MCPClientWrapper` has been updated to support an optional pre-configured HTTP client for improved request handling. New utility functions for managing tool permissions and access control have also been introduced, enhancing the overall functionality and security of the orchestrator actions. --- .../action/handoff_action/handoff_action.py | 406 ++++++++++++++---- jvagent/action/mcp/client.py | 16 +- jvagent/action/mcp/mcp_action.py | 35 +- jvagent/action/mcp/service_key_auth.py | 121 ++++++ jvagent/action/orchestrator/access.py | 52 +++ .../orchestrator_interact_action.py | 59 ++- jvagent/action/orchestrator/surface_policy.py | 17 +- jvagent/action/orchestrator/tools.py | 15 +- jvagent/version.py | 2 +- .../test_materialize_respects_exclusions.py | 112 +++++ .../orchestrator/test_skill_channel_gating.py | 71 +++ tests/action/test_handoff_action.py | 258 ++++++++++- 12 files changed, 1051 insertions(+), 113 deletions(-) create mode 100644 jvagent/action/mcp/service_key_auth.py diff --git a/jvagent/action/handoff_action/handoff_action.py b/jvagent/action/handoff_action/handoff_action.py index 7489ac93..bfd7a9a2 100644 --- a/jvagent/action/handoff_action/handoff_action.py +++ b/jvagent/action/handoff_action/handoff_action.py @@ -27,7 +27,7 @@ import uuid from datetime import datetime, timezone from pathlib import Path -from typing import Annotated, Any, ClassVar, Dict, List, Optional +from typing import Annotated, Any, Callable, ClassVar, Dict, List, Optional from jvspatial.core.annotations import attribute @@ -38,6 +38,8 @@ logger = logging.getLogger(__name__) +_PENDING_MUTATE_RETRIES = 8 + def _register_orchestrator_vocabulary() -> None: """Declare ``handoff__`` a trusted directive source. @@ -594,11 +596,30 @@ def _resolve_customer_contact( saved: Optional[str], visitor_channel: str, customer_contact_kind: str = "phone", + participant: Optional[str] = None, + group_turn: bool = False, ) -> str: - """Provided, then saved, then identity — all sanitized for the configured kind.""" + """Resolve a customer contact for the configured kind. + + Default order: provided → saved → identity. + + On a WhatsApp group turn the conversation-scoped saved contact may belong + to a prior participant, so order is provided → current participant → + saved only when the participant could not be resolved → identity. + """ kind = _normalized_customer_contact_kind(customer_contact_kind) + provided_s = _sanitize_contact(provided, customer_contact_kind=kind) + if provided_s: + return provided_s + if group_turn: + participant_s = _sanitize_contact(participant, customer_contact_kind=kind) + if participant_s: + return participant_s + saved_s = _sanitize_contact(saved, customer_contact_kind=kind) + if saved_s: + return saved_s + return _identity_contact(visitor_channel, customer_contact_kind=kind) for candidate in ( - _sanitize_contact(provided, customer_contact_kind=kind), _sanitize_contact(saved, customer_contact_kind=kind), _identity_contact(visitor_channel, customer_contact_kind=kind), ): @@ -1220,6 +1241,14 @@ class HandoffAction(Action): ), ) + pending_questions_version: int = attribute( + default=0, + description=( + "Monotonic version for optimistic pending_questions updates across " + "concurrent sessions." + ), + ) + # -- tools ----------------------------------------------------------------- def __getattribute__(self, name: str) -> Any: @@ -1496,19 +1525,58 @@ async def _dispatch_handoff( if provided_clean: await self._save_contact(provided_clean) effective_provided = provided_clean if provided_clean else contact + group_turn = False + participant = "" + if visitor_channel == "whatsapp": + from jvagent.action.whatsapp.utils.chat_ids import ( + is_group_whatsapp_turn, + participant_phone_from_payload, + whatsapp_payload_from_visitor_data, + ) + from jvagent.tooling.tool_executor import get_tool_visitor + + visitor = get_tool_visitor() + payload = whatsapp_payload_from_visitor_data( + getattr(visitor, "data", None) if visitor else None + ) + user_id = _dispatch_user_id() + group_turn = is_group_whatsapp_turn(payload, user_id) + if group_turn: + participant = participant_phone_from_payload(payload, user_id) + if not participant: + logger.warning( + "handoff group participant resolve attempt snapshot=%s", + _handoff_whatsapp_context_snapshot( + mode=mode, + visitor_channel=visitor_channel, + provided_clean=provided_clean, + contact_after_sync="", + saved_contact=saved, + saved_whatsapp_author=saved_author, + ), + ) + participant = await self._resolve_whatsapp_participant_contact() + logger.warning( + "handoff group participant resolve result contact=%s", + _mask(participant or ""), + ) contact = _resolve_customer_contact( provided=effective_provided, saved=saved, visitor_channel=visitor_channel, customer_contact_kind=kind, + participant=participant or None, + group_turn=group_turn, ) logger.warning( "handoff contact after sync resolve contact=%s provided=%s saved=%s " - "channel=%s snapshot=%s", + "channel=%s group_turn=%s participant=%s snapshot=%s", _mask(contact or ""), _mask(str(effective_provided or "")), _mask(saved), visitor_channel, + group_turn, + _mask(participant or ""), _handoff_whatsapp_context_snapshot( mode=mode, visitor_channel=visitor_channel, @@ -1519,10 +1587,19 @@ async def _dispatch_handoff( ), ) if ( + contact + and mode in ("consult", "transfer") + and group_turn + and contact == (participant or contact) + ): + await self._save_contact(contact) + await self._update_context({_HANDOFF_WHATSAPP_AUTHOR_KEY: contact}) + elif ( contact and mode in ("consult", "transfer") and not provided_clean and not saved + and not group_turn ): from jvagent.action.whatsapp.utils.chat_ids import ( participant_phone_from_payload, @@ -1537,27 +1614,6 @@ async def _dispatch_handoff( if participant_phone_from_payload(payload, _dispatch_user_id()) == contact: await self._save_contact(contact) await self._update_context({_HANDOFF_WHATSAPP_AUTHOR_KEY: contact}) - if not contact and visitor_channel == "whatsapp": - logger.warning( - "handoff group participant resolve attempt snapshot=%s", - _handoff_whatsapp_context_snapshot( - mode=mode, - visitor_channel=visitor_channel, - provided_clean=provided_clean, - contact_after_sync="", - saved_contact=saved, - saved_whatsapp_author=saved_author, - ), - ) - participant = await self._resolve_whatsapp_participant_contact() - logger.warning( - "handoff group participant resolve result contact=%s", - _mask(participant or ""), - ) - if participant: - contact = participant - await self._save_contact(contact) - await self._update_context({_HANDOFF_WHATSAPP_AUTHOR_KEY: contact}) if not contact and not (mode == "consult" and contact_declined): tool_name = f"handoff__{mode}" if mode == "consult": @@ -1721,33 +1777,70 @@ async def save_answer( ) group.append(row) anchor = group[0] - try: - short_q, short_a, chunk_id = await self._append_and_ingest( - _question_field(anchor, "question"), cleaned - ) - except Exception as exc: - logger.error("handoff__save_answer ingest failed: %s", exc, exc_info=True) - return ToolResult( - content=( - "handoff failed: could not save the answer to the knowledge " - "base. Report the issue to the team." - ), - is_error=True, - ) - for row in group: - rid = _question_field(row, "id") - if rid: - await self._remove_pending(rid, answer=cleaned, node=row) + already_ingested = all( + str(_question_field(row, "answer") or "").strip() for row in group + ) + short_q = str(_question_field(anchor, "question") or "").strip() + short_a = cleaned + chunk_id = str(_question_field(anchor, "chunk_id") or "").strip() + if not already_ingested: + try: + short_q, short_a, chunk_id = await self._append_and_ingest( + _question_field(anchor, "question"), cleaned + ) + except Exception as exc: + logger.error( + "handoff__save_answer ingest failed: %s", exc, exc_info=True + ) + return ToolResult( + content=( + "handoff failed: could not save the answer to the knowledge " + "base. Report the issue to the team." + ), + is_error=True, + ) + for row in group: + rid = _question_field(row, "id") + if rid: + await self._mark_pending_answered( + rid, answer=cleaned, chunk_id=chunk_id, node=row + ) replied_contacts: set[str] = set() + delivery_failures: List[str] = [] for row in group: contact = str(_question_field(row, "user_contact") or "").strip() + rid = _question_field(row, "id") if not contact or contact.lower() == "declined": + if rid: + await self._remove_pending(rid, answer=cleaned, node=row) continue if contact in replied_contacts: + if rid: + await self._remove_pending(rid, answer=cleaned, node=row) continue replied_contacts.add(contact) - await self._reply_to_customer(row, short_q, short_a) - await self._record_chunk_event(chunk_id, short_q) + delivered = await self._reply_to_customer(row, short_q, short_a) + if delivered: + if rid: + await self._remove_pending(rid, answer=cleaned, node=row) + else: + delivery_failures.append(rid or contact) + if rid: + await self._mark_pending_delivery_failed( + rid, answer=cleaned, chunk_id=chunk_id, node=row + ) + if delivery_failures: + ids = ", ".join(delivery_failures) + return ToolResult( + content=( + "handoff partial failure: the answer was saved to the " + "knowledge base, but delivery to the customer failed for " + f"{ids}. Keep those pend_ ids and retry delivery." + ), + is_error=True, + ) + if chunk_id: + await self._record_chunk_event(chunk_id, short_q) await self._record_event(_handoff_event("save_answer", short_q)) await self._clear_active() return ToolResult( @@ -1863,14 +1956,15 @@ async def _customer_reply(self, question: str, answer: str) -> str: async def _reply_to_customer( self, question: Any, short_q: str, short_a: str - ) -> None: + ) -> bool: + """Send the saved answer to the customer. True only on successful send.""" contact = str(_question_field(question, "user_contact") or "").strip() if not contact or contact.lower() == "declined": - return + return True text = await self._customer_reply(short_q, short_a) kind = _contact_kind(contact) if not kind: - return + return False try: if kind == "email": await self._send_email( @@ -1885,15 +1979,16 @@ async def _reply_to_customer( contact, kind, ) - return + return False except Exception: logger.error( "handoff reply to customer failed contact=%r", contact, exc_info=True, ) - return + return False await self._record_event("Handoff delivered the saved answer to the customer.") + return True def _channel_for(self, mode: str) -> str: channel = ( @@ -2334,6 +2429,12 @@ async def _invalidate_pending_caches(self) -> None: exc_info=True, ) + def _pending_lock(self): + from jvagent.core.async_locks import get_loop_lock + + aid = str(getattr(self, "id", "") or "").strip() or "default" + return get_loop_lock(f"handoff_pending:{aid}") + async def _refresh_pending_state(self) -> None: """Reload pending_questions from durable Action storage onto this instance.""" aid = str(getattr(self, "id", "") or "").strip() @@ -2351,19 +2452,33 @@ async def _refresh_pending_state(self) -> None: return rows = getattr(fresh, "pending_questions", None) self.pending_questions = list(rows) if isinstance(rows, list) else [] + try: + self.pending_questions_version = int( + getattr(fresh, "pending_questions_version", 0) or 0 + ) + except Exception: + self.pending_questions_version = 0 + + def _open_pending_rows(self) -> List[Dict[str, Any]]: + """Rows still needing staff attention or customer delivery.""" + out: List[Dict[str, Any]] = [] + for row in self.pending_questions or []: + if not isinstance(row, dict): + continue + answer = str(row.get("answer") or "").strip() + status = str(row.get("delivery_status") or "").strip() + if not answer or status in ("pending", "failed"): + out.append(row) + return out def _unanswered_rows(self) -> List[Dict[str, Any]]: - return [ - row - for row in (self.pending_questions or []) - if isinstance(row, dict) and not str(row.get("answer") or "").strip() - ] + return self._open_pending_rows() async def _list_pending(self) -> List[Dict[str, Any]]: """Pending questions as dict rows for tool output.""" await self._refresh_pending_state() loaded_id = await self._agent_id() - rows = [_question_row(row) for row in self._unanswered_rows()] + rows = [_question_row(row) for row in self._open_pending_rows()] logger.warning( "handoff list_pending action_agent_id=%r loaded_agent_id=%r count=%s", getattr(self, "agent_id", None), @@ -2377,7 +2492,7 @@ async def _get_pending_question(self, question_id: str) -> Any: if not qid: return None await self._refresh_pending_state() - for row in self._unanswered_rows(): + for row in self._open_pending_rows(): if str(row.get("id") or "") == qid: return row return None @@ -2387,6 +2502,41 @@ async def _persist_pending_rows(self, rows: List[Dict[str, Any]]) -> None: await self.save() await self._invalidate_pending_caches() + async def _mutate_pending_rows( + self, + mutator: Callable[[List[Dict[str, Any]]], Any], + ) -> Any: + """Atomically refresh/mutate/persist pending_questions with versioning.""" + async with self._pending_lock(): + last_exc: Optional[BaseException] = None + for _ in range(_PENDING_MUTATE_RETRIES): + await self._refresh_pending_state() + expected = int(getattr(self, "pending_questions_version", 0) or 0) + rows = [ + dict(row) + for row in (self.pending_questions or []) + if isinstance(row, dict) + ] + result = mutator(rows) + if hasattr(result, "__await__"): + result = await result + await self._refresh_pending_state() + current = int(getattr(self, "pending_questions_version", 0) or 0) + if current != expected: + last_exc = RuntimeError( + "handoff pending_questions version conflict " + f"(expected {expected}, saw {current})" + ) + continue + self.pending_questions_version = expected + 1 + await self._persist_pending_rows(rows) + return result + if last_exc is not None: + raise last_exc + raise RuntimeError( + "handoff pending_questions mutation failed after retries" + ) + async def _add_pending(self, message: str, contact: str = "") -> Dict[str, Any]: """Append a pending question on this Action and verify it is listable.""" from jvagent.tooling.tool_executor import get_dispatch_context @@ -2401,22 +2551,24 @@ async def _add_pending(self, message: str, contact: str = "") -> Dict[str, Any]: agent_id, stored_contact, ) - try: - await self._refresh_pending_state() - entry = { - "id": f"pend_{uuid.uuid4().hex}", - "question": message or "", - "user_channel": channel, - "user_contact": stored_contact, - "created_at": datetime.now(timezone.utc).isoformat(), - } - rows = self._unanswered_rows() + entry = { + "id": f"pend_{uuid.uuid4().hex}", + "question": message or "", + "user_channel": channel, + "user_contact": stored_contact, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + def _append(rows: List[Dict[str, Any]]) -> Dict[str, Any]: rows.append(entry) - await self._persist_pending_rows(rows) + return entry + + try: + await self._mutate_pending_rows(_append) await self._refresh_pending_state() if not any( str(row.get("id") or "") == entry["id"] - for row in self._unanswered_rows() + for row in self._open_pending_rows() ): raise RuntimeError( f"handoff question {entry['id']} was created but is not listable" @@ -2448,19 +2600,70 @@ async def _update_pending( if not qid: logger.warning("handoff update_pending missing question_id") return - await self._refresh_pending_state() - rows = self._unanswered_rows() - changed = False stored = (user_contact or "").strip() - for row in rows: - if str(row.get("id") or "") == qid: - row["user_contact"] = stored - changed = True - break + + def _update(rows: List[Dict[str, Any]]) -> bool: + for row in rows: + if str(row.get("id") or "") == qid: + row["user_contact"] = stored + return True + return False + + changed = await self._mutate_pending_rows(_update) if not changed: logger.warning("handoff update_pending missing question_id=%r", qid) + + async def _patch_pending_row( + self, + question_id: str, + *, + updates: Dict[str, Any], + node: Any = None, + ) -> None: + qid = (question_id or "").strip() or _question_field(node, "id").strip() + if not qid: return - await self._persist_pending_rows(rows) + + def _patch(rows: List[Dict[str, Any]]) -> bool: + for row in rows: + if str(row.get("id") or "") == qid: + row.update(updates) + return True + return False + + await self._mutate_pending_rows(_patch) + + async def _mark_pending_answered( + self, + question_id: str, + *, + answer: str, + chunk_id: str = "", + node: Any = None, + ) -> None: + updates: Dict[str, Any] = { + "answer": answer or "", + "delivery_status": "pending", + } + if chunk_id: + updates["chunk_id"] = chunk_id + await self._patch_pending_row(question_id, updates=updates, node=node) + + async def _mark_pending_delivery_failed( + self, + question_id: str, + *, + answer: str, + chunk_id: str = "", + node: Any = None, + ) -> None: + updates: Dict[str, Any] = { + "answer": answer or "", + "delivery_status": "failed", + } + if chunk_id: + updates["chunk_id"] = chunk_id + await self._patch_pending_row(question_id, updates=updates, node=node) async def _remove_pending( self, question_id: str, *, answer: str = "", node: Any = None @@ -2470,16 +2673,15 @@ async def _remove_pending( if not qid: logger.warning("handoff remove_pending missing question_id") return - await self._refresh_pending_state() - kept = [ - row - for row in (self.pending_questions or []) - if not (isinstance(row, dict) and str(row.get("id") or "") == qid) - ] - if len(kept) == len(self.pending_questions or []): + + def _remove(rows: List[Dict[str, Any]]) -> bool: + before = len(rows) + rows[:] = [row for row in rows if str(row.get("id") or "") != qid] + return len(rows) != before + + changed = await self._mutate_pending_rows(_remove) + if not changed: logger.warning("handoff remove_pending missing question_id=%r", qid) - return - await self._persist_pending_rows(kept) async def _conversation(self) -> Any: """Current conversation from the tool visitor, when one is bound.""" @@ -2702,25 +2904,39 @@ async def _append_and_ingest(self, question: str, answer: str) -> tuple: import_documents, ) - try: - exported = await export_documents( - collection_name=collection, doc_name=HANDOFF_DOC_NAME - ) - except Exception: - logger.debug("handoff: export of handoff.md skipped", exc_info=True) - exported = {} - graph = _append_handoff_chunk(exported or {}, short_q, short_a, collection) + # Absent documents return an empty graph; other export failures must + # abort before any destructive delete/import. + exported = await export_documents( + collection_name=collection, doc_name=HANDOFF_DOC_NAME + ) + prior_graph = exported or {} + graph = _append_handoff_chunk(prior_graph, short_q, short_a, collection) nodes = graph.get("nodes") or [] chunk_id = str(nodes[-1].get("id") or "") if nodes else "" _handoff_json_path().write_text( json.dumps(graph, indent=2) + "\n", encoding="utf-8" ) + deleted = False try: await delete_document(HANDOFF_DOC_NAME, collection_name=collection) + deleted = True except Exception: # document may not exist yet — import replaces it logger.debug("handoff: prior handoff.md delete skipped", exc_info=True) - await import_documents(graph, purge=False, collection_name=collection) + try: + await import_documents(graph, purge=False, collection_name=collection) + except Exception: + if deleted and prior_graph.get("roots"): + try: + await import_documents( + prior_graph, purge=False, collection_name=collection + ) + except Exception: + logger.error( + "handoff: failed to restore handoff.md after import error", + exc_info=True, + ) + raise return short_q, short_a, chunk_id async def healthcheck(self) -> Any: diff --git a/jvagent/action/mcp/client.py b/jvagent/action/mcp/client.py index 743cbe80..aee323d3 100644 --- a/jvagent/action/mcp/client.py +++ b/jvagent/action/mcp/client.py @@ -42,6 +42,7 @@ def __init__( url: str = "", connect_timeout: float = 10.0, call_timeout: float = 30.0, + http_client: Optional[Any] = None, ) -> None: """Initialize wrapper config. @@ -53,6 +54,9 @@ def __init__( url: For streamable_http, endpoint URL. connect_timeout: Timeout in seconds for connect + initialize + list_tools. call_timeout: Timeout in seconds for call_tool. + http_client: Optional pre-configured httpx.AsyncClient for the + streamable_http transport (e.g. carrying auth headers). Owned by + this wrapper and closed on disconnect. """ self._transport = transport self._command = command @@ -61,6 +65,7 @@ def __init__( self._url = url self._connect_timeout = connect_timeout self._call_timeout = call_timeout + self._http_client = http_client self._stack: Optional[AsyncExitStack] = None self._session: Any = None self._read_stream: Any = None @@ -151,7 +156,11 @@ async def _connect_streamable_http(self) -> None: stack = AsyncExitStack() try: - transport_ctx = streamable_http_client(self._url) + transport_ctx = ( + streamable_http_client(self._url, http_client=self._http_client) + if self._http_client is not None + else streamable_http_client(self._url) + ) read_stream, write_stream, _session_id = await stack.enter_async_context( transport_ctx ) @@ -182,6 +191,11 @@ async def disconnect(self) -> None: elif self._stack is not None: await self._stack.aclose() self._stack = None + if self._http_client is not None: + try: + await self._http_client.aclose() + except Exception: + pass self._session = None self._read_stream = None self._write_stream = None diff --git a/jvagent/action/mcp/mcp_action.py b/jvagent/action/mcp/mcp_action.py index fbec5ec4..f9b4fdc5 100644 --- a/jvagent/action/mcp/mcp_action.py +++ b/jvagent/action/mcp/mcp_action.py @@ -266,7 +266,10 @@ class MCPAction(Action): "sandbox_mode, sandbox_user_scoped, sandbox_root, " "show_tools (default true; false keeps the server hosted but omits " "its tools from get_tools / the orchestrator), " - "type (optional: jvspatial_fs | npx_filesystem for stdio filesystem servers)." + "type (optional: jvspatial_fs | npx_filesystem for stdio filesystem servers), " + "auth (optional: {type: service_key, key_env, user_id/user_id_env, " + "workspace_id/workspace_id_env} — signs every streamable_http request " + "with Core's service-key envelope)." ), ) model_action_type: str = attribute( @@ -424,6 +427,35 @@ async def _build_server_entries(self) -> None: ) args = a else: + auth_cfg = ( + raw.get("auth") if isinstance(raw.get("auth"), dict) else None + ) + http_client = None + if ( + auth_cfg + and str(auth_cfg.get("type") or "").strip().lower() + == "service_key" + ): + from jvagent.action.mcp.service_key_auth import ( + build_service_key_client, + ) + + http_client = build_service_key_client( + service_key=str(auth_cfg.get("service_key") or ""), + key_env=str( + auth_cfg.get("key_env") or "INTEGRAL_SERVICE_KEY" + ), + user_id=str(auth_cfg.get("user_id") or ""), + user_id_env=str( + auth_cfg.get("user_id_env") or "INTEGRAL_SERVICE_USER_ID" + ), + workspace_id=str(auth_cfg.get("workspace_id") or ""), + workspace_id_env=str( + auth_cfg.get("workspace_id_env") or "INTEGRAL_WORKSPACE_ID" + ), + connect_timeout=connect_timeout, + read_timeout=call_timeout, + ) client = MCPClientWrapper( transport, command=command, @@ -432,6 +464,7 @@ async def _build_server_entries(self) -> None: url=url, connect_timeout=connect_timeout, call_timeout=call_timeout, + http_client=http_client, ) ulock = asyncio.Lock() diff --git a/jvagent/action/mcp/service_key_auth.py b/jvagent/action/mcp/service_key_auth.py new file mode 100644 index 00000000..92303fe3 --- /dev/null +++ b/jvagent/action/mcp/service_key_auth.py @@ -0,0 +1,121 @@ +"""httpx auth flow that signs MCP requests with Core's service-key envelope. + +Integral Core's ``ServiceAuthMiddleware`` authenticates ``/api/mcp`` (and +``/api/agentive/*``) requests carrying:: + + X-Integral-Service-Key shared secret (== Core INTEGRAL_SERVICE_KEY) + X-Integral-User-Id the principal the key acts as + X-Integral-Timestamp unix seconds (within 300s of Core) + X-Integral-Signature HMAC-SHA256(key, "{timestamp}.{user_id}") + X-Integral-Scope optional "ws:" scope hint + +The timestamp + signature are per-request, so a static header dict cannot be +used. ``ServiceKeyAuth`` recomputes them on every request via httpx's auth flow, +and is installed on an ``httpx.AsyncClient`` that is passed to the MCP SDK's +``streamable_http_client``. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import os +import time +from typing import AsyncGenerator + +import httpx + +logger = logging.getLogger(__name__) + +DEFAULT_CONNECT_TIMEOUT = 10.0 +DEFAULT_READ_TIMEOUT = 60.0 + + +def compute_signature(service_key: str, timestamp: str, user_id: str) -> str: + """Canonical HMAC-SHA256 over ``{timestamp}.{user_id}`` (mirrors Core).""" + return hmac.new( + service_key.encode("utf-8"), + f"{timestamp}.{user_id}".encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +class ServiceKeyAuth(httpx.Auth): + """Signs every request with Core's service-key envelope.""" + + def __init__( + self, + service_key: str, + user_id: str, + workspace_id: str = "", + ) -> None: + if not service_key: + raise ValueError("service_key is required") + if not user_id: + raise ValueError("user_id is required") + self._service_key = service_key + self._user_id = user_id + self._workspace_id = (workspace_id or "").strip() + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + timestamp = str(int(time.time())) + request.headers["X-Integral-Service-Key"] = self._service_key + request.headers["X-Integral-User-Id"] = self._user_id + request.headers["X-Integral-Timestamp"] = timestamp + request.headers["X-Integral-Signature"] = compute_signature( + self._service_key, timestamp, self._user_id + ) + if self._workspace_id: + request.headers["X-Integral-Scope"] = f"ws:{self._workspace_id}" + yield request + + +def _resolve(value: str, env_name: str) -> str: + """Return the literal *value*, else the env var named by *env_name*.""" + if value: + return value.strip() + if env_name: + return (os.getenv(env_name) or "").strip() + return "" + + +def build_service_key_client( + *, + service_key: str = "", + key_env: str = "INTEGRAL_SERVICE_KEY", + user_id: str = "", + user_id_env: str = "INTEGRAL_SERVICE_USER_ID", + workspace_id: str = "", + workspace_id_env: str = "INTEGRAL_WORKSPACE_ID", + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + read_timeout: float = DEFAULT_READ_TIMEOUT, +) -> httpx.AsyncClient: + """Build an ``httpx.AsyncClient`` that signs every request for Core. + + Values may be given literally or via an env-var name (``*_env``). Raises + ``ValueError`` when the service key or user id cannot be resolved, so a + misconfigured server entry fails loudly at build time. + """ + resolved_key = _resolve(service_key, key_env) + resolved_user = _resolve(user_id, user_id_env) + resolved_ws = _resolve(workspace_id, workspace_id_env) + if not resolved_key: + raise ValueError( + "MCP service-key auth is not configured: " + f"set '{key_env}' (or a literal service_key)" + ) + if not resolved_user: + raise ValueError( + "MCP service-key auth is missing a user id: " + f"set '{user_id_env}' (or a literal user_id)" + ) + timeout = httpx.Timeout( + connect=connect_timeout, read=read_timeout, write=read_timeout, pool=None + ) + return httpx.AsyncClient( + auth=ServiceKeyAuth(resolved_key, resolved_user, resolved_ws), + timeout=timeout, + ) diff --git a/jvagent/action/orchestrator/access.py b/jvagent/action/orchestrator/access.py index 098f9984..ee94796f 100644 --- a/jvagent/action/orchestrator/access.py +++ b/jvagent/action/orchestrator/access.py @@ -70,6 +70,56 @@ async def drop_unpermitted_tools( longtail.discard(name) +async def named_tool_rule_applies(agent: Any, *, tool_name: str, channel: str) -> bool: + """True when ``permissions[channel].tools`` has an entry for *tool_name*.""" + ac = await _resolve_access_control(agent) + if ac is None: + return False + names = getattr(ac, "tool_permission_names", None) + if not callable(names): + return False + try: + return str(tool_name or "") in set(names(channel) or ()) + except Exception as exc: + logger.debug( + "orchestrator.access: tool_permission_names failed for %s: %s", + tool_name, + exc, + ) + return False + + +async def denied_named_tool_names( + agent: Any, *, user_id: Optional[str], channel: str +) -> set: + """Configured ``permissions[channel].tools`` names this sender cannot call.""" + ac = await _resolve_access_control(agent) + if ac is None: + return set() + names_fn = getattr(ac, "tool_permission_names", None) + check = getattr(ac, "has_tool_access", None) + if not callable(names_fn) or not callable(check): + return set() + try: + names = set(names_fn(channel) or ()) + except Exception: + return set() + denied: set = set() + for name in names: + try: + allowed = bool(await check(user_id or "", name, channel)) + except Exception as exc: + logger.warning( + "orchestrator.access: has_tool_access raised for %s — " "excluding: %s", + name, + exc, + ) + allowed = False + if not allowed: + denied.add(str(name)) + return denied + + async def is_named_tool_allowed( agent: Any, *, tool_name: str, user_id: Optional[str], channel: str ) -> bool: @@ -124,7 +174,9 @@ async def is_tool_allowed( __all__ = [ "delegate_resource_label", + "denied_named_tool_names", "drop_unpermitted_tools", "is_named_tool_allowed", "is_tool_allowed", + "named_tool_rule_applies", ] diff --git a/jvagent/action/orchestrator/orchestrator_interact_action.py b/jvagent/action/orchestrator/orchestrator_interact_action.py index 1d634070..5d06d676 100644 --- a/jvagent/action/orchestrator/orchestrator_interact_action.py +++ b/jvagent/action/orchestrator/orchestrator_interact_action.py @@ -1251,7 +1251,7 @@ async def _assemble_tools( # tool_servers MCP gate) and the wrapping each family needs, so a later # re-add (ensure_skill_tools_materialized) cannot re-derive a surface # this assembly deliberately excluded. - policy = self._tool_surface_policy(visitor, actions, agent=agent) + policy = await self._tool_surface_policy(visitor, actions, agent=agent) action_ids = sorted( getattr(a, "id", "") for a in actions if getattr(a, "id", None) ) @@ -1618,10 +1618,10 @@ async def _egress_exec( # referenced tools aren't surfaced on its behalf. hidden_docs = list(blocked_docs) + access_hidden if hidden_docs: - # Prefix drop is only for a channel-blocked skill's own - # ``__*`` custom tools. Shared tool namespaces (e.g. + # Prefix drop covers channel-blocked and identity-hidden skills' + # own ``__*`` custom tools. Shared tool namespaces (e.g. # ``handoff__*``) drop only the tools each hidden skill declares. - blocked_names = {getattr(d, "name", "") for d in blocked_docs} + blocked_names = {getattr(d, "name", "") for d in hidden_docs} blocked_prefixes = tuple(f"{n}__" for n in blocked_names if n) drop_names: Set[str] = set() for d in hidden_docs: @@ -1787,7 +1787,7 @@ async def _egress_exec( logger.debug("harness snapshot descriptor update skipped: %s", exc) return tools - def _tool_surface_policy( + async def _tool_surface_policy( self, visitor: Any, actions: List[Any], @@ -1798,9 +1798,11 @@ def _tool_surface_policy( Built from the channel-resolved config so ``_assemble_tools`` and any later re-add (``ensure_skill_tools_materialized``) apply exactly the - same rules. Pure — safe to rebuild at each seam rather than threading + same rules. Safe to rebuild at each seam rather than threading per-turn state through the loop. """ + from jvagent.action.orchestrator.access import denied_named_tool_names + selector = self.tool_servers allowed_mcp: Optional[FrozenSet[str]] if isinstance(selector, str) and selector.strip() == "-all": @@ -1811,13 +1813,50 @@ def _tool_surface_policy( names |= mcp_action_names(mcp_action) allowed_mcp = frozenset(names) denied = self._channel_cfg(visitor, "denied_tools", self.denied_tools) + user_id = getattr(visitor, "user_id", None) + channel = getattr(visitor, "channel", "default") or "default" + excluded_names: Set[str] = set() + excluded_prefixes: List[str] = [] + try: + skill_docs = self._discover_skills(agent) + skill_docs = await self._enforce_required_actions(skill_docs) + access_control = await self._resolve_action("AccessControlAction") + access_hidden = [ + d + for d in skill_docs + if not self._skill_access_control_allowed( + d, user_id or "", access_control + ) + ] + visible_docs = [d for d in skill_docs if d not in access_hidden] + channel_hidden = [ + d for d in visible_docs if not self._skill_channel_allowed(d, channel) + ] + hidden_docs = list(channel_hidden) + access_hidden + for d in hidden_docs: + skill_name = getattr(d, "name", "") or "" + if skill_name: + excluded_prefixes.append(f"{skill_name}__") + excluded_names |= { + t for t in getattr(d, "requires_tools", ()) or () if t + } + except Exception as exc: + logger.debug("orchestrator: skill exclusion policy skipped: %s", exc) + try: + excluded_names |= await denied_named_tool_names( + agent, user_id=user_id, channel=channel + ) + except Exception as exc: + logger.debug("orchestrator: named-tool exclusion policy skipped: %s", exc) return ToolSurfacePolicy( denied_patterns=tuple(str(p) for p in (denied or [])), allowed_mcp_names=allowed_mcp, mcp_action_class=self._mcp_action_class(), agent=agent, - user_id=getattr(visitor, "user_id", None), - channel=getattr(visitor, "channel", "default") or "default", + user_id=user_id, + channel=channel, + excluded_tool_names=frozenset(excluded_names), + excluded_skill_prefixes=tuple(excluded_prefixes), ) @staticmethod @@ -2050,7 +2089,7 @@ async def _apply_active_task_lock_skill( activated=activated, observations=observations, skill_docs=skill_docs, - policy=self._tool_surface_policy(visitor, loop_actions, agent=agent), + policy=await self._tool_surface_policy(visitor, loop_actions, agent=agent), ) async def _apply_task_lock_after_use_skill( @@ -2263,7 +2302,7 @@ async def _apply_unlocked_skill_surface( visitor, tools, visible, - policy=self._tool_surface_policy(visitor, loop_actions, agent=agent), + policy=await self._tool_surface_policy(visitor, loop_actions, agent=agent), ) if doc.name not in activated: activated.append(doc.name) diff --git a/jvagent/action/orchestrator/surface_policy.py b/jvagent/action/orchestrator/surface_policy.py index ab966f53..e799d4bf 100644 --- a/jvagent/action/orchestrator/surface_policy.py +++ b/jvagent/action/orchestrator/surface_policy.py @@ -14,6 +14,10 @@ - AccessControl labels — MCP tools dispatch through ``tool:delegate:{name}`` (ADR-0012 inv. 6); re-wrapping one without its label silently drops per-user gating. +- Hidden skills — channel-blocked or identity-gated skills drop their + ``__*`` custom tools and declared ``requires_tools`` names. +- Named tool permissions — ``permissions[channel].tools`` entries the sender + cannot call stay unreachable even if a skill later materializes them. :class:`ToolSurfacePolicy` is the single object carrying those rules, built once per turn from the orchestrator's (channel-resolved) config so assembly and @@ -56,6 +60,9 @@ class ToolSurfacePolicy: frozenset therefore means "no MCP server is selected", not "unrestricted". ``agent`` / ``user_id`` / ``channel`` supply the AccessControl context used when wrapping MCP tools. + ``excluded_tool_names`` are exact names removed by skill gating or named + tool permissions; ``excluded_skill_prefixes`` drops ``__*`` custom + tools for hidden skills. """ denied_patterns: Tuple[str, ...] = () @@ -64,6 +71,8 @@ class ToolSurfacePolicy: agent: Any = None user_id: Optional[str] = None channel: str = "default" + excluded_tool_names: FrozenSet[str] = frozenset() + excluded_skill_prefixes: Tuple[str, ...] = () # -- exclusions --------------------------------------------------------- @@ -85,9 +94,15 @@ def denied_names(self, names: Iterable[str]) -> Set[str]: return out def is_denied(self, name: str) -> bool: - """True if *name* is hard-excluded by ``denied_tools``.""" + """True if *name* is hard-excluded from this turn's surface.""" if name in STEER_EXEMPT: return False + if name in self.excluded_tool_names: + return True + if self.excluded_skill_prefixes and name.startswith( + self.excluded_skill_prefixes + ): + return True return bool(self.denied_names([name])) def is_mcp_action(self, action: Any) -> bool: diff --git a/jvagent/action/orchestrator/tools.py b/jvagent/action/orchestrator/tools.py index a453a73f..21ef45c8 100644 --- a/jvagent/action/orchestrator/tools.py +++ b/jvagent/action/orchestrator/tools.py @@ -96,9 +96,20 @@ async def _run(args: Dict[str, Any], _tool: Any = tool) -> str: call_args = dict(args or {}) reject_model_authority_fields(call_args) - if getattr(tool, "requires_tool_permission", False): - from jvagent.action.orchestrator.access import is_named_tool_allowed + from jvagent.action.orchestrator.access import ( + is_named_tool_allowed, + named_tool_rule_applies, + ) + # Decorated tools always check; any tool listed in + # ``permissions[channel].tools`` also checks so assembly-time drops + # cannot be bypassed after materialization re-adds the tool. + check_named = bool(getattr(tool, "requires_tool_permission", False)) + if not check_named: + check_named = await named_tool_rule_applies( + agent, tool_name=name, channel=channel + ) + if check_named: if not await is_named_tool_allowed( agent, tool_name=name, diff --git a/jvagent/version.py b/jvagent/version.py index 086f2037..1e0c5231 100644 --- a/jvagent/version.py +++ b/jvagent/version.py @@ -1,3 +1,3 @@ """Version information for jvagent package.""" -__version__ = "0.1.8rc19" +__version__ = "0.1.8rc20-dev3" diff --git a/tests/action/orchestrator/test_materialize_respects_exclusions.py b/tests/action/orchestrator/test_materialize_respects_exclusions.py index e2d8c14a..f2b62a06 100644 --- a/tests/action/orchestrator/test_materialize_respects_exclusions.py +++ b/tests/action/orchestrator/test_materialize_respects_exclusions.py @@ -260,3 +260,115 @@ async def test_pruned_action_tool_is_still_materialized( assert "weather__current" in out_tools assert "weather__current" in out_visible assert await out_tools["weather__current"].run({}) == "ran weather__current" + + +# --- 4. named tool permissions survive late materialization ----------------- + + +async def test_named_tool_permission_denial_survives_materialization( + make_orchestrator, make_visitor +): + """permissions[channel].tools drop must not be undone by skill unlock.""" + action = _PlainToolsAction(["secrets__list"]) + access_control = MagicMock() + access_control.policy_applies = MagicMock(return_value=True) + access_control.tool_permission_names = MagicMock(return_value={"secrets__list"}) + access_control.has_tool_access = AsyncMock(return_value=False) + agent = MagicMock() + agent.get_access_control_action = AsyncMock(return_value=access_control) + + ex = make_orchestrator(actions=[action], agent=agent) + visitor = make_visitor(utterance="hi", user_id="customer") + + tools, visible = await _assemble(ex, visitor) + assert "secrets__list" not in tools + + doc = SkillDoc( + name="needs_secrets", + description="d", + body="SOP", + requires_tools=("secrets__list",), + ) + out_tools, out_visible, _section = await ex._apply_unlocked_skill_surface( + doc, [action], visitor, tools, visible, [] + ) + assert "secrets__list" not in out_tools + assert "secrets__list" not in out_visible + + # Even if a tool is forced back onto the map, dispatch still denies. + from jvagent.action.orchestrator.tools import wrap_action_tool + + wrapped = wrap_action_tool( + action._tools[0], + agent=agent, + user_id="customer", + channel="web", + ) + assert await wrapped.run({}) == "(access denied)" + + +# --- 5. identity-hidden skill custom tools stay excluded -------------------- + + +async def test_identity_hidden_skill_prefix_cannot_be_materialized( + make_orchestrator, make_visitor, monkeypatch +): + from jvagent.action.orchestrator.orchestrator_interact_action import ( + OrchestratorInteractAction, + ) + + action = _PlainToolsAction(["staff_only__private_data", "faq__search"]) + access_control = MagicMock() + access_control.policy_applies = MagicMock(return_value=True) + access_control.get_user_groups = MagicMock(return_value={"staff": ["111"]}) + agent = MagicMock() + agent.get_access_control_action = AsyncMock(return_value=access_control) + + ex = make_orchestrator(actions=[action], agent=agent) + visitor = make_visitor(utterance="hi", user_id="999") + + staff_doc = SkillDoc( + name="staff_only", + description="private", + body="SOP", + access_action="HandoffAction", + allowed_groups=("staff",), + requires_tools=("staff_only__private_data",), + ) + faq_doc = SkillDoc( + name="faq", + description="public", + body="SOP", + requires_tools=("faq__search",), + ) + monkeypatch.setattr( + OrchestratorInteractAction, + "_discover_skills", + lambda self, _agent: [staff_doc, faq_doc], + ) + monkeypatch.setattr( + OrchestratorInteractAction, + "_enforce_required_actions", + AsyncMock(side_effect=lambda d: d), + ) + monkeypatch.setattr( + OrchestratorInteractAction, + "_resolve_action", + AsyncMock(return_value=access_control), + ) + + tools, visible = await _assemble(ex, visitor) + assert "staff_only__private_data" not in tools + assert "faq__search" in tools + + leaky = SkillDoc( + name="leaky", + description="d", + body="SOP", + requires_tools=("staff_only__private_data",), + ) + out_tools, out_visible, _section = await ex._apply_unlocked_skill_surface( + leaky, [action], visitor, tools, visible, [] + ) + assert "staff_only__private_data" not in out_tools + assert "staff_only__private_data" not in out_visible diff --git a/tests/action/orchestrator/test_skill_channel_gating.py b/tests/action/orchestrator/test_skill_channel_gating.py index 717f80d3..bcff01d3 100644 --- a/tests/action/orchestrator/test_skill_channel_gating.py +++ b/tests/action/orchestrator/test_skill_channel_gating.py @@ -597,3 +597,74 @@ def test_access_gate_fails_closed_without_access_control() -> None: ) aca = _FakeACA({"HandoffAction": {"staff": ["111"]}}, enforce=False) assert not OrchestratorInteractAction._skill_access_control_allowed(doc, "111", aca) + + +async def test_assemble_tools_hides_identity_gated_skill_custom_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Identity-hidden skills drop ``__*`` custom tools from the surface.""" + orch = OrchestratorInteractAction() + staff_doc = SkillDoc( + name="staff_only", + description="private", + body="b", + access_action="HandoffAction", + allowed_groups=("staff",), + requires_tools=("staff_only__private_data",), + ) + faq_doc = SkillDoc(name="faq", description="FAQs", body="b") + aca = _FakeACA({"HandoffAction": {"staff": ["111"]}}) + + monkeypatch.setattr(orch, "_discover_skills", lambda _agent: [staff_doc, faq_doc]) + monkeypatch.setattr( + OrchestratorInteractAction, + "_resolve_action", + AsyncMock(return_value=aca), + ) + + class _FakeAction: + def get_class_name(self) -> str: + return "CustomAction" + + async def get_tools(self): + return [ + _make_tool("staff_only__private_data"), + _make_tool("faq__search"), + ] + + monkeypatch.setattr( + OrchestratorInteractAction, + "_enabled_actions", + AsyncMock(return_value=[_FakeAction()]), + ) + monkeypatch.setattr( + OrchestratorInteractAction, "_safe_agent", AsyncMock(return_value=None) + ) + monkeypatch.setattr( + OrchestratorInteractAction, + "_enforce_required_actions", + AsyncMock(side_effect=lambda d: d), + ) + monkeypatch.setattr( + OrchestratorInteractAction, + "_select_code_execution_action", + staticmethod(lambda _actions: None), + ) + monkeypatch.setattr( + OrchestratorInteractAction, "get_responder", AsyncMock(return_value=None) + ) + monkeypatch.setattr( + "jvagent.action.orchestrator.skill_tasks.compose_skill_activate_hooks", + lambda *a, **k: (None, None), + ) + + visitor = MagicMock() + visitor.user_id = "999" + visitor.channel = "whatsapp" + visitor.utterance = "hi" + skill_docs: List[Any] = [] + visible: Set[str] = set() + tools = await orch._assemble_tools(visitor, [], visible, None, "hi", skill_docs, {}) + assert "staff_only" not in {getattr(d, "name", "") for d in skill_docs} + assert "staff_only__private_data" not in tools + assert "faq__search" in tools diff --git a/tests/action/test_handoff_action.py b/tests/action/test_handoff_action.py index 10137d92..3c3dce32 100644 --- a/tests/action/test_handoff_action.py +++ b/tests/action/test_handoff_action.py @@ -43,11 +43,13 @@ class _PendingStore: """Shared durable pending_questions keyed by action id (simulates Action.get).""" by_action: dict = {} + versions: dict = {} ACTION_ID = "n.HandoffAction.test" @classmethod def reset(cls): cls.by_action = {} + cls.versions = {} @classmethod def rows_for(cls, action_id=None): @@ -86,15 +88,19 @@ async def _save(self): aid = str(getattr(self, "id", "") or action_id).strip() or action_id object.__setattr__(self, "id", aid) _PendingStore.by_action[aid] = [ - row for row in (self.pending_questions or []) if isinstance(row, dict) + dict(row) for row in (self.pending_questions or []) if isinstance(row, dict) ] + _PendingStore.versions[aid] = int( + getattr(self, "pending_questions_version", 0) or 0 + ) async def _get(cls, eid): aid = str(eid or "").strip() rows = _PendingStore.by_action.get(aid, []) fresh = SimpleNamespace( id=aid, - pending_questions=list(rows), + pending_questions=[dict(row) for row in rows], + pending_questions_version=int(_PendingStore.versions.get(aid, 0) or 0), ) return fresh @@ -114,6 +120,9 @@ async def _noop_invalidate(self): def _bind_action(action, action_id=_PendingStore.ACTION_ID): object.__setattr__(action, "id", action_id) action.pending_questions = list(_PendingStore.by_action.get(action_id, [])) + action.pending_questions_version = int( + _PendingStore.versions.get(action_id, 0) or 0 + ) return action @@ -131,6 +140,9 @@ def _seed_pending(action, **kwargs): aid = str(getattr(action, "id", "") or _PendingStore.ACTION_ID) object.__setattr__(action, "id", aid) _PendingStore.by_action[aid] = list(rows) + _PendingStore.versions[aid] = int( + getattr(action, "pending_questions_version", 0) or 0 + ) return row @@ -1027,6 +1039,7 @@ async def _get_agent(self): assert listed[0]["user_contact"] == "5926431530" await reader._update_pending(created["id"], user_contact="5920001111") + await writer._refresh_pending_state() assert writer.pending_questions[0]["user_contact"] == "5920001111" assert (await reader._list_pending())[0]["user_contact"] == "5920001111" @@ -1046,7 +1059,11 @@ async def _get_agent(self): async def _append(self, question, answer): return question, answer, "n.DocumentNode.test" + async def _reply_ok(self, question, short_q, short_a): + return True + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + monkeypatch.setattr(HandoffAction, "_reply_to_customer", _reply_ok) with bind_dispatch_context(_Ctx(_STAFF_PHONE)): saved = await tools["handoff__save_answer"].call( question_ids=[created["id"]], answer="Yes, we deliver for 5000." @@ -2497,3 +2514,240 @@ async def _exec(**kwargs): wrapped = wrap_action_tool(tool, agent=None, user_id="999", channel="whatsapp") assert await wrapped.run({}) == SAVE_DENIED_LINE assert "yes" not in called + + +def test_resolve_customer_contact_group_prefers_participant_over_saved(): + from jvagent.action.handoff_action.handoff_action import _resolve_customer_contact + + assert ( + _resolve_customer_contact( + provided=None, + saved="5921111111", + visitor_channel="whatsapp", + participant="5922222222", + group_turn=True, + ) + == "5922222222" + ) + assert ( + _resolve_customer_contact( + provided="5923333333", + saved="5921111111", + visitor_channel="whatsapp", + participant="5922222222", + group_turn=True, + ) + == "5923333333" + ) + + +async def test_group_consults_use_current_participant_not_prior_saved(monkeypatch): + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + conversation = _Conversation() + sent = [] + + async def _send(self, recipient, message): + sent.append(recipient) + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + group_id = "120363428616636917" + + alice = _Ctx(group_id, channel="whatsapp", conversation=conversation) + alice.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": "5921110001", + } + } + with bind_dispatch_context(alice): + await tools["handoff__consult"].call(message="Alice question") + + bob = _Ctx(group_id, channel="whatsapp", conversation=conversation) + bob.data = { + "whatsapp_payload": { + "isGroup": True, + "sender": group_id, + "author": "5921110002", + } + } + with bind_dispatch_context(bob): + await tools["handoff__consult"].call(message="Bob question") + + contacts = [row["user_contact"] for row in action.pending_questions] + assert contacts == ["5921110001", "5921110002"] + + +async def test_concurrent_add_pending_keeps_both_rows(monkeypatch): + import asyncio + + _install_pending_store(monkeypatch) + a = _bind_action(HandoffAction()) + b = _bind_action(HandoffAction()) + + async def _one(action, message, contact): + with bind_dispatch_context(_Ctx(contact, channel="whatsapp")): + return await action._add_pending(message, contact) + + await asyncio.gather( + _one(a, "question a", "5921110001"), + _one(b, "question b", "5921110002"), + ) + rows = _PendingStore.rows_for() + assert len(rows) == 2 + assert {row["question"] for row in rows} == {"question a", "question b"} + + +async def test_append_and_ingest_aborts_when_export_fails(monkeypatch, tmp_path): + json_path = tmp_path / "handoff.json" + monkeypatch.setattr( + "jvagent.action.handoff_action.handoff_action._handoff_json_path", + lambda: json_path, + ) + deleted = {"hit": False} + + async def _export(collection_name="default", doc_name=None, root_id=None): + raise RuntimeError("export unavailable") + + async def _delete(doc_name, collection_name="default"): + deleted["hit"] = True + + async def _import(data, purge=False, collection_name=None): + raise AssertionError("import should not run") + + async def _condense(self, question, answer): + return question, answer + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + monkeypatch.setattr("jvagent.action.pageindex.documents.export_documents", _export) + monkeypatch.setattr("jvagent.action.pageindex.documents.delete_document", _delete) + monkeypatch.setattr("jvagent.action.pageindex.documents.import_documents", _import) + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_condense_qa", _condense) + + action = HandoffAction() + try: + await action._append_and_ingest("q", "a") + assert False, "expected export failure to propagate" + except RuntimeError as exc: + assert "export unavailable" in str(exc) + assert deleted["hit"] is False + + +async def test_append_and_ingest_restores_prior_graph_when_import_fails( + monkeypatch, tmp_path +): + json_path = tmp_path / "handoff.json" + monkeypatch.setattr( + "jvagent.action.handoff_action.handoff_action._handoff_json_path", + lambda: json_path, + ) + prior = { + "roots": [ + { + "id": "n.DocumentRootNode.existing", + "entity": "DocumentRootNode", + "doc_name": "handoff.md", + "collection_name": "n.Agent.test", + "metadata": {"access": "public"}, + "chunks": 1, + } + ], + "nodes": [{"id": "n.DocumentNode.existing", "title": "old"}], + "edges": [], + } + imports = [] + + async def _export(collection_name="default", doc_name=None, root_id=None): + return prior + + async def _delete(doc_name, collection_name="default"): + return True + + async def _import(data, purge=False, collection_name=None): + imports.append(data) + if len(imports) == 1: + raise RuntimeError("import failed") + + async def _condense(self, question, answer): + return "short q", "short a" + + class _Agent: + id = "n.Agent.test" + + async def _get_agent(self): + return _Agent() + + monkeypatch.setattr("jvagent.action.pageindex.documents.export_documents", _export) + monkeypatch.setattr("jvagent.action.pageindex.documents.delete_document", _delete) + monkeypatch.setattr("jvagent.action.pageindex.documents.import_documents", _import) + monkeypatch.setattr(HandoffAction, "get_agent", _get_agent) + monkeypatch.setattr(HandoffAction, "_condense_qa", _condense) + + action = HandoffAction() + try: + await action._append_and_ingest("q", "a") + assert False, "expected import failure" + except RuntimeError as exc: + assert "import failed" in str(exc) + assert len(imports) == 2 + assert imports[1]["nodes"][0]["id"] == "n.DocumentNode.existing" + + +async def test_save_answer_retains_pending_when_delivery_fails(monkeypatch): + from unittest.mock import AsyncMock + + _install_aca_staff(monkeypatch) + _install_pending_store(monkeypatch) + action = _bind_action(HandoffAction()) + row = _seed_pending( + action, + id="pend_delivery", + question="Do you deliver?", + user_contact="5926431530", + ) + ingested = {"count": 0} + sends = {"count": 0} + + async def _append(self, question, answer): + ingested["count"] += 1 + return question, answer, "n.DocumentNode.test" + + async def _send_customer(self, contact, text): + sends["count"] += 1 + if sends["count"] == 1: + raise RuntimeError("whatsapp down") + + monkeypatch.setattr(HandoffAction, "_append_and_ingest", _append) + monkeypatch.setattr(HandoffAction, "_send_whatsapp_customer", _send_customer) + monkeypatch.setattr( + HandoffAction, "_customer_reply", AsyncMock(return_value="here is the answer") + ) + + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + failed = await tools["handoff__save_answer"].call( + question_ids=[row["id"]], answer="Yes, we deliver." + ) + assert failed.is_error + assert "delivery" in failed.content.lower() + assert ingested["count"] == 1 + assert len(action.pending_questions) == 1 + assert action.pending_questions[0]["delivery_status"] == "failed" + assert action.pending_questions[0]["answer"] == "Yes, we deliver." + + with bind_dispatch_context(_Ctx(_STAFF_PHONE)): + retried = await tools["handoff__save_answer"].call( + question_ids=[row["id"]], answer="Yes, we deliver." + ) + assert not retried.is_error + assert ingested["count"] == 1 # no duplicate KB write + assert action.pending_questions == [] From 329273f6443c35346bd057913e47b085b6345a60 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Mon, 12 Oct 2026 09:19:46 -0400 Subject: [PATCH 16/20] refactor(mcp): streamline service key authentication logic This commit refines the service key authentication logic in the `MCPAction` class by consolidating the formatting of the `key_env` parameter. The changes enhance code readability and maintainability without altering the functionality of the authentication process. --- jvagent/action/mcp/mcp_action.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/jvagent/action/mcp/mcp_action.py b/jvagent/action/mcp/mcp_action.py index f9b4fdc5..c108bc31 100644 --- a/jvagent/action/mcp/mcp_action.py +++ b/jvagent/action/mcp/mcp_action.py @@ -433,8 +433,7 @@ async def _build_server_entries(self) -> None: http_client = None if ( auth_cfg - and str(auth_cfg.get("type") or "").strip().lower() - == "service_key" + and str(auth_cfg.get("type") or "").strip().lower() == "service_key" ): from jvagent.action.mcp.service_key_auth import ( build_service_key_client, @@ -442,9 +441,7 @@ async def _build_server_entries(self) -> None: http_client = build_service_key_client( service_key=str(auth_cfg.get("service_key") or ""), - key_env=str( - auth_cfg.get("key_env") or "INTEGRAL_SERVICE_KEY" - ), + key_env=str(auth_cfg.get("key_env") or "INTEGRAL_SERVICE_KEY"), user_id=str(auth_cfg.get("user_id") or ""), user_id_env=str( auth_cfg.get("user_id_env") or "INTEGRAL_SERVICE_USER_ID" From 49058738a092313b29848367c8d1a222cf141715 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Mon, 12 Oct 2026 09:21:27 -0400 Subject: [PATCH 17/20] chore(docker): update Dockerfile and workflow branch references This commit modifies the Dockerfile to clone the `dev-tj` branch of the jvagent repository instead of the `main` branch. Additionally, the GitHub Actions workflow is updated to trigger on pushes to the `dev-tj` branch, ensuring that version bumps in `jvagent/version.py` are automatically tagged in the correct branch context. --- .github/workflows/auto-tag.yaml | 2 +- Dockerfile.base | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-tag.yaml b/.github/workflows/auto-tag.yaml index 916cd60d..1d16ee80 100644 --- a/.github/workflows/auto-tag.yaml +++ b/.github/workflows/auto-tag.yaml @@ -20,7 +20,7 @@ name: Auto-tag on version bump on: push: branches: - - main + - dev-tj paths: - "jvagent/version.py" diff --git a/Dockerfile.base b/Dockerfile.base index de6052ac..a7809cb4 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -16,7 +16,7 @@ WORKDIR /var/task RUN set -eux && \ dnf -y update && \ dnf install -y git file file-libs && \ - git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvagent && \ + git clone --depth 1 --single-branch --branch dev-tj https://github.com/TrueSelph/jvagent && \ git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvspatial # Stage 2: venv and Python deps (Lambda image provides python3.12 under /var/lang/bin) From 00f2449f8e9eaccd43371efeb551e588a448fa11 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Wed, 14 Oct 2026 11:29:47 -0400 Subject: [PATCH 18/20] chore(docker): switch jvagent branch to main and update version This commit updates the Dockerfile to clone the `main` branch of the jvagent repository instead of the `dev-tj` branch. The GitHub Actions workflow is also modified to trigger on pushes to the `main` branch. Additionally, the jvagent version is bumped to `0.1.8rc19`, reflecting the latest changes and ensuring consistency across the project. --- .github/workflows/auto-tag.yaml | 2 +- Dockerfile.base | 2 +- .../action/facebook_action/facebook_action.py | 10 +- .../messenger_message_coalescer.py | 5 +- .../action/interact/voice_stream_endpoints.py | 3 +- jvagent/action/leadgen/engine.py | 19 +- jvagent/action/leadgen/sync.py | 16 + jvagent/action/mcp/client.py | 113 +++- jvagent/action/mcp/integral_oauth_auth.py | 293 +++++++++ jvagent/action/mcp/mcp_action.py | 98 ++- jvagent/action/mcp/service_key_auth.py | 29 +- jvagent/action/mcp_oauth/endpoints.py | 199 +++++- jvagent/action/mcp_oauth/integral_provider.py | 610 ++++++++++++++++++ jvagent/action/mcp_oauth/mcp_oauth_action.py | 14 + .../action/stt_action/deepgram/deepgram.py | 5 +- jvagent/action/whatsapp/whatsapp_action.py | 10 +- jvagent/core/distributed_lease.py | 14 +- .../memory/distributed_conversation_lock.py | 16 +- jvagent/version.py | 2 +- tests/action/mcp/test_mcp_auth_clients.py | 177 +++++ tests/action/mcp/test_mcp_client_lifecycle.py | 102 +++ .../mcp_oauth/test_integral_provider.py | 321 +++++++++ .../whatsapp/test_meta_register_skip_guard.py | 4 +- 23 files changed, 1987 insertions(+), 77 deletions(-) create mode 100644 jvagent/action/mcp/integral_oauth_auth.py create mode 100644 jvagent/action/mcp_oauth/integral_provider.py create mode 100644 tests/action/mcp/test_mcp_auth_clients.py create mode 100644 tests/action/mcp/test_mcp_client_lifecycle.py create mode 100644 tests/action/mcp_oauth/test_integral_provider.py diff --git a/.github/workflows/auto-tag.yaml b/.github/workflows/auto-tag.yaml index 1d16ee80..916cd60d 100644 --- a/.github/workflows/auto-tag.yaml +++ b/.github/workflows/auto-tag.yaml @@ -20,7 +20,7 @@ name: Auto-tag on version bump on: push: branches: - - dev-tj + - main paths: - "jvagent/version.py" diff --git a/Dockerfile.base b/Dockerfile.base index a7809cb4..de6052ac 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -16,7 +16,7 @@ WORKDIR /var/task RUN set -eux && \ dnf -y update && \ dnf install -y git file file-libs && \ - git clone --depth 1 --single-branch --branch dev-tj https://github.com/TrueSelph/jvagent && \ + git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvagent && \ git clone --depth 1 --single-branch --branch main https://github.com/TrueSelph/jvspatial # Stage 2: venv and Python deps (Lambda image provides python3.12 under /var/lang/bin) diff --git a/jvagent/action/facebook_action/facebook_action.py b/jvagent/action/facebook_action/facebook_action.py index 0773bff2..c2f69f03 100644 --- a/jvagent/action/facebook_action/facebook_action.py +++ b/jvagent/action/facebook_action/facebook_action.py @@ -10,6 +10,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Union import httpx +from jvspatial import create_task from jvspatial.api.auth.api_key_service import APIKeyService from jvspatial.core.annotations import attribute from jvspatial.core.context import GraphContext @@ -815,9 +816,9 @@ def _schedule_deferred_messenger_webhook_register(self) -> None: """Register Messenger webhook after the HTTP server is listening. ``on_startup`` runs inside ``asyncio.run(pre_startup_bootstrap)``; a bare - ``asyncio.create_task`` there may fire before uvicorn is listening, causing + ``jvspatial.create_task`` there may fire before uvicorn is listening, causing Meta's verification GET to fail. Hook into the jvspatial server lifecycle - instead (same pattern as WhatsAppAction). Falls back to asyncio.create_task + instead (same pattern as WhatsAppAction). Falls back to jvspatial.create_task if lifecycle_manager is unavailable. """ action_id = str(getattr(self, "id", "") or "") @@ -874,9 +875,10 @@ async def _run_after_startup() -> None: timeout, ) - asyncio.create_task( + await create_task( _run_after_startup(), name="facebook_messenger_webhook_register", + concurrent=True, ) server.lifecycle_manager.add_startup_hook( @@ -911,6 +913,8 @@ async def _fallback_deferred() -> None: await asyncio.sleep(delay_sec) await self._run_startup_messenger_webhook_register() + # Sync method (a startup-hook scheduler): jvspatial's ``create_task`` + # is async, so schedule the fallback on the running loop directly. _task = asyncio.create_task(_fallback_deferred()) _BACKGROUND_TASKS.add(_task) _task.add_done_callback(_BACKGROUND_TASKS.discard) diff --git a/jvagent/action/facebook_action/messenger_message_coalescer.py b/jvagent/action/facebook_action/messenger_message_coalescer.py index 1cc90597..5b4f4d46 100644 --- a/jvagent/action/facebook_action/messenger_message_coalescer.py +++ b/jvagent/action/facebook_action/messenger_message_coalescer.py @@ -4,6 +4,8 @@ import logging from typing import Any, Awaitable, Callable, Dict, List, Optional, Set +from jvspatial import create_task + from jvagent.action.facebook_action.facebook_api import FacebookAPI logger = logging.getLogger(__name__) @@ -123,9 +125,10 @@ async def schedule_merge( old = buf.get("task") if old is not None: old.cancel() - buf["task"] = asyncio.create_task( + buf["task"] = await create_task( cls._flush_after_quiet(buffer_key, window_seconds, on_flush), name=f"messenger_coalesce_{buffer_key[:40]}", + concurrent=True, ) @classmethod diff --git a/jvagent/action/interact/voice_stream_endpoints.py b/jvagent/action/interact/voice_stream_endpoints.py index 84dab1cb..62c029f3 100644 --- a/jvagent/action/interact/voice_stream_endpoints.py +++ b/jvagent/action/interact/voice_stream_endpoints.py @@ -36,6 +36,7 @@ from typing import Any, AsyncIterator, Optional, Tuple from fastapi import Request, WebSocket, WebSocketDisconnect +from jvspatial import create_task from jvspatial.api import endpoint from jvspatial.api.exceptions import ValidationError @@ -210,7 +211,7 @@ async def ws_reader() -> None: with contextlib.suppress(asyncio.QueueFull): queue.put_nowait(None) - reader_task = asyncio.create_task(ws_reader()) + reader_task = await create_task(ws_reader(), name="stt_ws_reader", concurrent=True) try: await stt.stream_transcribe(audio_iter(), on_event) if overflow: diff --git a/jvagent/action/leadgen/engine.py b/jvagent/action/leadgen/engine.py index a2cd46ba..59cc5edc 100644 --- a/jvagent/action/leadgen/engine.py +++ b/jvagent/action/leadgen/engine.py @@ -189,14 +189,24 @@ async def maybe_auto_sync( ) -> Dict[str, Any]: sync_cfg = spec.sync if not force and sync_cfg.mode == "manual": + logger.debug("leadgen sync: skipped (manual mode) user=%s", user_id) return {"status": "skipped", "reason": "manual mode"} if not force and sync_cfg.mode == "on_complete": - if record.get_missing_fields(): + missing = record.get_missing_fields() + if missing: + logger.debug( + "leadgen sync: skipped (required fields incomplete: %s) user=%s", + missing, + user_id, + ) return {"status": "skipped", "reason": "required fields incomplete"} if not force and sync_cfg.mode == "on_capture": if not sync_mod.sync_threshold_met( profile_data, sync_cfg.min_fields, sync_cfg.require_any ): + logger.debug( + "leadgen sync: skipped (sync thresholds not met) user=%s", user_id + ) return {"status": "skipped", "reason": "sync thresholds not met"} destinations = list(sync_cfg.destinations) @@ -204,6 +214,7 @@ async def maybe_auto_sync( destinations = list(action.sync_destinations) if not destinations: + logger.debug("leadgen sync: no destinations configured user=%s", user_id) return {"status": "no-op", "reason": "no destinations configured"} results, any_success = await sync_mod.sync_to_destinations( @@ -213,7 +224,11 @@ async def maybe_auto_sync( updated = dict(profile_data) updated[sync_mod.DIGEST_KEY] = sync_mod.compute_digest(profile_data) await record.set_yaml(updated) - return {"status": "sync-complete" if any_success else "no-op", "results": results} + status = "sync-complete" if any_success else "no-op" + logger.debug( + "leadgen sync: status=%s results=%s user=%s", status, results, user_id + ) + return {"status": status, "results": results} def capture_state(record: Any, spec: LeadGenSpec) -> Dict[str, Any]: diff --git a/jvagent/action/leadgen/sync.py b/jvagent/action/leadgen/sync.py index 1a50a261..9e82609c 100644 --- a/jvagent/action/leadgen/sync.py +++ b/jvagent/action/leadgen/sync.py @@ -116,6 +116,11 @@ async def sync_to_destinations( except Exception as exc: logger.debug("leadgen sync: MCPAction lookup failed: %s", exc) mcp_action = None + if mcp_action is not None and hasattr(mcp_action, "ensure_servers"): + try: + await mcp_action.ensure_servers() + except Exception as exc: # noqa: BLE001 + logger.debug("leadgen sync: ensure_servers failed: %s", exc) configured = set(mcp_action.get_server_names()) if mcp_action else set() results: Dict[str, str] = {} @@ -130,6 +135,11 @@ async def sync_to_destinations( results[server_name] = f"Unknown mode '{mode}'" continue if server_name not in configured: + logger.debug( + "leadgen sync: server=%s not configured (configured=%s); skipping", + server_name, + sorted(configured), + ) results[server_name] = "skipped: connector not configured" continue ok, msg = await _sync_mcp(mcp_action, entry, profile_data, user_id) @@ -158,6 +168,12 @@ async def _sync_mcp( resolved_args = substitute(raw_args, profile_data, uid) + if hasattr(mcp_action, "ensure_servers"): + try: + await mcp_action.ensure_servers() + except Exception as exc: # noqa: BLE001 + logger.debug("leadgen sync_mcp: ensure_servers failed: %s", exc) + try: client = await mcp_action.get_client_for_user(server_name, uid) except Exception as exc: diff --git a/jvagent/action/mcp/client.py b/jvagent/action/mcp/client.py index aee323d3..53b5d7b5 100644 --- a/jvagent/action/mcp/client.py +++ b/jvagent/action/mcp/client.py @@ -8,6 +8,8 @@ from contextlib import AsyncExitStack from typing import Any, Dict, List, Optional +from jvspatial import create_task + logger = logging.getLogger(__name__) @@ -26,10 +28,13 @@ class MCPClientWrapper: Session lifecycle: one long-lived session per wrapper. Use connect() to establish, disconnect() to tear down. - For stdio transport, the subprocess and task group lifecycle are managed by - running the stdio_client context manager as a background task. This avoids - the ExceptionGroup/BrokenResourceError that occurs when using AsyncExitStack - to manage the stdio_client's task group. + Both transports run their context managers inside a **dedicated background + task** that owns them (stdio and streamable_http alike). The MCP SDK's + transports wrap ``anyio`` task groups, whose cancel scope must be exited in + the same asyncio task that entered it; entering on an ``AsyncExitStack`` and + closing it from another task raises ``RuntimeError: Attempted to exit cancel + scope in a different task than it was entered in``. A dedicated task also + avoids the stdio ExceptionGroup/BrokenResourceError race. """ def __init__( @@ -70,10 +75,17 @@ def __init__( self._session: Any = None self._read_stream: Any = None self._write_stream: Any = None - # For stdio: background task managing the subprocess lifecycle + # Background task managing the subprocess lifecycle (stdio) or the + # streamable_http transport + ClientSession. Both are owned by a + # dedicated task so the SDK's anyio task group is entered and exited in + # the same asyncio task (see the class docstring). self._stdio_task: Optional[asyncio.Task] = None self._stdio_ready: Optional[asyncio.Event] = None self._stdio_error: Optional[Exception] = None + self._http_task: Optional[asyncio.Task] = None + self._http_ready: Optional[asyncio.Event] = None + self._http_error: Optional[Exception] = None + self._disconnect_event: Optional[asyncio.Event] = None self._connect_lock = asyncio.Lock() def _ensure_imports(self) -> Any: @@ -131,7 +143,9 @@ async def _run_stdio_session(): self._stdio_ready.set() self._disconnect_event = asyncio.Event() - self._stdio_task = asyncio.create_task(_run_stdio_session()) + self._stdio_task = await create_task( + _run_stdio_session(), name="mcp_stdio_session", concurrent=True + ) # Wait for the session to be ready (or error) try: @@ -150,36 +164,69 @@ async def _run_stdio_session(): raise err async def _connect_streamable_http(self) -> None: - """Connect via streamable_http transport using AsyncExitStack.""" - _, _, _, streamable_http_client = self._ensure_imports() - ClientSession = self._ensure_imports()[0] + """Connect via streamable_http, owning the transport in a background task. + + ``streamable_http_client`` runs an ``anyio`` task group whose cancel scope + must be exited in the same asyncio task that entered it. Entering on an + ``AsyncExitStack`` in the caller and closing it from another task raises + ``RuntimeError: Attempted to exit cancel scope in a different task than it + was entered in``. Mirror the stdio path: one dedicated task owns the + transport + ClientSession and is signalled/awaited by ``disconnect()``. + """ + ClientSession, _, _, streamable_http_client = self._ensure_imports() + + self._http_ready = asyncio.Event() + self._http_error = None + + async def _run_http_session() -> None: + """Run the transport + ClientSession lifecycle as a long-lived task.""" + try: + transport_ctx = ( + streamable_http_client(self._url, http_client=self._http_client) + if self._http_client is not None + else streamable_http_client(self._url) + ) + async with transport_ctx as (read_stream, write_stream, _session_id): + self._read_stream = read_stream + self._write_stream = write_stream + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + self._session = session + self._http_ready.set() + # Keep the context alive until disconnect() is called. + if self._disconnect_event: + await self._disconnect_event.wait() + except Exception as e: # noqa: BLE001 + self._http_error = e + self._http_ready.set() + + self._disconnect_event = asyncio.Event() + self._http_task = await create_task( + _run_http_session(), name="mcp_http_session", concurrent=True + ) - stack = AsyncExitStack() + # Wait for the session to be ready (or error). try: - transport_ctx = ( - streamable_http_client(self._url, http_client=self._http_client) - if self._http_client is not None - else streamable_http_client(self._url) - ) - read_stream, write_stream, _session_id = await stack.enter_async_context( - transport_ctx + await asyncio.wait_for( + self._http_ready.wait(), timeout=self._connect_timeout ) - self._read_stream = read_stream - self._write_stream = write_stream - session = await stack.enter_async_context( - ClientSession(read_stream, write_stream) + except asyncio.TimeoutError: + await self.disconnect() + raise TimeoutError( + f"MCP streamable_http connection timed out after " + f"{self._connect_timeout}s" ) - await asyncio.wait_for(session.initialize(), timeout=self._connect_timeout) - self._session = session - self._stack = stack - except Exception: - await stack.aclose() - raise + + if self._http_error is not None: + err = self._http_error + await self.disconnect() + raise err async def disconnect(self) -> None: """Close session and transport.""" if self._transport == "stdio" and self._stdio_task is not None: - # Signal the background task to exit + # Signal the background task to exit, then cancel + await it so the + # SDK task group is torn down in its own task. if self._disconnect_event: self._disconnect_event.set() self._stdio_task.cancel() @@ -188,6 +235,16 @@ async def disconnect(self) -> None: except (asyncio.CancelledError, Exception): pass self._stdio_task = None + elif self._transport == "streamable_http" and self._http_task is not None: + # Same dedicated-task teardown for the HTTP transport. + if self._disconnect_event: + self._disconnect_event.set() + self._http_task.cancel() + try: + await self._http_task + except (asyncio.CancelledError, Exception): + pass + self._http_task = None elif self._stack is not None: await self._stack.aclose() self._stack = None diff --git a/jvagent/action/mcp/integral_oauth_auth.py b/jvagent/action/mcp/integral_oauth_auth.py new file mode 100644 index 00000000..a911b51f --- /dev/null +++ b/jvagent/action/mcp/integral_oauth_auth.py @@ -0,0 +1,293 @@ +"""``httpx`` auth flow that authenticates MCP requests to Integral Core with OAuth. + +After the browser OAuth connect (see :mod:`jvagent.action.mcp_oauth.endpoints`), +Core issues an OAuth 2.1 access token scoped to the signed-in user. The mounted +``/api/mcp`` endpoint accepts it as ``Authorization: Bearer `` — the same +contract Core's resource-server auth middleware applies to any ``auth=True`` +endpoint (``accept_oauth_bearer=True``). + +``IntegralOAuthAuth`` loads the stored access token for a server/account, quietly +refreshes it when (near) expired, and stamps every request with the bearer. An +optional ``X-Integral-Scope: ws:`` header pins the workspace; Core honours it +only when the token's principal is a member, otherwise it fails closed to the +user's own default workspace. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, AsyncGenerator, Dict, Optional, Tuple + +import httpx + +from jvagent.action.mcp_oauth.integral_provider import ( + DEFAULT_ACCOUNT, + INTEGRAL_SERVER, + INTEGRAL_SERVICE, + discover_authorization_server, + load_registered_client, + normalize_token_payload, + payload_is_expired, + refresh_access_token, + resolve_scope, +) + +logger = logging.getLogger(__name__) + +DEFAULT_CONNECT_TIMEOUT = 10.0 +DEFAULT_READ_TIMEOUT = 60.0 + + +class IntegralOAuthNotConnectedError(RuntimeError): + """Raised when no OAuth token is stored for the server/account.""" + + +async def _load_token_node( + server_name: str, account_name: str +) -> Tuple[Any, Optional[Dict[str, Any]]]: + """Return ``(node, payload)`` for the stored token, or ``(None, None)``. + + Tolerates both storage shapes: a flat payload, and one nested under + ``service_tokens`` (the shape ``apply_service_rebind`` writes). + """ + import json + + from jvspatial.core.context import GraphContext + from jvspatial.db import get_database_manager + + from jvagent.action.mcp_oauth.mcp_oauth_node import MCPOAuthToken + + manager = get_database_manager() + db = manager.get_database() + ctx = GraphContext(db) + nodes = await ctx.find_nodes( + MCPOAuthToken, + {"context.server_name": server_name, "context.account_name": account_name}, + ) + if not nodes: + return None, None + node = nodes[0] + if not node.token_json: + return node, None + try: + payload = json.loads(node.token_json) + except Exception: + return node, None + if not isinstance(payload, dict): + return node, None + return node, _flatten_service_tokens(payload) + + +def _flatten_service_tokens(payload: Dict[str, Any]) -> Dict[str, Any]: + """Return a flat view of a token payload, unwrapping ``service_tokens``.""" + st = payload.get("service_tokens") + if isinstance(st, dict): + for key in (INTEGRAL_SERVICE, *st.keys()): + blob = st.get(key) + if isinstance(blob, dict) and ( + blob.get("access_token") or blob.get("refresh_token") + ): + merged = dict(payload) + merged.update(blob) + return merged + return payload + + +async def _save_token_payload(node: Any, payload: Dict[str, Any]) -> None: + import json + + node.token_json = json.dumps(payload) + node.updated = datetime.now(timezone.utc) + await node.save() + + +class IntegralOAuthAuth(httpx.Auth): + """Bears the stored Integral OAuth access token, refreshing when stale.""" + + requires_request_body = False + requires_response_body = False + + def __init__( + self, + *, + server_name: str = INTEGRAL_SERVER, + account_name: str = DEFAULT_ACCOUNT, + scope_id: str = "", + scope: str = "", + issuer: str = "", + mcp_url: str = "", + ) -> None: + self._server_name = server_name + self._account_name = account_name or DEFAULT_ACCOUNT + self._scope_id = (scope_id or "").strip() + self._scope = (scope or "").strip() + self._issuer = (issuer or "").strip() + self._mcp_url = (mcp_url or "").strip() + self._lock = asyncio.Lock() + self._cached_token = "" + self._cached_expiry: Optional[datetime] = None + + def _cache_valid(self) -> bool: + if not self._cached_token: + return False + if self._cached_expiry is None: + return True + return self._cached_expiry > datetime.now(timezone.utc) + timedelta(seconds=30) + + @staticmethod + def _expiry_from_payload(payload: Dict[str, Any]) -> Optional[datetime]: + raw = payload.get("expiry") or "" + if not raw: + return None + try: + expiry = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + except ValueError: + return None + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + return expiry + + async def _valid_token(self) -> str: + async with self._lock: + if self._cache_valid(): + return self._cached_token + node, payload = await _load_token_node( + self._server_name, self._account_name + ) + if payload and not payload_is_expired(payload): + self._cached_token = str( + payload.get("access_token") or payload.get("token") or "" + ) + self._cached_expiry = self._expiry_from_payload(payload) + if self._cached_token: + return self._cached_token + if not node or not payload: + raise IntegralOAuthNotConnectedError( + f"No Integral OAuth token stored for " + f"{self._server_name}/{self._account_name}. " + "Connect via the agent's MCPs panel first." + ) + return await self._refresh(node, payload) + + async def _refresh(self, node: Any, payload: Dict[str, Any]) -> str: + refresh_token = str(payload.get("refresh_token") or "") + client_id = str(payload.get("client_id") or "") + if not refresh_token: + raise IntegralOAuthNotConnectedError( + "Stored Integral token has no refresh_token; reconnect via the " + "agent's MCPs panel." + ) + if not client_id: + client = await load_registered_client(self._server_name) + client_id = client.client_id if client else "" + + meta = await discover_authorization_server( + mcp_url=self._mcp_url, issuer=self._issuer + ) + scope = self._scope or resolve_scope() + tokens = await refresh_access_token( + meta, + client_id=client_id, + refresh_token=refresh_token, + scope=scope, + ) + new_payload = normalize_token_payload( + tokens, + client_id=client_id, + token_uri=str(meta.get("token_endpoint") or payload.get("token_uri") or ""), + account_name=self._account_name, + scope=scope, + previous=payload, + ) + # Preserve display metadata from the original payload. + for key in ("account_alias", "email", "mcp_services", "type"): + if payload.get(key) is not None and new_payload.get(key) is None: + new_payload[key] = payload[key] + await _save_token_payload(node, new_payload) + + self._cached_token = str(new_payload.get("access_token") or "") + self._cached_expiry = self._expiry_from_payload(new_payload) + if not self._cached_token: + raise IntegralOAuthNotConnectedError( + "Integral token refresh returned no access token." + ) + logger.info( + "Refreshed Integral OAuth token for %s/%s", + self._server_name, + self._account_name, + ) + return self._cached_token + + async def apply_auth(self, request: httpx.Request) -> None: + """Set the bearer (and optional workspace scope) header on *request*.""" + token = await self._valid_token() + request.headers["Authorization"] = f"Bearer {token}" + if self._scope_id: + request.headers["X-Integral-Scope"] = f"ws:{self._scope_id}" + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + await self.apply_auth(request) + yield request + + +def _resolve(value: str, env_name: str) -> str: + import os + + if value: + return value.strip() + if env_name: + return (os.getenv(env_name) or "").strip() + return "" + + +def build_integral_oauth_client( + *, + server_name: str = INTEGRAL_SERVER, + account: str = "", + account_env: str = "INTEGRAL_MCP_ACCOUNT", + scope_id: str = "", + scope_id_env: str = "INTEGRAL_MCP_WORKSPACE_ID", + scope: str = "", + issuer: str = "", + issuer_env: str = "INTEGRAL_OAUTH_ISSUER", + mcp_url: str = "", + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + read_timeout: float = DEFAULT_READ_TIMEOUT, +) -> httpx.AsyncClient: + """Build an ``httpx.AsyncClient`` that bears the stored Integral OAuth token.""" + resolved_account = _resolve(account, account_env) or DEFAULT_ACCOUNT + resolved_scope = _resolve(scope_id, scope_id_env) + resolved_issuer = _resolve(issuer, issuer_env) + timeout = httpx.Timeout( + connect=connect_timeout, read=read_timeout, write=read_timeout, pool=None + ) + auth = IntegralOAuthAuth( + server_name=server_name, + account_name=resolved_account, + scope_id=resolved_scope, + scope=scope, + issuer=resolved_issuer, + mcp_url=mcp_url, + ) + + async def _ensure_auth(request: httpx.Request) -> None: + # httpx strips ``Authorization`` when it follows a *cross-origin* redirect + # (Core's ``/api/mcp`` -> ``/api/mcp/`` on the API port) and does not + # re-run a custom httpx.Auth flow for the redirect hop. Request event + # hooks DO run for every hop, so re-apply the bearer when it is missing. + if "authorization" in request.headers: + return + await auth.apply_auth(request) + + return httpx.AsyncClient( + auth=auth, + timeout=timeout, + # The MCP SDK raises on any 3xx (``raise_for_status``), so follow + # redirects; ``_ensure_auth`` re-applies the bearer on the redirect hop. + follow_redirects=True, + event_hooks={"request": [_ensure_auth]}, + ) diff --git a/jvagent/action/mcp/mcp_action.py b/jvagent/action/mcp/mcp_action.py index c108bc31..59ebea1a 100644 --- a/jvagent/action/mcp/mcp_action.py +++ b/jvagent/action/mcp/mcp_action.py @@ -269,7 +269,12 @@ class MCPAction(Action): "type (optional: jvspatial_fs | npx_filesystem for stdio filesystem servers), " "auth (optional: {type: service_key, key_env, user_id/user_id_env, " "workspace_id/workspace_id_env} — signs every streamable_http request " - "with Core's service-key envelope)." + "with Core's service-key envelope; or {type: oauth, account/account_env, " + "scopes (list) / scope (space-separated string) — the Core OAuth scopes " + "(e.g. integral:read, integral:propose, integral:execute) passed through " + "verbatim; scope_id/scope_id_env, issuer/issuer_env} — bears a stored " + "Integral OAuth 2.1 access token from the MCPs-panel connect flow; the " + "entry url is used for refresh discovery)." ), ) model_action_type: str = attribute( @@ -439,20 +444,74 @@ async def _build_server_entries(self) -> None: build_service_key_client, ) - http_client = build_service_key_client( - service_key=str(auth_cfg.get("service_key") or ""), - key_env=str(auth_cfg.get("key_env") or "INTEGRAL_SERVICE_KEY"), - user_id=str(auth_cfg.get("user_id") or ""), - user_id_env=str( - auth_cfg.get("user_id_env") or "INTEGRAL_SERVICE_USER_ID" - ), - workspace_id=str(auth_cfg.get("workspace_id") or ""), - workspace_id_env=str( - auth_cfg.get("workspace_id_env") or "INTEGRAL_WORKSPACE_ID" - ), - connect_timeout=connect_timeout, - read_timeout=call_timeout, + try: + http_client = build_service_key_client( + service_key=str(auth_cfg.get("service_key") or ""), + key_env=str( + auth_cfg.get("key_env") or "INTEGRAL_SERVICE_KEY" + ), + user_id=str(auth_cfg.get("user_id") or ""), + user_id_env=str( + auth_cfg.get("user_id_env") + or "INTEGRAL_SERVICE_USER_ID" + ), + workspace_id=str(auth_cfg.get("workspace_id") or ""), + workspace_id_env=str( + auth_cfg.get("workspace_id_env") + or "INTEGRAL_WORKSPACE_ID" + ), + connect_timeout=connect_timeout, + read_timeout=call_timeout, + ) + except ValueError as exc: + logger.warning( + "MCPAction: disabling server '%s' — service-key auth " + "not configured: %s", + name, + exc, + ) + continue + elif ( + auth_cfg + and str(auth_cfg.get("type") or "").strip().lower() == "oauth" + ): + from jvagent.action.mcp.integral_oauth_auth import ( + build_integral_oauth_client, + ) + from jvagent.action.mcp_oauth.integral_provider import ( + resolve_scopes_from_auth, ) + + resolved_scopes = resolve_scopes_from_auth(auth_cfg) + try: + http_client = build_integral_oauth_client( + server_name=name, + account=str(auth_cfg.get("account") or ""), + account_env=str( + auth_cfg.get("account_env") or "INTEGRAL_MCP_ACCOUNT" + ), + scope_id=str(auth_cfg.get("scope_id") or ""), + scope_id_env=str( + auth_cfg.get("scope_id_env") + or "INTEGRAL_MCP_WORKSPACE_ID" + ), + scope=resolved_scopes, + issuer=str(auth_cfg.get("issuer") or ""), + issuer_env=str( + auth_cfg.get("issuer_env") or "INTEGRAL_OAUTH_ISSUER" + ), + mcp_url=str(raw.get("url") or ""), + connect_timeout=connect_timeout, + read_timeout=call_timeout, + ) + except ValueError as exc: + logger.warning( + "MCPAction: disabling server '%s' — integral oauth " + "not configured: %s", + name, + exc, + ) + continue client = MCPClientWrapper( transport, command=command, @@ -492,6 +551,17 @@ async def _build_server_entries(self) -> None: show_tools=show_tools, ) + async def ensure_servers(self) -> None: + """(Re)build the server registry if this instance has none. + + ``self._servers_by_name`` is runtime-only PrivateAttr state. An action + instance loaded from storage (e.g. resolved via ``Action.get``) starts + with an empty registry even though the persisted ``self.servers`` config + is present, so callers must be able to rebuild it on demand. + """ + if not self._servers_by_name: + await self._build_server_entries() + async def _provision_sandboxes(self) -> None: from jvagent.core.app import App diff --git a/jvagent/action/mcp/service_key_auth.py b/jvagent/action/mcp/service_key_auth.py index 92303fe3..5cac2dc3 100644 --- a/jvagent/action/mcp/service_key_auth.py +++ b/jvagent/action/mcp/service_key_auth.py @@ -19,15 +19,12 @@ import hashlib import hmac -import logging import os import time from typing import AsyncGenerator import httpx -logger = logging.getLogger(__name__) - DEFAULT_CONNECT_TIMEOUT = 10.0 DEFAULT_READ_TIMEOUT = 60.0 @@ -58,9 +55,8 @@ def __init__( self._user_id = user_id self._workspace_id = (workspace_id or "").strip() - async def async_auth_flow( - self, request: httpx.Request - ) -> AsyncGenerator[httpx.Request, httpx.Response]: + def apply_auth(self, request: httpx.Request) -> None: + """Sign *request* with a fresh timestamp + HMAC.""" timestamp = str(int(time.time())) request.headers["X-Integral-Service-Key"] = self._service_key request.headers["X-Integral-User-Id"] = self._user_id @@ -70,6 +66,11 @@ async def async_auth_flow( ) if self._workspace_id: request.headers["X-Integral-Scope"] = f"ws:{self._workspace_id}" + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + self.apply_auth(request) yield request @@ -115,7 +116,21 @@ def build_service_key_client( timeout = httpx.Timeout( connect=connect_timeout, read=read_timeout, write=read_timeout, pool=None ) + auth = ServiceKeyAuth(resolved_key, resolved_user, resolved_ws) + + async def _ensure_service_key(request: httpx.Request) -> None: + # httpx drops the service-key headers when it follows a *cross-origin* + # redirect and does not re-run a custom httpx.Auth flow for that hop; + # request event hooks run on every hop, so re-sign when they are missing. + if "x-integral-service-key" in request.headers: + return + auth.apply_auth(request) + return httpx.AsyncClient( - auth=ServiceKeyAuth(resolved_key, resolved_user, resolved_ws), + auth=auth, timeout=timeout, + # The MCP SDK raises on any 3xx (``raise_for_status``), so follow + # redirects; ``_ensure_service_key`` re-signs on the redirect hop. + follow_redirects=True, + event_hooks={"request": [_ensure_service_key]}, ) diff --git a/jvagent/action/mcp_oauth/endpoints.py b/jvagent/action/mcp_oauth/endpoints.py index fffae230..ff3d670e 100644 --- a/jvagent/action/mcp_oauth/endpoints.py +++ b/jvagent/action/mcp_oauth/endpoints.py @@ -15,6 +15,21 @@ from jvagent.action.oauth.state import consume_oauth_state, create_oauth_state from jvagent.core.public_url import get_public_base_url +from .integral_provider import ( + INTEGRAL_LABEL, + INTEGRAL_SERVER, + INTEGRAL_SERVICE, + build_authorize_url, + discover_authorization_server, + ensure_registered_client, + exchange_authorization_code, + load_registered_client, + normalize_token_payload, + pkce_challenge, + pkce_verifier, + resolve_mcp_url, + resolve_scopes_from_auth, +) from .mcp_oauth_action import ( MCPOAuthAction, mcp_oauth_state_action_id, @@ -148,6 +163,52 @@ async def _enabled_microsoft_oauth_services( ) +async def _integral_mcp_url() -> str: + """Resolve the configured Integral MCP server URL from MCPAction.""" + try: + from jvspatial.core.context import GraphContext + from jvspatial.db import get_database_manager + + from jvagent.action.mcp.mcp_action import MCPAction + + manager = get_database_manager() + db = manager.get_database() + ctx = GraphContext(db) + nodes = await ctx.find_nodes(MCPAction, {}) + mcp = nodes[0] if nodes else None + for raw in getattr(mcp, "servers", None) or []: + if isinstance(raw, dict) and str(raw.get("name") or "") == INTEGRAL_SERVER: + url = str(raw.get("url") or "").strip() + if url: + return url + except Exception as exc: + logger.debug("Failed to read MCPAction Integral url: %s", exc) + return resolve_mcp_url() + + +async def _integral_auth_cfg() -> Dict[str, Any]: + """Return the ``integral_core`` server's ``auth`` block, if configured.""" + try: + from jvspatial.core.context import GraphContext + from jvspatial.db import get_database_manager + + from jvagent.action.mcp.mcp_action import MCPAction + + manager = get_database_manager() + db = manager.get_database() + ctx = GraphContext(db) + nodes = await ctx.find_nodes(MCPAction, {}) + mcp = nodes[0] if nodes else None + for raw in getattr(mcp, "servers", None) or []: + if isinstance(raw, dict) and str(raw.get("name") or "") == INTEGRAL_SERVER: + auth = raw.get("auth") + if isinstance(auth, dict): + return auth + except Exception as exc: + logger.debug("Failed to read MCPAction Integral auth: %s", exc) + return {} + + def _microsoft_client() -> Dict[str, str]: cid = microsoft_client_id() if not cid: @@ -390,7 +451,11 @@ def _oauth_error_html(message: str, status_code: int = 400) -> HTMLResponse: ) async def get_mcp_auth_status(server_name: str) -> Dict[str, Any]: """Return per-service connected emails for an MCP OAuth server.""" - supported = server_name in (GOOGLE_WORKSPACE_SERVER, MICROSOFT_365_SERVER) + supported = server_name in ( + GOOGLE_WORKSPACE_SERVER, + MICROSOFT_365_SERVER, + INTEGRAL_SERVER, + ) bindings: Dict[str, Dict[str, str]] = {} if supported: action = await _get_mcp_oauth_action() @@ -418,10 +483,15 @@ async def get_mcp_auth_url( if not action or not action.enabled: raise ResourceNotFoundError(message="MCPOAuthAction not enabled or found.") - if server_name not in (GOOGLE_WORKSPACE_SERVER, MICROSOFT_365_SERVER): + if server_name not in ( + GOOGLE_WORKSPACE_SERVER, + MICROSOFT_365_SERVER, + INTEGRAL_SERVER, + ): return _oauth_error_html( f"OAuth is not supported for server '{server_name}'. " - f"Supported: '{GOOGLE_WORKSPACE_SERVER}', '{MICROSOFT_365_SERVER}'.", + f"Supported: '{GOOGLE_WORKSPACE_SERVER}', '{MICROSOFT_365_SERVER}', " + f"'{INTEGRAL_SERVER}'.", 400, ) @@ -436,6 +506,10 @@ async def get_mcp_auth_url( return await _microsoft_auth_page( action, account=account, service=service, redirect_uri=redirect_uri ) + if server_name == INTEGRAL_SERVER: + return await _integral_auth_page( + action, account=account, service=service, redirect_uri=redirect_uri + ) return await _google_auth_page( action, account=account, service=service, redirect_uri=redirect_uri ) @@ -549,6 +623,66 @@ async def _microsoft_auth_page( ) +async def _integral_auth_page( + action: MCPOAuthAction, + *, + account: str, + service: str, + redirect_uri: str, +) -> HTMLResponse: + """Build the Integral Core (OAuth 2.1 / DCR + PKCE) authorization page.""" + scope = resolve_scopes_from_auth(await _integral_auth_cfg()) + mcp_url = await _integral_mcp_url() + try: + client_id, meta = await ensure_registered_client( + server_name=INTEGRAL_SERVER, + redirect_uri=redirect_uri, + scope=scope, + mcp_url=mcp_url, + ) + except Exception as exc: + logger.error("Integral OAuth client registration failed: %s", exc) + return _oauth_error_html(str(exc), 400) + + verifier = pkce_verifier() + challenge = pkce_challenge(verifier) + state_token = await create_oauth_state( + action_id=mcp_oauth_state_action_id(account, service), + provider="mcp_integral", + code_verifier=verifier, + redirect_uri=redirect_uri, + ) + try: + auth_url = build_authorize_url( + meta, + client_id=client_id, + redirect_uri=redirect_uri, + state=state_token, + code_challenge=challenge, + scope=scope, + ) + except Exception as exc: + logger.error("Integral authorize URL build failed: %s", exc) + return _oauth_error_html(str(exc), 400) + + issuer = str(meta.get("issuer") or "").strip() + access_copy = ( + "Connect this agent to Integral Core. You'll be asked to sign in to " + "Integral and approve access, then the agent can file leads and query " + "entries on your behalf." + ) + if issuer: + access_copy = f"{access_copy} (Authorization server: {issuer})" + return await _grant_html( + action, + title="Integral Core Authorization", + heading="Connect to Integral Core", + access_copy=access_copy, + button_label="Authorize with Integral", + auth_url=auth_url, + ) + + async def _grant_html( action: MCPOAuthAction, *, @@ -610,6 +744,8 @@ async def mcp_oauth_callback(server_name: str, code: str, state: str) -> HTMLRes provider = "mcp_google" elif server_name == MICROSOFT_365_SERVER: provider = "mcp_microsoft" + elif server_name == INTEGRAL_SERVER: + provider = "mcp_integral" else: return _oauth_error_html( f"OAuth is not supported for server '{server_name}'.", @@ -627,6 +763,10 @@ async def mcp_oauth_callback(server_name: str, code: str, state: str) -> HTMLRes result = await _exchange_microsoft_code( action, code, record, account_name, service=service ) + elif server_name == INTEGRAL_SERVER: + result = await _exchange_integral_code( + action, code, record, account_name, service=service + ) else: result = await _exchange_google_code( action, code, record, account_name, service=service @@ -856,3 +996,56 @@ async def _exchange_microsoft_code( else "Microsoft 365" ) return store_account, label + + +async def _exchange_integral_code( + action: MCPOAuthAction, + code: str, + record: Any, + account_name: str, + service: str = "", +) -> HTMLResponse | tuple[str, str]: + """Exchange an Integral authorization code (public client + PKCE).""" + mcp_url = await _integral_mcp_url() + try: + meta = await discover_authorization_server(mcp_url=mcp_url) + except Exception as exc: + return _oauth_error_html(f"Integral OAuth discovery failed: {exc}", 400) + + client = await load_registered_client(INTEGRAL_SERVER) + if not client or not client.client_id: + return _oauth_error_html( + "No registered Integral OAuth client. Restart the connect flow from " + "the MCPs panel.", + 400, + ) + + logger.info("Exchanging Integral auth code for tokens (account: %s)", account_name) + try: + tokens = await exchange_authorization_code( + meta, + client_id=client.client_id, + code=code, + code_verifier=record.code_verifier or "", + redirect_uri=record.redirect_uri, + ) + except Exception as exc: + logger.error("Integral token exchange failed: %s", exc) + return _oauth_error_html(str(exc), 400) + + store_account = (account_name or INTEGRAL_SERVICE).strip() or INTEGRAL_SERVICE + payload = normalize_token_payload( + tokens, + client_id=client.client_id, + token_uri=str(meta.get("token_endpoint") or ""), + account_name=store_account, + scope=resolve_scopes_from_auth(await _integral_auth_cfg()), + ) + if service: + payload["mcp_services"] = [service] + + # Store the token flat (not nested under ``service_tokens``) so the bearer + # auth client can read ``access_token`` directly. ``mcp_services`` still + # drives the MCPs-panel binding display. + await action.save_oauth_token(INTEGRAL_SERVER, store_account, payload) + return store_account, INTEGRAL_LABEL diff --git a/jvagent/action/mcp_oauth/integral_provider.py b/jvagent/action/mcp_oauth/integral_provider.py new file mode 100644 index 00000000..1416cb35 --- /dev/null +++ b/jvagent/action/mcp_oauth/integral_provider.py @@ -0,0 +1,610 @@ +"""Generic OAuth 2.1 provider for Integral Core's MCP endpoint. + +Unlike the Google/Microsoft providers (:mod:`hydrate`, :mod:`microsoft_hydrate`), +which use a *static* pre-registered ``client_id`` and hydrate token files onto a +stdio subprocess, Integral Core is an **OAuth 2.1 authorization server** in the +sense of the MCP authorization spec: + +* **Dynamic Client Registration** (RFC 7591) with open registration and public + clients (``token_endpoint_auth_method=none``) — no client secret. +* **PKCE** (RFC 7636) with ``S256`` — mandatory for public clients. +* **Discovery** via ``/.well-known/oauth-protected-resource`` (RFC 9728) and + ``/.well-known/oauth-authorization-server`` (RFC 8414), both served at the + Core root. +* The resulting access token is used directly as ``Authorization: Bearer`` on + the ``streamable_http`` MCP client (see :mod:`jvagent.action.mcp.integral_oauth_auth`); + there is no disk hydration step. + +Endpoints (Core defaults, see ``app/main.py`` / jvspatial ``oauth_prefix``): +authorize ``/api/oauth/authorize``, token ``/api/oauth/token``, register +``/api/oauth/register``. Discovery is preferred; the literal path layout is only +a fallback for when the ``/.well-known`` documents are not reachable (e.g. a +reverse proxy that forwards only ``/api``). +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import os +import re +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Tuple, cast +from urllib.parse import urlencode, urlparse + +import httpx +from jvspatial.core import Node +from jvspatial.core.annotations import attribute + +logger = logging.getLogger(__name__) + +INTEGRAL_SERVER = "integral_core" +INTEGRAL_SERVICE = "integral" +INTEGRAL_LABEL = "Integral" +DEFAULT_ACCOUNT = "integral" + +# Fallback OAuth path prefix when discovery documents are unreachable. Matches +# jvspatial's advertised ``{issuer}/api/oauth/*`` layout (AuthConfig default +# ``oauth_prefix="/oauth"`` mounted under the ``/api`` API prefix). +_FALLBACK_OAUTH_PREFIX = "/api/oauth" + +DEFAULT_TIMEOUT = 15.0 + +# Treat a token as expired this many seconds before its real expiry so a +# request never races the clock. +_EXPIRY_SKEW_SECONDS = 60 + + +class IntegralOAuthServiceError(RuntimeError): + """Raised when discovery / registration / token exchange fails.""" + + +class IntegralOAuthClient(Node): + """Persisted Dynamic Client Registration for one Integral MCP server. + + A DCR registration is reusable across connect flows; persisting it avoids + re-registering on every auth-page load and sidesteps the server's DCR + rate limit. + """ + + server_name: str = attribute( + indexed=True, + default=INTEGRAL_SERVER, + description="MCP server name this registration belongs to.", + ) + client_id: str = attribute( + default="", + description="DCR-issued public client identifier.", + ) + redirect_uri: str = attribute( + default="", + description="Redirect URI the client was registered with.", + ) + scope: str = attribute( + default="", + description="Space-delimited scope registered with the client.", + ) + metadata_json: str = attribute( + default="", + description="Serialized authorization-server metadata discovered at register time.", + ) + + +# --------------------------------------------------------------------------- +# URL helpers +# --------------------------------------------------------------------------- + + +def mcp_integral_core_auth_url( + account: str = DEFAULT_ACCOUNT, + service: Optional[str] = None, + base_url: Optional[str] = None, +) -> str: + """Public browser URL for the Integral MCP OAuth start page.""" + from jvagent.core.public_url import get_public_base_url + + base = (base_url or get_public_base_url() or "").rstrip("/") + params = [] + if account: + params.append(f"account={account}") + if service: + params.append(f"service={service}") + q = f"?{'&'.join(params)}" if params else "" + return f"{base}/api/mcp/{INTEGRAL_SERVER}/auth{q}" + + +def _origin(url: str) -> str: + """Return ``scheme://host[:port]`` for *url* (no trailing slash).""" + parsed = urlparse((url or "").strip()) + if not parsed.scheme or not parsed.netloc: + return "" + return f"{parsed.scheme}://{parsed.netloc}" + + +def resolve_mcp_url(mcp_url: str = "") -> str: + """Resolve the Integral MCP URL from config or the environment. + + Priority: explicit *mcp_url* → ``INTEGRAL_CORE_MCP_URL`` → + ``INTEGRAL_MCP_URL`` → ``http://127.0.0.1:9006/api/mcp/`` (Core's stable web + port via the bundled reverse proxy). + """ + candidate = (mcp_url or "").strip() + if not candidate: + candidate = ( + os.environ.get("INTEGRAL_CORE_MCP_URL") + or os.environ.get("INTEGRAL_MCP_URL") + or "" + ).strip() + return candidate or "http://127.0.0.1:9006/api/mcp/" + + +def resolve_issuer_override(issuer: str = "") -> str: + """Resolve an explicit authorization-server issuer, if configured.""" + candidate = (issuer or "").strip() + if not candidate: + candidate = (os.environ.get("INTEGRAL_OAUTH_ISSUER") or "").strip() + return candidate.rstrip("/") + + +def resolve_scope(scope: str = "") -> str: + """Resolve the OAuth scope string (may legitimately be empty).""" + candidate = (scope or "").strip() + if not candidate: + candidate = (os.environ.get("INTEGRAL_OAUTH_SCOPE") or "").strip() + return candidate + + +# Scopes are authored on the MCP server entry (``auth.scopes`` / ``auth.scope``) +# and forwarded to Core *verbatim* — no aliasing or filtering. Core intersects +# the requested scope with its own ``OAUTH_SUPPORTED_SCOPES`` ceiling, so adding +# or renaming a scope never requires a code change here. + + +def join_scopes(value: Any) -> str: + """Join a scope config value into a space-separated string, verbatim. + + Accepts a list/tuple/set (or a space/comma-separated string). Each token is + stripped and passed through unchanged — no aliasing, case-folding, or + filtering — so any scope Core advertises (or later adds) can be authored + directly on the server entry. Order is preserved; empties are dropped. + """ + if value is None: + return "" + if isinstance(value, str): + tokens = [t for t in re.split(r"[,\s]+", value) if t] + elif isinstance(value, (list, tuple, set)): + tokens = [str(t) for t in value if str(t).strip()] + else: + tokens = [str(value)] + return " ".join(str(t).strip() for t in tokens if str(t).strip()) + + +def resolve_scopes_from_auth(auth_cfg: Optional[Dict[str, Any]]) -> str: + """Resolve the requested scope from a server ``auth`` block, else the env. + + Precedence: ``auth.scopes`` (list) → ``auth.scope`` (string) → + ``INTEGRAL_OAUTH_SCOPE`` env (via :func:`resolve_scope`). Values are passed + through verbatim. + """ + if isinstance(auth_cfg, dict): + if auth_cfg.get("scopes"): + return join_scopes(auth_cfg.get("scopes")) + if auth_cfg.get("scope"): + return join_scopes(auth_cfg.get("scope")) + return resolve_scope() + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +def fallback_metadata(origin: str) -> Dict[str, Any]: + """Construct AS metadata from the origin when discovery docs are unreachable.""" + base = origin.rstrip("/") + ep_base = f"{base}{_FALLBACK_OAUTH_PREFIX}" + return { + "issuer": base, + "authorization_endpoint": f"{ep_base}/authorize", + "token_endpoint": f"{ep_base}/token", + "registration_endpoint": f"{ep_base}/register", + "jwks_uri": f"{base}/.well-known/jwks.json", + "code_challenge_methods_supported": ["S256"], + "grant_types_supported": ["authorization_code", "refresh_token"], + } + + +async def _get_json(client: httpx.AsyncClient, url: str) -> Optional[Dict[str, Any]]: + """GET *url* and return a JSON object, or ``None`` on any failure.""" + try: + resp = await client.get(url, headers={"Accept": "application/json"}) + except Exception as exc: # network / DNS / TLS + logger.debug("Integral OAuth discovery GET failed (%s): %s", url, exc) + return None + if resp.status_code != 200: + logger.debug("Integral OAuth discovery GET %s -> %s", url, resp.status_code) + return None + try: + data = resp.json() + except Exception: + return None + return data if isinstance(data, dict) else None + + +async def discover_authorization_server( + *, + mcp_url: str = "", + issuer: str = "", + timeout: float = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + """Discover Core's authorization-server metadata. + + Order: explicit *issuer* override → ``/.well-known/oauth-protected-resource`` + → ``/.well-known/oauth-authorization-server`` → a constructed fallback based + on the MCP origin. Always returns a usable dict (raising only when no origin + can be determined). + """ + override = resolve_issuer_override(issuer) + if override: + return await _metadata_for_issuer(override, timeout=timeout) + + origin = _origin(resolve_mcp_url(mcp_url)) + if not origin: + raise IntegralOAuthServiceError( + "Cannot discover Integral OAuth endpoints: no MCP URL configured." + ) + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + prm = await _get_json(client, f"{origin}/.well-known/oauth-protected-resource") + as_issuer = "" + if prm: + servers = prm.get("authorization_servers") or [] + if isinstance(servers, list) and servers: + as_issuer = str(servers[0]).strip().rstrip("/") + + if as_issuer and as_issuer != origin: + meta = await _metadata_for_issuer(as_issuer, timeout=timeout) + if meta.get("token_endpoint"): + return meta + + direct = await _get_json( + client, f"{origin}/.well-known/oauth-authorization-server" + ) + if direct and direct.get("token_endpoint"): + return direct + + logger.warning( + "Integral OAuth discovery docs unreachable at %s; " + "falling back to %s/* layout", + origin, + _FALLBACK_OAUTH_PREFIX, + ) + return fallback_metadata(origin) + + +async def _metadata_for_issuer( + issuer: str, *, timeout: float = DEFAULT_TIMEOUT +) -> Dict[str, Any]: + """Fetch AS metadata for a known *issuer*, else synthesize it.""" + issuer = issuer.rstrip("/") + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + meta = await _get_json( + client, f"{issuer}/.well-known/oauth-authorization-server" + ) + if meta and meta.get("token_endpoint"): + return meta + return fallback_metadata(issuer) + + +# --------------------------------------------------------------------------- +# Dynamic Client Registration +# --------------------------------------------------------------------------- + + +async def register_client( + meta: Dict[str, Any], + *, + redirect_uri: str, + scope: str = "", + client_name: str = "jvagent", + timeout: float = DEFAULT_TIMEOUT, +) -> str: + """Register a public client via DCR; return the ``client_id``.""" + endpoint = str(meta.get("registration_endpoint") or "").strip() + if not endpoint: + raise IntegralOAuthServiceError( + "Authorization-server metadata has no registration_endpoint." + ) + body: Dict[str, Any] = { + "client_name": client_name, + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + if scope: + body["scope"] = scope + if "S256" in (meta.get("code_challenge_methods_supported") or []): + pass # PKCE S256 is advertised; nothing to declare at registration. + + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + endpoint, json=body, headers={"Accept": "application/json"} + ) + if resp.status_code not in (200, 201): + raise IntegralOAuthServiceError( + f"Dynamic client registration failed ({resp.status_code}): {resp.text}" + ) + data = resp.json() + client_id = str((data or {}).get("client_id") or "").strip() + if not client_id: + raise IntegralOAuthServiceError( + "Dynamic client registration response carried no client_id." + ) + return client_id + + +async def load_registered_client( + server_name: str = INTEGRAL_SERVER, +) -> Optional[IntegralOAuthClient]: + """Return the persisted DCR client for *server_name*, if any.""" + try: + rows = await IntegralOAuthClient.find({"context.server_name": server_name}) + except Exception as exc: + logger.debug("Integral DCR client lookup failed: %s", exc) + return None + return cast(IntegralOAuthClient, rows[0]) if rows else None + + +async def save_registered_client( + *, + server_name: str, + client_id: str, + redirect_uri: str, + scope: str, + meta: Dict[str, Any], +) -> None: + """Persist (or update) the DCR client for *server_name*.""" + import json + + existing = await load_registered_client(server_name) + payload = json.dumps(meta or {}) + if existing: + existing.client_id = client_id + existing.redirect_uri = redirect_uri + existing.scope = scope + existing.metadata_json = payload + await existing.save() + return + await IntegralOAuthClient.create( + server_name=server_name, + client_id=client_id, + redirect_uri=redirect_uri, + scope=scope, + metadata_json=payload, + ) + + +async def ensure_registered_client( + *, + server_name: str = INTEGRAL_SERVER, + redirect_uri: str, + scope: str = "", + mcp_url: str = "", + issuer: str = "", + force: bool = False, +) -> Tuple[str, Dict[str, Any]]: + """Return ``(client_id, as_metadata)``, registering via DCR when needed.""" + existing = None if force else await load_registered_client(server_name) + if ( + existing + and existing.client_id + and existing.redirect_uri == redirect_uri + and (existing.scope or "") == scope + ): + meta = await discover_authorization_server(mcp_url=mcp_url, issuer=issuer) + return existing.client_id, meta + + meta = await discover_authorization_server(mcp_url=mcp_url, issuer=issuer) + client_id = await register_client(meta, redirect_uri=redirect_uri, scope=scope) + await save_registered_client( + server_name=server_name, + client_id=client_id, + redirect_uri=redirect_uri, + scope=scope, + meta=meta, + ) + logger.info( + "Registered Integral OAuth client for %s (client_id=%s…)", + server_name, + client_id[:8], + ) + return client_id, meta + + +# --------------------------------------------------------------------------- +# PKCE +# --------------------------------------------------------------------------- + + +def pkce_verifier(nbytes: int = 64) -> str: + """Return an RFC 7636 ``code_verifier`` (43-128 unreserved characters).""" + return secrets.token_urlsafe(nbytes) + + +def pkce_challenge(verifier: str) -> str: + """Return the ``S256`` ``code_challenge`` for *verifier*.""" + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +# --------------------------------------------------------------------------- +# Authorization URL, token exchange, refresh +# --------------------------------------------------------------------------- + + +def build_authorize_url( + meta: Dict[str, Any], + *, + client_id: str, + redirect_uri: str, + state: str, + code_challenge: str, + scope: str = "", +) -> str: + """Build the authorization-code URL with a PKCE S256 challenge.""" + endpoint = str(meta.get("authorization_endpoint") or "").strip() + if not endpoint: + raise IntegralOAuthServiceError( + "Authorization-server metadata has no authorization_endpoint." + ) + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + if scope: + params["scope"] = scope + return f"{endpoint}?{urlencode(params)}" + + +async def exchange_authorization_code( + meta: Dict[str, Any], + *, + client_id: str, + code: str, + code_verifier: str, + redirect_uri: str, + timeout: float = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + """Exchange an authorization code for tokens (public client + PKCE).""" + endpoint = str(meta.get("token_endpoint") or "").strip() + if not endpoint: + raise IntegralOAuthServiceError( + "Authorization-server metadata has no token_endpoint." + ) + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + } + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + endpoint, data=data, headers={"Accept": "application/json"} + ) + if resp.status_code != 200: + raise IntegralOAuthServiceError( + f"Integral token exchange failed ({resp.status_code}): {resp.text}" + ) + tokens = resp.json() + if not isinstance(tokens, dict) or not tokens.get("access_token"): + raise IntegralOAuthServiceError( + "Integral token exchange returned no access_token." + ) + return tokens + + +async def refresh_access_token( + meta: Dict[str, Any], + *, + client_id: str, + refresh_token: str, + scope: str = "", + timeout: float = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + """Refresh an access token (public client; no PKCE on refresh).""" + endpoint = str(meta.get("token_endpoint") or "").strip() + if not endpoint: + raise IntegralOAuthServiceError( + "Authorization-server metadata has no token_endpoint." + ) + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + } + if scope: + data["scope"] = scope + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + endpoint, data=data, headers={"Accept": "application/json"} + ) + if resp.status_code != 200: + raise IntegralOAuthServiceError( + f"Integral token refresh failed ({resp.status_code}): {resp.text}" + ) + tokens = resp.json() + if not isinstance(tokens, dict) or not tokens.get("access_token"): + raise IntegralOAuthServiceError( + "Integral token refresh returned no access_token." + ) + return tokens + + +# --------------------------------------------------------------------------- +# Stored token payload helpers +# --------------------------------------------------------------------------- + + +def normalize_token_payload( + tokens: Dict[str, Any], + *, + client_id: str, + token_uri: str, + account_name: str, + scope: str = "", + previous: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build the JSON payload persisted on :class:`MCPOAuthToken`. + + ``refresh`` tokens are rotated by some servers; keep the previous value when + the response omits a new one. + """ + expires_in = int(tokens.get("expires_in") or 3600) + expiry = datetime.now(timezone.utc) + timedelta(seconds=max(0, expires_in)) + refresh = tokens.get("refresh_token") or ( + (previous or {}).get("refresh_token") if previous else "" + ) + scopes = tokens.get("scope") + if isinstance(scopes, str): + scope_list = scopes.split() + else: + scope_list = list(scope.split()) if scope else [] + return { + "type": "oauth2", + "client_id": client_id, + "token": tokens.get("access_token") or "", + "access_token": tokens.get("access_token") or "", + "refresh_token": refresh or "", + "token_uri": token_uri, + "scopes": scope_list, + "account_alias": account_name, + "expiry": expiry.isoformat(), + } + + +def payload_is_expired(payload: Optional[Dict[str, Any]]) -> bool: + """Return True when the stored access token is missing or (near) expired.""" + if not payload: + return True + if not payload.get("access_token") and not payload.get("token"): + return True + raw = payload.get("expiry") or "" + if not raw: + return False + try: + expiry = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + except ValueError: + return True + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + return expiry <= datetime.now(timezone.utc) + timedelta( + seconds=_EXPIRY_SKEW_SECONDS + ) diff --git a/jvagent/action/mcp_oauth/mcp_oauth_action.py b/jvagent/action/mcp_oauth/mcp_oauth_action.py index 818ac5fc..f323b12f 100644 --- a/jvagent/action/mcp_oauth/mcp_oauth_action.py +++ b/jvagent/action/mcp_oauth/mcp_oauth_action.py @@ -53,6 +53,12 @@ def build_oauth_setup( ) -> List[Dict[str, Any]]: """Per-service OAuth endpoints for configured Google/Microsoft MCP servers.""" from .hydrate import mcp_google_workspace_auth_url + from .integral_provider import ( + INTEGRAL_LABEL, + INTEGRAL_SERVER, + INTEGRAL_SERVICE, + mcp_integral_core_auth_url, + ) from .microsoft_hydrate import mcp_microsoft_365_auth_url from .microsoft_scopes import ( MICROSOFT_365_SERVER, @@ -135,6 +141,14 @@ def _append( mcp_microsoft_365_auth_url, ) + if INTEGRAL_SERVER in names: + _append( + INTEGRAL_SERVER, + [INTEGRAL_SERVICE], + {INTEGRAL_SERVICE: INTEGRAL_LABEL}, + mcp_integral_core_auth_url, + ) + return setup diff --git a/jvagent/action/stt_action/deepgram/deepgram.py b/jvagent/action/stt_action/deepgram/deepgram.py index d2234f13..03c6b815 100644 --- a/jvagent/action/stt_action/deepgram/deepgram.py +++ b/jvagent/action/stt_action/deepgram/deepgram.py @@ -7,6 +7,7 @@ from deepgram import AsyncDeepgramClient from deepgram.core.api_error import ApiError +from jvspatial import create_task from jvspatial.core.annotations import attribute from jvspatial.env import env from pydantic import PrivateAttr @@ -254,7 +255,9 @@ async def _receiver() -> None: elif mtype == "UtteranceEnd": await on_event({"type": "utterance_end"}) - recv_task = asyncio.create_task(_receiver()) + recv_task = await create_task( + _receiver(), name="deepgram_receiver", concurrent=True + ) try: async for chunk in audio_iter: if chunk: diff --git a/jvagent/action/whatsapp/whatsapp_action.py b/jvagent/action/whatsapp/whatsapp_action.py index 4891d79d..3f1c53c3 100644 --- a/jvagent/action/whatsapp/whatsapp_action.py +++ b/jvagent/action/whatsapp/whatsapp_action.py @@ -6,6 +6,7 @@ import os from typing import Annotated, Any, ClassVar, Dict, List, Optional, Union +from jvspatial import create_task from jvspatial.api.auth.api_key_service import APIKeyService from jvspatial.core.annotations import attribute from jvspatial.core.context import GraphContext @@ -759,11 +760,12 @@ async def _run_deferred_meta_webhook_register_after_startup(self) -> None: timeout, ) - def _fire_meta_webhook_register_task(self) -> None: + async def _fire_meta_webhook_register_task(self) -> None: """Fire-and-forget register on long-running hosts (strongly referenced).""" - task = asyncio.create_task( + task = await create_task( self._run_deferred_meta_webhook_register_after_startup(), name="meta_whatsapp_webhook_register", + concurrent=True, ) _BACKGROUND_TASKS.add(task) task.add_done_callback(_BACKGROUND_TASKS.discard) @@ -773,13 +775,13 @@ async def _launch_deferred_meta_webhook_register(self) -> None: if WhatsAppAction._is_serverless_runtime(): await self._run_deferred_meta_webhook_register_after_startup() return - self._fire_meta_webhook_register_task() + await self._fire_meta_webhook_register_task() async def _schedule_deferred_meta_webhook_register(self) -> None: """Register Meta webhook override after the HTTP server is listening. ``on_startup`` runs inside ``asyncio.run(pre_startup_bootstrap)``; a bare - ``asyncio.create_task`` there is cancelled when that loop closes. Hook + ``jvspatial.create_task`` there is cancelled when that loop closes. Hook into the jvspatial server lifecycle instead (same pattern as startup summary). On serverless the lifecycle hook **awaits** register so Lambda does not freeze before ``jvconnect_webhook_secret`` is saved. diff --git a/jvagent/core/distributed_lease.py b/jvagent/core/distributed_lease.py index be9a78df..84222402 100644 --- a/jvagent/core/distributed_lease.py +++ b/jvagent/core/distributed_lease.py @@ -25,6 +25,8 @@ from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Optional +from jvspatial import create_task + from jvagent.memory.distributed_conversation_lock import ( _dynamo_table, _lease_renew_interval, @@ -119,8 +121,10 @@ async def _redis_lease(key: str, redis_url: str, ttl: int) -> AsyncIterator[None async def _renew() -> None: await client.eval(renew_script, 1, rkey, token, str(ttl)) - heartbeat = asyncio.create_task( - _run_lease_heartbeat(_renew, _lease_renew_interval(ttl), key) + heartbeat = await create_task( + _run_lease_heartbeat(_renew, _lease_renew_interval(ttl), key), + name="lease_heartbeat", + concurrent=True, ) yield finally: @@ -227,8 +231,10 @@ def release() -> None: async def _renew() -> None: await asyncio.to_thread(renew) - heartbeat = asyncio.create_task( - _run_lease_heartbeat(_renew, _lease_renew_interval(ttl), key) + heartbeat = await create_task( + _run_lease_heartbeat(_renew, _lease_renew_interval(ttl), key), + name="lease_heartbeat", + concurrent=True, ) yield finally: diff --git a/jvagent/memory/distributed_conversation_lock.py b/jvagent/memory/distributed_conversation_lock.py index 6fc7d639..5c156a48 100644 --- a/jvagent/memory/distributed_conversation_lock.py +++ b/jvagent/memory/distributed_conversation_lock.py @@ -21,10 +21,12 @@ from contextlib import asynccontextmanager from typing import AsyncIterator, Optional, Tuple +from jvspatial import create_task + logger = logging.getLogger(__name__) # Holder = (conversation_id, holding-task-identity). The task identity MUST be -# part of the guard: ``asyncio.create_task`` copies the current contextvar +# part of the guard: ``jvspatial.create_task`` copies the current contextvar # context into the child task, so a background task spawned while the turn holds # the lock would otherwise inherit ``_lock_holder`` and the reentrancy check # would report the lock as held — letting that background task (e.g. a @@ -270,8 +272,10 @@ async def _redis_conversation_lock( async def _renew() -> None: await client.eval(renew_script, 1, key, token, str(ttl)) - heartbeat = asyncio.create_task( - _run_lease_heartbeat(_renew, _lease_renew_interval(ttl), conversation_id) + heartbeat = await create_task( + _run_lease_heartbeat(_renew, _lease_renew_interval(ttl), conversation_id), + name="conversation_lock_heartbeat", + concurrent=True, ) yield finally: @@ -418,10 +422,12 @@ def renew() -> None: async def _renew() -> None: await asyncio.to_thread(renew) - heartbeat = asyncio.create_task( + heartbeat = await create_task( _run_lease_heartbeat( _renew, _lease_renew_interval(ttl_sec), conversation_id - ) + ), + name="conversation_lock_heartbeat", + concurrent=True, ) yield finally: diff --git a/jvagent/version.py b/jvagent/version.py index 1e0c5231..086f2037 100644 --- a/jvagent/version.py +++ b/jvagent/version.py @@ -1,3 +1,3 @@ """Version information for jvagent package.""" -__version__ = "0.1.8rc20-dev3" +__version__ = "0.1.8rc19" diff --git a/tests/action/mcp/test_mcp_auth_clients.py b/tests/action/mcp/test_mcp_auth_clients.py new file mode 100644 index 00000000..4a7d3531 --- /dev/null +++ b/tests/action/mcp/test_mcp_auth_clients.py @@ -0,0 +1,177 @@ +"""MCP transport httpx clients must follow Core's ``/api/mcp`` trailing-slash 307. + +Core advertises the MCP endpoint without a trailing slash; its Starlette Mount +answers ``POST /api/mcp`` with a 307 to ``/api/mcp/``. The MCP SDK consumes the +client we pass as-is and raises on that response, so both auth clients must +follow redirects. +""" + +from jvagent.action.mcp.integral_oauth_auth import build_integral_oauth_client +from jvagent.action.mcp.service_key_auth import build_service_key_client + + +async def test_integral_oauth_client_follows_redirects(): + client = build_integral_oauth_client( + server_name="integral_core", + account="integral", + connect_timeout=1.0, + read_timeout=1.0, + ) + try: + assert client.follow_redirects is True + finally: + await client.aclose() + + +async def test_service_key_client_follows_redirects(): + client = build_service_key_client( + service_key="k" * 32, + user_id="u", + connect_timeout=1.0, + read_timeout=1.0, + ) + try: + assert client.follow_redirects is True + finally: + await client.aclose() + + +def _request_auth_hook(client, name): + hooks = [h for h in client._event_hooks["request"] if h.__name__ == name] + assert hooks, f"missing request hook {name}" + return hooks[0] + + +async def test_integral_hook_reapplies_bearer_on_redirect_hop(): + """A cross-origin redirect strips ``Authorization``; the hook must re-add it.""" + + async def _fake_token(): + return "tok" + + client = build_integral_oauth_client( + server_name="integral_core", + account="integral", + connect_timeout=1.0, + read_timeout=1.0, + ) + try: + client._auth._valid_token = _fake_token + request = client.build_request("POST", "https://example.invalid/api/mcp/") + if "authorization" in request.headers: + del request.headers["authorization"] + await _request_auth_hook(client, "_ensure_auth")(request) + assert request.headers["Authorization"] == "Bearer tok" + finally: + await client.aclose() + + +async def test_service_key_hook_resigns_on_redirect_hop(): + client = build_service_key_client( + service_key="k" * 32, + user_id="u", + connect_timeout=1.0, + read_timeout=1.0, + ) + try: + request = client.build_request("POST", "https://example.invalid/api/mcp/") + if "x-integral-service-key" in request.headers: + del request.headers["x-integral-service-key"] + await _request_auth_hook(client, "_ensure_service_key")(request) + assert request.headers["X-Integral-Service-Key"] == "k" * 32 + assert request.headers["X-Integral-Signature"] + finally: + await client.aclose() + + +async def test_integral_client_stores_configured_scope(): + client = build_integral_oauth_client( + server_name="integral_core", + account="integral", + scope="integral:read integral:execute", + mcp_url="http://127.0.0.1:54853/api/mcp/", + connect_timeout=1.0, + read_timeout=1.0, + ) + try: + assert client._auth._scope == "integral:read integral:execute" + assert client._auth._mcp_url == "http://127.0.0.1:54853/api/mcp/" + finally: + await client.aclose() + + +class _FakeNode: + pass + + +async def test_integral_refresh_prefers_configured_scope(monkeypatch): + import jvagent.action.mcp.integral_oauth_auth as mod + + captured = {} + + async def _fake_discover(**kwargs): + return {"token_endpoint": "http://core.invalid/api/oauth/token"} + + async def _fake_refresh(meta, *, client_id, refresh_token, scope): + captured["scope"] = scope + return {"access_token": "at", "expires_in": 3600, "refresh_token": "rt"} + + async def _fake_save(node, payload): + captured["payload"] = payload + + def _env_scope(*a, **k): + return "env:scope" + + monkeypatch.setattr(mod, "discover_authorization_server", _fake_discover) + monkeypatch.setattr(mod, "refresh_access_token", _fake_refresh) + monkeypatch.setattr(mod, "_save_token_payload", _fake_save) + monkeypatch.setattr(mod, "resolve_scope", _env_scope) + + auth = mod.IntegralOAuthAuth( + server_name="integral_core", + account_name="integral", + scope="integral:read integral:execute", + ) + payload = { + "refresh_token": "rt", + "client_id": "cid", + "access_token": "old", + "token_uri": "http://core.invalid/api/oauth/token", + "expiry": "2000-01-01T00:00:00Z", + } + token = await auth._refresh(_FakeNode(), payload) + assert token == "at" + assert captured["scope"] == "integral:read integral:execute" + + +async def test_integral_refresh_falls_back_to_env_scope(monkeypatch): + import jvagent.action.mcp.integral_oauth_auth as mod + + captured = {} + + async def _fake_discover(**kwargs): + return {"token_endpoint": "http://core.invalid/api/oauth/token"} + + async def _fake_refresh(meta, *, client_id, refresh_token, scope): + captured["scope"] = scope + return {"access_token": "at", "expires_in": 3600} + + async def _fake_save(node, payload): + pass + + def _env_scope(*a, **k): + return "integral:read" + + monkeypatch.setattr(mod, "discover_authorization_server", _fake_discover) + monkeypatch.setattr(mod, "refresh_access_token", _fake_refresh) + monkeypatch.setattr(mod, "_save_token_payload", _fake_save) + monkeypatch.setattr(mod, "resolve_scope", _env_scope) + + auth = mod.IntegralOAuthAuth(server_name="integral_core", account_name="integral") + payload = { + "refresh_token": "rt", + "client_id": "cid", + "access_token": "old", + "expiry": "2000-01-01T00:00:00Z", + } + await auth._refresh(_FakeNode(), payload) + assert captured["scope"] == "integral:read" diff --git a/tests/action/mcp/test_mcp_client_lifecycle.py b/tests/action/mcp/test_mcp_client_lifecycle.py new file mode 100644 index 00000000..25b75ae2 --- /dev/null +++ b/tests/action/mcp/test_mcp_client_lifecycle.py @@ -0,0 +1,102 @@ +"""MCPClientWrapper streamable_http teardown must not cross asyncio tasks. + +``streamable_http_client`` (and ``stdio_client``) wrap an ``anyio`` task group +whose cancel scope must be entered and exited in the *same* asyncio task. The +wrapper therefore runs the transport + ClientSession inside a dedicated +background task. Regression: connect in one task, disconnect from another, and +assert the transport's enter/exit happened in the same task. +""" + +import asyncio +from contextlib import asynccontextmanager + +import pytest + +from jvagent.action.mcp.client import MCPClientWrapper + + +class _FakeStream: + pass + + +class _FakeSession: + def __init__(self, read_stream, write_stream): + self._read = read_stream + self._write = write_stream + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def initialize(self): + return None + + async def list_tools(self): + return type("R", (), {"tools": []})() + + async def call_tool(self, name, arguments): + return {"ok": True} + + +def _make_imports(entered): + """Build an ``_ensure_imports`` return with a fake transport recording tasks.""" + + @asynccontextmanager + async def _transport(url, http_client=None): + entered["enter"] = asyncio.current_task() + try: + yield (_FakeStream(), _FakeStream(), "sid") + finally: + entered["exit"] = asyncio.current_task() + + def _imports(): + return (_FakeSession, None, None, _transport) + + return _imports + + +async def test_streamable_http_enter_and_exit_share_one_task(monkeypatch): + entered = {} + monkeypatch.setattr( + MCPClientWrapper, "_ensure_imports", staticmethod(_make_imports(entered)) + ) + + wrapper = MCPClientWrapper( + "streamable_http", + url="http://127.0.0.1:1/api/mcp/", + connect_timeout=2.0, + call_timeout=2.0, + ) + await wrapper.connect() + assert wrapper.connected is True + + # Disconnect from a *different* task than connect — the transport must still + # be torn down in its own task (no cross-task cancel-scope error). + await asyncio.create_task(wrapper.disconnect()) + + assert wrapper.connected is False + assert entered["enter"] is entered["exit"] + + +async def test_streamable_http_connect_surfaces_transport_error(monkeypatch): + @asynccontextmanager + async def _boom(url, http_client=None): + raise ValueError("boom") + yield # pragma: no cover + + def _imports(): + return (_FakeSession, None, None, _boom) + + monkeypatch.setattr(MCPClientWrapper, "_ensure_imports", staticmethod(_imports)) + + wrapper = MCPClientWrapper( + "streamable_http", + url="http://127.0.0.1:1/api/mcp/", + connect_timeout=2.0, + call_timeout=2.0, + ) + with pytest.raises(ValueError, match="boom"): + await wrapper.connect() + assert wrapper.connected is False diff --git a/tests/action/mcp_oauth/test_integral_provider.py b/tests/action/mcp_oauth/test_integral_provider.py new file mode 100644 index 00000000..166cc4e9 --- /dev/null +++ b/tests/action/mcp_oauth/test_integral_provider.py @@ -0,0 +1,321 @@ +"""Integral Core OAuth 2.1 provider: discovery, DCR, PKCE, exchange, refresh.""" + +import json + +import pytest + +from jvagent.action.mcp_oauth import integral_provider as ip +from jvagent.action.mcp_oauth.mcp_oauth_action import build_oauth_setup + +BASE = "https://agent.example.com" +MCP_URL = "http://127.0.0.1:9006/api/mcp/" +ORIGIN = "http://127.0.0.1:9006" +TOKEN_URL = f"{ORIGIN}/api/oauth/token" +REGISTER_URL = f"{ORIGIN}/api/oauth/register" +AUTHORIZE_URL = f"{ORIGIN}/api/oauth/authorize" + +AS_META = { + "issuer": ORIGIN, + "authorization_endpoint": AUTHORIZE_URL, + "token_endpoint": TOKEN_URL, + "registration_endpoint": REGISTER_URL, + "code_challenge_methods_supported": ["S256"], + "grant_types_supported": ["authorization_code", "refresh_token"], +} +PRM = {"resource": MCP_URL, "authorization_servers": [ORIGIN]} + + +class _Resp: + def __init__(self, status: int, payload: dict): + self.status_code = status + self._payload = payload + self.headers = {"content-type": "application/json"} + + @property + def text(self) -> str: + return json.dumps(self._payload) + + def json(self) -> dict: + return self._payload + + +def _install_fake(monkeypatch, *, get=None, post=None): + """Replace ``httpx.AsyncClient`` with a canned-response stand-in.""" + calls = {"get": [], "post": []} + + class _Fake: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url, **kw): + calls["get"].append(str(url)) + return (get or {}).get(str(url), _Resp(404, {})) + + async def post(self, url, **kw): + calls["post"].append((str(url), kw)) + return (post or {}).get(str(url), _Resp(404, {})) + + monkeypatch.setattr(ip.httpx, "AsyncClient", _Fake) + return calls + + +# --------------------------------------------------------------------------- +# PKCE (RFC 7636 appendix B test vector) +# --------------------------------------------------------------------------- + + +def test_pkce_challenge_matches_rfc7636_vector(): + verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + assert ip.pkce_challenge(verifier) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + +def test_pkce_verifier_length_is_within_rfc_bounds(): + verifier = ip.pkce_verifier() + assert 43 <= len(verifier) <= 128 + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +async def test_discovery_prefers_protected_resource(monkeypatch): + _install_fake( + monkeypatch, + get={ + f"{ORIGIN}/.well-known/oauth-protected-resource": _Resp(200, PRM), + f"{ORIGIN}/.well-known/oauth-authorization-server": _Resp(200, AS_META), + }, + ) + meta = await ip.discover_authorization_server(mcp_url=MCP_URL) + assert meta["token_endpoint"] == TOKEN_URL + assert meta["registration_endpoint"] == REGISTER_URL + + +async def test_discovery_falls_back_when_docs_unreachable(monkeypatch): + _install_fake(monkeypatch, get={}) + meta = await ip.discover_authorization_server(mcp_url=MCP_URL) + assert meta["token_endpoint"] == TOKEN_URL + assert meta["authorization_endpoint"] == AUTHORIZE_URL + + +async def test_discovery_uses_explicit_issuer(monkeypatch): + _install_fake( + monkeypatch, + get={f"{ORIGIN}/.well-known/oauth-authorization-server": _Resp(200, AS_META)}, + ) + meta = await ip.discover_authorization_server(issuer=ORIGIN) + assert meta["token_endpoint"] == TOKEN_URL + + +# --------------------------------------------------------------------------- +# Dynamic Client Registration +# --------------------------------------------------------------------------- + + +async def test_register_client_posts_public_client(monkeypatch): + calls = _install_fake( + monkeypatch, + post={REGISTER_URL: _Resp(201, {"client_id": "dcr_client_123"})}, + ) + client_id = await ip.register_client( + AS_META, + redirect_uri=f"{BASE}/api/mcp/integral_core/auth/callback", + scope="", + ) + assert client_id == "dcr_client_123" + url, kw = calls["post"][0] + assert url == REGISTER_URL + body = kw["json"] + assert body["token_endpoint_auth_method"] == "none" + assert body["grant_types"] == ["authorization_code", "refresh_token"] + assert body["redirect_uris"] == [f"{BASE}/api/mcp/integral_core/auth/callback"] + + +async def test_register_client_raises_on_error(monkeypatch): + _install_fake(monkeypatch, post={REGISTER_URL: _Resp(400, {"error": "x"})}) + with pytest.raises(ip.IntegralOAuthServiceError): + await ip.register_client(AS_META, redirect_uri="https://x/cb") + + +# --------------------------------------------------------------------------- +# Authorize URL +# --------------------------------------------------------------------------- + + +def test_build_authorize_url_carries_pkce_s256(): + url = ip.build_authorize_url( + AS_META, + client_id="cid", + redirect_uri="https://x/cb", + state="st", + code_challenge="ch", + scope="", + ) + assert url.startswith(AUTHORIZE_URL + "?") + assert "code_challenge_method=S256" in url + assert "code_challenge=ch" in url + assert "client_id=cid" in url + + +# --------------------------------------------------------------------------- +# Token exchange + refresh +# --------------------------------------------------------------------------- + + +async def test_exchange_authorization_code(monkeypatch): + calls = _install_fake( + monkeypatch, + post={ + TOKEN_URL: _Resp( + 200, + { + "access_token": "at_1", + "refresh_token": "rt_1", + "expires_in": 3600, + "token_type": "Bearer", + }, + ) + }, + ) + tokens = await ip.exchange_authorization_code( + AS_META, + client_id="cid", + code="code", + code_verifier="verifier", + redirect_uri="https://x/cb", + ) + assert tokens["access_token"] == "at_1" + _url, kw = calls["post"][0] + assert kw["data"]["grant_type"] == "authorization_code" + assert kw["data"]["code_verifier"] == "verifier" + assert kw["data"]["client_id"] == "cid" + + +async def test_refresh_access_token(monkeypatch): + calls = _install_fake( + monkeypatch, + post={TOKEN_URL: _Resp(200, {"access_token": "at_2", "expires_in": 3600})}, + ) + tokens = await ip.refresh_access_token( + AS_META, client_id="cid", refresh_token="rt_1" + ) + assert tokens["access_token"] == "at_2" + _url, kw = calls["post"][0] + assert kw["data"]["grant_type"] == "refresh_token" + assert kw["data"]["refresh_token"] == "rt_1" + + +# --------------------------------------------------------------------------- +# Stored payload helpers +# --------------------------------------------------------------------------- + + +def test_normalize_token_payload_keeps_previous_refresh(): + payload = ip.normalize_token_payload( + {"access_token": "at", "expires_in": 3600}, + client_id="cid", + token_uri=TOKEN_URL, + account_name="integral", + previous={"refresh_token": "rt_old"}, + ) + assert payload["refresh_token"] == "rt_old" + assert payload["access_token"] == "at" + assert payload["type"] == "oauth2" + + +def test_payload_is_expired(): + assert ip.payload_is_expired(None) is True + assert ip.payload_is_expired({"access_token": "x"}) is False + assert ( + ip.payload_is_expired({"access_token": "x", "expiry": "2000-01-01T00:00:00Z"}) + is True + ) + + +# --------------------------------------------------------------------------- +# URL helpers + oauth_setup exposure +# --------------------------------------------------------------------------- + + +def test_mcp_integral_core_auth_url(): + url = ip.mcp_integral_core_auth_url(account="integral", base_url=BASE) + assert url == f"{BASE}/api/mcp/integral_core/auth?account=integral" + + +def test_build_oauth_setup_includes_integral(): + setup = build_oauth_setup(BASE, [{"name": "integral_core"}]) + assert len(setup) == 1 + entry = setup[0] + assert entry["server"] == "integral_core" + assert entry["service"] == "integral" + assert entry["label"] == "Integral" + assert entry["redirect_uri"] == f"{BASE}/api/mcp/integral_core/auth/callback" + assert entry["auth_url"] == ( + f"{BASE}/api/mcp/integral_core/auth?account=integral&service=integral" + ) + + +def test_build_oauth_setup_absent_when_not_configured(): + setup = build_oauth_setup(BASE, [{"name": "google_workspace"}]) + assert all(item["server"] != "integral_core" for item in setup) + + +# --------------------------------------------------------------------------- +# Scope passthrough (verbatim — authored on the server entry) +# --------------------------------------------------------------------------- + + +def test_join_scopes_verbatim_from_list(): + assert ip.join_scopes(["integral:read", "integral:execute"]) == ( + "integral:read integral:execute" + ) + + +def test_join_scopes_passes_through_unknown_tokens(): + # No aliasing/filtering: whatever is authored is forwarded (Core validates). + assert ip.join_scopes(["entry.create", "integral:admin"]) == ( + "entry.create integral:admin" + ) + assert ip.join_scopes(["integral:write"]) == "integral:write" + + +def test_join_scopes_accepts_string_and_splits_commas_or_spaces(): + assert ip.join_scopes("integral:read, integral:execute") == ( + "integral:read integral:execute" + ) + assert ip.join_scopes(" integral:propose integral:read ") == ( + "integral:propose integral:read" + ) + + +def test_join_scopes_empty(): + assert ip.join_scopes(None) == "" + assert ip.join_scopes([]) == "" + assert ip.join_scopes([" ", ""]) == "" + + +def test_resolve_scopes_from_auth_precedence(monkeypatch): + monkeypatch.setenv("INTEGRAL_OAUTH_SCOPE", "integral:propose") + # auth.scopes (list) wins, verbatim + assert ( + ip.resolve_scopes_from_auth({"scopes": ["integral:read", "integral:execute"]}) + == "integral:read integral:execute" + ) + # auth.scope (string) verbatim + assert ip.resolve_scopes_from_auth({"scope": "integral:read"}) == "integral:read" + # env fallback when the auth block has neither + assert ip.resolve_scopes_from_auth({}) == "integral:propose" + # scopes wins over scope + assert ( + ip.resolve_scopes_from_auth( + {"scopes": ["entry.create"], "scope": "integral:propose"} + ) + == "entry.create" + ) diff --git a/tests/action/whatsapp/test_meta_register_skip_guard.py b/tests/action/whatsapp/test_meta_register_skip_guard.py index 27ede295..7ea2d682 100644 --- a/tests/action/whatsapp/test_meta_register_skip_guard.py +++ b/tests/action/whatsapp/test_meta_register_skip_guard.py @@ -218,7 +218,9 @@ async def test_fallback_create_task_without_lifecycle_manager_long_running(): create_task.call_args.args[0].close() create_task.assert_called_once() assert task in whatsapp_module._BACKGROUND_TASKS - task.add_done_callback.assert_called_once() + # jvspatial's create_task also registers its own done-callback, so assert + # *our* discard callback was registered rather than an exact call count. + task.add_done_callback.assert_any_call(whatsapp_module._BACKGROUND_TASKS.discard) async def test_second_schedule_for_same_action_does_not_re_register(): From d496a5dec5829ea744ce3abdba9a2e825a856609 Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Wed, 14 Oct 2026 11:40:57 -0400 Subject: [PATCH 19/20] refactor(leadgen): simplify logging statement in maybe_auto_sync function This commit refines the logging statement in the `maybe_auto_sync` function by removing unnecessary line breaks, enhancing code readability without changing the functionality of the logging process. --- jvagent/action/leadgen/engine.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/jvagent/action/leadgen/engine.py b/jvagent/action/leadgen/engine.py index 59cc5edc..37618900 100644 --- a/jvagent/action/leadgen/engine.py +++ b/jvagent/action/leadgen/engine.py @@ -225,9 +225,7 @@ async def maybe_auto_sync( updated[sync_mod.DIGEST_KEY] = sync_mod.compute_digest(profile_data) await record.set_yaml(updated) status = "sync-complete" if any_success else "no-op" - logger.debug( - "leadgen sync: status=%s results=%s user=%s", status, results, user_id - ) + logger.debug("leadgen sync: status=%s results=%s user=%s", status, results, user_id) return {"status": status, "results": results} From 5230fc709fee7ca86067a75d5e0e26616c84e0de Mon Sep 17 00:00:00 2001 From: Tharick Jairam Date: Wed, 14 Oct 2026 16:47:54 -0400 Subject: [PATCH 20/20] refactor(leadgen): update field references from 'name' to 'full_name' This commit refactors the lead generation codebase by replacing instances of the field 'name' with 'full_name' across various files, including validation, specifications, and documentation. This change ensures consistency in field naming and improves clarity in the lead capture process. --- jvagent/action/leadgen/SKILL.md | 2 +- .../action/leadgen/docs/frontmatter-schema.md | 10 +++++----- jvagent/action/leadgen/engine.py | 4 ++-- .../leadgen/examples/example_leadgen/SKILL.md | 10 +++++----- jvagent/action/leadgen/leadgen_action.py | 2 +- jvagent/action/leadgen/spec.py | 4 ++-- jvagent/action/leadgen/store.py | 18 +++++++++--------- tests/action/leadgen/test_spec.py | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/jvagent/action/leadgen/SKILL.md b/jvagent/action/leadgen/SKILL.md index 5ebc3fed..a1621312 100644 --- a/jvagent/action/leadgen/SKILL.md +++ b/jvagent/action/leadgen/SKILL.md @@ -61,4 +61,4 @@ Never mention sync status to the user — continue the conversation naturally. ## Never expose internals -Do not send raw profile JSON, field names like `requested_items`, digests, or `sync_result` to the user. +Do not send raw profile JSON, field names like `order_items`, digests, or `sync_result` to the user. diff --git a/jvagent/action/leadgen/docs/frontmatter-schema.md b/jvagent/action/leadgen/docs/frontmatter-schema.md index 5dac245b..79ae9ac2 100644 --- a/jvagent/action/leadgen/docs/frontmatter-schema.md +++ b/jvagent/action/leadgen/docs/frontmatter-schema.md @@ -11,7 +11,7 @@ leadgen: title: My Lead Capture summary: Optional description fields: - - key: name + - key: full_name required: true guidance: Full name validator: person_name @@ -23,14 +23,14 @@ leadgen: required: false validator: phone phone_locale: E164 # or GY - - key: interested_products + - key: quote_items merge: true gap_fill: batch: true - priority: [name, phone, email, organization] + priority: [full_name, phone, email, organization] sync: mode: on_capture # on_capture | on_complete | manual - min_fields: [name] + min_fields: [full_name] require_any: [phone, email] # Destination-agnostic: any MCP server + tool. Below is a flat file; a # spreadsheet, email, CRM, or DB is the same shape (only server/tool/args @@ -82,7 +82,7 @@ deployment/infra, so the skill defines *what* to capture and the agent defines context: enabled: true sync_mode: on_capture # on_capture | on_complete | manual - sync_min_fields: [name] + sync_min_fields: [full_name] sync_require_any: [phone, email] sync_destinations: - server: leadfile # any configured MCP server diff --git a/jvagent/action/leadgen/engine.py b/jvagent/action/leadgen/engine.py index 37618900..607e3c86 100644 --- a/jvagent/action/leadgen/engine.py +++ b/jvagent/action/leadgen/engine.py @@ -294,8 +294,8 @@ async def handle_capture( if channel.lower() == "whatsapp": if "phone" not in raw_fields and user.user_id: raw_fields.setdefault("phone", user.user_id) - if not raw_fields.get("name") and getattr(user, "name", None): - raw_fields.setdefault("name", user.name) + if not raw_fields.get("full_name") and getattr(user, "name", None): + raw_fields.setdefault("full_name", user.name) validated, err = await validate_fields(raw_fields, spec) if err: diff --git a/jvagent/action/leadgen/examples/example_leadgen/SKILL.md b/jvagent/action/leadgen/examples/example_leadgen/SKILL.md index 92959a65..f78c212c 100644 --- a/jvagent/action/leadgen/examples/example_leadgen/SKILL.md +++ b/jvagent/action/leadgen/examples/example_leadgen/SKILL.md @@ -19,11 +19,11 @@ leadgen: Reference leadgen skill for product inquiries. Copy to agents/.../skills// and register in agent.yaml orchestrator skills. fields: - - key: name + - key: full_name required: true guidance: Full name of the contact validator: person_name - aliases: [full_name, contact_name] + aliases: [name, contact_name] - key: organization required: false guidance: Company or organization name @@ -38,7 +38,7 @@ leadgen: guidance: Phone number with country code if possible validator: phone phone_locale: E164 - - key: interested_products + - key: quote_items required: false guidance: Products or services the lead is interested in merge: true @@ -47,10 +47,10 @@ leadgen: guidance: Brief description of their project or need gap_fill: batch: true - priority: [name, phone, email, organization] + priority: [full_name, phone, email, organization] sync: mode: on_capture - min_fields: [name] + min_fields: [full_name] require_any: [phone, email] # Any MCP server + tool — a flat file, spreadsheet, email, CRM, or DB. # Only server/tool/arguments change; the sync path is identical. diff --git a/jvagent/action/leadgen/leadgen_action.py b/jvagent/action/leadgen/leadgen_action.py index 668f1f26..755b00da 100644 --- a/jvagent/action/leadgen/leadgen_action.py +++ b/jvagent/action/leadgen/leadgen_action.py @@ -52,7 +52,7 @@ class LeadGenAction(Action): ), ) sync_min_fields: List[str] = attribute( - default_factory=lambda: ["name"], + default_factory=lambda: ["full_name"], description=( "Fields that must all be present before an action-level sync fires." ), diff --git a/jvagent/action/leadgen/spec.py b/jvagent/action/leadgen/spec.py index 26c5064f..66c40437 100644 --- a/jvagent/action/leadgen/spec.py +++ b/jvagent/action/leadgen/spec.py @@ -62,7 +62,7 @@ class GapFillDef: @dataclass class SyncDef: mode: SyncMode = "on_capture" - min_fields: List[str] = field(default_factory=lambda: ["name"]) + min_fields: List[str] = field(default_factory=lambda: ["full_name"]) require_any: List[str] = field(default_factory=lambda: ["phone", "email"]) destinations: List[Dict[str, Any]] = field(default_factory=list) @@ -165,7 +165,7 @@ def _parse_sync(data: Any) -> SyncDef: raise ValueError("sync.mode must be on_capture, on_complete, or manual") return SyncDef( mode=mode, # type: ignore[arg-type] - min_fields=[str(x) for x in (data.get("min_fields") or ["name"]) if x], + min_fields=[str(x) for x in (data.get("min_fields") or ["full_name"]) if x], require_any=[ str(x) for x in (data.get("require_any") or ["phone", "email"]) if x ], diff --git a/jvagent/action/leadgen/store.py b/jvagent/action/leadgen/store.py index 37bf08a5..cc1e6005 100644 --- a/jvagent/action/leadgen/store.py +++ b/jvagent/action/leadgen/store.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) DEFAULT_REQUIRED_FIELDS: List[str] = [ - "name", + "full_name", "organization", "email", "phone", @@ -25,16 +25,16 @@ DEFAULT_OPTIONAL_FIELDS: List[str] = [ "project_description", "project_location", - "role", - "industry", - "team_size", - "budget", - "timeline", - "pain_points", + "contact_role", + "company_industry", + "company_size", + "project_budget", + "project_timeline", + "project_pain_points", "current_tools", "communication_preference", - "interested_products", - "requested_items", + "quote_items", + "order_items", "feedback", ] diff --git a/tests/action/leadgen/test_spec.py b/tests/action/leadgen/test_spec.py index 7807f723..d77857e3 100644 --- a/tests/action/leadgen/test_spec.py +++ b/tests/action/leadgen/test_spec.py @@ -39,5 +39,5 @@ def test_load_example_leadgen_skill(): spec = load_leadgen_spec_from_skill(EXAMPLE_SKILL) assert spec is not None assert spec.name == "example_leadgen" - assert "name" in spec.field_keys() + assert "full_name" in spec.field_keys() assert spec.sync.mode == "on_capture"