diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 99eefcd634af..6bb367d2d0b5 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -17,6 +17,13 @@ message that gets delivered. Use for external push notifications (Supabase, monitoring alerts, inter-agent pings) where zero LLM cost and sub-second delivery matter more than agent reasoning. + - cron_job: fire an existing cron job (by ID or name) on each event + instead of starting a fresh webhook agent session. The rendered + prompt becomes transient per-run context for that job; the job's own + prompt, skills, model, and delivery settings apply. Turns any cron + job into an event-triggered task (inspired by ChatGPT Work's + webhook-triggered scheduled tasks, Aug 2026). Mutually exclusive + with deliver_only. Security: - HMAC secret is required per route (validated at startup) @@ -275,6 +282,14 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: # Validate up-front so misconfiguration surfaces at startup rather # than on the first webhook POST. if route.get("deliver_only"): + if route.get("cron_job"): + raise ValueError( + f"[webhook] Route '{name}' sets both deliver_only and " + f"cron_job. They are mutually exclusive: deliver_only " + f"pushes the rendered template as a message, cron_job " + f"fires an existing cron job (which handles its own " + f"delivery)." + ) deliver = route.get("deliver", "log") if not deliver or deliver == "log": raise ValueError( @@ -861,6 +876,67 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": status=200, ) + # ── Cron-job trigger mode (cron_job) ──────────────────── + # Inspired by ChatGPT Work's webhook-triggered scheduled tasks + # (Aug 25 2026): an app event (Gmail/Slack/GitHub/anything that can + # POST) fires an EXISTING cron job instead of polling on a cadence. + # The rendered prompt template becomes transient per-run context + # (same rail as cronjob(action='run', prompt=...)); the job's own + # stored prompt, skills, model, and delivery settings all apply. + # Reuses the same HMAC auth, rate limiting, filters, script, and + # idempotency that protect agent-mode routes above. + if route_config.get("cron_job"): + job_ref = str(route_config["cron_job"]) + event_context = ( + f"This run was triggered by webhook event '{event_type}' " + f"on route '{route_name}' (not the schedule).\n\n{prompt}" + ) + logger.info( + "[webhook] cron-trigger event=%s route=%s job=%s delivery=%s", + event_type, + route_name, + job_ref, + delivery_id, + ) + + async def _fire_cron_job() -> None: + try: + from tools.cronjob_tools import execute_job_for_event + + # The job is a full agent run (minutes); keep it off the + # gateway event loop. + result = await asyncio.to_thread( + execute_job_for_event, job_ref, event_context + ) + if not result.get("success"): + logger.warning( + "[webhook] cron-trigger job=%s route=%s did not " + "complete cleanly: %s", + job_ref, + route_name, + result.get("error"), + ) + except Exception: + logger.exception( + "[webhook] cron-trigger failed job=%s route=%s", + job_ref, + route_name, + ) + + task = asyncio.create_task(_fire_cron_job()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return web.json_response( + { + "status": "accepted", + "route": route_name, + "cron_job": job_ref, + "event": event_type, + "delivery_id": delivery_id, + }, + status=202, + ) + # ── Direct delivery mode (deliver_only) ───────────────── # Skip the agent entirely — the rendered prompt IS the message we # deliver. Use case: external services (Supabase, monitoring, diff --git a/hermes_cli/subcommands/webhook.py b/hermes_cli/subcommands/webhook.py index 38085141b069..3ea33cb2fe05 100644 --- a/hermes_cli/subcommands/webhook.py +++ b/hermes_cli/subcommands/webhook.py @@ -62,6 +62,15 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: "payload is passed as JSON on stdin; empty stdout, [SILENT], or a " "nonzero exit code ignores the webhook.", ) + wh_sub.add_argument( + "--cron-job", + default="", + help="Fire an existing cron job (by ID or name) when this route " + "receives an event, instead of starting a fresh agent run. The " + "rendered --prompt template is passed to the job as transient " + "per-run context; the job's own prompt, skills, and delivery " + "settings apply. Mutually exclusive with --deliver-only.", + ) webhook_subparsers.add_parser( "list", aliases=["ls"], help="List all dynamic subscriptions" diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index 9b9de6cd5a6c..0f0d1c727024 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -182,6 +182,14 @@ def _cmd_subscribe(args): } if getattr(args, "deliver_only", False): + if getattr(args, "cron_job", ""): + print( + "Error: --deliver-only and --cron-job are mutually exclusive. " + "--deliver-only pushes the rendered template as a message; " + "--cron-job fires an existing cron job (which handles its own " + "delivery)." + ) + return if route["deliver"] == "log": print( "Error: --deliver-only requires --deliver to be a real target " @@ -190,6 +198,31 @@ def _cmd_subscribe(args): return route["deliver_only"] = True + cron_job = (getattr(args, "cron_job", "") or "").strip() + if cron_job: + # Validate the reference up-front so a typo surfaces here, not on + # the first inbound event. + try: + from cron.jobs import AmbiguousJobReference, resolve_job_ref + + try: + job = resolve_job_ref(cron_job) + except AmbiguousJobReference as e: + print(f"Error: {e}") + return + if job is None: + print( + f"Error: no cron job matches '{cron_job}'. " + "List jobs with: hermes cron list" + ) + return + cron_job = job["id"] + route["cron_job"] = cron_job + except ImportError: + # Cron subsystem unavailable — store the reference as-is; the + # adapter resolves it per-event. + route["cron_job"] = cron_job + script = getattr(args, "script", "") or "" if script.strip(): route["script"] = script.strip() @@ -213,6 +246,8 @@ def _cmd_subscribe(args): print(f" Deliver: {route['deliver']}") if route.get("deliver_only"): print(" Mode: direct delivery (no agent, zero LLM cost)") + if route.get("cron_job"): + print(f" Mode: cron-job trigger — fires job '{route['cron_job']}' on each event") if route.get("prompt"): prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "") label = "Message" if route.get("deliver_only") else "Prompt" @@ -238,6 +273,8 @@ def _cmd_list(args): deliver = route.get("deliver", "log") if route.get("deliver_only"): deliver = f"{deliver} (direct — no agent)" + if route.get("cron_job"): + deliver = f"cron job '{route['cron_job']}'" desc = route.get("description", "") print(f" ◆ {name}") if desc: diff --git a/tests/gateway/test_webhook_cron_trigger.py b/tests/gateway/test_webhook_cron_trigger.py new file mode 100644 index 000000000000..dbcc79574591 --- /dev/null +++ b/tests/gateway/test_webhook_cron_trigger.py @@ -0,0 +1,217 @@ +"""Tests for the webhook adapter's ``cron_job`` route mode. + +``cron_job`` routes turn an existing cron job into an event-triggered task +(inspired by ChatGPT Work's webhook-triggered scheduled tasks, Aug 2026): +an inbound webhook event fires the referenced job through the same +claimed-run body a manual ``cronjob(action='run')`` uses, instead of +starting a fresh webhook agent session. + +Covers: +- The referenced job is fired via ``execute_job_for_event`` with the + rendered prompt as transient per-run context +- The normal webhook agent session is NOT started (``handle_message`` + never called) +- HTTP returns 202 Accepted immediately +- Startup validation rejects routes that set both ``cron_job`` and + ``deliver_only`` +- ``execute_job_for_event`` resolves refs and fails cleanly on unknowns +""" + +import asyncio +import json +from unittest.mock import patch + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.config import PlatformConfig +from gateway.platforms.webhook import WebhookAdapter, _INSECURE_NO_AUTH + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_adapter(routes, **extra_kw) -> WebhookAdapter: + extra = {"host": "127.0.0.1", "port": 0, "routes": routes} + extra.update(extra_kw) + config = PlatformConfig(enabled=True, extra=extra) + return WebhookAdapter(config) + + +def _create_app(adapter: WebhookAdapter) -> web.Application: + app = web.Application() + app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook) + return app + + +async def _drain_background_tasks(adapter: WebhookAdapter) -> None: + tasks = list(adapter._background_tasks) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + +# =================================================================== +# Core behaviour: event fires the cron job, not a webhook session +# =================================================================== + +class TestCronJobTrigger: + @pytest.mark.asyncio + async def test_post_fires_job_with_event_context(self): + routes = { + "pr-feedback": { + "secret": _INSECURE_NO_AUTH, + "cron_job": "review-sweeper", + "prompt": "PR #{number} received feedback: {review.body}", + } + } + adapter = _make_adapter(routes) + + handle_message_calls = [] + + async def _capture(event): + handle_message_calls.append(event) + + adapter.handle_message = _capture + + fired = [] + + def _fake_execute(job_ref, extra_prompt=None): + fired.append((job_ref, extra_prompt)) + return {"claimed": True, "success": True, "error": None} + + app = _create_app(adapter) + body = json.dumps( + {"number": 7, "review": {"body": "needs tests"}} + ).encode() + + with patch( + "tools.cronjob_tools.execute_job_for_event", + side_effect=_fake_execute, + ): + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/pr-feedback", + data=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Delivery": "delivery-cron-1", + "X-GitHub-Event": "pull_request_review", + }, + ) + assert resp.status == 202 + data = await resp.json() + assert data["status"] == "accepted" + assert data["cron_job"] == "review-sweeper" + await _drain_background_tasks(adapter) + + # Job fired exactly once with the rendered prompt as run context + assert len(fired) == 1 + job_ref, extra_prompt = fired[0] + assert job_ref == "review-sweeper" + assert "PR #7 received feedback: needs tests" in extra_prompt + assert "pull_request_review" in extra_prompt # event provenance + + # No fresh webhook agent session was started + assert handle_message_calls == [] + + @pytest.mark.asyncio + async def test_job_failure_does_not_break_http_response(self): + routes = { + "flaky": {"secret": _INSECURE_NO_AUTH, "cron_job": "gone-job"} + } + adapter = _make_adapter(routes) + app = _create_app(adapter) + + def _fail(job_ref, extra_prompt=None): + return { + "claimed": False, + "success": False, + "error": "Cron job 'gone-job' not found.", + } + + with patch( + "tools.cronjob_tools.execute_job_for_event", side_effect=_fail + ): + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/flaky", + data=b"{}", + headers={ + "Content-Type": "application/json", + "X-GitHub-Delivery": "delivery-cron-2", + }, + ) + # Fire-and-forget: the POST is accepted even when the job + # later fails; the failure is logged, not surfaced. + assert resp.status == 202 + await _drain_background_tasks(adapter) + + +# =================================================================== +# Startup validation +# =================================================================== + +class TestCronJobRouteValidation: + @pytest.mark.asyncio + async def test_cron_job_plus_deliver_only_rejected_at_connect(self): + routes = { + "bad": { + "secret": "s3cret", + "cron_job": "some-job", + "deliver_only": True, + "deliver": "telegram", + } + } + adapter = _make_adapter(routes) + with pytest.raises(ValueError, match="mutually exclusive"): + await adapter.connect() + + +# =================================================================== +# execute_job_for_event unit behaviour +# =================================================================== + +class TestExecuteJobForEvent: + def test_unknown_job_returns_error(self): + from tools import cronjob_tools + + with patch.object(cronjob_tools, "resolve_job_ref", return_value=None): + result = cronjob_tools.execute_job_for_event("nope") + assert result["claimed"] is False + assert result["success"] is False + assert "not found" in result["error"] + + def test_ambiguous_ref_returns_error(self): + from cron.jobs import AmbiguousJobReference + from tools import cronjob_tools + + with patch.object( + cronjob_tools, + "resolve_job_ref", + side_effect=AmbiguousJobReference( + "x", [{"id": "job-a"}, {"id": "job-b"}] + ), + ): + result = cronjob_tools.execute_job_for_event("x") + assert result["claimed"] is False + assert result["success"] is False + assert "ambiguous" in result["error"].lower() + + def test_resolved_job_fires_with_extra_prompt(self): + from tools import cronjob_tools + + job = {"id": "job-123", "name": "sweeper"} + with patch.object( + cronjob_tools, "resolve_job_ref", return_value=job + ), patch.object( + cronjob_tools, + "_execute_job_now", + return_value={"claimed": True, "success": True, "error": None}, + ) as mock_exec: + result = cronjob_tools.execute_job_for_event( + "sweeper", extra_prompt="event context" + ) + assert result["success"] is True + mock_exec.assert_called_once_with(job, extra_prompt="event context") diff --git a/tests/hermes_cli/test_webhook_cli.py b/tests/hermes_cli/test_webhook_cli.py index 4fecf7f279c7..bddc89ad3c30 100644 --- a/tests/hermes_cli/test_webhook_cli.py +++ b/tests/hermes_cli/test_webhook_cli.py @@ -67,6 +67,44 @@ def test_auto_secret(self): assert len(secret) > 20 +class TestCronJobSubscribe: + """--cron-job: event-triggered cron jobs (ChatGPT Work-inspired).""" + + def test_valid_job_ref_stored_as_id(self, monkeypatch): + # resolve_job_ref is imported inside _cmd_subscribe from cron.jobs + import cron.jobs as jobs_mod + + monkeypatch.setattr( + jobs_mod, "resolve_job_ref", + lambda ref: {"id": "job-abc123", "name": ref}, + ) + webhook_command(_make_args( + webhook_action="subscribe", name="ev", cron_job="sweeper" + )) + assert _load_subscriptions()["ev"]["cron_job"] == "job-abc123" + + def test_unknown_job_rejected(self, monkeypatch, capsys): + import cron.jobs as jobs_mod + + monkeypatch.setattr(jobs_mod, "resolve_job_ref", lambda ref: None) + webhook_command(_make_args( + webhook_action="subscribe", name="ev", cron_job="nope" + )) + assert "no cron job matches" in capsys.readouterr().out + assert "ev" not in _load_subscriptions() + + def test_cron_job_plus_deliver_only_rejected(self, capsys): + webhook_command(_make_args( + webhook_action="subscribe", + name="ev", + cron_job="sweeper", + deliver_only=True, + deliver="telegram", + )) + assert "mutually exclusive" in capsys.readouterr().out + assert "ev" not in _load_subscriptions() + + class TestList: def test_with_entries(self, capsys): diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 035b616c5e84..2356e49635f0 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -935,6 +935,39 @@ def _heartbeat_loop() -> None: } +def execute_job_for_event( + job_ref: str, extra_prompt: Optional[str] = None +) -> Dict[str, Any]: + """Fire an existing cron job in response to an external event. + + Public entry point for event-driven triggers (the webhook adapter's + ``cron_job`` routes — inspired by ChatGPT Work's webhook-triggered + scheduled tasks, Aug 25 2026). Resolves ``job_ref`` (ID or name) and + fires it through the exact same claimed-run body a manual + ``cronjob(action='run')`` uses, so at-most-once claiming, in-flight + dedupe, delivery, and ``[SILENT]`` handling stay identical across the + scheduler / manual / event paths. + + ``extra_prompt`` is injected as transient per-run context (the job's + stored prompt is never mutated), exactly like ``action='run'`` with a + ``prompt`` argument. + + Returns the ``_execute_job_now`` result shape: + ``{"claimed": bool, "success": bool, "error": str|None}``. + """ + try: + job = resolve_job_ref(job_ref) + except AmbiguousJobReference as e: + return {"claimed": False, "success": False, "error": str(e)} + if job is None: + return { + "claimed": False, + "success": False, + "error": f"Cron job '{job_ref}' not found.", + } + return _execute_job_now(job, extra_prompt=extra_prompt) + + def _latest_job_output_excerpt(job_id: str, max_chars: int = 2000) -> Optional[str]: """Best-effort excerpt of the job's most recent saved output file. diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 7e8e477be5b5..72c9c6533385 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -18,6 +18,7 @@ Cron jobs can: - deliver results back to the origin chat, local files, or configured platform targets - run in fresh agent sessions with the normal static tool list - run in **no-agent mode** — a script on a schedule, its stdout delivered verbatim, zero LLM involvement (see the [no-agent mode](#no-agent-mode-script-only-jobs) section below) +- fire on **external events** — a webhook route with `cron_job` set fires the job the moment something happens (a PR gets feedback, a service posts an alert) instead of waiting for the next scheduled tick. See [Event-Triggered Cron Jobs](/user-guide/messaging/webhooks#event-triggered-cron-jobs). All of this is available to Hermes itself through the `cronjob` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required. diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md index dd1148b0fac1..0949c0f7f7e1 100644 --- a/website/docs/user-guide/messaging/webhooks.md +++ b/website/docs/user-guide/messaging/webhooks.md @@ -89,6 +89,7 @@ Routes define how different webhook sources are handled. Each route is a named e | `deliver` | No | Where to send the response: `github_comment`, `telegram`, `discord`, `slack`, `signal`, `sms`, `whatsapp`, `matrix`, `mattermost`, `homeassistant`, `email`, `dingtalk`, `feishu`, `wecom`, `weixin`, `bluebubbles`, `qqbot`, or `log` (default). | | `deliver_extra` | No | Additional delivery config — keys depend on `deliver` type (e.g. `repo`, `pr_number`, `chat_id`). Values support the same `{dot.notation}` templates as `prompt`. | | `deliver_only` | No | If `true`, skip the agent entirely — the rendered `prompt` template becomes the literal message that gets delivered. Zero LLM cost, sub-second delivery. See [Direct Delivery Mode](#direct-delivery-mode) for use cases. Requires `deliver` to be a real target (not `log`). | +| `cron_job` | No | Fire an existing cron job (by ID or name) on each event instead of starting a fresh webhook agent session. The rendered `prompt` becomes transient per-run context; the job's own prompt, skills, model, and delivery settings apply. Mutually exclusive with `deliver_only`. See [Event-Triggered Cron Jobs](#event-triggered-cron-jobs). | ### Full example @@ -397,6 +398,55 @@ hermes webhook subscribe antenna-matches \ --- +## Event-Triggered Cron Jobs {#event-triggered-cron-jobs} + +Set `cron_job` on a route to fire an **existing cron job** whenever an event arrives — instead of polling on a fixed cadence or starting a fresh webhook agent session. This turns any scheduled job into an event-driven task: keep the schedule as a fallback sweep (or make it a rarely-firing one) and let the webhook fire it the moment something actually changes. + +Inspired by ChatGPT Work's webhook-triggered scheduled tasks (August 2026), which let a scheduled task respond to new Gmail messages, Slack channel activity, or GitHub pull request changes instead of checking on a timer. + +How it works: + +1. The event passes the same HMAC auth, rate limiting, `events`/`filters`/`script` filtering, and idempotency as any other route. +2. The route's `prompt` template is rendered from the payload and injected into the job as **transient per-run context** (the same rail as `cronjob(action='run', prompt=...)` — the job's stored prompt is never mutated). +3. The job fires through the same at-most-once claim the scheduler uses, so a webhook burst cannot double-fire a job that is already running, and the job's own delivery target receives the output. + +### Example: fire a PR-review job on review feedback + +```yaml +platforms: + webhook: + enabled: true + extra: + routes: + pr-feedback: + events: ["pull_request_review"] + secret: "github-webhook-secret" + cron_job: "pr-review-sweeper" # existing job ID or name + prompt: | + PR #{number} in {repository.full_name} received new review feedback + from {review.user.login}: {review.body} +``` + +### Via the CLI + +```bash +hermes webhook subscribe pr-feedback \ + --events "pull_request_review" \ + --cron-job "pr-review-sweeper" \ + --prompt "PR #{number} received feedback: {review.body}" +``` + +The job reference is validated when you create the subscription, so typos surface immediately. + +### Notes + +- `cron_job` and `deliver_only` are mutually exclusive (the adapter refuses to start if a route sets both). A cron job handles its own delivery. +- The route-level `deliver`, `deliver_extra`, and `skills` fields are ignored on `cron_job` routes — the job's own settings apply. +- Paused/disabled jobs are not fired; the event is logged and dropped. +- The POST returns `202 Accepted` immediately; the job runs in the background. + +--- + ## Dynamic Subscriptions (CLI) {#dynamic-subscriptions} In addition to static routes in `config.yaml`, you can create webhook subscriptions dynamically using the `hermes webhook` CLI command. This is especially useful when the agent itself needs to set up event-driven triggers. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md index fb51b436538b..78f8a40ded44 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md @@ -18,6 +18,7 @@ Cron 任务可以: - 将结果回传到来源会话、本地文件或已配置的平台目标 - 在全新的 agent 会话中运行,使用正常的静态工具列表 - 以**无 agent 模式**运行——按计划执行脚本,其 stdout 原样投递,零 LLM 参与(参见下方[无 agent 模式](#no-agent-mode-script-only-jobs)章节) +- 由**外部事件触发**——设置了 `cron_job` 的 webhook 路由会在事情发生的那一刻(PR 收到反馈、服务发出告警)立即触发任务,而不是等待下一次定时 tick。参见[事件触发的 Cron 任务](/user-guide/messaging/webhooks#event-triggered-cron-jobs)。 所有这些功能均可通过 `cronjob` 工具由 Hermes 自身使用,因此你可以用自然语言创建、暂停、编辑和删除任务——无需 CLI。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md index 82597095fa18..9139ed4cd33d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/webhooks.md @@ -87,6 +87,7 @@ curl http://localhost:8644/health | `deliver` | 否 | 响应发送目标:`github_comment`、`telegram`、`discord`、`slack`、`signal`、`sms`、`whatsapp`、`matrix`、`mattermost`、`homeassistant`、`email`、`dingtalk`、`feishu`、`wecom`、`weixin`、`bluebubbles`、`qqbot`,或 `log`(默认)。 | | `deliver_extra` | 否 | 额外的投递配置——键取决于 `deliver` 类型(例如 `repo`、`pr_number`、`chat_id`)。值支持与 `prompt` 相同的 `{dot.notation}` 模板语法。 | | `deliver_only` | 否 | 若为 `true`,完全跳过 agent——渲染后的 `prompt` 模板直接作为消息体投递。零 LLM token 消耗,亚秒级投递。参见[直接投递模式](#direct-delivery-mode)了解使用场景。要求 `deliver` 为真实目标(非 `log`)。 | +| `cron_job` | 否 | 每次事件触发一个已有的 cron 任务(按 ID 或名称),而不是启动新的 webhook agent 会话。渲染后的 `prompt` 作为单次运行的临时上下文;以任务自身的 prompt、skills、模型和投递设置为准。与 `deliver_only` 互斥。参见[事件触发的 Cron 任务](#event-triggered-cron-jobs)。 | ### 完整示例 @@ -395,6 +396,55 @@ hermes webhook subscribe antenna-matches \ --- +## 事件触发的 Cron 任务 {#event-triggered-cron-jobs} + +在路由上设置 `cron_job`,即可在事件到达时触发一个**已有的 cron 任务**——无需按固定周期轮询,也不会启动新的 webhook agent 会话。这让任何定时任务都能变成事件驱动的任务:把原有的定时计划保留为兜底扫描(或设为低频),让 webhook 在事情真正发生的那一刻立即触发它。 + +灵感来自 ChatGPT Work 的 webhook 触发定时任务(2026 年 8 月):定时任务可以响应新的 Gmail 邮件、Slack 频道消息或 GitHub PR 变化,而不是靠定时检查。 + +工作原理: + +1. 事件经过与其他路由相同的 HMAC 认证、速率限制、`events`/`filters`/`script` 过滤和幂等去重。 +2. 路由的 `prompt` 模板根据 payload 渲染后,作为**单次运行的临时上下文**注入任务(与 `cronjob(action='run', prompt=...)` 走同一条通道——任务存储的 prompt 永远不会被修改)。 +3. 任务通过与调度器相同的 at-most-once 认领机制触发,因此 webhook 突发流量不会重复触发正在运行的任务,任务自身的投递目标会收到输出。 + +### 示例:在收到 review 反馈时触发 PR 审查任务 + +```yaml +platforms: + webhook: + enabled: true + extra: + routes: + pr-feedback: + events: ["pull_request_review"] + secret: "github-webhook-secret" + cron_job: "pr-review-sweeper" # 已有任务的 ID 或名称 + prompt: | + {repository.full_name} 的 PR #{number} 收到了来自 + {review.user.login} 的新审查反馈:{review.body} +``` + +### 通过 CLI + +```bash +hermes webhook subscribe pr-feedback \ + --events "pull_request_review" \ + --cron-job "pr-review-sweeper" \ + --prompt "PR #{number} 收到反馈:{review.body}" +``` + +创建订阅时会立即校验任务引用,拼写错误会当场报错。 + +### 注意事项 + +- `cron_job` 与 `deliver_only` 互斥(路由同时设置两者时适配器拒绝启动)。cron 任务自行处理投递。 +- `cron_job` 路由会忽略路由级的 `deliver`、`deliver_extra` 和 `skills` 字段——以任务自身的设置为准。 +- 已暂停/已禁用的任务不会被触发;事件会被记录并丢弃。 +- POST 立即返回 `202 Accepted`;任务在后台运行。 + +--- + ## 动态订阅(CLI) {#dynamic-subscriptions} 除了 `config.yaml` 中的静态路由,还可以使用 `hermes webhook` CLI 命令动态创建 webhook 订阅。当 agent 本身需要设置事件驱动触发器时,这尤为有用。