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 fa3c9986..4c910ff8 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`. + ### Changed - Pin the published `jvspatial==0.1.1` release across package metadata and requirements, incorporating PostgreSQL index naming and schema bootstrap fixes. @@ -18,6 +28,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/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/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/handoff_action/README.md b/jvagent/action/handoff_action/README.md new file mode 100644 index 00000000..565bc50d --- /dev/null +++ b/jvagent/action/handoff_action/README.md @@ -0,0 +1,444 @@ +# Handoff Action (`jvagent/handoff_action`) + +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__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. | + +## 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` | (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` | (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 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__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. +- 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/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 three tools on orchestrator +- Permissions on **every channel** (`default`, `whatsapp`, …): customers → + `handoff__consult` only; staff → save/update only + +```yaml +- action: jvagent/orchestrator + context: + pinned_tools: + - handoff__consult + - 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__save_answer: + deny: [] + allow: [{ group: staff, enabled: true }] + handoff__update_chunk: + deny: [] + allow: [{ group: staff, enabled: true }] + whatsapp: + tools: + # same three-tool matrix +``` + +### Smoke tests + +- Customer: policy question with empty PageIndex → `handoff__consult`, relay only +- Staff: answer matching pending → param ids + `handoff__save_answer` +- Customer receives async reply after save + +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 +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 + +**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` 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 + +**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/update 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). + +## 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..7489ac93 --- /dev/null +++ b/jvagent/action/handoff_action/handoff_action.py @@ -0,0 +1,2737 @@ +"""Human handoff capability action. + +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__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 + 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 +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__) + + +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__") + + +_register_orchestrator_vocabulary() + + +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 get back to you as soon as they respond." + +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__save_answer", + "handoff__update_chunk", + } + ), + "transfer": frozenset({"handoff__transfer"}), + "observe": frozenset({"handoff__observe"}), +} + + +_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 _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" + 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", + "response": ( + "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 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. " + f"PENDING QUESTIONS: {_pending_questions_block(pending_rows)}" + ), + } + ] + if mode == "transfer" and staff: + return [ + { + "scope": "orchestration", + "key": "handoff_transfer_staff", + "response": ( + "Do not call handoff__transfer to escalate a message that " + "needs no escalation; reply in the conversation when " + "appropriate." + ), + } + ] + 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 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 " + 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 escalation." + ), + } + ] + 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 consult or transfer. Call " + "handoff__observe with that fact." + ), + } + ] + return [ + { + "scope": "orchestration", + "key": "handoff_consult", + "condition": _CUSTOMER_CANNOT_ANSWER_CONDITION, + "response": ( + "This rule overrides the active skill and any skill " + "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 " + "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: + 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 the save flow.""" + 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 _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 _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) + 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_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() + 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) + body = f"{bolded} {notes}".strip() if notes else bolded + if token and token != "declined" and token not in body: + body = f"{body}\n{_staff_contact_suffix(token)}".strip() + return body + text = (message or "").strip() + if token and token not in text: + suffix = _staff_contact_suffix(token) + if suffix not in text: + text = f"{text}\n{suffix}".strip() + return text + + +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: + 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`` / ``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 + and not text.endswith(("@g.us", "@c.us", "@lid")) + and not is_whatsapp_group_chat_id(text) + ): + return "email" + 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 "" + 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 "" + + +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 _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 + + +_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 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 "" + if text.strip().lower() in _CONTACT_PLACEHOLDERS: + return "" + if not _contact_matches_kind(text, customer_contact_kind): + return "" + if _contact_kind(text) == "whatsapp_group": + return strip_whatsapp_suffix(text) + 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 _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()] + 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=( + "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 " + f"{label}. Do not notify staff until then." + ) + ) + + +_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=( + "Tell the user this in your own short, natural wording — 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 _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 + + 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 _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}".' + 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}." + + +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): + """One handoff mode: consult, transfer, or observe.""" + + tool_namespace: ClassVar[str] = "handoff" + + description: str = attribute( + default=( + "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=list, + description=( + "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." + ), + ) + + handoff_intro: str = attribute( + 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="", + description=( + "Optional fixed consult close. Set to force a deterministic literal " + "relay; when unset, the reply is generated." + ), + ) + transfer_close: str = attribute( + default="", + description=( + "Optional fixed transfer close. Set to force a deterministic literal " + "relay; when unset, the reply is generated." + ), + ) + consult_prompt: str = attribute( + default="", + description="Deprecated yaml alias for consult_close when set.", + ) + transfer_prompt: str = attribute( + default="", + description="Deprecated yaml alias for transfer_close when set.", + ) + + 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: { + "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=( + "Customer questions waiting for a staff answer. Source of truth for " + "the staff-turn parameter and handoff__save_answer (reloaded from DB " + "on read)." + ), + ) + + # -- tools ----------------------------------------------------------------- + + 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() + 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=is_staff, + customer_contact_kind=self._normalized_customer_contact_kind(), + pending_rows=pending_rows, + ) + + def _normalized_customer_contact_kind(self) -> str: + return _normalized_customer_contact_kind( + str(getattr(self, "customer_contact", None) or "phone") + ) + + def _normalized_mode(self) -> str: + mode = str(self.mode or "consult").strip().lower() + return mode if mode in _MODE_TOOLS else "consult" + + 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: 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. 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: + """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, + contact, + contact_declined=bool(contact_declined), + ) + + @tool(name="handoff__transfer", idempotency_class=IdempotencyClass.NON_RETRYABLE) + async def transfer( + self, + message: Annotated[ + str, + "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. Omit on WhatsApp. Pass only when the " + "user gives it after the tool asks. No placeholders.", + ] = None, + ) -> ToolResult: + """Escalate an issue the assistant cannot resolve to the team.""" + return await self._dispatch_handoff("transfer", message, contact) + + @tool( + name="handoff__observe", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + requires_tool_permission=True, + permission_denied_message=SAVE_DENIED_LINE, + ) + async def observe( + self, + fact: Annotated[ + str, + "The useful fact, policy, or answer from the group message.", + ], + ) -> ToolResult: + """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( + 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. 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 "").strip() + + def _effective_transfer_close(self) -> str: + legacy = (self.transfer_prompt or "").strip() + return legacy or (self.transfer_close or "").strip() + + def _handoff_relay_line( + 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() + ) + # 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, + topic, + continuing_handoff=continuing_handoff, + ) + + async def _dispatch_handoff( + self, + mode: str, + message: str, + contact: Optional[str], + contact_declined: bool = False, + ) -> ToolResult: + channel = self._channel_for(mode) + 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 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() + 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 + + 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 + ) + 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, + ) + 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 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, 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 " + "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) + staff_message = message + if mode == "consult": + logger.warning( + "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 or "") + ) + stored_issue = (await self._active_issue()).strip() + issue_source = message + if continuing_handoff and stored_issue: + contact_only = _is_contact_only_message( + message, + contact or provided_clean, + customer_contact_kind=kind, + ) + 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) + 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, + contact or "", + ) + + 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." + ), + is_error=True, + ) + + 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__save_answer", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + requires_tool_permission=True, + permission_denied_message=SAVE_DENIED_LINE, + ) + async def save_answer( + self, + question_ids: Annotated[ + List[str], + "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 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() + 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, + ) + 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="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(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) + 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=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, + requires_tool_permission=True, + permission_denied_message=SAVE_DENIED_LINE, + ) + async def update_chunk( + self, + 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 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() + 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) + await self._record_event(_handoff_event("update_chunk", title)) + await self._clear_active() + return ToolResult( + content=json.dumps( + { + "response_directive": ( + "Tell the user: 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. 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, + ) + 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 = 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 + try: + if kind == "email": + 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: + 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", + 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 = ( + 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 _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 _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 is_group=%s body_chars=%d agent=%s", + masked, + is_group, + 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 is_group=%s", + type(api).__name__, + masked, + is_group, + ) + result = await api.send_message( + phone=recipient, message=message or "", is_group=is_group + ) + 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"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() + 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 (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") + 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 + if not changed: + logger.warning("handoff update_pending missing question_id=%r", qid) + return + 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 _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: + 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 _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: + aca: Any = await self.get_action("AccessControlAction") + except Exception: + 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 + + 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_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): + return + try: + added = adder(text, "HandoffAction") + except Exception: + logger.debug("handoff 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 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.""" + 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 + + +__all__ = [ + "CONSULT_CLOSE", + "HANDOFF_DOC_NAME", + "HANDOFF_INTRO", + "HANDOFF_PARAMETERS", + "HandoffAction", + "TRANSFER_CLOSE", +] diff --git a/jvagent/action/handoff_action/info.yaml b/jvagent/action/handoff_action/info.yaml new file mode 100644 index 00000000..806d5155 --- /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: >- + One handoff mode at a time. consult asks staff and replies when they + 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. + 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/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/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/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/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/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/jvagent/action/mcp_oauth/mcp_oauth_action.py b/jvagent/action/mcp_oauth/mcp_oauth_action.py index 78972a18..818ac5fc 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) @@ -348,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") @@ -366,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( @@ -475,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") @@ -489,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 @@ -667,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/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 d5e10230..1d634070 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) # ------------------------------------------------------------------ @@ -1246,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) @@ -1323,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 @@ -1535,6 +1591,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 +1616,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 ( @@ -1645,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: @@ -2580,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, @@ -2594,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/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/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/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/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/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/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/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/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/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() 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) diff --git a/tests/action/mcp_oauth/test_mcp_oauth_action.py b/tests/action/mcp_oauth/test_mcp_oauth_action.py index a56654fb..298033fc 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,77 @@ 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_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", 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) diff --git a/tests/action/test_action_import_guard.py b/tests/action/test_action_import_guard.py index 3830bbfd..f476f59c 100644 --- a/tests/action/test_action_import_guard.py +++ b/tests/action/test_action_import_guard.py @@ -48,6 +48,10 @@ ("microsoft", "mcp_oauth"), ("pageindex", "google"), ("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 new file mode 100644 index 00000000..10137d92 --- /dev/null +++ b/tests/action/test_handoff_action.py @@ -0,0 +1,2499 @@ +"""HandoffAction — one mode at a time (consult, transfer, or observe). + +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 +from types import SimpleNamespace + +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, +) +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 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() + + 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_defaults(): + action = HandoffAction() + assert action.mode == "consult" + assert "9:00 AM" in action.handoff_hours + + +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__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_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() + action.mode = "transfer" + action.customer_contact = "phone" + 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")): + r = await tools["handoff__transfer"].call(message="need a person") + assert not r.is_error + 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 + assert "to" not in sent + + +async def test_transfer_web_two_step_uses_confirm_not_repeat_limitation( + 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()} + 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 "sort that out with our team" 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 "sort that out with our team" not in confirm.content.lower() + assert sent["to"] == _STAFF_PHONE + assert "Contact: 5926431530" in sent["message"] + + +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()} + 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 _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" + + 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="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 + + 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()} + with bind_dispatch_context(_Ctx("592000", conversation=conversation)): + r = await tools["handoff__transfer"].call(message="need a person") + assert not r.is_error + 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_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["message"] = message + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + tools = {t.name: t for t in await action.get_tools()} + 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 "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 "Tell the user this" 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"] + + +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 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): + _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) + + monkeypatch.setattr(HandoffAction, "_send_whatsapp", _send) + monkeypatch.setattr(HandoffAction, "_add_pending", _record) + tools = {t.name: t for t in await action.get_tools()} + + r = await tools["handoff__consult"].call(message="where do you deliver?") + assert not r.is_error + 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 + assert "question" not in sent + assert open_q.get("q") is None + + r = await tools["handoff__consult"].call(message="where do you deliver?") + assert sent["count"] == 0 + + r = await tools["handoff__consult"].call( + message=( + "The customer asked where we deliver. FAQ search returned nothing. " + "Customer provided phone number: 592000." + ), + contact="592000", + ) + 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"] + assert "where we deliver" in sent["message"] + + r = await tools["handoff__consult"].call( + message="The customer asked where we deliver. FAQ search found nothing useful.", + contact="592000", + ) + assert sent["count"] == 2 + + open_q.clear() + sent["count"] = 0 + r = await tools["handoff__consult"].call( + message="where do you deliver?", contact_declined=True + ) + 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 "checking with the team" in r.content + assert "WhatsApp 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__consult"].call( + message="where do you deliver?", contact_declined=True + ) + assert "checking with the team" 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_ids=[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__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 "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] == [ + "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 "checking with the team" 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_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()) + _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", + ) + 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_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()) + 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): + _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_transfer_sends_web_contact(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()} + visitor = _Ctx("o.User.abc", channel="default", conversation=conversation) + with bind_dispatch_context(visitor): + 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("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): + _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"] + + +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) + + aca = _aca_for_tools() + tools = {t.name: t for t in await action.get_tools()} + with bind_dispatch_context(_Ctx("999")): + denied = await _run_gated_tool( + tools["handoff__save_answer"], + aca=aca, + user_id="999", + question_ids=["q1"], + answer="Yes, we deliver.", + ) + 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): + _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"].startswith("pend_") + 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" + + aca = _aca_for_tools() + tools = {t.name: t for t in await reader.get_tools()} + with bind_dispatch_context(_Ctx("999")): + denied = await _run_gated_tool( + tools["handoff__save_answer"], + aca=aca, + user_id="999", + question_ids=[created["id"]], + answer="Yes.", + ) + assert not denied.is_error + assert "cannot save answers" 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_ids=[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 + + consult = HandoffAction() + params = orchestration_parameters(consult.parameters) + assert len(params) == 1 + assert params[0].get("key") == "handoff_consult" + rendered = render_parameters(params) + 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 "handoff__observe" in rendered_observe + assert "never send a message" not 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 "PENDING QUESTIONS:" in rendered + assert "corr-" in rendered + assert "not a question id or a chunk id" in rendered + assert "handoff__pending_questions" not 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(): + 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( + "consult", + "Customer asked if we sell car parts. No information found in the FAQ.", + "", + ) + 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__consult"].call( + message=( + "Customer asked for the company's location. " + "No information found in the knowledge base." + ), + contact="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_ids=["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 "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 + + +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_ids=["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_ids=["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_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 completed: save_answer "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 "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"] + 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?".' + ) + 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") + + +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 "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: 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 = {} + + 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 "Inform them in a simple note" 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, + ) + + 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__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 "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 + + 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 "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__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__save_answer", + "handoff__update_chunk", + "pageindex__search", + ] + staff_only = { + "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 ( + staff_visible + & { + "handoff__save_answer", + "handoff__update_chunk", + } + == staff_only + ) + 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 await aca.has_tool_access( + _STAFF_PHONE, "handoff__save_answer", "whatsapp" + ) + + +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 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" + )