mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +00:00
feat(cron): routing intent — deliver=all fans out to every connected channel (#21495)
Adds one reserved token to the cron `deliver` field: - `all` — expand to every platform with a configured home channel Resolves at fire time, not create time, so a job created before Telegram was wired up picks it up once `TELEGRAM_HOME_CHANNEL` is set. Composes with existing targets: `origin,all`, `all,telegram:-100:17`. Inspired by Vellum Assistant's reminder routing-intent system. ## Changes - cron/scheduler.py: _expand_routing_tokens + integrate into _resolve_delivery_targets - tools/cronjob_tools.py: schema description updated - tests/cron/test_scheduler.py: TestRoutingIntents (5 cases) - website/docs/user-guide/features/cron.md: docs + table rows ## Validation - tests/cron/test_scheduler.py -k 'Routing or Deliver' → 57 passed
This commit is contained in:
parent
81928f03ab
commit
486b14b423
4 changed files with 143 additions and 3 deletions
|
|
@ -360,12 +360,52 @@ def _normalize_deliver_value(deliver) -> str:
|
||||||
return str(deliver)
|
return str(deliver)
|
||||||
|
|
||||||
|
|
||||||
|
# Routing intent tokens — resolved at fire time, not create time, so a
|
||||||
|
# job created before Telegram was wired up will pick up Telegram once it
|
||||||
|
# comes online. ``all`` expands into the set of connected platforms
|
||||||
|
# (those with a configured home chat_id) in _expand_routing_tokens.
|
||||||
|
_ROUTING_TOKENS = frozenset({"all"})
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_routing_tokens(part: str) -> List[str]:
|
||||||
|
"""Expand a routing-intent token to concrete platform names.
|
||||||
|
|
||||||
|
``all`` expands to every platform in ``_iter_home_target_platforms()``
|
||||||
|
that has a configured home chat_id right now. Unknown / non-token
|
||||||
|
values pass through unchanged as a single-element list, so the caller
|
||||||
|
can treat every token uniformly.
|
||||||
|
"""
|
||||||
|
token = part.lower()
|
||||||
|
if token not in _ROUTING_TOKENS:
|
||||||
|
return [part]
|
||||||
|
expanded: List[str] = []
|
||||||
|
for platform_name in _iter_home_target_platforms():
|
||||||
|
if _get_home_target_chat_id(platform_name):
|
||||||
|
expanded.append(platform_name)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
def _resolve_delivery_targets(job: dict) -> List[dict]:
|
def _resolve_delivery_targets(job: dict) -> List[dict]:
|
||||||
"""Resolve all concrete auto-delivery targets for a cron job (supports comma-separated deliver)."""
|
"""Resolve all concrete auto-delivery targets for a cron job.
|
||||||
|
|
||||||
|
Accepts the legacy comma-separated ``deliver`` string plus the
|
||||||
|
``all`` routing-intent token, which expands to every platform with
|
||||||
|
a configured home channel. Tokens may be combined with explicit
|
||||||
|
targets: ``origin,all`` and ``all,telegram:-100:17`` both work.
|
||||||
|
Duplicate (platform, chat_id, thread_id) tuples are collapsed by the
|
||||||
|
existing dedup pass.
|
||||||
|
"""
|
||||||
deliver = _normalize_deliver_value(job.get("deliver", "local"))
|
deliver = _normalize_deliver_value(job.get("deliver", "local"))
|
||||||
if deliver == "local":
|
if deliver == "local":
|
||||||
return []
|
return []
|
||||||
parts = [p.strip() for p in deliver.split(",") if p.strip()]
|
|
||||||
|
raw_parts = [p.strip() for p in deliver.split(",") if p.strip()]
|
||||||
|
|
||||||
|
# Expand routing intents.
|
||||||
|
parts: List[str] = []
|
||||||
|
for raw in raw_parts:
|
||||||
|
parts.extend(_expand_routing_tokens(raw))
|
||||||
|
|
||||||
seen = set()
|
seen = set()
|
||||||
targets = []
|
targets = []
|
||||||
for part in parts:
|
for part in parts:
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,95 @@ class TestResolveDeliveryTarget:
|
||||||
assert _resolve_delivery_targets({"deliver": []}) == []
|
assert _resolve_delivery_targets({"deliver": []}) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutingIntents:
|
||||||
|
"""``all`` routing intent expands at fire time."""
|
||||||
|
|
||||||
|
def test_all_expands_to_every_connected_home_channel(self, monkeypatch):
|
||||||
|
"""deliver='all' fans out to every platform with a configured home channel."""
|
||||||
|
from cron.scheduler import _resolve_delivery_targets
|
||||||
|
|
||||||
|
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||||
|
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||||
|
monkeypatch.setenv("SLACK_HOME_CHANNEL", "C333")
|
||||||
|
# Sanity: platforms without the env var must NOT appear in the expansion.
|
||||||
|
monkeypatch.delenv("SIGNAL_HOME_CHANNEL", raising=False)
|
||||||
|
monkeypatch.delenv("MATRIX_HOME_ROOM", raising=False)
|
||||||
|
|
||||||
|
targets = _resolve_delivery_targets({"deliver": "all", "origin": None})
|
||||||
|
platforms = sorted(t["platform"] for t in targets)
|
||||||
|
|
||||||
|
assert "telegram" in platforms
|
||||||
|
assert "discord" in platforms
|
||||||
|
assert "slack" in platforms
|
||||||
|
assert "signal" not in platforms
|
||||||
|
assert "matrix" not in platforms
|
||||||
|
|
||||||
|
def test_all_combines_with_explicit_target_and_dedups(self, monkeypatch):
|
||||||
|
"""'telegram:-999,all' yields every home channel + the explicit target without dupes."""
|
||||||
|
from cron.scheduler import _resolve_delivery_targets
|
||||||
|
|
||||||
|
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||||
|
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||||
|
|
||||||
|
# Explicit telegram target precedes 'all'. Expansion adds discord;
|
||||||
|
# the dedup pass collapses any (platform, chat_id, thread_id) repeats.
|
||||||
|
job = {"deliver": "telegram:-999,all", "origin": None}
|
||||||
|
targets = _resolve_delivery_targets(job)
|
||||||
|
|
||||||
|
platforms = sorted(t["platform"].lower() for t in targets)
|
||||||
|
assert "telegram" in platforms
|
||||||
|
assert "discord" in platforms
|
||||||
|
# Every target is unique on (platform, chat_id, thread_id).
|
||||||
|
keys = [(t["platform"].lower(), str(t["chat_id"]), t.get("thread_id")) for t in targets]
|
||||||
|
assert len(keys) == len(set(keys))
|
||||||
|
|
||||||
|
def test_all_with_no_connected_channels_returns_empty(self, monkeypatch):
|
||||||
|
"""deliver='all' with nothing connected returns [] — delivery is recorded as failed upstream."""
|
||||||
|
from cron.scheduler import _resolve_delivery_targets
|
||||||
|
|
||||||
|
for var in ("TELEGRAM_HOME_CHANNEL", "DISCORD_HOME_CHANNEL", "SLACK_HOME_CHANNEL",
|
||||||
|
"SIGNAL_HOME_CHANNEL", "MATRIX_HOME_ROOM", "MATTERMOST_HOME_CHANNEL",
|
||||||
|
"SMS_HOME_CHANNEL", "EMAIL_HOME_ADDRESS", "DINGTALK_HOME_CHANNEL",
|
||||||
|
"FEISHU_HOME_CHANNEL", "WECOM_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL",
|
||||||
|
"BLUEBUBBLES_HOME_CHANNEL", "QQBOT_HOME_CHANNEL", "QQ_HOME_CHANNEL"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
assert _resolve_delivery_targets({"deliver": "all", "origin": None}) == []
|
||||||
|
|
||||||
|
def test_origin_comma_all_preserves_origin_first(self, monkeypatch):
|
||||||
|
"""'origin,all' delivers to the origin platform plus every other home channel."""
|
||||||
|
from cron.scheduler import _resolve_delivery_targets
|
||||||
|
|
||||||
|
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||||
|
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||||
|
|
||||||
|
job = {
|
||||||
|
"deliver": "origin,all",
|
||||||
|
"origin": {"platform": "discord", "chat_id": "888"},
|
||||||
|
}
|
||||||
|
targets = _resolve_delivery_targets(job)
|
||||||
|
platforms = sorted(t["platform"].lower() for t in targets)
|
||||||
|
assert "telegram" in platforms
|
||||||
|
assert "discord" in platforms
|
||||||
|
|
||||||
|
# The origin's explicit chat_id (888) wins the dedup race over the
|
||||||
|
# discord home channel (-222) because origin is resolved first.
|
||||||
|
discord = next(t for t in targets if t["platform"].lower() == "discord")
|
||||||
|
assert discord["chat_id"] == "888"
|
||||||
|
|
||||||
|
def test_all_token_case_insensitive(self, monkeypatch):
|
||||||
|
"""'ALL' / 'All' / 'all' are all recognized."""
|
||||||
|
from cron.scheduler import _resolve_delivery_targets
|
||||||
|
|
||||||
|
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||||
|
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||||
|
|
||||||
|
for token in ("ALL", "All", "all"):
|
||||||
|
targets = _resolve_delivery_targets({"deliver": token, "origin": None})
|
||||||
|
platforms = sorted(t["platform"].lower() for t in targets)
|
||||||
|
assert platforms == ["discord", "telegram"], f"token={token!r} -> {platforms}"
|
||||||
|
|
||||||
|
|
||||||
class TestDeliverResultWrapping:
|
class TestDeliverResultWrapping:
|
||||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||||
},
|
},
|
||||||
"deliver": {
|
"deliver": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting."
|
"description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), 'all' (fan out to every connected home channel), or platform:chat_id:thread_id for a specific destination. Combine with comma: 'origin,all' delivers to the origin plus every other connected channel. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567', 'all'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting. 'all' resolves at fire time, so a job created before a channel was wired up will pick it up automatically once connected."
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|
|
||||||
|
|
@ -240,9 +240,20 @@ When scheduling jobs, you specify where the output goes:
|
||||||
| `"weixin"` | Weixin (WeChat) | |
|
| `"weixin"` | Weixin (WeChat) | |
|
||||||
| `"bluebubbles"` | BlueBubbles (iMessage) | |
|
| `"bluebubbles"` | BlueBubbles (iMessage) | |
|
||||||
| `"qqbot"` | QQ Bot (Tencent QQ) | |
|
| `"qqbot"` | QQ Bot (Tencent QQ) | |
|
||||||
|
| `"all"` | Fan out to every connected home channel | Resolved at fire time |
|
||||||
|
| `"telegram,discord"` | Fan out to a specific set of channels | Comma-separated list |
|
||||||
|
| `"origin,all"` | Deliver to the origin **plus** every other connected channel | Combine any tokens |
|
||||||
|
|
||||||
The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt.
|
The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt.
|
||||||
|
|
||||||
|
### Routing intent (`all`)
|
||||||
|
|
||||||
|
`all` lets you ship one cron job to every messaging channel you have configured, without having to enumerate them by name. It is **resolved at fire time**, so a job created before you wired up Telegram will pick up Telegram on the next tick after you set `TELEGRAM_HOME_CHANNEL`.
|
||||||
|
|
||||||
|
Semantics: `all` expands to every platform with a configured home channel. Zero is fine; the job simply produces no delivery targets and is recorded as a delivery failure upstream.
|
||||||
|
|
||||||
|
`all` composes with explicit targets. `origin,all` delivers to the origin chat *plus* every other connected home channel, de-duplicating by `(platform, chat_id, thread_id)`.
|
||||||
|
|
||||||
### Response wrapping
|
### Response wrapping
|
||||||
|
|
||||||
By default, delivered cron output is wrapped with a header and footer so the recipient knows it came from a scheduled task:
|
By default, delivered cron output is wrapped with a header and footer so the recipient knows it came from a scheduled task:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue