[FEAT] Proactive Agents (#364)

This commit is contained in:
Tanvir Bhathal
2026-05-20 12:00:19 -07:00
committed by GitHub
parent 30f8d57398
commit 4652b6e8a8
14 changed files with 2102 additions and 52 deletions
+5
View File
@@ -79,6 +79,11 @@ try:
except ImportError:
pass
try:
import openjarvis.agents.proactive_agent # noqa: F401
except ImportError:
pass
# Hybrid local+cloud paradigm agents (Minions, Conductor, Archon, Advisors,
# SkillOrchestra, ToolOrchestra). Each module registers under its own name
# via @AgentRegistry.register(). Optional deps may make some unavailable.
+605
View File
@@ -0,0 +1,605 @@
# ruff: noqa: E501
"""Proactive Agent — runs on a cron (default 5am local) to autonomously handle
routine tasks based on learned user behavior.
Lifecycle per run
-----------------
1. Load USER.md + MEMORY.md for behavioral context.
2. Collect overnight data from connected sources via ``digest_collect``.
3. Use the LLM to classify each item and propose actions with a tier + permission key.
4. For each proposed action:
- TRIVIAL tier → queue + immediately approve
- Known always_approve → queue + immediately approve
- Known always_deny → skip silently
- Everything else → queue as pending, notify user
5. Execute all approved actions via ``execute_pending_actions``.
6. Send the user a concise summary: what was done + numbered list of what needs approval.
Approval reply format (user replies to the notification message):
``{action_id} yes`` approve one action
``{action_id} no`` deny one action
``always yes {action_id}`` approve + remember for this pattern
``always no {action_id}`` deny + remember for this pattern
``yes all`` / ``no all`` bulk decision
Wire up ``parse_approval_response`` from ``proactive_tools`` in your channel
message handler to process replies without running the full agent.
Scheduling
----------
The agent self-registers a 5am daily cron task when ``register_cron`` is
called from your app startup:
from openjarvis.agents.proactive_agent import register_cron
register_cron(scheduler, notification_channel_id="your-channel-id")
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.core.config import load_config
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall
from openjarvis.tools.approval_store import (
DECISION_ALWAYS_APPROVE,
DECISION_ALWAYS_DENY,
STATUS_APPROVED,
TIER_MEDIUM,
TIER_TRIVIAL,
ApprovalStore,
)
from openjarvis.tools.proactive_tools import get_store
_SYSTEM_PROMPT = """You are a proactive personal assistant agent. You have already collected
data from the user's connected sources (email, messages, calendar). Your job is to:
1. Analyze each item and decide what action (if any) should be taken.
2. For each action, output a JSON object in your response inside a ```json ... ``` block.
Each action object must have these fields:
- action_type: one of email_delete | email_archive | sms_send | sms_draft_reply |
calendar_decline | calendar_accept | no_action
- description: human-readable sentence explaining what you will do
- payload: dict with the data needed to execute. ALWAYS include:
- doc_id: copy the value of ``id=...`` from the digest line, EXACTLY
as shown. Example: a digest line ``[gmail id=gmail:18f9abc] From:
...`` means ``doc_id`` must be ``"gmail:18f9abc"``. NEVER invent
ids like ``"gmail:wells_fargo"`` or ``"msg_1"`` — the executor
will fail. If a digest line has no ``id=...`` segment, do not
propose an action for that line.
- For email actions: message_id MUST be the part of doc_id after
the ``gmail:`` prefix (e.g. ``"18f9abc"``).
- For sms actions: contact (phone/email), body (the message text)
- For calendar actions: event_id (the part after ``gcalendar:`` in
the digest's ``id=...``) and calendar_id (default "primary")
- permission_key: pattern string like "email_delete:domain:noreply.github.com"
- tier: one of trivial | low | medium | high
- reasoning: one sentence why
Tier guidance:
trivial — read-only or categorization only, no external effect
low — reversible, routine (delete a known-spam sender, archive newsletter)
medium — affects another party but is expected (reply to a simple scheduling text)
high — sends a message in the user's voice for the first time, or irreversible
Output a JSON array of action objects inside a single ```json ... ``` block.
Only include items where action_type is not 'no_action'.
If nothing needs to be done, output an empty array: ```json [] ```
HARD LIMITS — these keep responses parseable:
- Output AT MOST 8 action objects. Pick the highest-value ones (most
clearly safe-to-delete or obviously useful to handle).
- Keep each `reasoning` field to ONE short sentence (≤ 15 words).
- Keep each `description` field to ONE short sentence (≤ 15 words).
- No nested objects beyond what the schema requires.
- Your entire visible response MUST be ONLY the fenced JSON block —
no explanations, headers, or commentary before or after.
Example response when the digest has two newsletters and a calendar
invite (use exactly this shape; substitute real ids from the digest):
```json
[
{
"action_type": "email_archive",
"description": "Archive Substack newsletter from on+stories@substack.com",
"payload": {"doc_id": "gmail:18f9...", "message_id": "18f9..."},
"permission_key": "email_archive:from:on+stories@substack.com",
"tier": "low",
"reasoning": "Routine newsletter — safe to archive."
},
{
"action_type": "email_delete",
"description": "Delete Wells Fargo marketing email",
"payload": {"doc_id": "gmail:18fa...", "message_id": "18fa..."},
"permission_key": "email_delete:from:wf.com",
"tier": "low",
"reasoning": "Marketing email, user already has account."
}
]
```
Be generous about proposing low-tier actions for marketing emails,
newsletters, transactional receipts the user has already seen, and
calendar duplicates — these are the items the user wants triaged.
User context is provided below — use it to tailor decisions to their patterns.
"""
def _load_md_file(path: Path) -> str:
return path.read_text(encoding="utf-8") if path.exists() else ""
def _extract_json_block(text: str) -> Optional[List[Dict[str, Any]]]:
"""Extract a JSON array from LLM output.
Tries (in order):
1. ```json ... ``` fenced block (preferred).
2. ``` ... ``` fenced block with no language tag.
3. First ``[ ... ]`` array in the raw text.
Returns the parsed list, or ``None`` if nothing parses.
"""
import re
candidates: List[str] = []
# 1. ```json ... ``` (case-insensitive)
m = re.search(r"```(?:json|JSON)\s*(.*?)```", text, re.DOTALL)
if m:
candidates.append(m.group(1).strip())
# 2. Any ``` ... ``` block (model may omit the language tag)
for m in re.finditer(r"```\s*(.*?)```", text, re.DOTALL):
candidates.append(m.group(1).strip())
# 3. Raw top-level JSON array anywhere in the text (best-effort,
# balanced-bracket walk so nested objects don't trip us up).
start = text.find("[")
while start != -1:
depth = 0
for i in range(start, len(text)):
ch = text[i]
if ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
candidates.append(text[start : i + 1])
break
next_start = text.find("[", start + 1)
if next_start == start:
break
start = next_start
for raw in candidates:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
continue
if isinstance(parsed, list):
return parsed # type: ignore[return-value]
if isinstance(parsed, dict):
return [parsed]
return None
def _build_notification_channel(channel_spec: str) -> Optional[Any]:
"""Parse a ``"type:identifier"`` string into a channel backend instance.
Supports:
``imessage:+15551234567`` — sends via AppleScript directly
``telegram:123456789`` — instantiates TelegramChannel
``slack:D0123456789`` — instantiates registered Slack channel
Any other type registered in ChannelRegistry
Returns ``None`` (silently) if the spec is empty or the channel can't
be instantiated so the agent degrades gracefully to no notifications.
"""
if not channel_spec or ":" not in channel_spec:
return None
channel_type, _, channel_id = channel_spec.partition(":")
# iMessage: wrap send_imessage() in a minimal BaseChannel-compatible shim
if channel_type == "imessage":
from openjarvis.channels._stubs import (
BaseChannel,
ChannelStatus,
)
class _IMessageShim(BaseChannel):
channel_id = "imessage"
def __init__(self, handle: str) -> None:
self._handle = handle
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def send(
self, channel: str, content: str, *, conversation_id: str = ""
) -> bool:
from openjarvis.channels.imessage_daemon import send_imessage
return send_imessage(self._handle, content)
def status(self) -> ChannelStatus:
return ChannelStatus.CONNECTED
def list_channels(self) -> List[str]:
return [self._handle]
def on_message(self, handler: Any) -> None:
pass
return _IMessageShim(channel_id)
# All other channel types: look up in ChannelRegistry
try:
import openjarvis.channels # noqa: F401 trigger registration
from openjarvis.core.registry import ChannelRegistry
if ChannelRegistry.contains(channel_type):
channel_cls = ChannelRegistry.get(channel_type)
instance = channel_cls()
try:
instance.connect()
except Exception:
pass
return instance
except Exception:
pass
return None
@AgentRegistry.register("proactive")
class ProactiveAgent(ToolUsingAgent):
"""Autonomous agent that handles routine tasks based on learned user behavior."""
agent_id = "proactive"
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._notification_channel_id: str = kwargs.pop("notification_channel_id", "")
self._hours_back: int = kwargs.pop("hours_back", 24)
self._approval_store: Optional[ApprovalStore] = kwargs.pop(
"approval_store", None
)
self._timezone: str = kwargs.pop("timezone", "America/Los_Angeles")
# Read config defaults before super().__init__ so we can inject tools
try:
cfg = load_config()
p = cfg.proactive
if not self._notification_channel_id:
self._notification_channel_id = p.notification_channel
self._hours_back = p.hours_back
self._timezone = p.timezone
except Exception:
pass
# Build the required tools and inject them into the executor.
# This must happen before super().__init__ is called because
# ToolUsingAgent builds the ToolExecutor from kwargs["tools"].
store = self._approval_store or get_store()
self._approval_store = store
notification_channel = _build_notification_channel(
self._notification_channel_id
)
self._notification_channel = notification_channel
from openjarvis.tools.channel_tools import ChannelSendTool
from openjarvis.tools.digest_collect import DigestCollectTool
from openjarvis.tools.proactive_tools import (
CheckPermissionTool,
ExecutePendingActionsTool,
GetPendingActionsTool,
QueueActionTool,
RecordDecisionTool,
)
proactive_tools = [
DigestCollectTool(),
ExecutePendingActionsTool(store=store),
ChannelSendTool(channel=notification_channel),
CheckPermissionTool(store=store),
QueueActionTool(store=store),
GetPendingActionsTool(store=store),
RecordDecisionTool(store=store),
]
# Merge with any tools passed by the caller
caller_tools: List[Any] = kwargs.pop("tools", None) or []
kwargs["tools"] = proactive_tools + caller_tools
# The agent emits a JSON array of proposals — one entry per actionable
# item — and a typical morning digest produces dozens. The default
# max_tokens (often ~1024) truncates the array mid-element, which the
# parser then rejects. Give it real room unless the caller overrode.
kwargs.setdefault("max_tokens", 8192)
# Deterministic-ish output makes the JSON shape more reliable.
kwargs.setdefault("temperature", 0.2)
super().__init__(*args, **kwargs)
def _get_already_seen_ids(self, store: ApprovalStore) -> Set[str]:
return store.get_seen_ids()
def _store(self) -> ApprovalStore:
if self._approval_store is None:
self._approval_store = get_store()
return self._approval_store
def _build_system_prompt(self) -> str:
user_md = _load_md_file(Path.home() / ".openjarvis" / "USER.md")
memory_md = _load_md_file(Path.home() / ".openjarvis" / "MEMORY.md")
now = datetime.now()
context_block = ""
if user_md or memory_md:
context_block = "\n\n---\nUSER CONTEXT:\n"
if user_md:
context_block += f"\n{user_md.strip()}\n"
if memory_md:
context_block += f"\n{memory_md.strip()}\n"
return (
_SYSTEM_PROMPT
+ f"\nToday is {now.strftime('%A, %B %d, %Y')} ({self._timezone})."
+ context_block
)
def run(
self,
input: str = "",
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
self._emit_turn_start(input or "proactive_run")
store = self._store()
store.expire_stale()
# --- Step 1: Collect data — only items user hasn't acted on ---
sources = ["gmail", "imessage", "gcalendar", "slack", "google_tasks"]
seen_ids = self._get_already_seen_ids(store)
collect_call = ToolCall(
id="proactive-collect-1",
name="digest_collect",
arguments=json.dumps(
{
"sources": sources,
"hours_back": self._hours_back,
"unacted_only": True,
"seen_ids": list(seen_ids),
}
),
)
collect_result = self._executor.execute(collect_call)
if not collect_result.success or not collect_result.content.strip():
self._emit_turn_end(turns=1)
return AgentResult(
content="No data collected from connectors — nothing to do.",
turns=1,
)
# --- Step 2: Ask LLM to classify items and propose actions ---
messages = [
Message(role=Role.SYSTEM, content=self._build_system_prompt()),
Message(
role=Role.USER,
content=(
f"Here is the data collected from the last {self._hours_back} hours:\n\n"
f"{collect_result.content}\n\n"
"Analyze each item and output the JSON array of proposed actions."
),
),
]
llm_result = self._generate(messages)
raw_full = llm_result.get("content", "")
raw_output = self._strip_think_tags(raw_full)
proposed: List[Dict[str, Any]] = _extract_json_block(raw_output) or []
# Debug log — write the raw LLM output and what we parsed out so a
# human can diagnose "Nothing to report" without re-running the
# whole agent. Best-effort; never fail the run because of logging.
try:
from openjarvis.core.config import DEFAULT_CONFIG_DIR
log_dir = DEFAULT_CONFIG_DIR / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / "proactive_debug.log"
with log_path.open("a", encoding="utf-8") as f:
f.write(f"\n===== {datetime.now().isoformat()} =====\n")
f.write(f"--- digest ({len(collect_result.content)} chars) ---\n")
f.write(collect_result.content + "\n")
f.write(f"--- llm raw ({len(raw_full)} chars) ---\n")
f.write(raw_full + "\n")
f.write(f"--- parsed proposals: {len(proposed)} ---\n")
f.write(json.dumps(proposed, indent=2, default=str) + "\n")
except Exception:
pass
# --- Step 3: Route each proposed action ---
auto_approve_ids: List[str] = []
pending_actions = []
for item in proposed:
action_type = item.get("action_type", "")
tier = item.get("tier", TIER_MEDIUM)
permission_key = item.get("permission_key", f"{action_type}:default")
description = item.get("description", "")
payload = item.get("payload", {})
if not action_type or action_type == "no_action":
continue
# Check remembered permission first
rule = store.get_permission(permission_key)
if rule and rule.decision == DECISION_ALWAYS_DENY:
continue
# Queue the action
action = store.queue_action(
action_type=action_type,
description=description,
payload=payload,
permission_key=permission_key,
tier=tier,
)
if tier == TIER_TRIVIAL or (
rule and rule.decision == DECISION_ALWAYS_APPROVE
):
store.update_status(action.id, STATUS_APPROVED)
auto_approve_ids.append(action.id)
else:
pending_actions.append(action)
# --- Step 4: Execute all auto-approved actions ---
executed_results: List[Dict[str, Any]] = []
if auto_approve_ids:
exec_call = ToolCall(
id="proactive-exec-1",
name="execute_pending_actions",
arguments=json.dumps({"action_ids": auto_approve_ids}),
)
exec_result = self._executor.execute(exec_call)
if exec_result.success and exec_result.content:
try:
executed_results = json.loads(exec_result.content)
except json.JSONDecodeError:
pass
# --- Step 5: Build and send notification ---
notification = self._build_notification(executed_results, pending_actions)
if notification and self._notification_channel_id:
send_call = ToolCall(
id="proactive-notify-1",
name="channel_send",
arguments=json.dumps(
{
"channel": self._notification_channel_id,
"content": notification,
}
),
)
self._executor.execute(send_call)
for action in pending_actions:
store.update_status(action.id, action.status, notification_sent=True)
self._emit_turn_end(turns=1)
return AgentResult(
content=notification or "Nothing to report.",
turns=1,
metadata={
"auto_executed": len(executed_results),
"pending_approval": len(pending_actions),
},
)
def _build_notification(
self,
executed: List[Dict[str, Any]],
pending: List[Any],
) -> str:
lines: List[str] = []
if executed:
successes = [r for r in executed if r.get("success")]
failures = [r for r in executed if not r.get("success")]
lines.append(f"Done automatically ({len(successes)} actions):")
for r in successes:
lines.append(f"{r['description']}")
for r in failures:
lines.append(f"{r['description']}{r.get('message', 'error')}")
if pending:
if lines:
lines.append("")
lines.append(f"Needs your approval ({len(pending)} actions):")
for action in pending:
tier_label = {
"low": "low-risk",
"medium": "medium",
"high": "HIGH",
}.get(action.tier, action.tier)
lines.append(f" [{action.id}] ({tier_label}) {action.description}")
lines.append("")
lines.append(
"Reply with: '{id} yes/no' to decide. "
"Add 'always' to remember (e.g. 'always yes {id}'). "
"'yes all' / 'no all' for bulk."
)
if not lines:
return ""
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Convenience: register the 5am cron task
# ---------------------------------------------------------------------------
def register_cron(
scheduler: Any,
*,
notification_channel_id: str = "",
cron_expr: str = "",
hours_back: int = 0,
timezone: str = "",
) -> Any:
"""Register the proactive agent as a daily cron task.
All defaults are read from ``config.toml [proactive]`` when not explicitly
passed. Call this once from app startup after the scheduler is started.
Parameters
----------
scheduler:
A ``TaskScheduler`` instance.
notification_channel_id:
Override the channel ID from config. If empty, uses ``notification_channel``
from ``[proactive]`` in config.toml.
cron_expr:
Override the cron schedule. Defaults to config value (``"0 5 * * *"``).
hours_back:
Override hours of data to scan. Defaults to config value (24).
timezone:
Override timezone string. Defaults to config value.
"""
try:
cfg = load_config()
p = cfg.proactive
notification_channel_id = notification_channel_id or p.notification_channel
cron_expr = cron_expr or p.schedule
hours_back = hours_back or p.hours_back
timezone = timezone or p.timezone
except Exception:
cron_expr = cron_expr or "0 5 * * *"
hours_back = hours_back or 24
timezone = timezone or "America/Los_Angeles"
return scheduler.create_task(
prompt="Run the proactive agent: collect overnight data, execute approved actions, notify pending approvals.",
schedule_type="cron",
schedule_value=cron_expr,
agent="proactive",
context_mode="isolated",
metadata={
"notification_channel_id": notification_channel_id,
"hours_back": hours_back,
"timezone": timezone,
},
)
+29 -9
View File
@@ -58,27 +58,47 @@ def poll_new_messages(
def send_imessage(chat_identifier: str, message: str) -> bool:
"""Send an iMessage via AppleScript."""
"""Send an iMessage via AppleScript.
``chat_identifier`` is the recipient handle:
- phone number in E.164 format (e.g. ``+15551234567``)
- or email address registered with iMessage
Internally addresses the recipient via the iMessage service's
``participant`` lookup — the previous ``chat id "..."`` form
expected an internal chat handle (e.g. ``iMessage;-;+1555...``)
and silently failed on raw phone numbers, returning success while
no message was actually sent.
"""
escaped = message.replace("\\", "\\\\").replace('"', '\\"')
script = (
f'tell application "Messages"\n'
f" set targetChat to a reference to "
f'chat id "{chat_identifier}"\n'
f' send "{escaped}" to targetChat\n'
f"end tell"
'tell application "Messages"\n'
" set targetService to 1st account whose service type = iMessage\n"
f' set targetBuddy to participant "{chat_identifier}" of targetService\n'
f' send "{escaped}" to targetBuddy\n'
"end tell"
)
try:
subprocess.run(
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=30,
check=False,
)
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
logger.error("Failed to send iMessage via AppleScript")
logger.error("Failed to invoke osascript for iMessage send")
return False
if result.returncode != 0:
logger.error(
"AppleScript iMessage send failed (rc=%s): %s",
result.returncode,
(result.stderr or "").strip(),
)
return False
return True
def run_daemon(
*,
+77
View File
@@ -218,6 +218,83 @@ def scheduler_logs(task_id: str, limit: int) -> None:
store.close()
@scheduler.command("run-task")
@click.argument("agent_name")
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="Print what would run without executing.",
)
def scheduler_run_task(agent_name: str, dry_run: bool) -> None:
"""Immediately execute the active task for AGENT_NAME.
Finds the first active scheduled task whose agent matches AGENT_NAME
and runs it right now — useful for testing and for launchd invocation
when OpenJarvis is not running as a persistent daemon.
Example (launchd plist ProgramArguments):
jarvis scheduler run-task proactive
"""
console = Console()
store = _get_store()
try:
sched = _get_scheduler(store)
tasks = sched.list_tasks(status="active")
match = next((t for t in tasks if t.agent == agent_name), None)
if match is None:
console.print(
f"[yellow]No active task found for agent '{agent_name}'. "
"Register it first with 'jarvis scheduler create'.[/yellow]"
)
return
if dry_run:
console.print("[dim]Dry run — would execute:[/dim]")
console.print(f" Task : {match.id}")
console.print(f" Agent: {match.agent}")
console.print(f" Prompt: {match.prompt[:80]}")
return
console.print(f"Running task [cyan]{match.id}[/cyan] (agent: {match.agent})…")
from openjarvis.core.config import load_config
from openjarvis.system import SystemBuilder
system = SystemBuilder(load_config()).build()
result = system.ask(match.prompt, agent=match.agent)
# Log the run result in the scheduler store
from datetime import datetime, timezone
if isinstance(result, (dict, list)):
import json as _json
result_str = _json.dumps(result, default=str)
else:
result_str = str(result) if result is not None else ""
now = datetime.now(timezone.utc).isoformat()
store.log_run(
task_id=match.id,
started_at=now,
finished_at=now,
success=True,
result=result_str,
error="",
)
console.print("[green]Done.[/green]")
if result:
console.print(result)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
raise SystemExit(1)
finally:
store.close()
@scheduler.command("start")
@click.option(
"--poll-interval",
+93
View File
@@ -40,6 +40,48 @@ _DEFAULT_CREDENTIALS_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "gcalendar.j
# ---------------------------------------------------------------------------
def _gcal_api_user_email(token: str) -> str:
"""Return the authenticated user's email via the Google userinfo endpoint."""
try:
resp = httpx.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
resp.raise_for_status()
return resp.json().get("email", "")
except Exception:
return ""
def _gcal_api_event_get(token: str, calendar_id: str, event_id: str) -> Dict[str, Any]:
"""Fetch a single calendar event resource."""
resp = httpx.get(
f"{_GCAL_API_BASE}/calendars/{calendar_id}/events/{event_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
def _gcal_api_event_patch(
token: str,
calendar_id: str,
event_id: str,
body: Dict[str, Any],
) -> Dict[str, Any]:
"""Patch a calendar event with a partial update body."""
resp = httpx.patch(
f"{_GCAL_API_BASE}/calendars/{calendar_id}/events/{event_id}",
headers={"Authorization": f"Bearer {token}"},
json=body,
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
def _gcal_api_calendars_list(token: str) -> Dict[str, Any]:
"""Call the Calendar ``calendarList.list`` endpoint.
@@ -358,6 +400,13 @@ class GCalendarConnector(BaseConnector):
content = _format_event(event)
# Find the self-attendee's response status
self_status = ""
for att in attendees:
if att.get("self"):
self_status = att.get("responseStatus", "")
break
doc = Document(
doc_id=f"gcalendar:{evt_id}",
source="gcalendar",
@@ -371,6 +420,7 @@ class GCalendarConnector(BaseConnector):
metadata={
"calendar_id": calendar_id,
"event_id": evt_id,
"response_status": self_status,
},
)
synced += 1
@@ -386,6 +436,49 @@ class GCalendarConnector(BaseConnector):
self._items_synced = synced
self._last_sync = datetime.now()
def _get_token(self) -> str:
tokens = load_tokens(self._credentials_path)
if not tokens:
raise RuntimeError("Google Calendar not authenticated")
token = tokens.get("access_token", tokens.get("token", ""))
if not token:
raise RuntimeError("Google Calendar token missing")
return token
def accept_event(self, event_id: str, calendar_id: str = "primary") -> None:
"""Accept a calendar invite by setting responseStatus to 'accepted'."""
token = self._get_token()
user_email = _gcal_api_user_email(token)
event = _gcal_api_event_get(token, calendar_id, event_id)
attendees = event.get("attendees", [])
updated = []
found = False
for att in attendees:
if att.get("self") or (user_email and att.get("email") == user_email):
att = {**att, "responseStatus": "accepted"}
found = True
updated.append(att)
if not found and user_email:
updated.append({"email": user_email, "responseStatus": "accepted"})
_gcal_api_event_patch(token, calendar_id, event_id, {"attendees": updated})
def decline_event(self, event_id: str, calendar_id: str = "primary") -> None:
"""Decline a calendar invite by setting responseStatus to 'declined'."""
token = self._get_token()
user_email = _gcal_api_user_email(token)
event = _gcal_api_event_get(token, calendar_id, event_id)
attendees = event.get("attendees", [])
updated = []
found = False
for att in attendees:
if att.get("self") or (user_email and att.get("email") == user_email):
att = {**att, "responseStatus": "declined"}
found = True
updated.append(att)
if not found and user_email:
updated.append({"email": user_email, "responseStatus": "declined"})
_gcal_api_event_patch(token, calendar_id, event_id, {"attendees": updated})
def sync_status(self) -> SyncStatus:
"""Return sync progress from the most recent :meth:`sync` call."""
return SyncStatus(
+120 -14
View File
@@ -29,6 +29,7 @@ from openjarvis.connectors.oauth import (
build_google_auth_url,
delete_tokens,
load_tokens,
refresh_google_token,
resolve_google_credentials,
save_tokens,
)
@@ -97,6 +98,38 @@ def _gmail_api_list_messages(
return resp.json()
def _gmail_api_trash_message(token: str, msg_id: str) -> None:
"""Move a Gmail message to Trash via the ``messages.trash`` endpoint."""
resp = httpx.post(
f"{_GMAIL_API_BASE}/messages/{msg_id}/trash",
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
resp.raise_for_status()
def _gmail_api_modify_message(
token: str,
msg_id: str,
*,
add_labels: Optional[List[str]] = None,
remove_labels: Optional[List[str]] = None,
) -> None:
"""Modify labels on a Gmail message via the ``messages.modify`` endpoint."""
body: Dict[str, Any] = {}
if add_labels:
body["addLabelIds"] = add_labels
if remove_labels:
body["removeLabelIds"] = remove_labels
resp = httpx.post(
f"{_GMAIL_API_BASE}/messages/{msg_id}/modify",
headers={"Authorization": f"Bearer {token}"},
json=body,
timeout=30.0,
)
resp.raise_for_status()
def _gmail_api_get_message(token: str, msg_id: str) -> Dict[str, Any]:
"""Fetch a single Gmail message by ID (``full`` format).
@@ -148,9 +181,28 @@ class _HTMLTextExtractor(HTMLParser):
_SKIP_TAGS = {"script", "style", "head", "title", "meta", "link"}
_BLOCK_TAGS = {
"p", "div", "br", "li", "ul", "ol", "tr", "td", "table",
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "hr",
"article", "section", "header", "footer", "pre",
"p",
"div",
"br",
"li",
"ul",
"ol",
"tr",
"td",
"table",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"hr",
"article",
"section",
"header",
"footer",
"pre",
}
def __init__(self) -> None:
@@ -158,9 +210,7 @@ class _HTMLTextExtractor(HTMLParser):
self._parts: List[str] = []
self._skip_depth = 0
def handle_starttag(
self, tag: str, attrs: List[Tuple[str, Optional[str]]]
) -> None:
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
if tag in self._SKIP_TAGS:
self._skip_depth += 1
elif tag in self._BLOCK_TAGS and self._skip_depth == 0:
@@ -311,20 +361,24 @@ class GmailConnector(BaseConnector):
# ------------------------------------------------------------------
def is_connected(self) -> bool:
"""Return ``True`` if a credentials file with a valid token exists."""
"""Return ``True`` if a credentials file with a valid access token exists.
The previous "any non-empty dict counts" check returned True for
files containing only client_id/client_secret (no actual OAuth
token), which made `jarvis connect gmail` short-circuit with
"already connected" before any OAuth flow ran.
"""
tokens = load_tokens(self._credentials_path)
if tokens is None:
return False
# Accept any non-empty dict that contains at least one key
# (simplified: real impl would also check expiry / refresh token)
return bool(tokens)
return bool(tokens.get("access_token") or tokens.get("token"))
def disconnect(self) -> None:
"""Delete the stored credentials file."""
delete_tokens(self._credentials_path)
def auth_url(self) -> str:
"""Return a Google OAuth consent URL requesting ``gmail.readonly`` scope."""
"""Return a Google OAuth consent URL for the shared Google scopes."""
return build_google_auth_url(
client_id="", # placeholder — real client_id from config
scopes=GOOGLE_ALL_SCOPES,
@@ -343,6 +397,7 @@ class GmailConnector(BaseConnector):
*,
since: Optional[datetime] = None,
cursor: Optional[str] = None,
query_extra: str = "",
) -> Iterator[Document]:
"""Yield :class:`Document` objects for Gmail messages.
@@ -356,14 +411,15 @@ class GmailConnector(BaseConnector):
returned. Translated to a Gmail ``after:<epoch>`` search query.
cursor:
``nextPageToken`` from a previous sync to resume pagination.
query_extra:
Additional Gmail search operators appended to the base query,
e.g. ``"is:unread"`` to restrict to unread messages only.
"""
# Existence check only — the actual access token is reloaded on every
# API call by _call_with_refresh so a mid-sync refresh is picked up
# transparently.
tokens = load_tokens(self._credentials_path)
if not tokens:
return
if not (tokens.get("access_token") or tokens.get("token")):
if not tokens or not (tokens.get("token") or tokens.get("access_token")):
return
# Default to no filter so SENT, labeled, and category-tabbed mail
@@ -374,6 +430,8 @@ class GmailConnector(BaseConnector):
if since is not None:
# Gmail's after: operator accepts Unix epoch seconds.
query_parts.append(f"after:{int(since.timestamp())}")
if query_extra:
query_parts.append(query_extra)
query = " ".join(query_parts)
page_token: Optional[str] = cursor
@@ -461,6 +519,54 @@ class GmailConnector(BaseConnector):
self._items_synced = synced
self._last_sync = datetime.now()
def _current_token(self) -> str:
"""Return the cached access token (may be expired)."""
tokens = load_tokens(self._credentials_path)
if not tokens:
raise RuntimeError("Gmail not authenticated")
return tokens.get("token") or tokens.get("access_token") or ""
def _refresh_token(self) -> str:
"""Refresh the access token using the stored refresh token.
Raises ``RuntimeError`` if refresh fails (typically because the
refresh token has been revoked — user must re-authorise).
"""
new = refresh_google_token(self._credentials_path)
if not new:
raise RuntimeError(
"Gmail token refresh failed — re-run `jarvis connect gmail`"
)
return new
def _call_with_refresh(self, fn: Any, *args: Any, **kwargs: Any) -> Any:
"""Invoke a ``_gmail_api_*`` function with auto-refresh on 401.
Tries with the cached token first. If the call raises an
``httpx.HTTPStatusError`` with a 401, refresh the access token
once and retry. Any other failure propagates unchanged.
"""
import httpx
token = self._current_token()
try:
return fn(token, *args, **kwargs)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 401:
raise
token = self._refresh_token()
return fn(token, *args, **kwargs)
def delete_message(self, msg_id: str) -> None:
"""Move a message to Trash (recoverable for 30 days)."""
self._call_with_refresh(_gmail_api_trash_message, msg_id)
def archive_message(self, msg_id: str) -> None:
"""Archive a message by removing the INBOX label."""
self._call_with_refresh(
_gmail_api_modify_message, msg_id, remove_labels=["INBOX"]
)
def sync_status(self) -> SyncStatus:
"""Return sync progress from the most recent :meth:`sync` call."""
return SyncStatus(
+9 -1
View File
@@ -54,7 +54,15 @@ class GoogleTasksConnector(BaseConnector):
return tokens.get("access_token") or tokens.get("token", "")
def is_connected(self) -> bool:
return self._credentials_path.exists()
"""Return ``True`` if the credentials file has a real access token.
File existence alone is not enough — the shared ``google.json``
may contain only client_id/client_secret without OAuth tokens.
"""
tokens = load_tokens(str(self._credentials_path))
if tokens is None:
return False
return bool(tokens.get("access_token") or tokens.get("token"))
def disconnect(self) -> None:
if self._credentials_path.exists():
+61 -2
View File
@@ -58,9 +58,12 @@ GOOGLE_ALL_SCOPES: List[str] = [
"email",
"profile",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
# calendar (not .readonly) so the proactive agent can accept/decline events.
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
# gmail.modify (a superset of gmail.readonly) so the proactive agent
# can trash and label-modify (archive) emails after user approval.
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/tasks.readonly",
]
@@ -271,6 +274,62 @@ def delete_tokens(path: str) -> None:
p.unlink()
def refresh_google_token(path: str) -> Optional[str]:
"""Refresh a Google access token using the stored refresh token.
Reads the credentials file at *path*, exchanges its ``refresh_token``
(plus ``client_id``/``client_secret``) for a new ``access_token``
against Google's OAuth token endpoint, persists the refreshed payload
back to *path*, and returns the new access token.
Returns ``None`` if any required field is missing or the refresh call
fails (network error or Google returns a non-2xx response — typically
``invalid_grant`` when the refresh token has been revoked).
"""
import httpx
tokens = load_tokens(path)
if not tokens:
return None
refresh_token = tokens.get("refresh_token")
client_id = tokens.get("client_id")
client_secret = tokens.get("client_secret")
if not (refresh_token and client_id and client_secret):
return None
try:
resp = httpx.post(
"https://oauth2.googleapis.com/token",
data={
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
},
timeout=15.0,
)
except httpx.HTTPError:
return None
if resp.status_code >= 400:
return None
body = resp.json()
new_access = body.get("access_token")
if not new_access:
return None
tokens.update(
{
"access_token": new_access,
"token": new_access, # legacy key used by some connectors
"token_type": body.get("token_type", tokens.get("token_type", "Bearer")),
"expires_in": body.get("expires_in", tokens.get("expires_in", 3600)),
}
)
save_tokens(path, tokens)
return new_access
# ---------------------------------------------------------------------------
# Token exchange & full OAuth flow
# ---------------------------------------------------------------------------
+15
View File
@@ -1008,6 +1008,19 @@ class TracesConfig:
db_path: str = str(DEFAULT_CONFIG_DIR / "traces.db")
@dataclass(slots=True)
class ProactiveConfig:
"""Proactive agent — autonomous action scheduling and approval routing."""
enabled: bool = False
schedule: str = "0 5 * * *" # cron expression (default: 5am daily)
hours_back: int = 24 # how many hours of unacted items to scan
timezone: str = "America/Los_Angeles"
# Channel to send approval notifications and receive yes/no replies.
# Format: "{type}:{id}", e.g. "imessage:+15551234567" or "telegram:123456789"
notification_channel: str = ""
@dataclass(slots=True)
class TelegramChannelConfig:
"""Per-channel config for Telegram."""
@@ -1510,6 +1523,7 @@ class JarvisConfig:
compression: CompressionConfig = field(default_factory=CompressionConfig)
skills: SkillsConfig = field(default_factory=SkillsConfig)
digest: DigestConfig = field(default_factory=DigestConfig)
proactive: ProactiveConfig = field(default_factory=ProactiveConfig)
mining: Optional["MiningConfig"] = None
@property
@@ -1766,6 +1780,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
"optimize",
"agent_manager",
"digest",
"proactive",
)
for section_name in top_sections:
if section_name in data:
+404
View File
@@ -0,0 +1,404 @@
"""ApprovalStore — SQLite-backed store for proactive agent action approvals.
Two tables:
- ``pending_actions``: actions proposed by the proactive agent awaiting user decision
- ``permission_memory``: remembered user decisions keyed by action pattern
Permission key format: ``"{action_type}:{fingerprint}"``
e.g. ``"email_delete:domain:noreply.github.com"``
``"sms_draft_reply:contact:+15551234567"``
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
# ---------------------------------------------------------------------------
# Decision constants
# ---------------------------------------------------------------------------
DECISION_ALWAYS_APPROVE = "always_approve"
DECISION_ALWAYS_DENY = "always_deny"
DECISION_ASK = "ask"
STATUS_PENDING = "pending"
STATUS_APPROVED = "approved"
STATUS_DENIED = "denied"
STATUS_EXPIRED = "expired"
STATUS_EXECUTED = "executed"
# Tiers govern default ask behavior
TIER_TRIVIAL = "trivial" # Execute immediately, no ask
TIER_LOW = "low" # Ask once, then remember
TIER_MEDIUM = "medium" # Ask each time unless remembered
TIER_HIGH = "high" # Always ask, never auto-remember
@dataclass
class PendingAction:
"""An action proposed by the proactive agent."""
id: str
action_type: str
description: str
payload: Dict[str, Any]
permission_key: str
tier: str
status: str = STATUS_PENDING
created_at: str = ""
expires_at: str = ""
notification_sent: bool = False
decision_at: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"action_type": self.action_type,
"description": self.description,
"payload": json.dumps(self.payload),
"permission_key": self.permission_key,
"tier": self.tier,
"status": self.status,
"created_at": self.created_at,
"expires_at": self.expires_at,
"notification_sent": int(self.notification_sent),
"decision_at": self.decision_at,
}
@classmethod
def from_row(cls, row: tuple) -> PendingAction:
(
id_,
action_type,
description,
payload_json,
permission_key,
tier,
status,
created_at,
expires_at,
notification_sent,
decision_at,
) = row
return cls(
id=id_,
action_type=action_type,
description=description,
payload=json.loads(payload_json) if payload_json else {},
permission_key=permission_key,
tier=tier,
status=status,
created_at=created_at or "",
expires_at=expires_at or "",
notification_sent=bool(notification_sent),
decision_at=decision_at,
)
@dataclass
class PermissionRule:
"""A remembered user decision for a permission pattern."""
permission_key: str
decision: str # always_approve | always_deny
times_approved: int = 0
times_denied: int = 0
last_updated: str = ""
notes: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"permission_key": self.permission_key,
"decision": self.decision,
"times_approved": self.times_approved,
"times_denied": self.times_denied,
"last_updated": self.last_updated,
"notes": self.notes,
}
@classmethod
def from_row(cls, row: tuple) -> PermissionRule:
(
permission_key,
decision,
times_approved,
times_denied,
last_updated,
notes,
) = row
return cls(
permission_key=permission_key,
decision=decision,
times_approved=times_approved,
times_denied=times_denied,
last_updated=last_updated or "",
notes=notes or "",
)
class ApprovalStore:
"""SQLite store for proactive agent action approvals and permission memory."""
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(Path.home() / ".openjarvis" / "approvals.db")
self._db_path = db_path
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._create_tables()
self._conn.commit()
def _create_tables(self) -> None:
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS pending_actions (
id TEXT PRIMARY KEY,
action_type TEXT NOT NULL,
description TEXT NOT NULL,
payload TEXT NOT NULL,
permission_key TEXT NOT NULL,
tier TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
notification_sent INTEGER NOT NULL DEFAULT 0,
decision_at TEXT
);
CREATE TABLE IF NOT EXISTS permission_memory (
permission_key TEXT PRIMARY KEY,
decision TEXT NOT NULL,
times_approved INTEGER NOT NULL DEFAULT 0,
times_denied INTEGER NOT NULL DEFAULT 0,
last_updated TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT ''
);
""")
# -- Pending actions -------------------------------------------------------
def queue_action(
self,
action_type: str,
description: str,
payload: Dict[str, Any],
permission_key: str,
tier: str,
ttl_hours: int = 24,
) -> PendingAction:
"""Create and persist a new pending action."""
now = datetime.now(timezone.utc)
action = PendingAction(
id=uuid.uuid4().hex[:12],
action_type=action_type,
description=description,
payload=payload,
permission_key=permission_key,
tier=tier,
status=STATUS_PENDING,
created_at=now.isoformat(),
expires_at=(now + timedelta(hours=ttl_hours)).isoformat(),
)
self._conn.execute(
"""
INSERT OR REPLACE INTO pending_actions
(id, action_type, description, payload, permission_key,
tier, status, created_at, expires_at, notification_sent, decision_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
action.id,
action.action_type,
action.description,
json.dumps(action.payload),
action.permission_key,
action.tier,
action.status,
action.created_at,
action.expires_at,
int(action.notification_sent),
action.decision_at,
),
)
self._conn.commit()
return action
def get_action(self, action_id: str) -> Optional[PendingAction]:
row = self._conn.execute(
"SELECT id, action_type, description, payload, permission_key, "
"tier, status, created_at, expires_at, notification_sent, decision_at "
"FROM pending_actions WHERE id = ?",
(action_id,),
).fetchone()
return PendingAction.from_row(row) if row else None
def list_pending(self) -> List[PendingAction]:
"""Return all non-expired pending actions."""
now = datetime.now(timezone.utc).isoformat()
rows = self._conn.execute(
"SELECT id, action_type, description, payload, permission_key, "
"tier, status, created_at, expires_at, notification_sent, decision_at "
"FROM pending_actions WHERE status = ? AND expires_at > ? "
"ORDER BY created_at",
(STATUS_PENDING, now),
).fetchall()
return [PendingAction.from_row(r) for r in rows]
def list_approved(self) -> List[PendingAction]:
"""Return approved-but-not-yet-executed actions."""
rows = self._conn.execute(
"SELECT id, action_type, description, payload, permission_key, "
"tier, status, created_at, expires_at, notification_sent, decision_at "
"FROM pending_actions WHERE status = ? ORDER BY created_at",
(STATUS_APPROVED,),
).fetchall()
return [PendingAction.from_row(r) for r in rows]
def update_status(
self,
action_id: str,
status: str,
*,
notification_sent: Optional[bool] = None,
) -> None:
now = datetime.now(timezone.utc).isoformat()
if notification_sent is not None:
self._conn.execute(
"UPDATE pending_actions SET status = ?, decision_at = ?, "
"notification_sent = ? WHERE id = ?",
(status, now, int(notification_sent), action_id),
)
else:
self._conn.execute(
"UPDATE pending_actions SET status = ?, decision_at = ? WHERE id = ?",
(status, now, action_id),
)
self._conn.commit()
def expire_stale(self) -> int:
"""Mark past-TTL pending actions as expired. Returns count."""
now = datetime.now(timezone.utc).isoformat()
cur = self._conn.execute(
"UPDATE pending_actions SET status = ? "
"WHERE status = ? AND expires_at <= ?",
(STATUS_EXPIRED, STATUS_PENDING, now),
)
self._conn.commit()
return cur.rowcount
# -- Permission memory -----------------------------------------------------
def get_permission(self, permission_key: str) -> Optional[PermissionRule]:
row = self._conn.execute(
"SELECT permission_key, decision, times_approved, times_denied, "
"last_updated, notes FROM permission_memory WHERE permission_key = ?",
(permission_key,),
).fetchone()
return PermissionRule.from_row(row) if row else None
def set_permission(
self,
permission_key: str,
decision: str,
*,
approved: bool = False,
notes: str = "",
) -> None:
"""Upsert a permission rule, incrementing the relevant counter."""
now = datetime.now(timezone.utc).isoformat()
existing = self.get_permission(permission_key)
if existing:
times_approved = existing.times_approved + (1 if approved else 0)
times_denied = existing.times_denied + (0 if approved else 1)
self._conn.execute(
"UPDATE permission_memory SET decision = ?, times_approved = ?, "
"times_denied = ?, last_updated = ?, notes = ? "
"WHERE permission_key = ?",
(
decision,
times_approved,
times_denied,
now,
notes or existing.notes,
permission_key,
),
)
else:
self._conn.execute(
"INSERT INTO permission_memory "
"(permission_key, decision, times_approved, times_denied, "
"last_updated, notes) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
permission_key,
decision,
1 if approved else 0,
0 if approved else 1,
now,
notes,
),
)
self._conn.commit()
def clear_permission(self, permission_key: str) -> None:
self._conn.execute(
"DELETE FROM permission_memory WHERE permission_key = ?",
(permission_key,),
)
self._conn.commit()
def list_permissions(self) -> List[PermissionRule]:
rows = self._conn.execute(
"SELECT permission_key, decision, times_approved, times_denied, "
"last_updated, notes FROM permission_memory ORDER BY last_updated DESC"
).fetchall()
return [PermissionRule.from_row(r) for r in rows]
def get_seen_ids(self) -> set:
"""Return all doc_ids and message_ids previously queued (any status).
Used by ``ProactiveAgent`` to skip items it has already proposed so
they don't resurface on every run while still unread/unanswered.
"""
seen: set = set()
rows = self._conn.execute("SELECT payload FROM pending_actions").fetchall()
for (payload_json,) in rows:
try:
payload = json.loads(payload_json) if payload_json else {}
except json.JSONDecodeError:
continue
doc_id = payload.get("doc_id", "")
if doc_id:
seen.add(doc_id)
msg_id = payload.get("message_id", "")
if msg_id:
seen.add(f"gmail:{msg_id}")
return seen
def close(self) -> None:
self._conn.close()
__all__ = [
"ApprovalStore",
"PendingAction",
"PermissionRule",
"DECISION_ALWAYS_APPROVE",
"DECISION_ALWAYS_DENY",
"DECISION_ASK",
"STATUS_PENDING",
"STATUS_APPROVED",
"STATUS_DENIED",
"STATUS_EXPIRED",
"STATUS_EXECUTED",
"TIER_TRIVIAL",
"TIER_LOW",
"TIER_MEDIUM",
"TIER_HIGH",
]
+71 -7
View File
@@ -1,3 +1,4 @@
# ruff: noqa: E501
"""Digest collection tool — fetches recent data from configured connectors."""
from __future__ import annotations
@@ -157,13 +158,17 @@ def _format_strava(doc: Document) -> str:
def _format_gmail(doc: Document) -> str:
"""Format a Gmail email document."""
"""Format a Gmail email document.
Includes ``doc_id`` so the proactive agent can reference the
real Gmail ``messages.get/modify`` id in its action proposals
instead of hallucinating one.
"""
sender = doc.author or "Unknown"
subject = doc.title or "(no subject)"
ago = _time_ago(doc.timestamp)
# Include body preview (first 150 chars, single line)
body = doc.content.replace("\n", " ").strip()[:150] if doc.content else ""
line = f'[gmail] From: {sender}"{subject}" ({ago})'
line = f'[gmail id={doc.doc_id}] From: {sender}"{subject}" ({ago})'
if body:
line += f"\n Preview: {body}"
return line
@@ -174,7 +179,7 @@ def _format_gmail_imap(doc: Document) -> str:
sender = doc.author or "Unknown"
subject = doc.title or "(no subject)"
ago = _time_ago(doc.timestamp)
return f'[gmail] From: {sender}"{subject}" ({ago})'
return f'[gmail id={doc.doc_id}] From: {sender}"{subject}" ({ago})'
def _format_google_tasks(doc: Document) -> str:
@@ -271,7 +276,7 @@ def _format_gcalendar(doc: Document) -> str:
time_range = f" ({duration})"
except (ValueError, TypeError):
pass
return f"[gcalendar] {time_str}{title}{time_range}"
return f"[gcalendar id={doc.doc_id}] {time_str}{title}{time_range}"
def _format_spotify(doc: Document) -> str:
@@ -381,6 +386,39 @@ def _format_music_section(
return lines
def _filter_unanswered_threads(docs: List[Document]) -> List[Document]:
"""Keep only iMessage threads where the last message is NOT from the user.
Groups by chat title, finds the most-recent message per chat, and returns
only that message if ``author != "me"``. Threads the user has already
replied to are silently dropped.
"""
from collections import defaultdict
by_chat: Dict[str, List[Document]] = defaultdict(list)
for doc in docs:
by_chat[doc.title or doc.author or ""].append(doc)
result: List[Document] = []
for chat_docs in by_chat.values():
latest = max(chat_docs, key=lambda d: d.timestamp)
if latest.author != "me":
result.append(latest)
return result
def _filter_pending_invites(docs: List[Document]) -> List[Document]:
"""Keep only calendar events the user has not yet responded to."""
pending: List[Document] = []
for doc in docs:
response_status = doc.metadata.get("response_status", "")
# Include if status is explicitly needsAction, or if no status recorded
# (connector may not populate it — safer to include than to drop)
if response_status in ("needsAction", ""):
pending.append(doc)
return pending
@ToolRegistry.register("digest_collect")
class DigestCollectTool(BaseTool):
"""Collect recent data from multiple connectors for digest synthesis."""
@@ -413,6 +451,18 @@ class DigestCollectTool(BaseTool):
"type": "number",
"description": "How many hours back to look (default: 24).",
},
"unacted_only": {
"type": "boolean",
"description": (
"When true, only return items the user has not yet acted on: "
"unread emails, unanswered iMessage threads, pending calendar invites."
),
},
"seen_ids": {
"type": "array",
"items": {"type": "string"},
"description": "doc_ids to exclude (already queued or acted on).",
},
},
"required": ["sources"],
},
@@ -426,6 +476,8 @@ class DigestCollectTool(BaseTool):
sources: List[str] = params.get("sources", [])
hours_back: float = params.get("hours_back", 24)
unacted_only: bool = bool(params.get("unacted_only", False))
seen_ids: set = set(params.get("seen_ids", []))
since = datetime.now() - timedelta(hours=hours_back)
# Collect raw documents per source
@@ -450,11 +502,23 @@ class DigestCollectTool(BaseTool):
# Cap per-source to avoid overwhelming the LLM context
max_per_source = 15
docs: List[Document] = []
for d in connector.sync(since=since):
docs.append(d)
sync_kwargs: Dict[str, Any] = {"since": since}
if unacted_only and source == "gmail":
sync_kwargs["query_extra"] = "is:unread"
for d in connector.sync(**sync_kwargs):
if d.doc_id not in seen_ids:
docs.append(d)
if len(docs) >= max_per_source:
break
if unacted_only and source == "imessage":
docs = _filter_unanswered_threads(docs)
if unacted_only and source == "gcalendar":
docs = _filter_pending_invites(docs)
collected_docs[source] = docs
except Exception as exc:
errors.append(f"Error fetching from '{source}': {exc}")
+585
View File
@@ -0,0 +1,585 @@
# ruff: noqa: E501
"""Proactive agent tools — check/record permissions, queue and execute actions.
These tools are used exclusively by ``ProactiveAgent`` to manage the
propose → approve → execute lifecycle for autonomous actions.
Permission key convention: ``"{action_type}:{context_key}"``
Approval response parsing
-------------------------
When the user replies to a pending-actions notification, their message is
expected to contain one or more tokens of the form:
``{action_id} yes`` or ``{action_id} no``
``yes {action_id}`` or ``no {action_id}``
``always yes {action_id}`` → approve + remember
``always no {action_id}`` → deny + remember
``yes all`` / ``no all`` → bulk approve/deny all pending
Call ``parse_approval_response(text, store)`` from any channel message handler
to process these replies without running the full agent.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional, Tuple
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
from openjarvis.tools.approval_store import (
DECISION_ALWAYS_APPROVE,
DECISION_ALWAYS_DENY,
STATUS_APPROVED,
STATUS_DENIED,
STATUS_EXECUTED,
TIER_HIGH,
TIER_LOW,
TIER_MEDIUM,
TIER_TRIVIAL,
ApprovalStore,
PendingAction,
)
# ---------------------------------------------------------------------------
# Shared store (lazily initialised, one per process)
# ---------------------------------------------------------------------------
_store: Optional[ApprovalStore] = None
def get_store() -> ApprovalStore:
global _store
if _store is None:
_store = ApprovalStore()
return _store
# ---------------------------------------------------------------------------
# check_permission
# ---------------------------------------------------------------------------
@ToolRegistry.register("check_permission")
class CheckPermissionTool(BaseTool):
"""Look up whether the user has a remembered decision for a permission key."""
tool_id = "check_permission"
def __init__(self, store: Optional[ApprovalStore] = None) -> None:
self._store = store
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="check_permission",
description=(
"Check whether the user has a remembered permission decision for "
"an action pattern. Returns 'always_approve', 'always_deny', or 'unknown'."
),
parameters={
"type": "object",
"properties": {
"permission_key": {
"type": "string",
"description": (
"Permission pattern key, e.g. "
"'email_delete:domain:noreply.github.com'"
),
},
},
"required": ["permission_key"],
},
category="proactive",
)
def execute(self, **params: Any) -> ToolResult:
key = params.get("permission_key", "")
store = self._store or get_store()
rule = store.get_permission(key)
decision = rule.decision if rule else "unknown"
return ToolResult(
tool_name=self.spec.name,
success=True,
content=decision,
metadata={"permission_key": key, "decision": decision},
)
# ---------------------------------------------------------------------------
# queue_action
# ---------------------------------------------------------------------------
@ToolRegistry.register("queue_action")
class QueueActionTool(BaseTool):
"""Queue a proposed action for user approval or immediate execution."""
tool_id = "queue_action"
def __init__(self, store: Optional[ApprovalStore] = None) -> None:
self._store = store
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="queue_action",
description=(
"Queue a proposed action. Tier controls whether user approval is required:\n"
f" '{TIER_TRIVIAL}' — execute immediately, no approval needed\n"
f" '{TIER_LOW}' — ask once per pattern, then remember\n"
f" '{TIER_MEDIUM}' — ask each time unless user said 'always'\n"
f" '{TIER_HIGH}' — always ask, never auto-remember\n"
"Returns the action_id so you can reference it in notifications."
),
parameters={
"type": "object",
"properties": {
"action_type": {
"type": "string",
"description": "Short slug, e.g. 'email_delete', 'sms_draft_reply'.",
},
"description": {
"type": "string",
"description": "Human-readable description of what will be done.",
},
"payload": {
"type": "object",
"description": "JSON payload the executor will use to carry out the action.",
},
"permission_key": {
"type": "string",
"description": "Pattern key for permission memory lookup.",
},
"tier": {
"type": "string",
"enum": [TIER_TRIVIAL, TIER_LOW, TIER_MEDIUM, TIER_HIGH],
"description": "Approval tier.",
},
},
"required": [
"action_type",
"description",
"payload",
"permission_key",
"tier",
],
},
category="proactive",
)
def execute(self, **params: Any) -> ToolResult:
store = self._store or get_store()
action = store.queue_action(
action_type=params["action_type"],
description=params["description"],
payload=params.get("payload", {}),
permission_key=params["permission_key"],
tier=params["tier"],
)
return ToolResult(
tool_name=self.spec.name,
success=True,
content=action.id,
metadata={"action_id": action.id, "status": action.status},
)
# ---------------------------------------------------------------------------
# get_pending_actions
# ---------------------------------------------------------------------------
@ToolRegistry.register("get_pending_actions")
class GetPendingActionsTool(BaseTool):
"""Return all pending (not yet decided) actions as a JSON list."""
tool_id = "get_pending_actions"
def __init__(self, store: Optional[ApprovalStore] = None) -> None:
self._store = store
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="get_pending_actions",
description="Return all pending actions awaiting user approval as a JSON list.",
parameters={"type": "object", "properties": {}},
category="proactive",
)
def execute(self, **params: Any) -> ToolResult:
store = self._store or get_store()
store.expire_stale()
actions = store.list_pending()
data = [
{
"id": a.id,
"action_type": a.action_type,
"description": a.description,
"tier": a.tier,
"permission_key": a.permission_key,
"created_at": a.created_at,
}
for a in actions
]
return ToolResult(
tool_name=self.spec.name,
success=True,
content=json.dumps(data, indent=2),
metadata={"count": len(data)},
)
# ---------------------------------------------------------------------------
# record_decision
# ---------------------------------------------------------------------------
@ToolRegistry.register("record_decision")
class RecordDecisionTool(BaseTool):
"""Record a user approval or denial for a queued action."""
tool_id = "record_decision"
def __init__(self, store: Optional[ApprovalStore] = None) -> None:
self._store = store
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="record_decision",
description=(
"Record the user's approval or denial for a pending action. "
"Set remember=true to save the decision to permission memory so "
"the same pattern is handled automatically in future."
),
parameters={
"type": "object",
"properties": {
"action_id": {
"type": "string",
"description": "The action_id returned by queue_action.",
},
"approved": {
"type": "boolean",
"description": "True to approve, false to deny.",
},
"remember": {
"type": "boolean",
"description": "Save decision to permission memory for this pattern.",
},
"notes": {
"type": "string",
"description": "Optional note to store alongside the permission rule.",
},
},
"required": ["action_id", "approved"],
},
category="proactive",
)
def execute(self, **params: Any) -> ToolResult:
store = self._store or get_store()
action_id = params["action_id"]
approved = bool(params.get("approved", False))
remember = bool(params.get("remember", False))
notes = params.get("notes", "")
action = store.get_action(action_id)
if action is None:
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Action not found: {action_id}",
)
new_status = STATUS_APPROVED if approved else STATUS_DENIED
store.update_status(action_id, new_status)
if remember:
decision = DECISION_ALWAYS_APPROVE if approved else DECISION_ALWAYS_DENY
store.set_permission(
action.permission_key,
decision,
approved=approved,
notes=notes,
)
msg = f"Action {action_id} {'approved' if approved else 'denied'}."
if remember:
msg += f" Permission '{action.permission_key}' saved as {decision}."
return ToolResult(
tool_name=self.spec.name,
success=True,
content=msg,
metadata={
"action_id": action_id,
"approved": approved,
"remembered": remember,
},
)
# ---------------------------------------------------------------------------
# execute_pending_actions
# ---------------------------------------------------------------------------
@ToolRegistry.register("execute_pending_actions")
class ExecutePendingActionsTool(BaseTool):
"""Execute all approved (or trivial) actions and return a summary."""
tool_id = "execute_pending_actions"
def __init__(
self,
store: Optional[ApprovalStore] = None,
executor_fn: Optional[Any] = None,
) -> None:
self._store = store
# executor_fn(action: PendingAction) -> (success: bool, message: str)
self._executor_fn = executor_fn
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="execute_pending_actions",
description=(
"Execute all approved actions in the queue. "
"Returns a JSON summary of what succeeded and what failed."
),
parameters={
"type": "object",
"properties": {
"action_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of specific action IDs to execute. "
"If omitted, executes all approved actions.",
},
},
},
category="proactive",
)
def execute(self, **params: Any) -> ToolResult:
store = self._store or get_store()
action_ids: Optional[List[str]] = params.get("action_ids")
if action_ids:
actions = [a for a in store.list_approved() if a.id in set(action_ids)]
else:
actions = store.list_approved()
results: List[Dict[str, Any]] = []
for action in actions:
success, message = self._run_action(action)
store.update_status(action.id, STATUS_EXECUTED)
results.append(
{
"id": action.id,
"action_type": action.action_type,
"description": action.description,
"success": success,
"message": message,
}
)
return ToolResult(
tool_name=self.spec.name,
success=True,
content=json.dumps(results, indent=2),
metadata={"executed": len(results)},
)
def _run_action(self, action: PendingAction) -> Tuple[bool, str]:
if self._executor_fn is not None:
try:
return self._executor_fn(action)
except Exception as exc:
return False, str(exc)
# Built-in dispatcher — extend as connectors grow
payload = action.payload
atype = action.action_type
try:
if atype == "email_delete":
return _exec_email_delete(payload)
if atype == "email_archive":
return _exec_email_archive(payload)
if atype == "sms_send":
return _exec_sms_send(payload)
if atype == "sms_draft_reply":
# Draft only — surface in next digest, don't send
return True, f"Draft saved: {payload.get('draft', '')[:80]}"
if atype == "calendar_decline":
return _exec_calendar_decline(payload)
if atype == "calendar_accept":
return _exec_calendar_accept(payload)
return False, f"No executor registered for action_type '{atype}'"
except Exception as exc:
return False, str(exc)
# ---------------------------------------------------------------------------
# Built-in action executors (thin wrappers around connector/channel APIs)
# ---------------------------------------------------------------------------
def _exec_email_delete(payload: Dict[str, Any]) -> Tuple[bool, str]:
msg_id = payload.get("message_id", "")
if not msg_id:
return False, "Missing message_id in payload"
try:
from openjarvis.connectors.gmail import GmailConnector
conn = GmailConnector()
conn.delete_message(msg_id)
return True, f"Deleted email {msg_id}"
except Exception as exc:
return False, str(exc)
def _exec_email_archive(payload: Dict[str, Any]) -> Tuple[bool, str]:
msg_id = payload.get("message_id", "")
if not msg_id:
return False, "Missing message_id in payload"
try:
from openjarvis.connectors.gmail import GmailConnector
conn = GmailConnector()
conn.archive_message(msg_id)
return True, f"Archived email {msg_id}"
except Exception as exc:
return False, str(exc)
def _exec_sms_send(payload: Dict[str, Any]) -> Tuple[bool, str]:
contact = payload.get("contact", "")
body = payload.get("body", "")
if not contact or not body:
return False, "Missing contact or body in payload"
try:
from openjarvis.channels.imessage_daemon import send_imessage
send_imessage(contact, body)
return True, f"Sent iMessage to {contact}"
except Exception as exc:
return False, str(exc)
def _exec_calendar_decline(payload: Dict[str, Any]) -> Tuple[bool, str]:
event_id = payload.get("event_id", "")
calendar_id = payload.get("calendar_id", "primary")
if not event_id:
return False, "Missing event_id in payload"
try:
from openjarvis.connectors.gcalendar import GCalendarConnector
conn = GCalendarConnector()
conn.decline_event(event_id, calendar_id=calendar_id)
return True, f"Declined calendar event {event_id}"
except Exception as exc:
return False, str(exc)
def _exec_calendar_accept(payload: Dict[str, Any]) -> Tuple[bool, str]:
event_id = payload.get("event_id", "")
calendar_id = payload.get("calendar_id", "primary")
if not event_id:
return False, "Missing event_id in payload"
try:
from openjarvis.connectors.gcalendar import GCalendarConnector
conn = GCalendarConnector()
conn.accept_event(event_id, calendar_id=calendar_id)
return True, f"Accepted calendar event {event_id}"
except Exception as exc:
return False, str(exc)
# ---------------------------------------------------------------------------
# Approval response parser (for channel message handlers)
# ---------------------------------------------------------------------------
# Matches: "abc123 yes", "yes abc123", "always yes abc123", "yes all", etc.
_APPROVAL_RE = re.compile(
r"\b(?P<always>always\s+)?(?P<decision>yes|no|approve|deny)\s+(?P<target>[a-f0-9]{12}|all)\b"
r"|"
r"\b(?P<target2>[a-f0-9]{12}|all)\s+(?P<always2>always\s+)?(?P<decision2>yes|no|approve|deny)\b",
re.IGNORECASE,
)
def parse_approval_response(
text: str,
store: Optional[ApprovalStore] = None,
) -> List[Dict[str, Any]]:
"""Parse a free-text message for approval tokens and update the store.
Returns a list of dicts describing each decision that was processed,
for use in an acknowledgement message back to the user.
Call this from any channel message handler before routing the message
to the main agent, e.g. inside the iMessage daemon or Telegram bot.
"""
s = store or get_store()
processed: List[Dict[str, Any]] = []
# Notification template displays ids as `[abc123]`; users naturally reply
# with `{abc123} yes`, `(abc123) yes`, etc. Strip those surrounding
# brackets/braces/parens before regex matching so the word-boundary
# check sees a clean id.
text = re.sub(r"[\[\]\{\}\(\)]", " ", text)
for m in _APPROVAL_RE.finditer(text):
target = (m.group("target") or m.group("target2") or "").lower()
raw_decision = (m.group("decision") or m.group("decision2") or "").lower()
always = bool(m.group("always") or m.group("always2"))
approved = raw_decision in ("yes", "approve")
if target == "all":
pending = s.list_pending()
for action in pending:
new_status = STATUS_APPROVED if approved else STATUS_DENIED
s.update_status(action.id, new_status)
if always and action.tier in (TIER_LOW, TIER_MEDIUM):
decision = (
DECISION_ALWAYS_APPROVE if approved else DECISION_ALWAYS_DENY
)
s.set_permission(action.permission_key, decision, approved=approved)
processed.append(
{"id": action.id, "approved": approved, "remembered": always}
)
else:
action = s.get_action(target)
if action is None:
continue
new_status = STATUS_APPROVED if approved else STATUS_DENIED
s.update_status(target, new_status)
remember = always and action.tier in (TIER_LOW, TIER_MEDIUM)
if remember:
decision = DECISION_ALWAYS_APPROVE if approved else DECISION_ALWAYS_DENY
s.set_permission(action.permission_key, decision, approved=approved)
processed.append(
{"id": target, "approved": approved, "remembered": remember}
)
return processed
__all__ = [
"CheckPermissionTool",
"ExecutePendingActionsTool",
"GetPendingActionsTool",
"QueueActionTool",
"RecordDecisionTool",
"get_store",
"parse_approval_response",
]
+27 -18
View File
@@ -112,7 +112,7 @@ def test_auth_url_returns_string(connector) -> None:
url = connector.auth_url()
assert isinstance(url, str)
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
assert "gmail.readonly" in url
assert "gmail.modify" in url
# ---------------------------------------------------------------------------
@@ -409,8 +409,7 @@ def test_html_to_text_strips_basic_tags() -> None:
from openjarvis.connectors.gmail import _html_to_text # noqa: PLC0415
html = (
"<html><body><p>Hello <b>world</b>!</p>"
"<p>Second paragraph.</p></body></html>"
"<html><body><p>Hello <b>world</b>!</p><p>Second paragraph.</p></body></html>"
)
text = _html_to_text(html)
assert "Hello" in text
@@ -473,8 +472,7 @@ def test_sync_strips_html_when_no_text_plain(
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
html_bytes = (
b"<html><body><p>Hello <b>world</b>!</p>"
b"<p>Second paragraph.</p></body></html>"
b"<html><body><p>Hello <b>world</b>!</p><p>Second paragraph.</p></body></html>"
)
html_b64 = base64.urlsafe_b64encode(html_bytes).decode().rstrip("=")
@@ -524,12 +522,14 @@ def test_sync_prefers_text_plain_over_text_html(
creds_path = Path(connector._credentials_path)
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
plain_b64 = base64.urlsafe_b64encode(
b"Plain text version preferred."
).decode().rstrip("=")
html_b64 = base64.urlsafe_b64encode(
b"<html><body><p>HTML version</p></body></html>"
).decode().rstrip("=")
plain_b64 = (
base64.urlsafe_b64encode(b"Plain text version preferred.").decode().rstrip("=")
)
html_b64 = (
base64.urlsafe_b64encode(b"<html><body><p>HTML version</p></body></html>")
.decode()
.rstrip("=")
)
msg_alt = {
"id": "msg-alt-1",
@@ -579,6 +579,7 @@ class _FakeResponse:
def raise_for_status(self):
if self.status_code >= 400:
import httpx as _httpx
raise _httpx.HTTPStatusError(
f"HTTP {self.status_code}", request=None, response=self
)
@@ -625,8 +626,10 @@ def test_401_triggers_refresh_and_retries_with_new_token(tmp_path: Path) -> None
json_data={"access_token": "fresh-access-token", "expires_in": 3599},
)
with patch.object(gmail_mod.httpx, "get", side_effect=fake_get), \
patch.object(gmail_mod.httpx, "post", side_effect=fake_post):
with (
patch.object(gmail_mod.httpx, "get", side_effect=fake_get),
patch.object(gmail_mod.httpx, "post", side_effect=fake_post),
):
result = gmail_mod._call_with_refresh(
gmail_mod._gmail_api_get_message, creds_path, "msg-1"
)
@@ -668,9 +671,13 @@ def test_non_401_status_is_not_refreshed(tmp_path: Path) -> None:
def fake_get(url, *, headers, params, timeout):
return _FakeResponse(status_code=503, text="service unavailable")
fake_post = patch.object(gmail_mod.httpx, "post", side_effect=AssertionError(
"_call_with_refresh must not refresh on non-401 status"
))
fake_post = patch.object(
gmail_mod.httpx,
"post",
side_effect=AssertionError(
"_call_with_refresh must not refresh on non-401 status"
),
)
with patch.object(gmail_mod.httpx, "get", side_effect=fake_get), fake_post:
with pytest.raises(_httpx.HTTPStatusError):
@@ -749,8 +756,10 @@ def test_sync_recovers_when_list_returns_401(tmp_path: Path) -> None:
json_data={"access_token": "fresh-token-after-401", "expires_in": 3599},
)
with patch.object(gmail_mod.httpx, "get", side_effect=fake_get), \
patch.object(gmail_mod.httpx, "post", side_effect=fake_post):
with (
patch.object(gmail_mod.httpx, "get", side_effect=fake_get),
patch.object(gmail_mod.httpx, "post", side_effect=fake_post),
):
docs: List[Document] = list(connector.sync())
assert len(docs) == 1
+1 -1
View File
@@ -43,7 +43,7 @@ def test_digest_collect_executes():
assert result.success is True
assert "=== MESSAGES ===" in result.content
assert "[gmail] From: alice@example.com" in result.content
assert "[gmail id=test-1] From: alice@example.com" in result.content
assert "Team standup" in result.content
assert result.metadata["total_items"] == 1