Agent-initiated message delivery: explicit sends, scheduled completions, and...
run a fleet of AI agents on your own Kubernetes
Brought to you by:
agent-barn
Originally created by: kalibetre
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.[SILENT], NO_REPLY, HEARTBEAT_OK, etc.) suppress delivery.agent_message_repository/agent_message_service, execution context, delivery repository additions, and new gateway routes.plugins/base.py.BOOT.md at startup via a new boot-run.py, since pinned Hermes has no gateway:startup hook for it (unlike OpenClaw).AgentRepository.lifecycle_lock) to serialize start/stop/delete and fail concurrent lifecycle requests with 409 instead of racing runtime provisioning.c92d0eaf3101_agent_initiated_delivery) for the supporting schema changes.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.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.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.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.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.
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-22An unrecognized
ctx.channelIdyieldsorigin = null, whichdestination_for_originreads 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
Noneso the caller refuses.It matters because the field is unverified. The tool hook in the same file uses
ctx.sessionKey; the completion hook usesctx.channelId— two different context fields — andapi/tests/fixtures/openclaw_message_hooks_driver.mjs:16supplies the value it then asserts on. Nothing proves the real runtime populateschannelIdon 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
nullwhenever a channel identifier was present but unrecognized, and letcapture_completionrefuse 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:207drain_onceretries 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:
receiptis 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:257main()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:14The capture is inserted immediately before
output_file = save_job_output(job["id"], output).capture_completionraises on a locked/full SQLite (30s busy timeout), on a missing run identity, and onValueError("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/exceptthat logs and continues, or move it aftersave_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 isagentbarn-messages.sqlite3— a superstring. So during a cron run an ordinaryls /opt/data/agentbarn-messages.sqlite3is 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), andHERMES_HOMEis set nowhere in this repo's deployment — it comes from the base image.openclaw/start.shexportsAGENTBARN_MESSAGE_SPOOLexplicitly;hermes/start.shdoesn'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.mdre-runs on every gateway start with no idempotency guard —boot-run.pyDeliberate ("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".
Originally posted by: kalibetre
Addressed all seven review remarks:
agentbarn-messages.sqlite3do not trigger the messaging hook.