feat(gateway): add LINE Messaging API platform plugin - #23197
Merged
Conversation
Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.
Highlights merged into one plugin:
- **Reply token preferred, Push fallback.** Try the free reply token
first (single-use, ~60s TTL); fall back to metered Push when the
token is absent, expired, or rejected. (PR #21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
the original reply token to send a "Get answer" button bubble. The
user taps it to fetch the cached answer via a fresh reply token —
also free. State machine: PENDING → READY → DELIVERED, ERROR for
cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
/stop). Set threshold to 0 to disable. (PR #18153)
- **Three-allowlist gating** — separate user / group / room allowlists
with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR #18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
markers (LINE renders them literally) but keep `[label](url)` →
`label (url)` so URLs stay tappable. (PR #18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
busy-acks reach the user as visible bubbles instead of being
swallowed into the postback cache. (PR #18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
images/audio/video must be HTTPS-reachable. The adapter serves
registered tempfiles under `/line/media/<token>/<filename>` from the
same aiohttp app. Allowed-roots traversal guard covers
`tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
`HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
setups behind tunnels/proxies. (PR #8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR #21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR #21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
endpoint, DM-only (LINE rejects it for groups/rooms). (PR #21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
`deliver: line` cron jobs work even when cron runs detached from
the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
signature verification, dedup, scoped lock so two profiles can't
bind the same channel.
Validation
----------
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
tests/gateway/test_irc_adapter.py
tests/gateway/test_plugin_platform_interface.py
tests/gateway/test_platform_registry.py
tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
bundled-plugin discovery verified against current `origin/main`.
Closes the seven open LINE PRs (#18153, #16832, #6676, #21023, #14942,
#14988, #8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.
Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
4 |
unresolved-attribute |
3 |
invalid-method-override |
3 |
not-subscriptable |
1 |
First entries
plugins/platforms/line/adapter.py:962: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_message_event` has no attribute `create_source`
plugins/platforms/line/adapter.py:1578: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `get_env_var`
plugins/platforms/line/adapter.py:1578: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `set_env_var`
plugins/platforms/line/adapter.py:1306: [invalid-method-override] invalid-method-override: Invalid override of method `send_image_file`: Definition is incompatible with `BasePlatformAdapter.send_image_file`
tests/gateway/test_line_plugin.py:467: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
plugins/platforms/line/adapter.py:972: [unresolved-attribute] unresolved-attribute: Class `MessageType` has no attribute `IMAGE`
plugins/platforms/line/adapter.py:1473: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
tests/gateway/test_line_plugin.py:25: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/platforms/line/adapter.py:1336: [invalid-method-override] invalid-method-override: Invalid override of method `send_voice`: Definition is incompatible with `BasePlatformAdapter.send_voice`
plugins/platforms/line/adapter.py:1195: [unresolved-attribute] unresolved-attribute: Attribute `reply` is not defined on `None` in union `_LineClient | None`
plugins/platforms/line/adapter.py:1360: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`
✅ Fixed issues: none
Unchanged: 4210 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR #23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh.
This was referenced May 10, 2026
3 tasks
JinyuID
pushed a commit
to JinyuID/hermes-agent
that referenced
this pull request
May 11, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
dusterbloom
pushed a commit
to dusterbloom/hermes-agent
that referenced
this pull request
May 12, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
bot-ted
added a commit
to bot-ted/hermes-agent
that referenced
this pull request
May 13, 2026
* feat(stream-retry): add upstream + timing diagnostics to drop log (#23005)
The previous PR (#22993) gave us a structured WARNING per stream drop
but the only diagnostic was 'error_type=APIError error=Network
connection lost.' — same nothing the user started with. To actually
diagnose why subagents drop streams disproportionately we need to know
WHERE the drop happened.
Adds three breadcrumbs to the agent.log WARNING:
1. Inner exception chain. openai SDK wraps httpx errors as
APIConnectionError / APIError so the catch site only sees the
wrapper. _flatten_exception_chain walks __cause__/__context__ up to
4 levels deep and renders 'Outer(msg) <- Inner(msg)' so we can
tell ConnectError vs RemoteProtocolError vs ReadError vs
ProxyError without enabling verbose mode.
2. Upstream HTTP headers. Snapshots cf-ray, x-openrouter-provider,
x-openrouter-model, x-openrouter-id, x-request-id, server, via,
etc. from stream.response immediately after open (so they survive
even when the stream dies before the first chunk). These answer
'is one CF edge / one downstream provider responsible, or random?'
3. Per-attempt counters. bytes streamed, chunk count, elapsed time on
the dying attempt, and time-to-first-byte. Distinguishes 'couldn't
connect at all' (0s, 0 bytes) from 'died after 30s mid-stream'
(very different root causes — first is auth/routing, second is
upstream idle-kill or proxy timeout).
Plumbing:
- _stream_diag_init / _stream_diag_capture_response live on AIAgent
and produce a per-attempt dict held on request_client_holder['diag']
for closure access from the retry block.
- _call_chat_completions and _call_anthropic both initialize the diag
and increment counters per chunk/event (best-effort, never raises in
the streaming hot path).
- _log_stream_retry / _emit_stream_drop accept an optional diag and
render the new fields. Final-exhaustion log goes through the same
helper so it gets the same diagnostic dump.
- UI status line gains a brief 'after Xs' suffix when timing is
available — distinguishes 'connect failed' from 'died mid-stream'
at a glance without grepping logs.
Sample WARNING after this change:
Stream drop mid tool-call on attempt 2/3 — retrying.
subagent_id=sa-2-cafef00d depth=1 provider=openrouter
base_url=https://openrouter.ai/api/v1
error_type=APIError error=Connection error.
chain=APIError(Connection error.) <- RemoteProtocolError(peer
closed connection without sending complete message body)
http_status=200 bytes=12400 chunks=47 elapsed=12.00s ttfb=0.83s
upstream=[cf-ray=8f1a2b3c4d5e6f7g-LAX
x-openrouter-provider=Anthropic
x-openrouter-id=gen-abc123 server=cloudflare]
Tests: 10 covering diag init, header capture (whitelist enforced for
PII), exception-chain walking + depth cap, log content with full diag,
log content without diag (placeholders), UI elapsed-suffix on/off.
* fix(review): tell background reviewer not to capture transient env failures as skills (#23004)
Closes #6051.
Reported failure mode: agent migrated to WSL2, browser launch failed
because Playwright wasn't installed yet. Background reviewer captured
the failure as a durable skill (`browser-tool-launch-issue`) and the
agent kept refusing the browser tool for weeks after Playwright was
installed and verified working. Negative claims also propagated into
unrelated skills ("browser tools do not work", "cannot use Y from
execute_code").
Root cause: `_SKILL_REVIEW_PROMPT` and `_COMBINED_REVIEW_PROMPT` both
lean hard on "be active, save things, a pass that does nothing is a
missed learning opportunity." Neither distinguished durable knowledge
from transient environment state. The reviewer was doing what it was
told.
Fix at the write site — both prompts now carry a "Do NOT capture"
section calling out:
• Environment-dependent failures (missing binaries, fresh-install
errors, post-migration path mismatches, 'command not found',
unconfigured credentials, uninstalled packages)
• Negative claims about tools or features ("X does not work")
that harden into self-cited refusals
• Session-specific transient errors that resolved before the
conversation ended
• One-off task narratives ("summarize today's market", "analyze
this PR") — also addresses the #12812 / #4538 family
Plus a positive-reframing line: when a tool fails because of setup
state, capture the FIX (install command, config step, env var)
under an existing setup/troubleshooting skill — never "this tool
doesn't work" as a standalone constraint.
Targeted tests: 24/24 passing in tests/run_agent/test_review_prompt_class_first.py
(2 new + all existing review-prompt assertions). Substring-based
checks so future prompt edits don't false-fail.
* feat(codex): add gpt-5.3-codex-spark model
* fix(model-metadata): restore gpt-5.3-codex-spark fallback context
* fix(model-metadata): set codex-spark fallback context to 128k
* fix: surface Codex CLI-only models
* chore: add codex-spark salvage contributors to AUTHOR_MAP
Maps olegwn@gmail.com → nederev (PR #18286) and vesper@askclaw.dev →
askclaw-vesper (PR #19530) so the contributor attribution check passes
when their commits land via this salvage.
* docs(codex-spark): document ChatGPT Pro entitlement gating
PR #12994 stripped gpt-5.3-codex-spark on the assumption that it was
unsupported. It's actually research-preview, ChatGPT-Pro-only, exposed
via the Codex OAuth backend at chatgpt.com/backend-api/codex/models —
not via the public OpenAI API.
Add explanatory comments in:
- DEFAULT_CODEX_MODELS / _FORWARD_COMPAT_TEMPLATE_MODELS (codex_models.py)
- _CODEX_OAUTH_CONTEXT_FALLBACK (model_metadata.py)
- list_authenticated_providers' live-discovery branch (model_switch.py)
so future maintainers don't strip the entry again. Also documents the
intentional asymmetry that Spark stays out of the "openai" provider
catalog (it isn't on the public API) and why the supported_in_api
filter is *not* applied for the openai-codex route.
* test(codex-spark): add live-API regression and make picker test deterministic
Two follow-ups from self-review:
1. Add unit test for _fetch_models_from_api covering the live HTTP path.
The salvaged PR #19530 dropped the supported_in_api:false filter in
both _fetch_models_from_api and _read_cache_models, but only the
cache path had a regression test. This adds the symmetric live-fetch
test (mocked httpx) so a future drive-by change to the HTTP path
can't silently re-introduce the filter.
2. Pin test_codex_picker_uses_live_codex_catalog to the cache fallback.
The test wrote a fake JWT and a CODEX_HOME cache, but provider_model_ids
('openai-codex') still issued a real 10s HTTP probe to
chatgpt.com/backend-api/codex/models before falling back to the cache.
That made the test slow and non-deterministic in restricted/CI
networks. Patch _fetch_models_from_api to return [] so we go straight
to the cache path the test actually means to exercise.
* fix(codex-spark): defensive 128k entry in DEFAULT_CONTEXT_LENGTHS + clarify validation test docstring
Two follow-ups from self-review:
1. Add gpt-5.3-codex-spark to DEFAULT_CONTEXT_LENGTHS at 128k. The
primary resolution path for Spark goes through provider='openai-codex'
→ _CODEX_OAUTH_CONTEXT_FALLBACK (already correct). But if any future
code path resolves Spark's context with a different provider (custom
proxy, generic fallthrough), the longest-substring-first lookup in
step 8 would match 'gpt-5' and report 400k, which is wrong by ~3x.
Adding the explicit override is a cheap defensive correctness fix
matching how gpt-5.4-mini and gpt-5.4-nano already shadow the generic
gpt-5 entry.
2. Update test_openai_codex_model_validation_fallback.py docstring. The
bug it was originally written for (gpt-5.3-codex-spark missing from
listing) is now resolved by this PR's catalog restoration. The test
still validly exercises the soft-accept code path for any future
entitlement-gated Codex slug that ships before Hermes catalogs it,
but the framing was stale — clarified.
* feat(kanban): add orchestrator board tools
* fix(kanban): parse include_archived explicitly
* fix(kanban): parse triage flag explicitly
* fix(kanban): restrict board routing tools to orchestrators
Adapted from PR #20568 commit ce3518578 (Eric Litovsky / @kallidean).
Adds two-tier gating for the kanban tool surface so dispatcher-spawned
workers see only task-lifecycle tools (show/complete/block/heartbeat/
comment/create/link) while orchestrator profiles with `toolsets: [kanban]`
also see board-routing tools (kanban_list, kanban_unblock).
Workers shouldn't be enumerating or unblocking the board — they should
close their own task via the lifecycle tools. Hiding board-routing tools
from worker schemas keeps the worker focused and the toolset-isolation
contract honest.
Plus inherited from the same upstream commit:
- 50/200 row bound on kanban_list with `truncated` + `next_limit` metadata.
- Belt-and-suspenders runtime guard `_require_orchestrator_tool()` inside
the orchestrator handlers in case a stale registration ever routes a
worker to one of them.
- Tests for the new gate, the stricter bound, and the fact that even a
worker with `toolsets: [kanban]` in config still doesn't see board
routing.
Co-authored-by: Eric Litovsky <elitovsky@zenproject.net>
* chore: AUTHOR_MAP entry for kallidean (#20568)
* docs(user-stories): add 4 entries from @emmagine79 thread (#23204)
Captain Awesome's May 10 thread on hermes + Discord with GPT-5.5 / DeepSeek v4:
- life-changing umbrella tweet
- Google-me -> SSH-deploy landing page to VPS
- cron jobs triaging tech news into Discord channels by urgency
- PM paperclip agent running morning + evening standups for ADHD
* docs(web-search): explain auxiliary-model summarization for web_extract (#23211)
web_extract runs returned page content through the web_extract auxiliary
model when pages exceed 5 000 chars (single-pass up to 500k, chunked up
to 2M, refused above that). The user-guide page didn't mention this —
users were surprised that long-page extracts produced summaries instead
of raw markdown, and that those summaries cost main-model tokens by
default.
Adds:
- size-driven behavior table (under 5k / 5k–500k / 500k–2M / over 2M)
- which auxiliary task does the work (auxiliary.web_extract)
- how to route summaries to a cheap model regardless of main
- escape hatch: browser_navigate when you need raw content
- troubleshooting entry for summarization timeouts
* feat(gateway): add LINE Messaging API platform plugin (#23197)
* feat(gateway): add LINE Messaging API platform plugin
Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.
Highlights merged into one plugin:
- **Reply token preferred, Push fallback.** Try the free reply token
first (single-use, ~60s TTL); fall back to metered Push when the
token is absent, expired, or rejected. (PR #21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
the original reply token to send a "Get answer" button bubble. The
user taps it to fetch the cached answer via a fresh reply token —
also free. State machine: PENDING → READY → DELIVERED, ERROR for
cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
/stop). Set threshold to 0 to disable. (PR #18153)
- **Three-allowlist gating** — separate user / group / room allowlists
with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR #18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
markers (LINE renders them literally) but keep `[label](url)` →
`label (url)` so URLs stay tappable. (PR #18153)
- **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. —
busy-acks reach the user as visible bubbles instead of being
swallowed into the postback cache. (PR #18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
images/audio/video must be HTTPS-reachable. The adapter serves
registered tempfiles under `/line/media/<token>/<filename>` from the
same aiohttp app. Allowed-roots traversal guard covers
`tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
`HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
setups behind tunnels/proxies. (PR #8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR #21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR #21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
endpoint, DM-only (LINE rejects it for groups/rooms). (PR #21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
`deliver: line` cron jobs work even when cron runs detached from
the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
signature verification, dedup, scoped lock so two profiles can't
bind the same channel.
Validation
----------
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
tests/gateway/test_irc_adapter.py
tests/gateway/test_plugin_platform_interface.py
tests/gateway/test_platform_registry.py
tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
bundled-plugin discovery verified against current `origin/main`.
Closes the seven open LINE PRs (#18153, #16832, #6676, #21023, #14942,
#14988, #8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.
Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
* docs(platforms): document platform-specific slow-LLM UX pattern
Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.
Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)
Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.
Filed because the LINE plugin (PR #23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.
---------
Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
* feat(curator): hint at `hermes curator pin` in the rename block (#23212)
Surfaces the pin command at the moment users care about it: when a
consolidation just landed against their skill library and they're
looking at the umbrella name in the curator output. Previously `hermes
curator pin` existed but had no discovery surface — users only learned
it existed by reading docs or stumbling onto `hermes curator --help`.
The hint:
archived 3 skill(s):
• docx-extraction → document-tools
• pdf-extraction → document-tools
• old-stale — pruned (stale)
full report: hermes curator status
keep an umbrella stable: hermes curator pin document-tools
Gated on having at least one consolidation that produced an umbrella.
Pruned-only runs (nothing surviving to pin) skip the hint. When
multiple umbrellas were produced, picks alphabetically first as a
concrete example rather than listing them all.
3 new tests in tests/agent/test_curator_classification.py covering:
consolidation produces hint with real umbrella name, pruned-only run
omits it, multi-umbrella picks one example.
* fix(security): require dashboard auth for plugin API routes
Remove the blanket /api/plugins/* exemption from auth_middleware so
plugin API routes (e.g. Kanban dashboard) require the same session
token as all other /api/ endpoints.
Fixes #19533
* test(security): broaden plugin API auth coverage + correct stale docstring
Follow-up to the previous commit's middleware fix.
- plugins/kanban/dashboard/plugin_api.py: rewrite the "Security note"
docstring. The previous text said "/api/plugins/ is unauthenticated by
design" — that's now actively wrong and dangerously misleading. New
text explains that plugin routes flow through the same session-token
middleware as core API routes and that --host 0.0.0.0 is safe to use
on a LAN as a result.
- tests/hermes_cli/test_web_server.py: extend TestPluginAPIAuth to cover
the surfaces the original PR didn't pin:
* test_plugin_route_allows_auth now exercises a real plugin path
(/api/plugins/example/hello) instead of accepting 200 OR 404 from
a maybe-loaded kanban plugin — the assertion was effectively vacuous.
* test_plugin_patch_requires_auth + test_plugin_delete_requires_auth
cover non-GET mutation methods in case a future regression
whitelists them by accident.
* test_non_kanban_plugin_route_requires_auth proves the fix is
plugin-agnostic, not kanban-specific (hits hermes-achievements +
a non-existent plugin namespace; both 401 before route resolution).
* test_plugin_websocket_unaffected_by_http_middleware locks in that
the HTTP middleware change didn't accidentally start gating WS
upgrades — kanban /events still uses its own ?token= check.
Plus a cosmetic blank-line cleanup.
* feat(plugins): run any LLM call from inside a plugin via ctx.llm (#23194)
* feat(plugins): host-owned LLM access via ctx.llm
Plugins can now ask the host to run a one-shot chat or structured
completion against the user's active model and auth, without ever
seeing an OAuth token or API key. Closes the gap where plugins that
needed bounded structured inference (receipts, CRM extraction,
support classification) had to either bring their own provider keys
or register a tool the agent had to call.
New surface on PluginContext:
- ctx.llm.complete(messages, ...)
- ctx.llm.complete_structured(instructions, input, json_schema, ...)
- async siblings ctx.llm.acomplete / acomplete_structured
Backed by the existing auxiliary_client.call_llm pipeline — every
provider, fallback chain, vision routing, and timeout policy Hermes
already supports applies automatically.
Trust gate (fail-closed by default):
- plugins.entries.<id>.llm.allow_model_override
- plugins.entries.<id>.llm.allowed_models (allowlist; '*' = any)
- plugins.entries.<id>.llm.allow_agent_id_override
- plugins.entries.<id>.llm.allow_profile_override
Embedded model@profile shorthand goes through the same gate as
explicit profile=, so it can't bypass the auth-profile policy.
Conflicting explicit and embedded profiles fail closed.
Also lands:
- plugins/plugin-llm-example/ — reference plugin that registers
/receipt-extract, demonstrating image+text structured input,
jsonschema validation, and the trust-gate config.
- website/docs/developer-guide/plugin-llm-access.md — full API docs.
- 45 unit tests covering trust gates, JSON parsing, schema
validation, image encoding, async surface, and config loading.
Validation:
- 2628 tests pass in tests/agent/
- E2E: bundled plugin loaded with isolated HERMES_HOME, slash
command produced parsed JSON via stubbed call_llm
- response_format extra_body wired correctly for both json_object
and json_schema modes
* docs(plugin-llm): rewrite quickstart and framing
The quickstart now uses a meeting-notes-to-tasks example instead of
a receipt extractor, and the page leads with hook-time / gateway
pre-filter / scheduled-job framing rather than the OpenClaw
KB/support/CRM/finance/migration enumeration that the original
upstream PR used. Receipt example moved to a separate worked
example link so the docs page itself doesn't echo any of the
upstream framing.
Also clarifies where ctx.llm fits in the broader plugin surface
(table comparing register_tool / register_platform / register_hook
/ etc.) and what makes this lane different from auxiliary_client
internals.
No code change.
* docs(plugin-llm): reframe as any LLM call, not just structured output
The original draft leaned heavily on complete_structured() and made
the chat lane (complete() / acomplete()) feel like a footnote.
Restructure so:
- The page title and description say 'any LLM call.'
- The lead shows BOTH a plain chat call (error rewriter) AND a
structured call (triage scorer) up top.
- Quick start has two complete plugin examples — /tldr (chat) and
/paste-to-tasks (structured).
- New 'When to use which' table for choosing complete() vs
complete_structured() vs the async siblings.
- Trust-gate sections explicitly note 'all four methods,' and the
request-shaping list calls out chat-only fields (messages) and
structured-only fields (instructions, input, json_schema)
alongside each other.
- The 'Where this fits' section now says 'for any reason,
structured or not.'
The receipt-extractor reference plugin still exists under
plugins/plugin-llm-example/ — but the docs page no longer treats
it as the canonical surface example. It's now described as 'a third
worked example, this time with image input.'
No code change.
* feat(plugin-llm): split provider/model into independent explicit kwargs
The first cut accepted a single 'provider/model' slug on every method
and split it internally. That looked clean but broke under live test:
the model-override path tried to use the slug's vendor prefix as a
literal Hermes provider id, which silently switched the user off
their aggregator (e.g. plugin asks for 'openai/gpt-4o-mini' on a user
who routes through OpenRouter — host attempted to call the 'openai'
provider directly, failed because OPENAI_API_KEY wasn't set).
New shape mirrors the host's main config:
ctx.llm.complete(
messages=[...],
provider='openrouter', # gated, optional
model='openai/gpt-4o-mini', # gated, optional
profile='work', # gated, optional
...
)
Each is independently gated by its own allow_*_override flag.
Granting model-override does NOT auto-grant provider-override.
Allowlists are now per-axis (allowed_providers, allowed_models)
matched literally against whatever string the plugin sends.
Dropped 'model@profile' embedded-suffix shorthand entirely. Hermes
doesn't use that pattern anywhere else; profile= is its own kwarg.
Live E2E (against real OpenRouter via Teknium's config) confirms:
- zero-config call works
- default-deny blocks each override with a helpful error
- model-only override stays on user's active provider (the bug)
- provider+model override switches cleanly
- allowlist refuses non-listed entries
- structured output round-trip parses + schema-validates
Tests: 49 cases (up from 45); all green. Docs updated to match the
new shape, including a 'most plugins never need this section' callout
on the trust-gate config block.
* fix+cleanup(plugin-llm): real attribution, hook-mode coverage, move example out of core
Three integration fixes for the ctx.llm surface:
1. Attribution bug — result.provider and result.model now reflect
what call_llm actually used, not placeholder fallbacks ('auto',
'default'). New _resolve_attribution() helper:
- explicit overrides win (what the call targeted)
- response.model wins for the recorded model (provider
canonicalisation: 'gpt-4o' → 'gpt-4o-2024-08-06' etc.)
- falls back to _read_main_provider() / _read_main_model()
when no override is set, so audit logs reflect the user's
active main provider/model
- 'auto' / 'default' only when EVERYTHING is empty
Live verified: zero-config call now records
provider='openrouter', model='anthropic/claude-4.7-opus-20260416'
instead of provider='auto', model='default'.
2. Hook-mode coverage — TestHookMode confirms ctx.llm.complete
works from inside a registered post_tool_call callback. The
docs page promised hook integration; now there's a test that
exercises the lazy-import path through the real invoke_hook
machinery. Two cases: traceback-rewrite hook with conditional
ctx.llm.complete, and minimal hook regression for the
sync-hook + sync-llm path.
3. Reference plugin moved out of core. plugins/plugin-llm-example/
is gone from hermes-agent — it now lives in the new
NousResearch/hermes-example-plugins companion repo. The docs
page links there. Hermes' bundled plugins should be plugins
users actually run; reference / docs-companion plugins live
externally.
Test count: 56 (up from 49). Wider sweep on tests/hermes_cli/
+ tests/gateway/ + tests/tools/ + tests/agent/ shows 16770
passing; the 12 failures are all pre-existing on origin/main
(verified by stashing this branch's changes and re-running) —
kanban-boards, delegate-task, gateway-restart, tts-routing —
none touch the plugin_llm surface.
* chore(plugins): move all example plugins to companion repo
Reference / docs-companion plugins now live exclusively in
NousResearch/hermes-example-plugins, not bundled with the core repo:
- example-dashboard
- strike-freedom-cockpit
A new fourth example, plugin-llm-async-example, was added to that
repo demonstrating ctx.llm's async surface (acomplete()) with
asyncio.gather() — registers /translate <lang>: <text> which fires
forward translation + sentiment classifier in parallel, then a
back-translation for QA. Live-tested at 2.5s for three real
provider round-trips (would be ~5-6s sequential).
Docs updated:
- developer-guide/plugin-llm-access.md links both sync and async
examples in the Reference section
- user-guide/features/extending-the-dashboard.md repoints both demo
sections to the companion repo with corrected install paths
- user-guide/features/built-in-plugins.md drops the two demo rows
- AGENTS.md notes that example plugins live in the companion repo
Net: hermes-agent's plugins/ directory now contains only plugins
users actually run (memory providers, dashboard tabs that ship real
features, the disk-cleanup hook, platform adapters). All four
demo / reference plugins live externally where they can be cloned
on demand instead of inflating the core install.
* fix(kanban): use sys.executable -m hermes for dispatcher spawn
In NixOS container mode, hermes is installed at a store path with no
symlink on PATH (e.g. /data/current-package/bin/hermes). The kanban
dispatcher spawns workers via _default_spawn() using a bare 'hermes'
subprocess call, which fails with 'hermes executable not found on PATH'
in container mode.
Fix by calling sys.executable -m hermes instead, which is guaranteed
to resolve to the same Python interpreter running the dispatcher.
* fix(kanban): correct dispatcher spawn module name + PATH-first lookup
Follow-up to the previous commit's contributor cherry-pick.
The cherry-picked change replaced the bare ``["hermes", ...]`` spawn with
``[sys.executable, "-m", "hermes", ...]``. The intent was right (avoid
PATH dependence — cron, systemd User= services, launchd jobs, and other
detached dispatcher invocations routinely run with a stripped $PATH that
doesn't include the venv's bin/, breaking the bare-shim spawn) but the
module name is wrong: there is no top-level ``hermes`` package. The
console-script entry point in pyproject.toml is
``hermes = "hermes_cli.main:main"``, and ``python -m hermes`` fails with
``No module named hermes``. The cherry-picked form would have replaced a
sometimes-broken spawn with an always-broken one.
This commit:
- Adds ``_resolve_hermes_argv()``, mirroring ``gateway.run._resolve_hermes_bin``.
Tries ``shutil.which("hermes")`` first (preferred — keeps existing ``ps``
output and log lines familiar in the common case) and falls back to
``[sys.executable, "-m", "hermes_cli.main"]`` when the shim is not on
PATH. The fallback goes through the running interpreter so it's
PATH-independent. Kept as a local helper rather than imported from
gateway because ``hermes_cli`` sits below ``gateway`` in the dependency
order.
- Switches the dispatcher's ``cmd`` list to use ``*_resolve_hermes_argv()``.
- Adds three regression tests:
* ``test_resolve_hermes_argv_prefers_path_shim`` — pins the PATH-first
branch so a future refactor doesn't silently flip the order.
* ``test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim`` —
pins the correct module name (``hermes_cli.main``, NOT ``hermes``).
Direct regression guard for the form that shipped in the original PR.
* ``test_resolve_hermes_argv_module_actually_runs`` — runs the fallback
invocation as a real subprocess and asserts ``--version`` works, so
losing ``hermes_cli.main``'s ``__main__`` handling can't slip past the
string-match test.
Verified end-to-end: with the shim on PATH the resolver returns
``[/.../hermes]`` and ``--version`` works; with the shim removed the
resolver returns ``[python, -m, hermes_cli.main]`` and ``--version``
still works; the original PR's ``python -m hermes`` invocation fails as
expected (``No module named hermes``).
* feat(i18n): localize all gateway commands + web dashboard, add 8 new locales (16 total) (#22914)
* feat(i18n): localize /model command output
Reported by @tianma8888: when Chinese users run /model, the labels
("Provider:", "Context:", "_session only_", etc.) are still English.
This routes the static prose through the existing i18n catalog so it
follows display.language / HERMES_LANGUAGE.
Changes:
- locales/{en,zh,ja,de,es,fr,tr,uk}.yaml: add 17 keys under
gateway.model.* covering switched/provider/context/max_output/cost/
capabilities/prompt_caching/warning/saved_global/session_only_hint/
current_label/current_tag/more_models_suffix/usage_*.
- gateway/run.py _handle_model_command: replace hardcoded f-strings in
the picker callback, the text-list fallback, and the direct-switch
confirmation block with t("gateway.model.<key>", ...).
What stays English:
- model IDs, provider slugs, capability strings, cost figures, and the
"[Note: model was just switched...]" prepended to the model's next
prompt (LLM-facing, not user-facing).
- The two slightly-different session-only hints unify on a single key
with the em-dash phrasing.
Validation: tests/agent/test_i18n.py 27/27 passing (parity contract
holds), tests/gateway/ -k 'model or i18n' 74/74 passing.
* feat(i18n): localize all gateway slash command outputs
Expands the i18n catalog from 7 strings to 234 keys across 35 gateway
slash command handlers, so non-English users see localized output for
\`/profile\`, \`/status\`, \`/help\`, \`/personality\`, \`/voice\`, \`/reset\`,
\`/agents\`, \`/restart\`, \`/commands\`, \`/goal\`, \`/retry\`, \`/undo\`,
\`/sethome\`, \`/title\`, \`/yolo\`, \`/background\`, \`/approve\`, \`/deny\`,
\`/insights\`, \`/debug\`, \`/rollback\`, \`/reasoning\`, \`/fast\`,
\`/verbose\`, \`/footer\`, \`/compress\`, \`/topic\`, \`/kanban\`,
\`/resume\`, \`/branch\`, \`/usage\`, \`/reload-mcp\`, \`/reload-skills\`,
\`/update\`, \`/stop\` (plus the \`/model\` block already added in the
previous commit).
Reported by @tianma8888 — Chinese users want command output prose in
their language, not just the labels we already had.
Translations are hand-written for all 8 supported locales (en, zh, ja,
de, es, fr, tr, uk), matching each catalog's existing style: full-width
punctuation in zh, em-dashes in zh/ja/uk, French spaced colons,
German noun capitalization, etc.
What stays English (unchanged):
- Identifiers/values: model IDs, file paths, profile names, session IDs,
command flag names like --global, URLs, config keys.
- Backtick code spans: \`/foo\`, \`config.yaml\`.
- Log messages (logger.info/warning/error).
- LLM-facing system notes prepended to next prompt (e.g. [Note: model
was just switched...]).
- Strings produced by external modules (gateway_help_lines,
format_gateway, manual_compression_feedback) — those have their
own surfaces.
New shared keys for cross-handler boilerplate:
- gateway.shared.session_db_unavailable (5 call sites: branch, title,
resume, topic, _disable_telegram_topic_mode_for_chat)
- gateway.shared.session_not_found (1 site)
- gateway.shared.warn_passthrough (2 sites in /title's f"⚠️ {e}" pattern)
YAML gotcha fixed: \`yolo.on\` and \`yolo.off\` were originally written
unquoted, which YAML 1.1 parses as boolean True/False keys. Renamed to
\`yolo.enabled\` / \`yolo.disabled\` for both safety and clarity.
Test fix: tests/agent/test_i18n.py::test_t_missing_key_in_non_english_falls_back_to_english
now resets the catalog cache on teardown, so the fake "foo: English Foo"
locale doesn't poison the module-level cache for subsequent tests in
the same xdist worker. (Without this, every gateway slash command test
that shares a worker with the i18n suite would see the fake catalog.)
Validation:
- tests/agent/test_i18n.py: 27/27 (parity contract — every key in every
locale, matching placeholder tokens).
- tests/gateway/: 5077 passed, 0 failed (full gateway suite).
- 180 t() call sites added across 35 handlers; 1872 catalog entries
total (234 keys × 8 locales).
* feat(i18n): add 8 new locales — af, ko, it, ga, zh-hant, pt, ru, hu
Expands the static-message catalog from 8 → 16 languages, each with full
270-key parity against the English source-of-truth. Every locale now
covers the same surface PR #22914 added: approval prompts plus all 35
gateway slash command outputs.
New locales:
- af Afrikaans (community ask in #21961 by @GodsBoy; PRs #21962, #21970)
- ko Korean (PRs #20297 by @tmdgusya, #22285 by @project820)
- it Italian (PR #20371 by @leprincep35700)
- ga Irish/Gaeilge (PR #20962 by @ryanmcc09-dot)
- zh-hant Traditional Chinese (PRs #20523 by @jackey8616, #13140 by @anomixer)
- pt Portuguese (PRs #20443 by @pedroborges, #15737 by @carloshenriquecarniatto, #22063 by @Magaav)
- ru Russian (PR #22770 by @DrMaks22)
- hu Hungarian (PR #22336 by @lunasec007)
Each locale uses native-quality translations matching the existing tone
and conventions of the older 8 locales:
- zh-hant uses 繁體 characters with TW/HK technical vocabulary (軟體
not 软件, 連線 not 连接, 設定 not 设置, 訊息 not 消息, 工作階段 not 会话, 程式
not 程序, 預設 not 默认, 伺服器 not 服务器), full-width punctuation 「:()」.
- ko uses formal 합니다체 (습니다/합니다) register throughout.
- pt uses European Portuguese as baseline with neutral PT/BR vocabulary
where possible.
- ga uses standard An Caighdeán Oifigiúil; English loanwords retained
for tech terms without good Irish equivalents (gateway, API, JSON).
- All preserve {placeholder} tokens, backtick code spans, slash commands,
brand names (Hermes, MCP, TTS, YOLO, OpenAI, Telegram, etc.), and emoji.
Aliases added in agent/i18n.py:
- af-za, Afrikaans → af
- ko-kr, Korean, 한국어 → ko
- it-it, italiano → it
- ga-ie, Irish, Gaeilge → ga
- zh-tw, zh-hk, zh-mo, traditional-chinese → zh-hant (note: zh-tw used to
alias to zh; now aliases to its own zh-hant catalog)
- zh-cn, zh-hans, zh-sg → zh (unchanged from before)
- pt-pt, pt-br, brazilian, portuguese → pt
- ru-ru, Russian, русский → ru
- hu-hu, Magyar → hu
The zh-tw alias re-routing is intentional: previously typing 'zh-TW' got
the Simplified Chinese catalog (wrong vocabulary for Taiwan/HK users).
Now those users get the proper Traditional Chinese catalog.
Validation:
- tests/agent/test_i18n.py: 43/43 (parity contract holds for all 16
languages × 270 keys = 4320 catalog entries, with matching placeholder
tokens).
- E2E alias resolution verified for all 19 alias inputs (Afrikaans, ko-KR,
한국어, italiano, Gaeilge, zh-TW, zh-HK, traditional-chinese, pt-BR,
brazilian, Magyar, etc.).
- tests/gateway/: 5198 passed (3 pre-existing TTS routing failures
unrelated to i18n).
Credit to all contributors whose PRs surfaced these language requests.
Their original PRs may now be closed as superseded with credit.
* feat(dashboard-i18n): add 14 web dashboard locales matching the static catalog
Brings the React dashboard (web/src/) up to the same 16-language
coverage the static catalog already has after the previous commits in
this PR. The Translations interface is TypeScript-typed, so every new
locale must provide every key — tsc -b is the parity guard.
Languages added (each is a complete 429-line locale file):
- af Afrikaans
- ja Japanese (PR #22513 by @snuffxxx surfaced this)
- de German (PR #21749 by @mag1art)
- es Spanish (PR #21749)
- fr French (PRs #21749, #10310 by @foXaCe)
- tr Turkish
- uk Ukrainian
- ko Korean (PRs #21749, #18894 by @ovstng, #22285 by @project820)
- it Italian
- ga Irish (Gaeilge)
- zh-hant Traditional Chinese (PR #13140 by @anomixer)
- pt Portuguese (PRs #22063 by @Magaav, #22182 by @wesleysimplicio, #15737 by @carloshenriquecarniatto)
- ru Russian (PRs #21749, #22770 by @DrMaks22)
- hu Hungarian (PR #22336 by @lunasec007)
Each translation covers all 15 namespaces with full key parity vs en.ts,
preserves every {placeholder} token verbatim, keeps identifiers
untranslated (brand names, file paths, cron expressions, code spans),
translates the language.switchTo tooltip into the target language, and
matches existing tone conventions (zh-hant uses TW/HK vocab; ja uses
formal desu/masu; ko uses formal seumnida register; ga uses An
Caighdean Oifigiuil with English loanwords for tech vocab without good
Irish equivalents).
Plumbing:
- web/src/i18n/types.ts: Locale union expanded to all 16 codes.
- web/src/i18n/context.tsx: imports all 16 catalogs; exports
LOCALE_META (endonym + flag per locale); isLocale() type guard.
- web/src/i18n/index.ts: re-export LOCALE_META.
- web/src/components/LanguageSwitcher.tsx: replaced two-state EN-ZH
toggle with a click-to-open dropdown listing all 16 languages.
Note: zh-hant.ts exports zhHant (camelCase) since hyphen is invalid in
a JS identifier; the canonical 'zh-hant' string keys it in TRANSLATIONS.
Validation:
- npx tsc -b: 0 errors. Every locale satisfies Translations.
- npm run build (tsc + vite production): green, 2062 modules.
- Each locale file is exactly 429 lines.
Out of scope: plugin dashboards (kanban/achievements ship as prebuilt
bundles with no source in repo); Docusaurus docs (separate surface);
TUI (no i18n yet).
* feat(plugin-i18n): localize achievements + kanban plugin dashboards across all 16 locales
Brings the two shipped plugin dashboards (hermes-achievements, kanban)
under the same i18n umbrella as the core dashboard PR #22914 just
established. Both bundles now read user-facing strings from the host's
i18n catalog via SDK.useI18n() instead of hardcoded English.
## Approach
Plugin dashboards ship as prebuilt IIFE bundles in
plugins/<name>/dashboard/dist/index.js — no build step, no source in
repo (upstream-authored, vendored as compiled JS). Earlier contributor
PRs (#22594, #22595, #18747) tried direct edits but didn't actually
wire the bundles to read translations.
This change does the wiring properly:
1. Each bundle gets a useI18n shim at IIFE scope:
const useI18n = SDK.useI18n
|| function () { return { t: { kanban: null }, locale: "en" }; };
Older host SDKs without useI18n still load the bundle and render
English fallbacks.
2. A small tx(t, path, fallback, vars) helper resolves dotted keys
under the plugin's namespace (t.kanban.* or t.achievements.*) and
interpolates {placeholder} tokens.
3. Every React component starts with const { t } = useI18n() and
each user-visible string is wrapped in tx(t, "key", "English fallback").
Helpers called outside React components (window.prompt callers,
constants used during init) take t as a parameter.
4. Top-level constants that were English dictionaries (COLUMN_LABEL,
COLUMN_HELP, DESTRUCTIVE_TRANSITIONS, DIAGNOSTIC_EVENT_LABELS in
kanban) become getColumnLabel(t, status)-style functions backed by
FALLBACK_* dictionaries.
## Translations added
Two new top-level namespaces added to the dashboard's TypeScript-typed
Translations interface:
- achievements: ~70 keys covering the hero, scan banner, achievement
card, share dialog, stats, filters, and empty states.
- kanban: ~145 keys covering the board, columns (with nested
columnLabels and columnHelp sub-dicts), card detail panel,
bulk-actions toolbar, dependency editor, board switcher, and
diagnostic callouts.
Each key is provided across all 16 supported locales:
en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu.
Total new translation entries: ~3,440 (215 keys × 16 locales).
## What stays English (deliberate)
- API paths, CSS class names, data-* attributes, JSON keys, regex
strings, URLs, file paths (~/.hermes/kanban.db, boards/_archived/).
- State identifier strings used as lookup keys (triage / todo / ready /
running / blocked / done / archived) — labels translate, key strings
don't.
- The PNG share-card text rendered to canvas in the achievements
ShareDialog (HERMES AGENT watermark, UNLOCKED stamp, tier names) —
these become part of a globally-shared image and stay English.
- localStorage keys (hermes.kanban.selectedBoard).
- Brand names (Kanban, Hermes, WebSocket, Nous Research).
## Contributor credit
PR #22594 by @02356abc and PR #22595 by @02356abc supplied the
en + zh kanban namespace skeleton (145 keys); used as the en source-
of-truth in this commit and translated to the other 14 locales.
PR #18747 by @laolaoshiren first surfaced the achievements
localization request.
## Validation
- npx tsc -b: 0 errors. All 16 locale .ts files satisfy the
Translations type with full key parity.
- npm run build (tsc + vite production build): green, 2062 modules,
1.56MB JS / 95KB CSS, ~2.5s build.
- node --check on both plugin bundles: parse cleanly.
- 126 tx() call sites in kanban, 46 in achievements.
## Out of scope
- TUI (ui-tui/) has no i18n infrastructure yet.
- Docusaurus docs (website/i18n/) — already had zh-Hans; expanding
is a separate translation workstream (Thai / Korean / Hindi PRs).
* fix(kanban): guard task_age against corrupt created_at values like '%s'
task_age() crashed with ValueError when created_at contained the
literal format string '%s' instead of a Unix timestamp, taking down
the entire GET /board endpoint with a 500.
- Add _safe_int() helper that returns None on non-numeric values
- Refactor task_age() to use _safe_int instead of bare int() casts
- Wrap task_age() call in _task_dict with try/except fallback so one
corrupt row never kills the whole board endpoint
* test(kanban): cover task_age safe-int guards + AUTHOR_MAP entry
Follow-up to the previous commit's safe-int task_age fix.
The original PR shipped without test coverage. This commit adds:
- test_safe_int_accepts_int_and_int_string — sanity for the well-typed
path so the helper itself can't quietly start swallowing valid values.
- test_safe_int_returns_none_on_corrupt_inputs — the failure modes
(None, '%s', 'abc', '', '1.5', random objects). Covers both the
ValueError and TypeError catch branches.
- test_task_age_handles_corrupt_created_at — the headline regression:
a task with created_at='%s' used to raise ValueError and turn
GET /api/plugins/kanban/board into a 500.
- test_task_age_handles_corrupt_started_and_completed — confirms the
safe-int treatment is consistent across all three timestamp fields.
- test_task_age_well_formed_task — regression that the safe path
doesn't change observable output for normal data.
- test_task_dict_survives_corrupt_created_at — defense in depth.
Writes a corrupt row directly via SQL, reads it back through the
ORM, and confirms task_age + the surrounding plugin_api guard
degrade gracefully instead of crashing.
Also adds the AUTHOR_MAP entry for the contributor's GitHub-noreply
email so release notes credit @baocin (the commit was authored locally
as `aoi <aoi@hino.local>` — re-attributed during salvage to the
github noreply form).
* fix(kanban): preserve assignee casing in dashboard
* test(kanban-dashboard): pin assignee-casing static-asset regressions + AUTHOR_MAP
Follow-up to the previous commit's casing fix.
The original PR shipped the dist edits without test coverage. The
contributor's reasoning (UI-only attributes in a pre-built JS bundle,
nothing meaningful to unit-test) is fair, but a static-asset assertion
catches the most likely regression vector — a future rebuild of the
dist bundle that loses the attributes — at near-zero cost.
Adds two regression tests in tests/plugins/test_kanban_dashboard_plugin.py:
- test_dashboard_assignee_inputs_preserve_casing — reads dist/index.js
and asserts autoCapitalize="none", autoCorrect="off", spellCheck=false,
and textTransform="none" each appear at least twice (one per assignee
input — inline triage/lane create + task-edit panel).
- test_dashboard_lane_head_preserves_assignee_casing — reads dist/style.css
and asserts the .hermes-kanban-lane-head rule body does NOT contain
text-transform: uppercase. Locates the rule by marker so unrelated CSS
churn nearby doesn't flake the test.
Both follow the same shape as the existing test_dashboard_requests_default_board_explicitly
static-asset guard from PR #22940's salvage.
Also adds the AUTHOR_MAP entry for princepal9120's GitHub-noreply email
so release notes credit the right account.
* perf(browser): route browser_console eval through supervisor's persistent CDP WS (180x faster) (#23226)
Adds CDPSupervisor.evaluate_runtime() and wires it into _browser_eval as a
fast path when a supervisor is alive for the current task_id. Replaces the
~180ms agent-browser subprocess fork+exec+Node-startup hop with a ~1ms
Runtime.evaluate over the supervisor's already-connected WebSocket.
Falls through to the existing agent-browser CLI path when no supervisor is
running (e.g. backends without CDP, or before the first browser_navigate
attaches one), so behaviour is unchanged where it can't apply.
JS-side exceptions surface directly without falling through to the
subprocess (the subprocess would just re-raise the same error, slower);
supervisor-side failures (loop down, no session) fall through cleanly.
Benchmark — 30 iterations of `1 + 1` against headless Chrome:
supervisor WS mean= 0.96ms median= 0.91ms
agent-browser subprocess mean=179.35ms median=167.73ms
→ 187x speedup mean
Tests: 14 unit tests (mocked supervisor + response-shape coverage), 5
real-Chrome e2e tests in test_browser_supervisor.py (gated on Chrome
being installed). Browser test suite: 355 passed, 1 skipped.
* fix(kanban-dashboard): tone down completed-run metadata panel (#19548)
Hand-rebased onto current main from PR #19980; the original branch was stale
against main (~6 unrelated dashboard fixes had landed since), so applying
the PR's dist files directly would have silently reverted them.
The run-history panel in the task drawer rendered each completed run's
`metadata` field as a `<code class="hermes-kanban-run-meta">` containing
`JSON.stringify(r.metadata)` — a single unindented monoline. With
`white-space: pre-wrap` and a monospace font, a writer task's metadata
(changed_files paths, source URLs, generated-artifact details) wrapped
into a tall block of code-ish text that filled the parent run row. The
container's faint `--color-foreground 3%` background then made the whole
thing read like a crash dump even though the run completed normally.
Restyle and label, no interactivity changes:
- Wrap the meta payload in a `.hermes-kanban-run-meta-block` sub-block
with an explicit `Metadata` label (small, uppercase, muted) so the
panel reads as auxiliary detail at a glance.
- Pretty-print the JSON (`indent=2`) so the structure is scannable
instead of a wall of monoline text.
- Cap `.hermes-kanban-run-meta` at `max-height: 8.5rem; overflow: auto`
so a verbose blob scrolls inside its own pane rather than swamping
the run row.
- Sub-block uses a thin `border-left` rule and `background: transparent`
— distinct from the destructive-tinted treatment used by crashed /
timed_out / blocked / spawn_failed runs higher in the same file.
Tests: two new static-asset assertions in
`tests/plugins/test_kanban_dashboard_plugin.py` lock in the rendered
shape (the plugin ships built-only, no src/).
* feat(kanban-dashboard): native <details> collapse + skip empty metadata
Two follow-up improvements to Tranquil-Flow's metadata-panel restyle.
Both stay within the parent PR's "tone down the panel" scope.
1. Native <details>/<summary> collapse for verbose metadata.
The parent PR consciously deferred this ("adding native expand/collapse
would be the next step but requires UX agreement"). The default they
asked for is straightforward: collapsed when the rendered JSON exceeds
300 chars (the threshold where the max-height: 8.5rem cap actually
starts mattering), expanded otherwise. <details>/<summary> is the right
primitive — zero JS, browser-handled state, accessible by default
(keyboard-navigable, screen-reader announces the disclosure state),
and survives any react-state churn for free.
The OS-default disclosure marker is suppressed (list-style: none +
::-webkit-details-marker hidden) and replaced with a CSS ::before
chevron that rotates 90deg on the [open] attribute, so the look is
consistent across Firefox/WebKit/Blink without the double-marker
that would otherwise appear on the platforms that still render the
default triangle.
2. Skip rendering when metadata is an empty object.
`r.metadata && ...` truthy-checks, but `{}` is truthy in JS — so a
completed task with no actual metadata would render a "Metadata"
labeled disclosure block containing literal `{}`. Adds an
Object.keys(r.metadata).length > 0 guard so empty payloads render
nothing instead of an empty disclosure stub.
Tests: three new static-asset assertions covering the <details> shape,
the empty-object skip, and the suppress-default-marker + animated-chevron
CSS — all in `tests/plugins/test_kanban_dashboard_plugin.py`.
* fix(kanban): reject toolset names in task skills
* feat(kanban): aggregate all toolset-name typos in skills before raising
Follow-up to the previous commit's toolset-vs-skill validation.
The contributor's fix raises ValueError on the first toolset name found
in the skills list. That works for one mistake, but agents that confuse
skills with toolsets usually pass several at once
(`skills=["web", "browser", "terminal"]`) — and serial-correcting one
per failure round-trip wastes tokens. Collect all toolset-shaped
entries first, then raise once with the full list.
The error message is also slightly clearer:
'web', 'browser', 'terminal' are toolset names, not skill name(s).
Put toolsets in the assignee profile's `toolsets:` config instead of
per-task skills. Skills are named skill bundles (e.g. `kanban-worker`,
`blogwatcher`); toolsets are runtime capabilities (e.g. `web`,
`browser`, `terminal`).
vs. the previous "the assignee profile's toolsets" — explicitly naming
the YAML key (`toolsets:`) and giving concrete examples in both
categories closes the conceptual gap that produced the bug to begin
with.
Adds one regression test (test_create_task_skills_lists_all_toolset_typos)
covering the multi-name aggregation path. The single-typo test from
the original PR still passes (the loose `match="toolset name"` matches
both singular and plural forms).
* feat(gateway): shutdown forensics — non-blocking diag, per-phase timing, stale-unit warning (#23285)
When the gateway received SIGTERM, the shutdown_signal_handler ran a
synchronous 'ps aux' (3s timeout) inside the asyncio event loop, then
asyncio.create_task(runner.stop()). On a busy host that ate 1-3s of
the teardown budget before draining could even start, and the resulting
log line was a multi-line ps dump that didn't tell us who sent the
signal. The shutdown path itself logged 'Stopping gateway...' and then
nothing until 'Gateway stopped' — when systemd SIGKILLed mid-drain,
there was no way to see which phase wedged.
Changes:
- New gateway/shutdown_forensics.py:
* snapshot_shutdown_context(sig) — sub-millisecond /proc-only capture
of signal name, parent pid+name+cmdline, INVOCATION_ID (systemd
marker), loadavg_1m, TracerPid, takeover/planned-stop marker
presence + whether-it-names-self. Pure stdlib, never raises.
* spawn_async_diagnostic(log_path, sig) — detached subprocess with
its own 'timeout 5s', start_new_session=True, writes ps auxf +
pstree + dmesg to ~/.hermes/logs/gateway-shutdown-diag.log.
Returns immediately, can't block the event loop or the cgroup
teardown.
* check_systemd_timing_alignment(drain_timeout) — reads
/proc/self/cgroup for our unit, asks systemctl show for
TimeoutStopUSec, returns mismatch info when the unit's stop
timeout is smaller than restart_drain_timeout + 30s headroom
(the case where systemd SIGKILLs mid-drain).
* _parse_systemd_duration_to_us — covers '90s', '1min 30s',
'500ms', '1h' style values from systemctl show.
* format_context_for_log — single scannable key=value line, parent
cmdline last.
- gateway/run.py shutdown_signal_handler:
* Replaces synchronous ps aux + ad-hoc 'hermes-related lines' filter
with snapshot + detached spawn.
* Always logs 'Shutdown context: signal=... parent_pid=...
parent_cmdline=...' regardless of planned/unexpected so we can
correlate signal source even on planned restarts.
- gateway/run.py _stop_impl:
* Per-phase '+X.XXs' timing for notify_active_sessions, drain
(with drain_seconds, active_at_start, active_now, timed_out),
post-interrupt tool kill, each adapter disconnect (Xs),
all adapters disconnected, final-cleanup tool kill, SessionDB
close, total teardown.
- gateway/run.py start():
* Stale-unit warning at startup when the running systemd unit's
TimeoutStopSec is smaller than the configured drain timeout.
Points the user at 'hermes gateway service install --replace'
to regenerate, or at shortening agent.restart_drain_timeout.
Tests: 30 new in tests/gateway/test_shutdown_forensics.py — snapshot
speed bound, signal name resolution, marker detection self-vs-other,
async diag spawn doesn't block caller, systemd duration parser, and
alignment check returns None outside systemd. Wider tests/gateway/
suite: 5258 passing, 3 pre-existing TTS-routing failures unchanged
on main.
* fix(kanban): cap dispatch by running workers
* docs(kanban): document max_spawn as live concurrency cap (not per-tick budget)
Follow-up to the previous commit's behavior fix.
Adds a paragraph to dispatch_once's docstring making the concurrency-cap
semantic explicit, and an inline comment near the running_count query
explaining why we do the count (so a future reader doesn't refactor it
back to per-tick semantics thinking it's redundant). Both call out the
unbounded-accumulation failure mode that motivated the fix, since
nothing in the codebase or skills currently documents what max_spawn
is supposed to mean.
The semantic is per-board: each kanban board has its own SQLite file,
so the running-count COUNT(*) is naturally scoped to the board the
dispatcher tick is processing.
* chore: AUTHOR_MAP entry for guglielmofonda (#21505)
* fix(xai): drop models being retired May 15, 2026 from pickers (#23291)
xAI is retiring grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},
grok-4-1-fast{,-reasoning,-non-reasoning}, and grok-code-fast-1 on
May 15, 2026 at 12:00 PT. Remove them from the static fallbacks so the
`hermes model` picker, gateway /model picker, and setup wizard stop
auto-suggesting models that will be dead in days.
- _XAI_STATIC_FALLBACK in hermes_cli/models.py now lists only grok-4.20-*
and grok-4.3 (the live replacements).
- copilot lists in hermes_cli/models.py and hermes_cli/setup.py drop
grok-code-fast-1 (Copilot proxies it through xAI, so the upstream
retirement breaks it there too).
Old configs that already reference retired IDs keep working until xAI
flips the switch — context-length lookups in agent/model_metadata.py and
the cache-affinity-header logic in provider_profiles still recognise the
old names. The cleanup here is purely about not advertising them to new
users.
Closes #23278.
Source: https://docs.x.ai/developers/migration/may-15-retirement
* feat(gateway): per-platform admin/user split for slash commands (salvage of #4443) (#23373)
* feat(gateway): per-platform admin/user split for slash commands
Adds an opt-in two-list access control on top of the existing per-platform
`allow_from` allowlists, scoped to slash commands only:
- allow_admin_from — full slash command access
- user_allowed_commands — what non-admins may run
- group_allow_admin_from — same, group/channel scope
- group_user_allowed_commands
When `allow_admin_from` is unset for a scope, gating is disabled and every
allowed user keeps full access (backward compat). Plain chat is unaffected.
`/help` and `/whoami` are always reachable so users can see what they
can run.
Gate runs at the slash command dispatch site in gateway/run.py and uses
`is_gateway_known_command()`, so it covers built-in AND plugin-registered
commands through the live registry without per-feature wiring.
Adds `/whoami` showing platform, scope, tier, and runnable commands.
Salvage of PR #4443's permission tier work, scoped down. The full tier
system, tool filtering, audit log, usage tracking, rate limiting,
`/promote` flow, and persistent SQLite stores are not included here —
those can be re-expanded later if needed.
Co-authored-by: ReqX <mike@grossmann.at>
* fix(gateway): close running-agent fast-path bypass + add coverage and central docs
The slash command access gate was only applied at the cold dispatch site
(line ~5921). When an agent was already running, the running-agent
fast-path block (line ~5574) dispatched /restart, /stop, /new, /steer,
/model, /approve, /deny, /agents, /background, /kanban, /goal, /yolo,
/verbose, /footer, /help, /commands, /profile, /update directly
without going through the gate — letting non-admins bypass gating just
because an agent happens to be busy.
Refactored the gate into _check_slash_access() and called from BOTH
paths. /status remains intentionally pre-gate so users can always see
session state.
Also added 18 more dispatch tests covering:
- Running-agent fast-path: blocks non-admin, allows admin, /status
always works
- Alias canonicalization (gate uses canonical name, not user alias)
- Unknown / unregistered commands pass through (don't false-positive)
- DM admin scope-locked when group has its own admin list
- Multi-platform isolation (Discord gated, Telegram unrestricted)
Docs: added Slash Command Access Control section to the central
messaging index page + /whoami row in the chat commands table.
Co-authored-by: ReqX <mike@grossmann.at>
---------
Co-authored-by: ReqX <mike@grossmann.at>
* refactor(kanban-orchestrator): drop hardcoded specialist roster, add Step-0 profile discovery
The skill enumerated 8 specialist profile names (researcher, analyst,
writer, reviewer, backend-eng, frontend-eng, ops, pm) as "the standard
roster" and told orchestrators to "assume these exist." Almost no real
Hermes setup matches that fleet — single-profile setups, Docker-worker
setups, and curated-team setups all violate it — so following the skill
literally produced cards assigned to non-existent profiles, which the
dispatcher silently failed to spawn (no autocorrect, no fallback, just
sits in `ready` forever).
Changes:
- Drop the standard-specialist-roster table.
- Add a "Profiles are user-configured — not a fixed roster" section at
the top with a Step 0 that prescribes `hermes profile list` (or asking
the user) before fanning out. Cache the result in working memory.
- Rewrite the worked task-graph example with placeholder names
(<profile-A>, <profile-B>, <profile-C>) so the structure is still
teachable but doesn't invite copy-paste of role names that may not
exist.
- Reframe the "If no specialist fits" anti-temptation rule: don't
invent profile names; ask the user.
- Add a "Inventing profile names that doesn't exist" entry to Pitfalls.
- Bump skill version 2.0.0 → 3.0.0 (semantic break: previous behavior
promised a roster the skill no longer enumerates).
- Update website/docs/user-guide/features/kanban.md to drop the
matching "(researcher, writer, analyst, backend-eng, reviewer, ops)"
line and explain the discovery prompt instead.
- Re-run website/scripts/generate-skill-docs.py to refresh the
auto-generated skill page + catalog.
Closes #21131 in spirit — addresses the same hardcoded-names footgun
@yehuosi flagged, with a different shape than their PR (delete the
roster rather than replace each name with placeholder, since the
roster table was the load-bearing footgun and the worked example is
salvageable with placeholder profile names).
Co-authored-by: yehuosi <yehuosi@users.noreply.github.com>
* feat(session): add /handoff command for cross-platform session transfer
Adds /handoff <platform> CLI command that queues the current session for
resume on the configured home channel of any messaging platform.
CLI side:
- /handoff telegram — marks session in shared DB, sends summary to
the Telegram home channel via send_message
- /handoff discord — same for Discord
- Supports telegram, discord, slack, whatsapp, signal, matrix
Gateway side:
- On new session creation, checks for pending handoffs for the
incoming message's platform
- If found, loads the CLI session's full conversation history and
injects it into the context prompt as a handoff transcript
- Agent continues the conversation seamlessly
Files:
- hermes_state.py: handoff_pending, handoff_platform columns + helpers
- cli.py: _handle_handoff_command dispatch + handler
- hermes_cli/commands.py: CommandDef entry
- gateway/run.py: handoff detection in _handle_message_with_agent
- tests/hermes_cli/test_session_handoff.py: 8 tests
* feat(session): make /handoff actually transfer the session live
Builds on @kshitijk4poor's CLI handoff stub. The original PR's flow
deferred everything to whenever a real user happened to message the
target platform; this rewrites it so the gateway picks up handoffs
immediately and the destination chat just starts working.
State machine on sessions table replaces the boolean flag:
None -> 'pending' -> 'running' -> ('completed' | 'failed')
plus handoff_error for failure reasons. CLI request_handoff /
get_handoff_state / list_pending_handoffs / claim_handoff /
complete_handoff / fail_handoff helpers wrap the transitions.
CLI side (cli.py): /handoff <platform> validates the platform's home
channel via load_gateway_config, refuses if the agent is mid-turn,
flips the row to 'pending', and poll-blocks (60s) on terminal state.
On 'completed' it prints the /resume hint and exits the CLI like
/quit. On 'failed' or timeout it surfaces the reason and the CLI
session stays intact.
Gateway side (gateway/run.py): new _handoff_watcher background task
scans state.db every 2s, atomically claims pending rows, and runs
_process_handoff for each. _process_handoff:
1. Resolves the platform's home channel.
2. Asks the adapter for a fresh thread via the new
create_handoff_thread(parent_chat_id, name) capability so the
handed-off conversation gets its own scrollback. Adapters that
don't support threads (or fail) return None and the watcher
falls back to the home channel directly.
3. Constructs a SessionSource keyed as 'thread' when a thread was
created, 'dm' otherwise, then session_store.switch_session
re-binds the destination key to the CLI session_id. The full
role-aware transcript replays via load_transcript on the next
turn (no flat-text injection into context_prompt).
4. For…
jsboige
pushed a commit
to jsboige/hermes-agent
that referenced
this pull request
May 14, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
1 task
19 tasks
AlexFoxD
pushed a commit
to AlexFoxD/hermes-agent
that referenced
this pull request
May 21, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
gweeteve
pushed a commit
to gweeteve/hermes-agent
that referenced
this pull request
Jun 2, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
28 tasks
Seven74AI
pushed a commit
to Seven74AI/hermes-agent
that referenced
this pull request
Jun 13, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
T02200059
pushed a commit
to T02200059/hermes-agent
that referenced
this pull request
Jun 18, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
linxule
pushed a commit
to linxule/hermes-agent
that referenced
this pull request
Jun 28, 2026
Bundled platform plugin following the LINE precedent (NousResearch#23197) and the more recent SimpleX precedent (NousResearch#26232). KimiClaw is Moonshot AI's agentic bot platform on kimi.com (launched Feb 2026). Distinct from the Moonshot LLM provider (kimi-for-coding profile in plugins/model-providers/kimi-coding/, aliased in hermes_cli/providers.py) — this PR adds a separate chat platform and does not touch that integration. The adapter bridges two channels under one bot identity: - DM via Zed ACP over WebSocket (sentinel session im:kimi:main) - Group rooms via Connect RPC Subscribe server-stream over HTTP/1.1 long-poll (chat_id room:<uuid>); outbound replies via unary Connect RPC SendMessage Deployment model: Moonshot's intended bot deployment uses the official OpenClaw runtime (claw-install.sh, V2026.4.5+) to own the wire connection. This plugin instead speaks the wire protocol directly from Python while sending the OpenClaw-shaped runtime metadata headers kimi.com gates group-room participation on ("OpenClaw 3.13 or above"). Identity layers are honest: User-Agent is hermes-kimi-adapter/1.0, X-Kimi-Claw-ID is prefixed hermes-kimi-, X-Kimi-OpenClaw-Skills is suppressed by default, and X-Kimi-OpenClaw-Version is set to the documented group-gate floor (2026.3.13) — not a "real install" value. All five OpenClaw-shaped headers are overridable via config.extra. See plugins/platforms/kimiclaw/adapter.py:23-28 (module docstring) and :103-112 (_GROUP_GATE_DEFAULTS) for the rationale. Production wear: the same adapter code has run on one Raspberry Pi gateway under daily user traffic since 2026-04-27. The adapter previously shipped (and continues to ship) as an external plugin at linxule/hermes-kimi-plugin; this PR upstreams it at version 1.0.0 — a stable snapshot intentionally decoupled from the external repo's bleeding-edge stream. Files: - plugins/platforms/kimiclaw/{__init__,adapter,plugin}.{py,yaml} - tests/gateway/test_kimiclaw_plugin.py - website/docs/user-guide/messaging/kimiclaw.md (setup + deployment model + known limitations) - website/docs/reference/environment-variables.md (+ KIMI_* table) - website/docs/user-guide/messaging/index.md (capability row) - website/sidebars.ts (sidebar entry) - cli-config.yaml.example (kimiclaw: bridge block) - pyproject.toml + uv.lock (declares websockets==15.0.1 in [messaging] extra for the DM ACP socket) - scripts/release.py (AUTHOR_MAP attribution entry) Internal class names retained as KimiAdapter / check_kimi_requirements — implementation detail; matches the QQAdapter precedent. Env-var names retained as KIMI_* because credentials are issued by kimi.com itself. 265 tests passing (pytest tests/gateway/test_kimiclaw_plugin.py). Includes regression coverage for the tool_only DM-inflight closure path (test_3b_4, test_3b_5), bug-fixes for 429 retryability and upload-path exception handling in the standalone send code path, and the registry-gate (_check_for_registry) requiring KIMI_BOT_TOKEN before auto-enable so messaging-equipped installs don't light up KimiClaw without credentials.
choguun
added a commit
to choguun/real-estate-ai-agent
that referenced
this pull request
Jul 3, 2026
…rms, real-adapter bones Driven by review of NousResearch/hermes-agent#23197 (1638 LOC LINE plugin) against our mocks-first FastAPI implementation. Doc at docs/line-integration-gap-analysis.md walks the full diff and explains what's worth porting for our Thai real-estate AI agent. This PR lands the four highest-leverage fixes: 1. **Webhook body cap (1 MiB).** Memory-exhaustion guard rejecting oversized payloads with 413 BEFORE the signature check. Constant from app.adapters.line.base so both adapters and the router share it. 2. **Outbound Markdown stripper** (app.adapters.line.base). LINE can't render Markdown reliably across iOS/Android/web/ macOS clients. strip_markdown() removes ATX headings, bold/italic (*/_/__), inline code + code fences, leading list bullets, and blockquote markers while leaving bare URLs untouched. Used by future real wiring; tested on the mock. 3. **LINE 5-message / 4500-char chunker** (split_for_line()). Per LINE Messaging API docs each bubble caps at 5000 chars and each Reply/Push call caps at 5 message objects. Naive splitter: paragraph boundaries first, then Thai 。/Western . sentence boundaries, hard cut as last resort. Capped at LINE_MAX_MESSAGES_PER_CALL (5). Tested with 11 cases including the Thai sentence terminator. 4. **LineRealAdapter: bot user-id cache + reply-token cache + self-message filter stub.** When the real HTTP wiring ships, these are the bones it will use: - bot_user_id: str | None set at __init__ (auto-fetched from GET /v2/bot/info when wiring lands) - _reply_tokens: dict[chat_id, (token, expires_at)] with set_reply_token() + consume_reply_token() (Reply tokens are single-use; ~60s TTL; expire-test covered) - send_reply() short-circuits with skipped='self-message' when line_user_id == bot_user_id — prevents the inbound-outbound echo loop that hits any production bot without this filter. send_reply() still raises NotImplementedError for real sends; the doc-string in the method lists the exact strip_markdown → split_for_line → consume_reply_token → Reply-or-Push order the eventual wiring will follow. ## Tests - backend/tests/test_line_helpers.py — 28 new tests (TestStripMarkdown × 11, TestSplitForLine × 8, TestWebhookBodyCap × 1, TestLineRealAdapterStructure × 8) - backend/tests/test_line_webhook.py — 2 new tests (oversized → 413, exactly-at-cap → 400 to prove boundary) ## Verified - pytest: 168/168 (was 138; +30 new) — coverage 92.88% ≥ 80% ✅ - RUN_REAL_ADAPTER_TESTS=1 pytest tests/test_real_swap.py: 6/6 ✅ (real adapter isinstance checks now also cover the new set_reply_token / consume_reply_token surface) - ruff + mypy strict: clean - Frontend unchanged (lib/api.ts: 36/36 vitest still passing) ## Out of scope (logged in docs/line-integration-gap-analysis.md) - Three-allowlist gating: N/A for single-tenant MVP - Media inbound (image/audio/video/file/sticker/location): defer until listing creation needs inbound photos - Media SEND with HTTPS serving: defer; we have /api/upload-image as the upload path - Slow-LLM postback button (their headline feature): N/A, we don't run an async-streaming LLM - Loading indicator / typing animation: defer - accountLink / memberJoined / things: defer (no LIFF, no IoT) - unsend event handling: 1-migration scope, defer to follow-up
choguun
added a commit
to choguun/real-estate-ai-agent
that referenced
this pull request
Jul 3, 2026
…rms, real-adapter bones Driven by review of NousResearch/hermes-agent#23197 (1638 LOC LINE plugin) against our mocks-first FastAPI implementation. Doc at docs/line-integration-gap-analysis.md walks the full diff and explains what's worth porting for our Thai real-estate AI agent. This PR lands the four highest-leverage fixes: 1. **Webhook body cap (1 MiB).** Memory-exhaustion guard rejecting oversized payloads with 413 BEFORE the signature check. Constant from app.adapters.line.base so both adapters and the router share it. 2. **Outbound Markdown stripper** (app.adapters.line.base). LINE can't render Markdown reliably across iOS/Android/web/ macOS clients. strip_markdown() removes ATX headings, bold/italic (*/_/__), inline code + code fences, leading list bullets, and blockquote markers while leaving bare URLs untouched. Used by future real wiring; tested on the mock. 3. **LINE 5-message / 4500-char chunker** (split_for_line()). Per LINE Messaging API docs each bubble caps at 5000 chars and each Reply/Push call caps at 5 message objects. Naive splitter: paragraph boundaries first, then Thai 。/Western . sentence boundaries, hard cut as last resort. Capped at LINE_MAX_MESSAGES_PER_CALL (5). Tested with 11 cases including the Thai sentence terminator. 4. **LineRealAdapter: bot user-id cache + reply-token cache + self-message filter stub.** When the real HTTP wiring ships, these are the bones it will use: - bot_user_id: str | None set at __init__ (auto-fetched from GET /v2/bot/info when wiring lands) - _reply_tokens: dict[chat_id, (token, expires_at)] with set_reply_token() + consume_reply_token() (Reply tokens are single-use; ~60s TTL; expire-test covered) - send_reply() short-circuits with skipped='self-message' when line_user_id == bot_user_id — prevents the inbound-outbound echo loop that hits any production bot without this filter. send_reply() still raises NotImplementedError for real sends; the doc-string in the method lists the exact strip_markdown → split_for_line → consume_reply_token → Reply-or-Push order the eventual wiring will follow. ## Tests - backend/tests/test_line_helpers.py — 28 new tests (TestStripMarkdown × 11, TestSplitForLine × 8, TestWebhookBodyCap × 1, TestLineRealAdapterStructure × 8) - backend/tests/test_line_webhook.py — 2 new tests (oversized → 413, exactly-at-cap → 400 to prove boundary) ## Verified - pytest: 168/168 (was 138; +30 new) — coverage 92.88% ≥ 80% ✅ - RUN_REAL_ADAPTER_TESTS=1 pytest tests/test_real_swap.py: 6/6 ✅ (real adapter isinstance checks now also cover the new set_reply_token / consume_reply_token surface) - ruff + mypy strict: clean - Frontend unchanged (lib/api.ts: 36/36 vitest still passing) ## Out of scope (logged in docs/line-integration-gap-analysis.md) - Three-allowlist gating: N/A for single-tenant MVP - Media inbound (image/audio/video/file/sticker/location): defer until listing creation needs inbound photos - Media SEND with HTTPS serving: defer; we have /api/upload-image as the upload path - Slow-LLM postback button (their headline feature): N/A, we don't run an async-streaming LLM - Loading indicator / typing animation: defer - accountLink / memberJoined / things: defer (no LIFF, no IoT) - unsend event handling: 1-migration scope, defer to follow-up
choguun
added a commit
to choguun/real-estate-ai-agent
that referenced
this pull request
Jul 3, 2026
…orms + real-adapter bones (#2) * spec: month-1 mvp — mocks-first real-estate AI agent Full AIDLC spec for Month-1 MVP. Scope: - Next.js 15 + FastAPI + Supabase + LINE + Claude/Gemini adapters - Every external integration behind a mock/real adapter pair - Single agent only (no teams), Thai property UX, PDPA-safe defaults - 12 acceptance criteria + 20 ST-NNN test scenarios Layout: backend/app/{domain,adapters,routers,services}/ + web/app/(marketing|auth|app)/. Adapters live behind Protocol classes so USE_MOCKS=false swaps mock→real adapters with no router change. Open questions parked for plan phase. * feat(T-001): repo scaffold + FastAPI + Next.js shells + /health + CI Backend (Python 3.11 / FastAPI 0.115 / pydantic v2): - pyproject.toml with ruff+mypy(strict)+pytest config - requirements.txt (fastapi, uvicorn, pydantic-settings, jwt, bcrypt, httpx, ...) - app/main.py factory + CORS + lifespan - app/config.py (pydantic-settings) — env-driven, mock-first defaults - app/routers/health.py — GET /health → {"status":"ok"} - tests/conftest.py + tests/test_health.py (ST-001) - .env.example Frontend (Next.js 15 / React 19 / Tailwind v3 / shadcn config): - package.json, tsconfig.json, next.config.mjs, tailwind.config.ts, postcss - app/globals.css + design tokens (shadcn variables) - app/layout.tsx + app/page.tsx (landing shows backend health badge) - app/api/health/route.ts (proxy → backend) - lib/api.ts (typed fetch + ApiError + token cache) - lib/utils.ts (cn helper) - vitest.config + setup + 1 passing unit test - components.json (shadcn config), .eslintrc.json, .env.example CI (GitHub Actions): - Backend matrix: ruff check, format check, mypy strict, pytest - Frontend matrix: lint, typecheck, vitest Toolchains locally verified: - uvicorn app.main:app → 200 OK on /health and / - pytest -v: 3/3 passed - npm run lint: clean - npm run typecheck: clean - npm test: 2/2 passed * feat(T-002): mock Supabase adapter + migration runner + factory Backend (Python): - app/adapters/supabase/base.py — SupabaseAdapter Protocol (query, count, insert, update, delete, get_by_id) - app/adapters/supabase/_schema.py — Schema/Table/Column dataclasses; DEFAULT_SCHEMA declares all 10 tables (users, teams, properties, leads, messages, appointments, generated_listings, contracts, user_settings, audit_logs) with PG types, NOT NULL, defaults callable (UUID mint, NOW(), role='agent', status='draft', etc.) - app/adapters/supabase/mock.py — MockSupabaseAdapter: in-memory, insertion-ordered, NOT NULL validation, auto-id, updated_at re-stamp on update, reset() helper - app/adapters/supabase/real.py — RealSupabaseAdapter: stub, raises NotImplementedError, implements the Protocol so isinstance() succeeds (ST-019-shaped) - app/adapters/supabase/_factory.py — get_db() selects adapter by Settings(use_real_supabase) - app/adapters/supabase/__init__.py — public re-exports - app/adapters/__init__.py - app/deps.py — DBDep = Annotated[SupabaseAdapter, Depends(get_db_dep)] - migrations/001_init.sql — canonical Postgres DDL mirroring DB.md - migrations/__init__.py Tests (pytest, 22 new tests, total 25/25 passing): - Schema presence — all 10 expected tables exist (covers AC-01 partial) - SQL ↔ mock schema parity (table names must match, test fails on drift) - Round-trip CRUD on users, properties, leads - Defaults: user.role='agent', property.status='draft', lead.source='line', lead.status='new' - Auto-id is canonical UUID (36 chars, 4 hyphens) - NOT NULL enforcement - update of unknown id → None - delete of unknown id → False - count() with and without filters - Stable insertion-order query (no order_by) - order_by + desc sorting - limit + offset pagination - snapshot stability across fresh adapters - factory: mock by default, real when USE_REAL_SUPABASE=true Verified locally: - pytest -q → 25/25 in 0.03s - coverage: --cov=app = 91% (real.py only low because stubs are intentional) - ruff check app/ tests/ → all checks passed - ruff format app/ tests/ → 18 files left unchanged - mypy app/ (strict + pydantic plugin) → success, no issues - uvicorn /health → 200 {"status":"ok"} (no regression) * chore(aidlc): mark T-002 done; next is T-003 (auth) * feat(T-003): auth — signup / login / LIFF / /me + auth pages Backend (Python, FastAPI, Pydantic v2, bcrypt, PyJWT): - app/domain/user.py — User + SignupIn + LoginIn + LiffIn + AuthResponse DTOs - app/services/auth.py — AuthService (signup/login/liff_login/user_from_token), hash_password / verify_password (bcrypt), create_access_token / decode_token (HS256, jwt_ttl configurable), typed AuthError subclasses (DuplicateEmail 409, InvalidCredentials 401, UserNotFound 404) with HTTP mapping - app/routers/auth.py — POST /api/auth/{signup,login,liff}, GET /api/auth/me - app/deps.py — get_db_dep already; new AuthServiceDep wired in router - app/main.py — register auth_router - app/adapters/supabase/_factory.py — process-singleton mock with thread safety; one bug found and fixed during E2E (per-request mock lost state) - app/adapters/supabase/{_schema.py, ../../migrations/001_init.sql} — added password_hash TEXT to users (both mirror each other) - tests/test_auth.py — 15 tests ST-002: signup OK + duplicate → 409 ST-003: login OK + wrong pwd → 401 + unknown email → 401 ST-004: LIFF OK + reuse same user + placeholder email /me: valid token → 200, no header → 401, malformed → 401, wrong scheme → 401 Frontend (Next.js 15, React 19, Tailwind, ts): - lib/api.ts — extended: apiGet/apiPost, clearAuthToken, getAuthToken, User type - lib/auth.ts — login/signup/liffLogin/fetchMe/describeAuthError - app/(auth)/layout.tsx — card-style auth layout - app/(auth)/login/page.tsx — email+password + green LINE button - app/(auth)/signup/page.tsx — full_name+email+password - app/(app)/dashboard/page.tsx — placeholder that loads /me (real dashboard lands in T-011) - __tests__/auth.test.ts — 7 tests covering wrappers + error mapping Verified locally: - pytest: 40/40 (15 new from T-003) - coverage on app/: 94% (target was 80%) - ruff + mypy strict: clean - frontend: lint, typecheck, vitest 9/9 - next build: compiled + 7 routes generated - end-to-end via curl: signup → JWT → /me returns user across requests * feat(T-004): properties CRUD + scoped list page Backend: - app/domain/property.py — PropertyType + PropertyStatus enums; PropertyCreate (required), PropertyUpdate (all-optional including status), Property (response) - app/deps.py — CurrentUserIdDep (bearer token → user id without DB hit) + SettingsDep - app/routers/properties.py — list/create/get/patch/archive scoped to user_id; cross-user reads / writes / archives return 404 (not 403) to avoid id probing - app/main.py — register properties_router - tests/test_properties.py — 16 tests: auth gate (401 without token) ST-005: round-trip create + defaults (status='draft', foreign_quota=False) list scopes to caller, excludes archived by default get / patch / archive flow ?status= filter and ?include_archived=true flag archive is idempotent cross-user isolation: 404 on read, patch, archive; not in list payload validation: invalid property_type, negative price, extra fields (422) minimal payload accepted Frontend (Next.js 15): - lib/types.ts — Property + PropertyCreateInput + PropertyUpdateInput + formatTHB (Intl) + propertyTypeLabel (en/th) - lib/api.ts — added apiPatch/apiDelete/apiPostNoBody - lib/properties.ts — listProperties/getProperty/createProperty/updateProperty/archiveProperty - app/(app)/layout.tsx — auth-gated chrome with nav - app/(app)/properties/page.tsx — client component, redirects if no token, loading/error/empty/list states - components/properties/PropertyCard.tsx — Thai labels + THB format + status pill - components/properties/PropertyCard.test.tsx — 5 RTL tests (Thai text, fallback, status) - vitest.config.ts — added @vitejs/plugin-react for JSX automatic runtime Verified locally: - pytest: 56/56 ✅ (16 new from T-004) - coverage on app/: 94% - ruff + mypy strict: clean - next lint + tsc + build: clean, 8 routes generated - vitest: 14/14 ✅ (5 new component tests) - end-to-end via curl: signup → create condo + house → list 2 → archive one → list 1 - 401 without token * feat(T-005): mock storage adapter + image upload endpoint + new property form Backend (Python): - app/adapters/storage/base.py — StorageAdapter Protocol + StoredObject DTO + is_allowed_image() allow-list (jpeg/png/webp/gif) - app/adapters/storage/local_mock.py — disk-backed mock; writes to ${var_dir}/uploads/{uuid}{ext}; path-traversal defence (rejects '/' / '\\' / '.'); max 10 MiB per file - app/adapters/storage/supabase_real.py — stub for MVP (NotImplementedError) - app/adapters/storage/_factory.py — picks by Settings.use_real_supabase - app/adapters/storage/__init__.py — public re-exports - app/deps.py — StorageDep; SettingsDep now reads settings from request.app.state (no longer global cache; tests override cleanly) - app/main.py — register storage_router; app.state.settings set in factory - app/routers/storage.py — POST /api/upload-image (multipart, auth-gated, 415 on bad type, 400 on empty/too-big) GET /static/{key} (no auth, 404 on missing/path traversal) - app/config.py — public_base_url setting (default http://localhost:8000) - .env.example — PUBLIC_BASE_URL line - tests/test_storage.py — 14 tests: Direct adapter: writes to disk, rejects empty, get round-trip, path-traversal blocked, delete idempotent HTTP: ST-015 upload returns URL with key; uploaded file served via /static/{key}; 401 without auth; 415 on bad extension or MIME; accept jpg; 404 on /static/no-such Frontend (Next.js 15): - lib/api.ts — apiUpload for FormData (browser sets content-type with boundary; Content-Type not forced to JSON for FormData) - lib/uploads.ts — uploadImage + uploadImages wrappers - components/forms/ImageUploader.tsx — multi-file picker, generates preview thumbnails via URL.createObjectURL, remove button, accepts image/* with allow-list - components/forms/PropertyForm.tsx — client component, all property fields (Thai labels, ตร.ม. units, BTS/MRT, foreign quota), upload-on-submit flow, error/loading/redirect-to-detail - app/(app)/properties/new/page.tsx — entry point Verified locally: - pytest: 70/70 ✅ (14 new from T-005) - coverage on app/: 94% - ruff + mypy strict: clean - next lint + typecheck + build: clean, 9 routes incl. /properties/new - vitest: 14/14 ✅ - end-to-end via curl on backend: signup → upload PNG → GET /static/{key} returns 200 with PNG bytes * feat(T-006): mock AI adapter + /api/generate-listing + ListingPreview Backend (Python): - app/domain/listing.py — Platform enum (4), PropertySummary, ListingRequest (forbid extras), GeneratedContent response - app/adapters/ai/base.py — AiAdapter Protocol + FallbackToNext + BadRequest (the two error categories the service distinguishes) - app/adapters/ai/anthropic_mock.py — deterministic Thai templates per (property_type, platform); DDProperty/Livinginsider/Facebook/General; includes ตร.ม., ห้องนอน, ห้องน้ำ, ชั้น, โควต้าต่างชาติ; Facebook emits 6 hashtags; General is bilingual. - app/adapters/ai/gemini_mock.py — different model name + tone; used as the fallback chain's secondary - app/adapters/ai/anthropic_real.py, gemini_real.py — stubs raise FallbackToNext('not wired in MVP') - app/adapters/ai/_factory.py — build_ai_chain([primary, secondary]) - app/services/listing_generator.py — orchestrates the chain; raises BadRequest immediately; tries next adapter on FallbackToNext OR any transient exception; surfaces RuntimeError if all fail - app/deps.py — AIChainDep - app/routers/ai.py — POST /api/generate-listing; auth required; filters by platforms (default = all 4); 400/422 on bad payload - app/main.py — register ai_router - tests/test_ai_generator.py — 13 tests: ST-006: condo DDProperty contains คอนโด/ตร.ม./ห้องนอน/BTS Asok condo Facebook has ≥ 5 hashtags all 4 platforms returned ST-007: house General mentions บ้านเดี่ยว or 'house' house DDProperty mentions 200 ตร.ม. p99 latency < 2s (10 × 4 platforms) fallback when primary raises FallbackToNext 4xx surfaces immediately (no fallback chain) fallback when primary raises arbitrary exception HTTP: returns 4 platforms, accepts platforms filter, 401 without auth, rejects unknown platform (422) Frontend (Next.js 15): - lib/types.ts — added Platform/PLATFORM_LABELS/GeneratedContent/ PropertySummaryForAi - lib/listings.ts — generateListing() wrapper - components/forms/ListingPreview.tsx — 4 platform tabs + copy button + model badge - components/forms/ListingPreview.test.tsx — 6 tests - components/forms/PropertyForm.tsx — added '✨ Generate' button + previews ListingPreview when present + generation error handling Verified locally: - pytest: 83/83 ✅ (13 new from T-006) - coverage on app/: 94% - ruff + mypy strict: clean - next lint + typecheck + build: clean, 9 routes - vitest: 20/20 ✅ (6 new from T-006) - curl E2E: signup → generate 4 platforms → all return Thai text, Facebook has 6 hashtags * feat(T-007): generated listings persistence + property detail page Backend (Python): - app/domain/listing.py — GeneratedListingCreate + GeneratedListingUpdate + GeneratedListing (DB row); strict validation (extra='forbid', title ≥1) - app/routers/listings.py — POST /api/listings, GET /api/listings?property_id=..., PATCH /api/listings/{id}, DELETE /api/listings/{id} (204). Cross-user returns 404 (not 403). - app/main.py — register listings_router - tests/test_listings.py — 12 tests covering round-trip, no-op PATCH, delete idempotency, auth gate, cross-user isolation, validation Frontend (Next.js 15): - lib/types.ts — SavedListing (extends GeneratedContent with id/created_at etc.) + SaveListingInput + UpdateListingInput. Widened property_type to string to match backend's permissive PropertySummary. - lib/listings.ts — added listListingsForProperty, saveListing, updateListing, deleteListing - components/forms/ListingEditor.tsx — tab-style editor per platform variant: editable title/description/hashtags/SEO; Save button tracks dirty state, shows 'Saved at HH:MM:SS' feedback, falls back to 'Re-save' after first save - components/forms/ListingEditor.test.tsx — 6 tests (rendering, platform label, dirty/save state) - components/forms/PropertyForm.tsx — auto-save generated listings on property submission (sequential, errors logged not blocking) - app/(app)/properties/[id]/page.tsx — detail page: property header (title, status pill, district/province, key fields, image strip), listings grid with one editor per platform variant, generation from detail (lazy import saveListing). Has loading/error/empty states. Verified locally: - pytest: 95/95 ✅ (12 new from T-007) - coverage on app/: 94% - ruff + mypy strict: clean - next lint + typecheck + build: clean, 11 routes incl. /properties/[id] (dynamic) - vitest: 26/26 ✅ (6 new from T-007) - curl E2E: signup → property → 2 listings (POST 201) → list 2 → PATCH (updated title) → DELETE (204) → 401 without token * feat(T-008): mock LINE adapter + signed webhook (HMAC-SHA256) Backend (Python): - app/adapters/line/base.py — LineAdapter Protocol + sign_line_webhook / verify_line_webhook helpers using hmac.compare_digest (constant-time). SIGNATURE_HEADER = 'X-Line-Signature'. - app/adapters/line/mock.py — LineMockAdapter in-memory; sign() helper for tests - app/adapters/line/real.py — LineRealAdapter stub (no HTTP yet; same sign/verify surface so the rest of the app is unaffected) - app/adapters/line/_factory.py — get_line_adapter (mock when use_real_line=false) - app/adapters/line/__init__.py — public re-exports - app/routers/line_webhook.py — POST /webhook/line: reads RAW body bytes first, then signature, then verifies BEFORE JSON parsing. Verified + ack returns {'ok': true, 'received': N}. Failed verification returns 401 with NO DB writes. JSON parse fails → 400. - app/main.py — register line_webhook_router - tests/test_line_webhook.py — 11 tests: ST-009 valid signature → 200 ST-010 invalid signature → 401 missing signature → 401 empty signature → 401 body tampered after signing → 401 signature from different secret → 401 invalid JSON after signature passes → 400 (not 401) no DB writes on unverified request helper round-trip None signature rejected same signed payload twice → 200, 200 (idempotency in T-009) Verified locally: - pytest: 107/107 ✅ (12 new from T-008) - coverage on app/: 94% - ruff + mypy strict: clean - python urllib smoke: valid → 200, invalid → 401, missing → 401 Security property: signature is verified against the raw request bytes (hmac.compare_digest) BEFORE JSON parsing — a body-tampering attacker cannot smuggle events past verification. * feat(T-009): LINE → Lead + Message pipeline (idempotent) Backend (Python): - app/domain/lead.py — Lead DTO with from_row helper - app/domain/message.py — Message DTO with from_row helper - app/services/lead_pipeline.py — LeadPipeline service: • idempotency via event_id scan over messages.raw_data • find-or-create lead by line_user_id • insert inbound Message + raw_data (full event) • bump lead.updated_at on contact • never crashes on malformed payloads; returns ProcessResult with reason in {ok, replay, no_event_id, no_source, non_message} - app/routers/line_webhook.py — now wires verified events through LeadPipeline. Agent lookup happens ONLY when events are non-empty. • uses settings.line_default_agent_id (env var) if set • falls back to first active user in mock mode (helpful for dev) • 503 only when events present + no agent config + no users - app/config.py — added line_default_agent_id field - .env.example — added LINE_DEFAULT_AGENT_ID - tests/test_line_webhook.py — 8 new tests (T-008's 12 still pass): ST-011 replay of same event_id is ignored ST-012 two events from same user → one Lead, two Messages well-formed event creates lead + message non-message (follow) event ignored missing source → no_source missing event_id → no_event_id empty events array → 200 with received=0 no-agent scenario → 503 (with events) - Added autouse _isolate fixture to reset mock singleton between tests Verified locally: - pytest: 114/114 ✅ - coverage on app/: 94% - ruff + mypy strict: clean - python urllib E2E: signup → 2 events same LINE user → 1 lead, 2 messages; replay same body → 0 processed (all reason='replay') Behavioural properties: - empty events → 200 (no agent needed) - verified + has events + no agent → 503 - verified + has events + has agent (env or first user) → process - duplicate event_id → skip with reason='replay' - non-message / missing event_id / missing source → skip, do not crash * feat(T-010): leads list/chat UI + outbound reply via mock LINE Backend (Python): - app/domain/lead.py — LeadStatus enum + LeadUpdate DTO (strict) - app/adapters/line/base.py — added send_reply() to Protocol - app/adapters/line/mock.py — added send_reply() records line_user_id + text + sent_at; keeps sent_replies list for tests - app/adapters/line/real.py — send_reply() stub raises NotImplementedError - app/deps.py — added LineDep + get_line_dep - app/routers/leads.py — GET /api/leads (status filter, limit, ordered by updated_at desc), GET /api/leads/{id} (lead + messages ascending), PATCH /api/leads/{id} (status enum validated, extras forbid) - app/routers/messages.py — POST /api/leads/{id}/messages • validates lead.user_id matches caller (404 on cross-user) • rejects leads without line_user_id (400) • calls line.send_reply() then inserts outbound Message with is_ai_generated=false; bumps lead.updated_at - app/routers/line_webhook.py — switched to DBDep for test injectability - app/main.py — register leads_router + messages_router - tests/test_leads.py — 14 tests: list: scoped to caller / status filter / 401 without auth get: returns messages created-order / cross-user 404 / unknown 404 patch: fields update / unknown status 422 / extras forbid 422 reply: inserts outbound + calls line.send_reply + cross-user 404 + 400 without line_user_id + 401 without auth + both directions Frontend (Next.js 15): - lib/types.ts — Lead, Message, LeadWithMessages; Thai LEAD_STATUS_LABELS - lib/leads.ts — listLeads(opts), getLead, updateLead - lib/messages.ts — sendReply(leadId, text) - components/chat/MessageList.tsx — inbound (left card border) vs outbound (right emerald bubble), timestamp, agent/lead label - components/chat/ComposeBox.tsx — controlled textarea + send button, disabled when empty, surfaces ApiError detail - app/(app)/leads/page.tsx — list with status-pill filter row, empty state, lead row links to detail; Thai status badges - app/(app)/leads/[id]/page.tsx — header (name, line_user_id, status, budget/interest/notes), MessageList + ComposeBox Verified locally: - pytest: 128/128 ✅ (14 new from T-010) - coverage on app/: 94% - ruff + mypy strict: clean - next lint + typecheck + build: clean, 11 routes incl. /leads + /leads/[id] - vitest: 26/26 ✅ - python urllib E2E: signup → webhook (2 leads) → list → get with messages → reply (201) → directions = ['inbound','outbound'] → PATCH status (200) * feat(T-011): dashboard endpoint + agent home page Backend (Python): - app/routers/dashboard.py — GET /api/dashboard • new_leads_count: count of leads with status='new' for caller • recent_inbound: last 20 inbound messages, each enriched with lead preview (id, name, line_user_id) • recent_properties: last 5 non-archived properties, newest first - app/main.py — register dashboard_router - tests/test_dashboard.py — 8 tests (ST-013 + shape + auth + isolation): three blocks always present; new leads counted; lead preview attached; newest properties first; archived excluded; 401 without auth; cross-user isolation (B sees no A's data); inbound capped at 20 Frontend (Next.js 15): - lib/types.ts — DashboardData + DashboardInboundMessage + DashboardLeadPreview - lib/dashboard.ts — getDashboard() - components/dashboard/NewLeadsCounter.tsx — large emerald number, dims to muted-foreground when 0, links to /leads, Thai caption - components/dashboard/RecentMessages.tsx — last 20 inbound items with truncate, time-ago label, link to /leads/[id]; Thai empty state - components/dashboard/RecentProperties.tsx — reuses PropertyCard; empty state with CTA - app/(app)/dashboard/page.tsx — replaces the placeholder; greeting + sign-out + the three sections; auto-refresh every 5s via setInterval; redirects to /login on 401 Verified locally: - pytest: 136/136 ✅ (8 new from T-011) - coverage on app/: 94% - ruff + mypy strict: clean - next lint + typecheck + build: clean, 11 routes incl. /dashboard (3.74 kB) - vitest: 26/26 ✅ - python urllib E2E: empty dashboard → 2 leads + 3 inbound + 1 active property (archived filtered out), newest first * chore(aidlc): mark T-012 done — Month-1 MVP shipped * feat(T-012): Playwright E2E + real_swap tests + docs + coverage gate Backend: - tests/test_real_swap.py — 6 tests gated by RUN_REAL_ADAPTER_TESTS=1 • isinstance(RealSupabaseAdapter, SupabaseAdapter) • isinstance(RealAnthropicAdapter, AiAdapter) • isinstance(RealGeminiAdapter, AiAdapter) • isinstance(RealLineAdapter, LineAdapter) • isinstance(SupabaseStorageAdapter, StorageAdapter) • sign_line_webhook round-trip - pyproject.toml — addopts gets --cov-fail-under=80 (current coverage: 92.88% — passes the gate) Frontend: - playwright.config.ts — fullyParallel off, single worker, webServer starts backend in CI, baseURL configurable for frontend - tests/e2e/happy-path.spec.ts — full happy path: UI signup → /dashboard → API creates property + 4 listings → UI logs in fresh → /properties/[id] → 4 editors visible → edit one + save → 'Saved at HH:MM:SS' feedback → sign out - package.json — @playwright/test devDep + test:e2e script - tsconfig.json — exclude playwright.config.ts + tests/e2e/ - vitest.config.ts — exclude tests/e2e/ from collection Docs (shipped in docs/): - architecture.md — layers, request lifecycles, state model, what is intentionally NOT here - adapters.md — the 4-pair contract, mock→real switch matrix, per-adapter behaviour tables - runbook.md — quick start, where things live, adding features, debugging recipes, production rollout, incident oncall README rewritten with quick-start + doc map. Verified locally: - pytest: 136/136 (real_swap +1 pass + 5 skip without flag; all 6 pass with RUN_REAL_ADAPTER_TESTS=1) - coverage gate: 92.88% ≥ 80% ✅ - ruff + mypy strict: clean - vitest: 26/26 ✅ (e2e excluded) - next lint + typecheck + build: clean; e2e excluded from build * fix(review): Tier-1 fixes from sub-agent codebase review Driven by findings from 4 parallel review sub-agents (backend, frontend, docs, adapter contract). 10 real bugs surfaced across all three tiers; this commit resolves every Tier-1 finding. ## Backend - **auth.py:_map_auth_error** — Drop UserNotFound from the union. The mapper is called by signup/login/liff which never raise UserNotFound; /me handles it explicitly with 404. Previously the same exception would map to two different status codes depending on caller — classic foot-gun. - **auth.py:get_auth_service** — Switch from Depends(get_settings) (lru_cached global) to SettingsDep (per-request app.state). Tests passing Settings(...) to create_app() will now actually see their isolated Settings — no more latent test-isolation leak. - **line_webhook.py** — Switch from request.app.state.settings inside the handler body to SettingsDep parameter. Same rationale. - **dashboard.py:get_dashboard** — Replace N+1 db.get_by_id(leads, ...) loop with a single db.query(leads, ...) + dict lookup. Defense-in- depth: re-check user_id when enriching so a future cross-user message insert doesn't leak the lead preview fields. - **line/base.py:LineAdapter protocol** — add send_reply() to the Protocol so it's discoverable; mocks/reals that lack it will fail isinstance() instead of TypeError'ing at runtime. Drops the '# type: ignore[attr-defined]' hack in messages.py. - **ai/_factory.py, line/_factory.py, storage/_factory.py, supabase/_factory.py** — use_mocks=True is now the master switch, short-circuiting to <Mock> regardless of any USE_REAL_* flag. Documents the 'mocks win' semantics in module docstrings. - **tests/adapters/test_mock_supabase.py** — updated test_factory_returns_real_when_flag_set to explicitly set use_mocks=False (master switch now requires it). Added test_factory_master_switch_overrides_real_flag to lock the semantics in. - **coverage on routers/dashboard.py** jumped to 100% (new branches exercised). ## Frontend - **components/forms/ImageUploader.tsx** — fix URL.createObjectURL leak: useEffect cleanup now runs on previews state change, not just unmount. Long editing sessions were leaking blob refs on every re-selection. - **components/forms/ListingEditor.tsx** — fix dirty-after-save bug: track a lastSaved snapshot (not initial); on save, bump the snapshot so the next edit round trips correctly. Also handles parent re-fetch via useEffect-on-initial. - **app/(app)/layout.tsx** — hoist auth gate from per-page into the layout itself. Closes the deep-link foot-gun: /properties/new and /properties/[id] now redirect to /login if no token is in localStorage, instead of rendering an empty form that fails on Save. - **lib/types.ts** — add team_id (string | null) to Property and Lead so the frontend types match backend DTOs. extra='ignore' had been silently dropping them, breaking any future UI needing the field. - **components/properties/PropertyCard.test.tsx** — added team_id: null to baseProperty fixture. ## Verified - pytest: 138/138 ✅ (was 137; new master-switch test in test_mock_supabase) - coverage: 92.42% ≥ 80% ✅ - ruff + mypy strict: clean - vitest: 26/26 ✅ - next build: 11 routes, e2e excluded ✅ * fix(review-t2): Tier-2 review fixes (frontend behavior + missing tests) Driven by the second wave of findings from the 4 sub-agent reviews. Tier-3 doc cleanup lands in a separate commit. ## Backend - app/routers/auth.py — TODO(security) comment above /api/auth/login flagging the missing rate limiter. Post-MVP follow-up; not part of Month-1 scope. - app/adapters/supabase/_schema.py — promote _now() to now_iso(), the public helper. _now kept as a module-internal alias so the table definitions don't churn. - app/adapters/supabase/mock.py — use the shared now_iso() instead of a duplicate _now() definition. Single source of truth — a future timezone-aware change reaches both places. ## Frontend behavior - app/(app)/properties/[id]/page.tsx — drop the dead dynamic 'await import("@/lib/listings")' (already imported at the top of the file). Also: the Generate / Regenerate button was previously disabled once any listings existed (no re-generate possible). Now only disabled while generating; shows '🔄 Regenerate' once saved. - components/forms/ListingEditor.tsx — TypeScript now narrows 'initial.description' correctly (extra guard for nullable). - web/lib/api.ts — drop the module-level cachedToken mutable. readToken() now reads localStorage on every call. The cache was an architectural leftover that caused StrictMode double-render races and one-component-clears-another bugs. Cost: ~10 µs per fetch — invisible. - web/lib/api.ts + lib/{auth,dashboard}.ts — apiGet/apiPost/apiPatch accept a {signal?: AbortSignal} option so call sites can abort. - app/(app)/dashboard/page.tsx — Polling loop now uses AbortController per tick so a slow backend doesn't cause overlapping in-flight fetches. Also catches + swallows DOMException('AbortError') so aborts don't surface as 'Failed to load dashboard' errors. - app/(app)/leads/page.tsx — filter row uses role='group' + aria-pressed instead of the misleading role='tablist' + aria-selected pattern (these are filter chips, not tabs). ## Missing tests (the bulk of T2) - components/forms/ImageUploader.test.tsx — 4 tests: empty state, onFilesChange fires with images only (filters out non-images), revokeObjectURL is called on remove. jsdom doesn't ship URL.createObjectURL / revokeObjectURL; we polyfill via Object.defineProperty. - components/forms/ComposeBox.test.tsx — 4 tests: disabled-empty, send happy path + textarea clears, two error surfaces (real ApiError → backend detail; non-ApiError → 'Send failed' fallback). - __tests__/dashboard.test.tsx — 2 tests (ST-014): all three sections render with full payload; counter dims when new_leads_count=0. - components/dashboard/RecentProperties.tsx — added data-testid='recent-properties' on the <ul> so the dashboard test can assert on it. ## Verified - pytest: 138/138 ✅ (still 92% coverage on app/) - ruff + mypy strict: clean - vitest: 36/36 ✅ (was 26; +10 new tests across 3 files) - next lint + tsc + build: clean, 11 routes * docs(review-t3): accuracy fixes — docs match shipped code Third commit from the 4-sub-agent code review. Doc-only changes that bring README.md / docs/ / .aidlc/ in line with the code that's been landing in T-001..T-012 + Tier-1/2 review fixes. ## .aidlc/state.md - Updated final stats: 19 commits, +20,716 lines, 132 files (was 17 commits, +19,451 lines, 124 files). - Test counts: 138 passed, 36 vitest, 92.29% coverage (was 137/26, 92.88%). - Added a 'Tier-1 + Tier-2 review fixes applied' note pointing to the two review commits. ## docs/runbook.md - Quick-start now shows the right numbers: 138 passed / 142 collected, 92.29% coverage, 36 vitest (was '~136 tests', 26 vitest). - 'Where things live' adds a row for property/listing fields (cross- cutting backend + frontend edit). - 'Coverage gate' section cites the actual 92.29% (was the wrong 94%) and names the gate command verbatim. ## docs/architecture.md - Layer diagram: * Drops (marketing) (the route group never existed; landing lives at app/page.tsx). * lib/ now lists all 10 files (was 8). * Routers/services/deps blocks all match the actual structure. * 'USE_MOCKS is the master switch' called out (was just 'flips in via env flags'). * Contract file layout corrected: '<real>.py' with a note that naming isn't uniform (supabase/line say 'real.py'; AI & Storage are per-provider; factories are uniform regardless). ## docs/adapters.md - USE_MOCKS doc rewritten as 'master switch — when true, mocks win even if individual USE_REAL_* flags are set' (now matches the post-Tier-1 factory semantics). - T-009 reference ('Required for T-009's webhook') generalised to 'Required for the LINE webhook (real LINE is multi-tenant)'. - Contract layout schema in the docs matches the actual filename pattern (with the same uniformity note as architecture.md). - 'tests/test_real_swap.py' wording drops the dead 'sign_ai_request' helper reference (the helper doesn't exist — was a copy-paste). - The signing helpers used in the test now correctly named: sign_line_webhook / verify_line_webhook. ## README.md - Quick-start test count corrected: 'npm test # vitest — 36 tests'. - Deploy row in tech-stack table references 'runbook + rollout checklist in docs/runbook.md'. ## .aidlc/spec.md - Removed the spec-time fictional file references that never landed: * (marketing)/page.tsx → app/page.tsx (landing at root) * web/vercel.json → not shipped (config deferred to deploy) * backend/Dockerfile → not shipped * backend/railway.toml → not shipped (replaced with a one-line 'Dockerfile + railway.toml deferred to deploy phase' note) - router error-mapping promise fixed: was 'app/main.py::register_exception_handlers' (does not exist); now 'app/routers/<name>.py::_map_*_error helpers; per-router' (matches actual implementation). ## Verification - backend pytest: 138/138 ✅ (92.29% coverage, gate ≥ 80%) - ruff + mypy strict: clean - frontend vitest: 36/36 ✅ - frontend next lint + tsc + build: clean - grep 'marketing|register_exception_handlers|Dockerfile|railway.toml|vercel.json|sign_ai_request' docs/ .aidlc/ README.md: → only intentional parenthetical mention of the deferred Dockerfile/railway.toml remains. * chore(state): bump stats after Tier-3 docs cleanup * feat(line): hermes-agent#23197 takeaways — body cap, outbound transforms, real-adapter bones Driven by review of NousResearch/hermes-agent#23197 (1638 LOC LINE plugin) against our mocks-first FastAPI implementation. Doc at docs/line-integration-gap-analysis.md walks the full diff and explains what's worth porting for our Thai real-estate AI agent. This PR lands the four highest-leverage fixes: 1. **Webhook body cap (1 MiB).** Memory-exhaustion guard rejecting oversized payloads with 413 BEFORE the signature check. Constant from app.adapters.line.base so both adapters and the router share it. 2. **Outbound Markdown stripper** (app.adapters.line.base). LINE can't render Markdown reliably across iOS/Android/web/ macOS clients. strip_markdown() removes ATX headings, bold/italic (*/_/__), inline code + code fences, leading list bullets, and blockquote markers while leaving bare URLs untouched. Used by future real wiring; tested on the mock. 3. **LINE 5-message / 4500-char chunker** (split_for_line()). Per LINE Messaging API docs each bubble caps at 5000 chars and each Reply/Push call caps at 5 message objects. Naive splitter: paragraph boundaries first, then Thai 。/Western . sentence boundaries, hard cut as last resort. Capped at LINE_MAX_MESSAGES_PER_CALL (5). Tested with 11 cases including the Thai sentence terminator. 4. **LineRealAdapter: bot user-id cache + reply-token cache + self-message filter stub.** When the real HTTP wiring ships, these are the bones it will use: - bot_user_id: str | None set at __init__ (auto-fetched from GET /v2/bot/info when wiring lands) - _reply_tokens: dict[chat_id, (token, expires_at)] with set_reply_token() + consume_reply_token() (Reply tokens are single-use; ~60s TTL; expire-test covered) - send_reply() short-circuits with skipped='self-message' when line_user_id == bot_user_id — prevents the inbound-outbound echo loop that hits any production bot without this filter. send_reply() still raises NotImplementedError for real sends; the doc-string in the method lists the exact strip_markdown → split_for_line → consume_reply_token → Reply-or-Push order the eventual wiring will follow. ## Tests - backend/tests/test_line_helpers.py — 28 new tests (TestStripMarkdown × 11, TestSplitForLine × 8, TestWebhookBodyCap × 1, TestLineRealAdapterStructure × 8) - backend/tests/test_line_webhook.py — 2 new tests (oversized → 413, exactly-at-cap → 400 to prove boundary) ## Verified - pytest: 168/168 (was 138; +30 new) — coverage 92.88% ≥ 80% ✅ - RUN_REAL_ADAPTER_TESTS=1 pytest tests/test_real_swap.py: 6/6 ✅ (real adapter isinstance checks now also cover the new set_reply_token / consume_reply_token surface) - ruff + mypy strict: clean - Frontend unchanged (lib/api.ts: 36/36 vitest still passing) ## Out of scope (logged in docs/line-integration-gap-analysis.md) - Three-allowlist gating: N/A for single-tenant MVP - Media inbound (image/audio/video/file/sticker/location): defer until listing creation needs inbound photos - Media SEND with HTTPS serving: defer; we have /api/upload-image as the upload path - Slow-LLM postback button (their headline feature): N/A, we don't run an async-streaming LLM - Loading indicator / typing animation: defer - accountLink / memberJoined / things: defer (no LIFF, no IoT) - unsend event handling: 1-migration scope, defer to follow-up * fix(line): P0 review fixes — protocol bones, mock↔real parity, doc lies Applies all 4 P0 findings from the PR #2 review. C1+C2 are the load-bearing ones; C3+C4 are doc/comment accuracy. ## C1 — Mock applies the same outbound transforms as the real adapter Previously LineMockAdapter.send_reply recorded text verbatim. When real wiring lands and the real adapter strips **bold, headings, code fences, etc.** + chunks at the 5-message cap, mock and real diverge on what LINE would actually receive. The mock now mirrors the real adapter's outbound path: strip_markdown(text) \u2192 split_for_line(cleaned) \u2192 record each chunk with chunk_index 0..N-1. Tests in test_line_helpers.py::TestLineMockAdapterOutboundParity verify that **bold / *italic* are stripped, long text is chunked, and the chunk count respects LINE_MAX_MESSAGES_PER_CALL=5. Reply-token routing: tokens are single-use. The mock now consumes the token on first send_reply so a second send to the same chat falls back to push (matching real behavior). Tests verify mode='reply' on the first call and mode='push' on the second. ## C2 \u2014 Reply-token cache + bot_user_id live on the Protocol Previously the bones sat only on LineRealAdapter and the webhook couldn't reach them without an isinstance() check \u2014 breaking the 4-adapter contract ("no router imports a concrete class by name"). Both bot_user_id and set_reply_token(chat_id, token) are now on the LineAdapter Protocol. Both the mock and the real adapter implement them. The webhook takes a LineDep and calls line.set_reply_token(chat_id, reply_token) on every inbound message event with a replyToken field. The router has a new _cache_reply_token helper that: - Reads replyToken from the event - Resolves chat_id from source.userId / groupId / roomId - Silently skips when neither is set (defensive against malformed events) Tests in test_line_webhook.py cover: - Inbound message with replyToken \u2192 cache populated - Inbound follow/unfollow/etc. \u2192 cache stays empty (no replyToken) - set_reply_token consumes on first send_reply (Reply mode) - Second send_reply without re-caching falls back to Push (mode='push') The TTL constant REPLY_TOKEN_TTL_SECONDS = 60 was moved from real.py to base.py so the mock and real share it. ## C3 \u2014 Doc lies Two doc lies fixed: - TL;DR table at the top of docs/line-integration-gap-analysis.md marked unsend event handling as **"Fix in this PR"** \u2014 it wasn't. Now correctly marked **"Defer (1-migration scope; not in this PR)"**. - The "TL;DR for the PR description" block listed unsend as one of the 4 things shipped \u2014 it wasn't. Now lists the actual 4 (body-cap, transforms, self-message + reply-token, LineDep wiring). The "Verdict + suggested action" section (line 239) already said "defer" correctly; only the two stale bits are now consistent with it. ## C4 \u2014 aiohttp comment was a copy-paste from hermes-agent backend/app/routers/line_webhook.py:33 had a comment claiming "aiohttp's client_max_size doesn't apply in all body modes". This project uses FastAPI + Starlette + uvicorn, NOT aiohttp \u2014 the reference was a copy-paste from the source PR. Rewrote the comment to reference Starlette's Request.body() and mention uvicorn's h11_max_incomplete_event_size for nginx-fronted deployments. The 1 MiB cap (line 41) is now correctly described as memory-exhaustion defence for Starlette's unbounded buffer. ## Verified - pytest: 184/184 (was 168; +16 new) \u2014 coverage 92.99% \u2265 80% \u2705 - RUN_REAL_ADAPTER_TESTS=1: 6/6 \u2705 (Protocol conformance still holds after bones promotion; isinstance(LineRealAdapter, LineAdapter) + isinstance(LineMockAdapter, LineAdapter) both return True) - ruff + mypy strict: clean ## Out of scope - strip_markdown leaks content from code spans (Backend warning) \u2014 noted, follow-up - _MD_ITALIC_STAR matches arithmetic (Backend warning) \u2014 noted, follow-up - self-message filter default-off (Backend warning) \u2014 real wiring must set bot_user_id; documented - chat_id / line_user_id reconciliation \u2014 currently same in DMs; groups deferred to when we add group support - unsend event handling \u2014 1-migration, deferred (correctly noted in C3 fix) * fix(line): deduplicate REPLY_TOKEN_TTL_SECONDS The P0 fix on the previous commit added REPLY_TOKEN_TTL_SECONDS to app.adapters.line.base, but left the same constant re-defined in app.adapters.line.real (line 25-27). Behavior was correct (both values are 60) but the dedup wasn't actually a dedup — real.py imported nothing from base for the constant. Removes the local definition in real.py and adds the constant to the existing import block from base. No behavior change.
1 task
Gravezzz
pushed a commit
to Gravezzz/hermes-agent
that referenced
this pull request
Jul 21, 2026
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds LINE as a bundled platform plugin under
plugins/platforms/line/— synthesized from the strongest pieces of seven open community PRs, requiring zero core edits (the bundled-plugin scan ingateway/config.pyauto-discoversPlatform("line")).Closes #6081 and supersedes seven open community PRs by merging the best of each into one plugin-form implementation.
Synthesis credits
send_voice/send_video,LINE_PUBLIC_URLenv, macOS/tmp→/private/tmprootADDING_A_PLATFORMpath), reply→push fallback at 50s TTL, loading-animation indicator, source dispatcher, dedupLINE_SLOW_RESPONSE_THRESHOLD=0fallback path)All seven contributors are credited via
Co-authored-by:on the synthesis commit and AUTHOR_MAP'd inscripts/release.py.Changes
plugins/platforms/line/adapter.pyLineAdapter(BasePlatformAdapter)— webhook server, signature verification, reply/push routing, postback cache state machine, media serving, send batching (~1100 LOC)plugins/platforms/line/plugin.yamlrequires_env/optional_envblock (irc/teams style)plugins/platforms/line/__init__.pytests/gateway/test_line_plugin.pyregister()metadata, env enablement, standalone send, postback button shape,check_requirements,validate_config, adapter initscripts/release.pywebsite/docs/user-guide/messaging/line.mdwebsite/docs/user-guide/messaging/index.mdwebsite/docs/reference/environment-variables.mdLINE_*reference tablewebsite/sidebars.tsNo new runtime deps — uses
aiohttp(already in[messaging]).Design highlights
Reply token preferred, Push fallback. LINE's reply token is single-use and expires roughly 60 seconds after the inbound event. Reply is free, Push is metered. The adapter caches the reply token from each inbound message, tries Reply first, and falls back to Push when the token is absent, expired, or rejected.
Slow-LLM Template Buttons postback (the headline novel piece). When the LLM is still running past
LINE_SLOW_RESPONSE_THRESHOLD(default 45s), the adapter consumes the original reply token to send a Template Buttons bubble:The user taps Get answer — that postback delivers a fresh reply token, which the adapter uses to send the cached answer (still free). State machine:
PENDING → READY → DELIVERED, plusERRORfor cancelled runs (the orphan PENDING resolves toLINE_INTERRUPTED_TEXTafter/stopso the persistent button doesn't loop). Set threshold to 0 to disable.Template Buttons over Quick Reply chips because Quick Reply chips are dismissed by the LINE client the moment any new message arrives — Template Buttons stay tappable from chat history. Lifted directly from #18153.
Three-allowlist gating — separate
LINE_ALLOWED_USERS(U-prefixed),LINE_ALLOWED_GROUPS(C-prefixed),LINE_ALLOWED_ROOMS(R-prefixed) lists withLINE_ALLOW_ALL_USERS=truedev-only escape hatch.Media via public HTTPS URLs. LINE's Messaging API does not accept binary uploads. The adapter serves registered tempfiles from the same aiohttp app at
/line/media/<token>/<filename>, with a defence-in-depth allowed-roots traversal guard (tempfile.gettempdir(),/tmp→/private/tmpon macOS,HERMES_HOME).LINE_PUBLIC_URLoverrides URL construction so the URLs are reachable when bind is0.0.0.0or behind a reverse proxy.Webhook hardening. 1 MiB body cap, constant-time HMAC-SHA256 signature verification,
webhookEventIdLRU dedup, scoped lock prevents two profiles from binding the same channel.Validation
scripts/run_tests.sh tests/gateway/test_line_plugin.pytest_irc_adapter.py+test_plugin_platform_interface.py+test_platform_registry.py+test_config.pyPlatform("line")discovered ✓ ·LineAdapterinstantiates ✓ ·register()wires all hooks ✓ · signature roundtrip ✓ · postback cache state transitions ✓ ·_standalone_senderrors gracefully without creds ✓Setup (one-line)
~/.hermes/.env:~/.hermes/config.yaml:Then
hermes gateway. Webhook listens on:8646/line/webhook. Full setup walkthrough inwebsite/docs/user-guide/messaging/line.md.Notes
ADDING_A_PLATFORM.mdand the IRC/Teams precedent — no edits togateway/run.py,gateway/config.py,tools/send_message_tool.py,cron/scheduler.py,agent/prompt_builder.py, etc.