Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
07e7967
fix(leadgen): keep the lead profile from being replaced by its sectio…
Tharickv75 Sep 29, 2026
47d6f3a
Merge branch 'feat/leadgen-store-resolution' into dev-tj
Tharickv75 Sep 29, 2026
0c1271e
fix(mcp-oauth): persist Google token expiry so first use does not for…
Tharickv75 Sep 29, 2026
1cb0d7b
Merge branch 'feat/google-oauth-exchange-expiry' into dev-tj
Tharickv75 Sep 29, 2026
8d2286a
feat(orchestrator): hide skills by AccessControl group
Tharickv75 Sep 29, 2026
ea0ec14
Merge branch 'feat/skill-access-gating' into dev-tj
Tharickv75 Sep 29, 2026
b6e057d
feat(handoff): add a skill-gated handoff action
Tharickv75 Sep 29, 2026
9c827f6
Merge branch 'feat/handoff-action' into dev-tj
Tharickv75 Sep 29, 2026
11cf551
Update Dockerfile and workflows for dev-tj branch; bump jvagent versi…
Tharickv75 Sep 29, 2026
ed6b470
refactor(handoff): clarify usage of contact details and staff lookup …
Tharickv75 Sep 29, 2026
7608fa5
fix(mcp-oauth): keep a cleared service binding from coming back via s…
Tharickv75 Sep 29, 2026
a59777d
fix(mcp-oauth): send with the mailbox last authorized for that service
Tharickv75 Sep 29, 2026
808435e
chore(version): update jvagent version to 0.1.8rc20-dev1 for development
Tharickv75 Sep 30, 2026
e30a33b
docs: update documentation for leadgen and handoff skills
Tharickv75 Sep 30, 2026
565e178
chore: update Dockerfile and workflows to use main branch; bump jvage…
Tharickv75 Sep 30, 2026
0669e0a
Merge branch 'dev' into dev-tj
Tharickv75 Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .planning/reference/actions-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` 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) |

Expand Down Expand Up @@ -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
Expand All @@ -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()`) |

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<files_root>/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.
Expand Down
32 changes: 31 additions & 1 deletion jvagent/action/google/google_action.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)."""

Expand Down Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions jvagent/action/handoff_action/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Handoff Action (`jvagent/handoff_action`)

Human-support capability tools, **skill-gated** by the `handoff` library skill.
The tools stay on the orchestrator surface but refuse to run until a skill
declaring them in `allowed-tools` is active — so configure the orchestrator with
`skill_only_tools: ["handoff__*"]` and enable the `handoff` skill.

## Tools

| Tool | Purpose |
|---|---|
| `handoff__contact_details()` | Return office hours plus a random staff number and every staff email from `HandoffAction.staff`. No notification. |
| `handoff__staff_lookup(message, phone_numbers?, emails?)` | Notify staff about a question you cannot answer and record it as pending. Contact is optional. Non-retryable. |
| `handoff__agent_escalation(message, phone_numbers?, emails?)` | Notify staff that the customer wants a person now. Needs a contact. Non-retryable. |
| `handoff__scheduled_callback(message, phone_numbers?, emails?)` | Notify staff to reach the customer later. Needs a contact. Non-retryable. |
| `handoff__pending_questions()` | List customer questions waiting for an answer. |
| `handoff__save_answer(question_id, answer)` | Save the answer for one pending question into `handoff.md` and return a thank-you. Non-retryable. |

`handoff__staff_lookup` records the question on `HandoffAction.pending_questions`
(reloaded from the Action row before list/get, caches invalidated after write)
on the first call, before staff are notified. Staff are notified once the
customer shares a phone or email, or declines. A later call with the same
contact does not send again. When a later message looks like an answer, call
`handoff__pending_questions`, then `handoff__save_answer`. Pending questions are
shared across conversations for the same Action so staff answering in their own
thread can see customer questions.

## Channels

Each tool chooses its channel from `handoff_channels` (`whatsapp` via `handoff_notify_action_type`,
default `WhatsAppAction`) or `"email"` (via `handoff_email_action_type`, default
`EmailAction`). Set the per-mode default in the skill body; the recipient is
**one staff target chosen at random** from the configured list.

## Configuration

AccessControlAction `user_groups.HandoffAction.staff` in agent.yaml — a mixed
list of WhatsApp numbers and email addresses. That list is the staff identity
allowlist and the notify/contact target source. Members are classified as
phone or email; notify picks one matching target at random for the channel.

| Source | Role |
|---|---|
| `user_groups.HandoffAction.staff` | Staff WhatsApp numbers and emails. Any listed sender may save answers. Notify picks one matching target at random. |

PageIndex target is fixed: `doc_name="handoff.md"`,
`metadata={"access": "public"}`, written to `<files_root>/handoff.md` (default
`./.files/handoff.md`) — the file is created on first resolve and appended
cumulatively thereafter.

### Email channel setup

To use `channel="email"` you must wire outbound **and** inbound email:

1. Add `jvagent/mcp_oauth`, `jvagent/mcp` (a `google_workspace` server), and
`jvagent/google_gmail_action` (Gmail) — or the SendGrid/Outlook equivalents.
2. Add `jvagent/email_action` with `provider: gmail` (or `sendgrid` / `outlook`).
3. Authorize: `/api/mcp/google_workspace/auth?service=gmail`.
4. Create the **email webhook** (`GET /api/actions/{action_id}/email/webhook-url`)
and point your provider's inbound parse / poll at it (SendGrid Inbound Parse
requires SPF+DKIM pass).
5. Set `EMAIL_DEFAULT_SENDER` (or the provider's from-address).

See [`../email_action/README.md`](../email_action/README.md) for the provider
matrix.

## Skill

The SOP lives in [`jvagent/skills/handoff/SKILL.md`](../../skills/handoff/SKILL.md)
(`allowed-tools` = these four, `requires-actions: [HandoffAction]`). The action
also contributes an always-on orchestration parameter (`key: handoff_routing`)
that tells the loop when to hand off.

## Orchestrator wiring (agent.yaml)

```yaml
- action: jvagent/orchestrator
context:
skill_only_tools: ["handoff__*"] # gated until the handoff skill is active
skills: [..., handoff]
- action: jvagent/handoff_action
context:
enabled: true
```

## License

See the application-level [LICENSE](../../../../LICENSE).

## Author

**Tharick Jairam** · jvagent/handoff_action / V75 Inc.
5 changes: 5 additions & 0 deletions jvagent/action/handoff_action/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Human handoff action (capability tools)."""

from .handoff_action import HandoffAction

__all__ = ["HandoffAction"]
Loading
Loading