Menu

#188 Agent-initiated message delivery: explicit sends, scheduled completions, and default delivery targets

open
nobody
None
2 days ago
2 days ago
Anonymous
No

Originally created by: kalibetre

Summary

  • Add a shared runtime messaging client (agentbarn_message.py) used by both Hermes and OpenClaw, exposing an agentbarn-message CLI for explicit sends and a durable SQLite spool for scheduled/cron completions.
  • Bind outbound message calls to the active inbound execution (via plugin/tool-call hooks in Hermes and OpenClaw) so explicit sends can only target the Connection/session the conversation arrived on.
  • Deliver scheduled (cron) run results automatically: to the job's origin channel/thread when created inside a conversation, or to the Agent's configured default when created at startup; silence markers ([SILENT], NO_REPLY, HEARTBEAT_OK, etc.) suppress delivery.
  • Communications domain: accept agent-initiated messages, add agent_message_repository/agent_message_service, execution context, delivery repository additions, and new gateway routes.
  • Slack plugin: resolve and police outbound targets behind the plugin seam; add outbound target validation in plugins/base.py.
  • Hermes: run BOOT.md at startup via a new boot-run.py, since pinned Hermes has no gateway:startup hook for it (unlike OpenClaw).
  • Add a Postgres advisory lifecycle lock (AgentRepository.lifecycle_lock) to serialize start/stop/delete and fail concurrent lifecycle requests with 409 instead of racing runtime provisioning.
  • UI: add a default delivery target input/component, surface agent-initiated deliveries, and simplify the default delivery target configuration flow; platform-stats panel/hooks/schemas updates.
  • CI: run Hermes/OpenClaw runtime image tests when messaging bridge scripts change; add an OpenClaw message-hooks smoke test.
  • Fix API lint and type-check issues surfaced by the new code.
  • Add a new Alembic migration (c92d0eaf3101_agent_initiated_delivery) for the supporting schema changes.

Follow-up fixes

  • An approval reply can now reach the run waiting for it. An in-flight delivery held its ordering key, so the answer to an approval request queued behind the very run that was blocked on it, and the heartbeat renewed the lease forever so it never timed out. Deliveries now carry awaiting_input, reported by the runtime through the heartbeat it already sends, and claim_next_inbound stops treating a parked delivery as blocking. This does not open the thread to concurrent turns: claiming the released message re-takes the ordering key, so exactly one answer is let through at a time. Adds migration b3d1f47c9a20.
  • An approval answer is matched past the provider's mention markup. A Slack reply carries the bot mention like any other message, so always arrived as <@U…> always, never equalled an advertised choice, and the run re-prompted on every attempt. Stripped in the adapter rather than a plugin — Slack, Discord, and Teams all wrap mentions this way and all reach the same exact match, while the text a model reads is better left intact.
  • The adapter's control-plane calls are bounded. Every http_request shared one 900-second timeout, including the delivery worker's own claim. A Communications stall parked the worker for fifteen minutes with nothing raised and nothing logged — indistinguishable from an idle queue, and seen in dev as a message sitting PENDING with attempt_count 0 while the adapter process looked healthy. Now 30 seconds, with the long timeout kept only for OpenClaw's blocking turn.
  • Silence filtering and destination parsing moved into the shared client. The OpenClaw agent_end hook duplicated the marker set in JavaScript and captured completions with no destination, so a job created in a conversation lost its channel and thread. It now forwards a Connection-scoped channel target as origin metadata and leaves marker filtering to capture_completion — one policy for both runtimes instead of two that drift. Also corrects the interactive-send matrix: a live execution authorizes a send on its own inbound Connection and cannot select the configured default.
  • Fixed two pinned-image contract tests that had never passed. The Hermes scheduled-completion driver counted HEARTBEAT_OK as deliverable when the bridge suppresses it (as runtime_policy promises every agent it will), and the OpenClaw hooks driver expected a spooled completion that its own inputs cannot produce. ci-openclaw / Build and ci-api were both red on these.

