mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-26 18:16:58 -04:00
2ac7d41533
Release-As: 0.1.0
210 lines
6.9 KiB
Python
210 lines
6.9 KiB
Python
"""Lightweight hook dispatch for external tool integration.
|
|
|
|
Loads hook configuration from `~/.deepagents/hooks.json` and fires matching
|
|
commands with JSON payloads on stdin. Subprocess work is offloaded to a
|
|
background thread so the caller's event loop is never stalled. Failures are
|
|
logged but never bubble up to the caller.
|
|
|
|
Config format (`~/.deepagents/hooks.json`):
|
|
|
|
```json
|
|
{"hooks": [{"command": ["bash", "adapter.sh"], "events": ["session.start"]}]}
|
|
```
|
|
|
|
If `events` is omitted or empty the hook receives **all** events.
|
|
|
|
Onboarding emits `user.name.set` with `{"name": "...", "assistant_id": "..."}`
|
|
after the user submits a non-empty preferred name.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import subprocess # noqa: S404
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_hooks_config: list[dict[str, Any]] | None = None
|
|
"""Cached config — loaded lazily on first dispatch."""
|
|
|
|
_background_tasks: set[asyncio.Task[None]] = set()
|
|
"""Strong references to fire-and-forget tasks to prevent GC."""
|
|
|
|
|
|
def _load_hooks() -> list[dict[str, Any]]:
|
|
"""Load and cache hook definitions from the config file.
|
|
|
|
Returns:
|
|
An empty list when the file is missing or malformed so that normal
|
|
execution is never interrupted.
|
|
"""
|
|
global _hooks_config # noqa: PLW0603
|
|
if _hooks_config is not None:
|
|
return _hooks_config
|
|
|
|
from deepagents_code.model_config import DEFAULT_CONFIG_DIR
|
|
|
|
hooks_path = DEFAULT_CONFIG_DIR / "hooks.json"
|
|
|
|
if not hooks_path.is_file():
|
|
_hooks_config = []
|
|
return _hooks_config
|
|
|
|
try:
|
|
data = json.loads(hooks_path.read_text())
|
|
if not isinstance(data, dict):
|
|
logger.warning(
|
|
"Hooks config at %s must be a JSON object, got %s",
|
|
hooks_path,
|
|
type(data).__name__,
|
|
)
|
|
_hooks_config = []
|
|
return _hooks_config
|
|
hooks = data.get("hooks", [])
|
|
if not isinstance(hooks, list):
|
|
logger.warning(
|
|
"Hooks config 'hooks' key at %s must be a list, got %s",
|
|
hooks_path,
|
|
type(hooks).__name__,
|
|
)
|
|
_hooks_config = []
|
|
return _hooks_config
|
|
_hooks_config = hooks
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
logger.warning("Failed to load hooks config from %s: %s", hooks_path, exc)
|
|
_hooks_config = []
|
|
|
|
return _hooks_config
|
|
|
|
|
|
def _run_single_hook(command: list[str], event: str, payload_bytes: bytes) -> None:
|
|
"""Execute a single hook command, writing the JSON payload to its stdin.
|
|
|
|
Uses `subprocess.run` which automatically kills the child process on
|
|
timeout, preventing zombie/orphan process leaks.
|
|
|
|
Args:
|
|
command: The command and arguments to run.
|
|
event: Event name (for logging).
|
|
payload_bytes: JSON payload to write to the command's stdin.
|
|
"""
|
|
try:
|
|
subprocess.run( # noqa: S603
|
|
command,
|
|
input=payload_bytes,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("Hook command timed out (>5s) for event %s: %s", event, command)
|
|
except (FileNotFoundError, PermissionError) as exc:
|
|
logger.warning("Hook command failed for event %s: %s — %s", event, command, exc)
|
|
except Exception:
|
|
logger.debug(
|
|
"Hook dispatch failed for event %s: %s",
|
|
event,
|
|
command,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def _dispatch_hook_sync(
|
|
event: str, payload_bytes: bytes, hooks: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Dispatch matching hooks, running them concurrently via a thread pool.
|
|
|
|
Iterates over all configured hooks, skipping those whose event filter
|
|
does not match or whose `command` is missing/invalid. Matching hooks are
|
|
executed concurrently with a 5-second timeout per command. Errors are caught
|
|
per-hook and logged without propagating.
|
|
|
|
Args:
|
|
event: Dotted event name (e.g. `'session.start'`).
|
|
payload_bytes: JSON payload to write to each command's stdin.
|
|
hooks: List of hook definition dicts from the config file.
|
|
"""
|
|
matching: list[list[str]] = []
|
|
for hook in hooks:
|
|
command = hook.get("command")
|
|
if not isinstance(command, list) or not command:
|
|
continue
|
|
|
|
events = hook.get("events")
|
|
# Empty/missing events list means "subscribe to everything".
|
|
if events and event not in events:
|
|
continue
|
|
|
|
matching.append(command)
|
|
|
|
if not matching:
|
|
return
|
|
|
|
if len(matching) == 1:
|
|
_run_single_hook(matching[0], event, payload_bytes)
|
|
return
|
|
|
|
with ThreadPoolExecutor(max_workers=len(matching)) as pool:
|
|
futures = [
|
|
pool.submit(_run_single_hook, cmd, event, payload_bytes) for cmd in matching
|
|
]
|
|
for future in futures:
|
|
future.result()
|
|
|
|
|
|
async def dispatch_hook(event: str, payload: dict[str, Any]) -> None:
|
|
"""Fire matching hook commands with `payload` serialized as JSON on stdin.
|
|
|
|
The `event` name is automatically injected into the payload under the
|
|
`"event"` key so callers don't need to duplicate it.
|
|
|
|
The blocking subprocess work is offloaded to a thread so the caller's
|
|
event loop is never stalled. Matching hooks run concurrently, each with
|
|
a 5-second timeout. Errors are logged and never propagated.
|
|
|
|
Args:
|
|
event: Dotted event name (e.g. `'session.start'`).
|
|
payload: Arbitrary JSON-serializable dict sent on the command's stdin.
|
|
"""
|
|
try:
|
|
hooks = _load_hooks()
|
|
if not hooks:
|
|
return
|
|
|
|
payload_bytes = json.dumps({"event": event, **payload}).encode()
|
|
await asyncio.to_thread(_dispatch_hook_sync, event, payload_bytes, hooks)
|
|
except Exception:
|
|
logger.warning(
|
|
"Unexpected error in dispatch_hook for event %s",
|
|
event,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def dispatch_hook_fire_and_forget(event: str, payload: dict[str, Any]) -> None:
|
|
"""Schedule `dispatch_hook` as a background task with a strong reference.
|
|
|
|
Use this instead of bare `create_task(dispatch_hook(...))` to prevent the
|
|
task from being garbage collected before completion.
|
|
|
|
Safe to call from sync code as long as an event loop is running.
|
|
|
|
Args:
|
|
event: Dotted event name (e.g. `'session.start'`).
|
|
payload: Arbitrary JSON-serializable dict sent on the command's stdin.
|
|
"""
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
logger.debug("No running event loop; skipping hook for %s", event)
|
|
return
|
|
task = loop.create_task(dispatch_hook(event, payload))
|
|
_background_tasks.add(task)
|
|
task.add_done_callback(_background_tasks.discard)
|