Discussion

  • Anonymous

    Anonymous - 2 days ago

    Originally posted by: dominykas-aai-labs

    1. OpenClaw origin capture inverts this branch's own routing rule — api/domains/agents/scripts/messaging/openclaw-messaging.js:19-22

    An unrecognized ctx.channelId yields origin = null, which destination_for_origin reads as "created outside any conversation" → the Agent's configured default channel.

    That's the opposite of the rule the Python side states in its own docstring: an origin we cannot map "is NOT the default … delivering it elsewhere would put it in front of people who never asked for it", and it returns None so the caller refuses.

    It matters because the field is unverified. The tool hook in the same file uses ctx.sessionKey; the completion hook uses ctx.channelId — two different context fields — and api/tests/fixtures/openclaw_message_hooks_driver.mjs:16 supplies the value it then asserts on. Nothing proves the real runtime populates channelId on a cron run. If it doesn't, every OpenClaw conversation-created cron job silently posts to the default channel.

    Suggested fix: distinguish "no conversation" from "unrecognized conversation" in the JS — emit an explicit unmappable marker rather than null whenever a channel identifier was present but unrecognized, and let capture_completion refuse it. Separately, confirm against the pinned image which context field carries the Connection-scoped session key on a cron run, and assert it from a real cron-triggered run.

    2. The spool has no terminal state and never shrinks — agentbarn_message.py:207

    drain_once retries every failure identically, forever, capped at 300s. A permanent refusal — 409 "Agent has no configured default delivery target", 409 "Submission key was already used for a different request", 403 after a policy change — retries for the life of the pod, printing a line every few minutes with no operator-visible signal (these were never accepted, so they never reach the Communications journal). A stale message can also land hours later once an operator configures a default.

    Acknowledged rows are never pruned either: receipt is set and the row stays forever, so the SQLite file grows for the life of the PVC. Retention is load-bearing for dedupe, so this needs a retention window rather than deletion on ack.

    Suggested fix: classify the response — 4xx other than 408/429 is terminal (mark dead, log once, stop retrying); everything else retries. Attempts ceiling as a backstop, plus a prune of acknowledged rows past a retention window.

    3. The CLI tells the model to retry a permanent refusal — agentbarn_message.py:257

    main() prints "Message was not acknowledged ({type}); retry the same tool invocation." for every exception, including 403 "Outbound recipient is not allowed by this Connection". The model will loop on something it cannot fix. Distinguish permanent refusals and tell it to stop and report.

    4. Spool failure takes down Hermes job-output persistence — hermes-base/patch-agent-message-completions.py:14

    The capture is inserted immediately before output_file = save_job_output(job["id"], output). capture_completion raises on a locked/full SQLite (30s busy timeout), on a missing run identity, and on ValueError("Scheduled run identity already contains a different completion"). Any of those now propagate before the job's own output is saved.

    Delivery is the newer and less critical of the two side effects. Wrap the capture in try/except that logs and continues, or move it after save_job_output.

    5. Tool-hook matching is a substring, and the spool filename contains it

    Both hooks gate on command.includes("agentbarn-message") (hermes-messaging.py:15, openclaw-messaging.js:31). The default spool filename is agentbarn-messages.sqlite3 — a superstring. So during a cron run an ordinary ls /opt/data/agentbarn-messages.sqlite3 is blocked with "Explicit messaging requires an active inbound execution", and during an inbound run any command merely mentioning that path gets the execution binding env injected. Match the resolved argv[0] or a word boundary.

    6. Hermes spool path is inferred; OpenClaw's is explicit

    _spool() falls back to $HERMES_HOME (default /opt/data), and HERMES_HOME is set nowhere in this repo's deployment — it comes from the base image. openclaw/start.sh exports AGENTBARN_MESSAGE_SPOOL explicitly; hermes/start.sh doesn't. If the Hermes process's own environment ever differs from start.sh's, the scheduler captures into one file and the drain loop polls another and nothing is ever delivered, with no error anywhere. Export it explicitly and make the two runtimes symmetric.

    7. BOOT.md re-runs on every gateway start with no idempotency guard — boot-run.py

    Deliberate ("run whatever is there"), and the intent is that BOOT.md content is idempotent — but nothing in the wrapper prompt says so, and a template whose BOOT.md creates a cron job without checking will accumulate duplicates across restarts. That's exactly the failure this feature exists to prevent. Cheapest fix is one clause in the wrapper prompt at boot-run.py:63 ("repair existing scheduled jobs, do not duplicate them"); stronger is a marker file on the PVC recording the config revision last booted.

    Also fire-and-forget: a run that starts and then fails still logs "BOOT.md checklist submitted".

     
  • Anonymous

    Anonymous - 2 days ago

    Originally posted by: kalibetre

    Addressed all seven review remarks:

    1. OpenClaw now refuses unmappable cron origins instead of incorrectly routing them to the default target.
    2. The spool now has terminal states, permanent-error classification, a retry ceiling, seven-day pruning, and an in-place schema upgrade for existing SQLite spools.
    3. The CLI now tells the model not to retry permanent 4xx refusals.
    4. Hermes completion capture is isolated so spool failures cannot prevent native job-output persistence.
    5. Both runtime hooks now use command-boundary matching, so paths such as agentbarn-messages.sqlite3 do not trigger the messaging hook.
    6. Hermes now exports the spool path explicitly so capture and drain always use the same file.
    7. The BOOT.md wrapper now instructs the agent to repair/update scheduled jobs without duplicating them, and startup logging only claims that the run started.
     

Log in to post a comment.