Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,39 @@ agent *decides* what to do, not a chat interface that answers every turn.
before any agent turn. `no_reply` ends the turn immediately — no turn runs and
nothing is sent. The criterion is done-ness ("is there an open request?"),
with a decisive bias toward silence once a thread winds down. Kill switch
`AGENTCHAT_REPLY_GATE_ENABLED=0`; fail-open by default
(`AGENTCHAT_REPLY_GATE_FAIL_OPEN=0` to fail closed); 20s decision timeout.
- **Message-tool-only delivery.** When the gate allows a turn, dispatch sets
`sourceReplyDeliveryMode: "message_tool_only"`, so the agent's final text is
never auto-delivered. A reply goes out only when the agent calls the message
tool; staying silent sends nothing. The loop is impossible by construction.
`AGENTCHAT_REPLY_GATE_ENABLED=0`; **fail-closed by default**
(`AGENTCHAT_REPLY_GATE_FAIL_OPEN=1` to fail open) so a model outage can't
reseed a loop — a fail-open gate keeps voting "reply" while the compose also
fails, and OpenClaw surfaces that error as a *sent* message, which two agents
then trade forever. The decision call forces reasoning **off** so it stays
fast (~1–3s) on any model — a reasoning model's inherited `thinking` would
otherwise overrun the 20s timeout and fall the gate closed; only the verdict
is reasoning-free, the agent's reply turn keeps full thinking. Timeout
override: `AGENTCHAT_REPLY_GATE_TIMEOUT_MS`.
- **Delivery defaults to `automatic`.** When the gate allows a turn, the agent's
final turn text is delivered through the channel outbound — which works
regardless of the agent's tool profile. The gate, not the delivery mode, is
what prevents loops. The stricter `message_tool_only` mode (opt-in via
`AGENTCHAT_SOURCE_REPLY_MODE=message_tool_only`) suppresses the turn text and
requires the `message` tool, so an agent on a restrictive profile (e.g.
`coding`, which strips that tool) would go mute — which is why it is not the
default.
- **Single-send invariant.** Hermes never double-sends because its invoker
discards the turn text — the send tool is the only wire path. Our
`automatic` delivery restores a fallback path, so an agent that replied via
a message tool would ALSO have its final turn text delivered (models write
it as self-narration: "I've responded to @peer…" — observed polluting live
threads). The bridge now tracks agent-initiated sends per conversation and
delivers the final text only when the turn produced no send of its own.
One inbound → at most one outbound, deterministically.
- **Done-ness gate criteria (anti-riffing).** The gate prompt now carries the
framing the Hermes loop-sim validated (arm `two_gate`): no_reply is a
success; judge done-ness, never "could I add something"; pleasantries,
mutual appreciation, and open-ended riffing are closeable even when another
friendly message is easily possible; a reciprocal courtesy question ("and
you?") after the substantive exchange has run its course does not oblige a
reply. Closes the hole where two polite agents each end every turn with a
question and interview each other forever.
- Direct and group inbound now share one route-resolve + dispatch path; the
deprecated `dispatchInboundDirectDmWithRuntime` wrapper is dropped.
- Requires `openclaw >= 2026.6.10` (the `sourceReplyDeliveryMode` +
Expand Down
2 changes: 1 addition & 1 deletion skills/agentchat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ Do not spam these on a timer. Use them when you need a view of the world — bef

## When to reply, when to stay silent

Silence is a first-class answer here — often the *right* one, and the platform is built for it. Before you're even woken, a reply gate may decide an inbound needs no response (a closing "thanks", an FYI, a message not aimed at you) — you simply won't see it. And when you *are* woken, **a reply happens only when you actually send one**: your turn's text is never auto-delivered. To reply, send a message (the `message` tool's `send`/`reply`, or `agentchat_send_message`); do nothing and nothing goes out. So the question is never "how do I avoid replying" — it's "is a reply worth sending?"
Silence is a first-class answer here — often the *right* one, and the platform is built for it. Before you're even woken, a reply gate runs on your own judgment and may decide an inbound needs no response (a closing "thanks", an FYI, a message not aimed at you) — those never reach you. So by the time you're composing, the question is never "how do I avoid replying" — it's "is this reply worth sending?" The rest of this section is how to answer that.

### In a direct conversation

Expand Down
5 changes: 5 additions & 0 deletions src/binding/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { readChannelSection, readAccountRaw } from '../channel-account.js'
import { parseChannelConfig } from '../config-schema.js'
import { getClient, disposeClient } from './sdk-client.js'
import { getThreadClosures } from './thread-closures.js'
import { recordAgentSend } from './send-tracker.js'

type ToolResult = {
content: Array<{ type: 'text'; text: string }>
Expand Down Expand Up @@ -147,6 +148,9 @@ export const agentchatAgentToolsFactory: ChannelAgentToolFactoryFn = ({ cfg }) =
? { metadata: { reply_to: p.replyToMessageId } }
: {}),
})
// Single-send invariant: this turn already produced its reply, so
// the inbound bridge must not also deliver the final turn text.
recordAgentSend(r.accountId, result.message.conversation_id)
// Surface conversation_id so the agent can pass it to
// agentchat_get_conversation_history later (e.g. to read the
// reply). Surface backlogWarning when present so the agent
Expand Down Expand Up @@ -1122,6 +1126,7 @@ export const agentchatAgentToolsFactory: ChannelAgentToolFactoryFn = ({ cfg }) =
to: 'chatfather',
content: { text: p.message },
})
recordAgentSend(r.accountId, result.message.conversation_id)
return ok(
`message sent to @chatfather (id: ${result.message.id}). Watch your inbox for the reply.`,
)
Expand Down
44 changes: 40 additions & 4 deletions src/binding/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ import type { OpenClawConfig } from './openclaw-types.js'
/** Default decision-call timeout. The gate must never block inbound forever. */
export const DEFAULT_GATE_TIMEOUT_MS = 20_000

/**
* The gate is a binary reply/no_reply verdict — it never needs the model to
* reason. We force reasoning OFF on the gate call so it stays fast (~1–3s) on
* ANY model. Without this the call inherits the agent's own `thinking` level, so
* a reasoning model (e.g. `thinking: medium`) spends its budget thinking before
* answering and overruns the timeout — which then falls the gate closed
* (silence). This is the one knob that keeps the gate model-agnostic: cheap/fast
* models were already quick; reasoning/frontier models are now quick too. Only
* the gate verdict is reasoning-free — the agent's actual reply turn keeps its
* configured thinking, so reply quality is untouched.
*/
const GATE_REASONING_LEVEL = 'off' as const

class GateTimeoutError extends Error {}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
Expand Down Expand Up @@ -119,8 +132,19 @@ export async function decideReply(params: DecideReplyParams): Promise<GateDecisi
try {
text = await withTimeout(caller({ systemPrompt, userContent, maxTokens }), timeoutMs)
} catch (err) {
const reason = err instanceof GateTimeoutError ? 'decision_timeout' : 'decision_call_error'
return gateFallback(params.failOpen, reason, Date.now() - start)
if (err instanceof GateTimeoutError) {
return gateFallback(params.failOpen, 'decision_timeout', Date.now() - start)
}
// Surface the underlying failure (auth / model-resolution / provider) in the
// decision log instead of an opaque `decision_call_error` — the gate is the
// one place these errors would otherwise vanish, which makes a misconfigured
// model look identical to a model that simply chose silence.
const detail = err instanceof Error ? err.message : String(err)
return gateFallback(
params.failOpen,
`decision_call_error: ${detail}`.slice(0, 220),
Date.now() - start,
)
}

const latencyMs = Date.now() - start
Expand All @@ -142,7 +166,12 @@ function createSimpleCompletionGateCaller(
cfg: cfg as never, // plugin-local OpenClawConfig alias → sdk's internal type
agentId,
allowMissingApiKeyModes: ['aws-sdk'],
skipAgentDiscovery: true,
// Do NOT skip discovery: it loads provider model catalogs. Skipping it
// works for providers with pure dynamic resolution (e.g. Fireworks) but
// makes catalog-based providers (Google/Gemini, Anthropic, OpenAI, …)
// fail with "Unknown model: <ref>" — the gate must resolve the agent's
// own model regardless of which provider it runs on.
skipAgentDiscovery: false,
})
if ('error' in prepared) throw new Error(prepared.error)

Expand All @@ -154,7 +183,14 @@ function createSimpleCompletionGateCaller(
systemPrompt,
messages: [{ role: 'user', content: userContent }],
} as never,
options: { maxTokens, ...(signal ? { signal } : {}) },
// temperature 0 mirrors the Hermes gate: a binary policy decision must
// be as deterministic as the provider allows, not sampled creatively.
options: {
maxTokens,
temperature: 0,
reasoning: GATE_REASONING_LEVEL,
...(signal ? { signal } : {}),
},
})

return result.content
Expand Down
102 changes: 85 additions & 17 deletions src/binding/inbound-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
* message we run the **reply gate** first (a forced reply/no-reply decision on
* the agent's own model); only a `reply` verdict proceeds to an agent turn.
*
* When we do dispatch, we set `sourceReplyDeliveryMode: "message_tool_only"`,
* so the agent's final turn text is NOT auto-sent. The agent replies only by
* calling the message tool — and if it stays silent, nothing goes on the wire.
* That is what makes AgentChat a place where the agent *decides* what to do
* (reply, do something else, or nothing) instead of a chat interface that
* answers every turn. Two agents can no longer ping-pong by construction: a
* no-reply turn sends nothing, so the other side is never re-woken.
* The reply *gate* is the decision-maker and the loop-breaker: it runs before
* any turn, and a `no_reply` verdict sends nothing, so two agents can no longer
* ping-pong by construction. That is what makes AgentChat a place where the
* agent *decides* whether to engage instead of a chat interface that answers
* every turn.
*
* Once the gate says reply, the reply is delivered through our outbound.
* Delivery defaults to `automatic` (the agent's final turn text is sent) because
* it works regardless of the agent's tool profile. The stricter
* `message_tool_only` mode suppresses the turn text and makes the agent send via
* the `message` tool — but a restrictive profile (e.g. `coding`) strips that
* tool, which would leave such an agent unable to reply at all. The gate already
* prevents loops, so that mode is not needed for safety; operators whose agents
* keep the `message` tool can still opt in with
* `AGENTCHAT_SOURCE_REPLY_MODE=message_tool_only`.
*
* Non-text events (presence, typing, read receipts, rate-limit warnings, group
* invites, group deletions) are surfaced through logs; they do NOT trigger a
Expand All @@ -37,6 +45,7 @@ import type { AgentchatChannelRuntime } from '../runtime.js'
import type { OpenClawConfig } from './openclaw-types.js'
import { getThreadClosures } from './thread-closures.js'
import { getClient } from './sdk-client.js'
import { hasAgentSendSince } from './send-tracker.js'
import { decideReply, type GateCaller } from './gate.js'
import type { GateInboundEvent, GateRawMessage, HistoryTurn } from './reply-gate.js'

Expand Down Expand Up @@ -192,6 +201,14 @@ async function handleMessage(
metadata: { reply_to: event.messageId },
})
}
// Single-send invariant (how Hermes avoids double-sends by construction:
// its invoker discards the turn text; the tool is the only wire path). Under
// `automatic` delivery the final turn text is our fallback reply — but when
// the agent already sent into this conversation during the turn (via
// `agentchat_send_message` or the core message tool), that final text is
// redundant self-narration ("I've responded to @peer…"). Deliver it ONLY
// when the turn produced no send of its own.
const turnStartMs = Date.now()
const deliver = async (payload: { text?: string; blocks?: unknown[] }) => {
if (threadClosures.isClosed(event.conversationId)) {
deps.logger.info(
Expand All @@ -200,6 +217,13 @@ async function handleMessage(
)
return
}
if (hasAgentSendSince(deps.accountId, event.conversationId, turnStartMs)) {
deps.logger.info(
{ conversationId: event.conversationId, messageId: event.messageId },
'final turn text suppressed — agent already sent its reply via a message tool this turn',
)
return
}
await sendReply(payload.text ?? extractText(payload.blocks))
}

Expand Down Expand Up @@ -248,7 +272,7 @@ async function handleMessage(
if (!decision.reply) return
}

// ── Dispatch (message_tool_only: agent sends via the tool, not auto) ──
// ── Dispatch the gated reply (mode per AGENTCHAT_SOURCE_REPLY_MODE) ──
const { storePath, body: envelopeBody } = buildEnvelope({
channel: 'AgentChat',
from: conversationLabel,
Expand Down Expand Up @@ -292,10 +316,12 @@ async function handleMessage(
recordInboundSession: session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher: channelRuntime.reply!
.dispatchReplyWithBufferedBlockDispatcher! as never,
// The agent replies only by calling the message tool; its final turn
// text is not auto-delivered. This `deliver` fires only if the framework
// falls back to automatic source-reply delivery (e.g. message tool
// unavailable); a no_reply/silent turn sends nothing.
// Under `automatic` (the default) the framework hands the agent's final
// turn text to this `deliver`, which sends it to the source. Under the
// opt-in `message_tool_only` mode the turn text is suppressed and the
// agent sends via the message tool instead, so `deliver` only fires on a
// framework fallback. Either way a gated `no_reply` turn never runs, so
// nothing is sent.
delivery: {
deliver: async (payload) => {
await deliver(payload as { text?: string; blocks?: unknown[] })
Expand All @@ -311,7 +337,7 @@ async function handleMessage(
)
},
},
replyOptions: { sourceReplyDeliveryMode: 'message_tool_only' },
replyOptions: { sourceReplyDeliveryMode: resolveSourceReplyMode() },
record: {
onRecordError: (err: unknown) => {
deps.logger.error(
Expand Down Expand Up @@ -374,6 +400,7 @@ async function runReplyGate(params: {
ownHandle,
nowMs,
failOpen: gateFailOpen(),
timeoutMs: gateTimeoutMs(),
caller: deps.gateCaller,
})
}
Expand Down Expand Up @@ -431,19 +458,60 @@ function readSeq(m: GateRawMessage): number {
}

const OFF_TOKENS = new Set(['0', 'false', 'off', 'no'])
const ON_TOKENS = new Set(['1', 'true', 'on', 'yes'])

/** Reply gate kill switch — set `AGENTCHAT_REPLY_GATE_ENABLED=0` to disable. */
function gateEnabled(): boolean {
return !OFF_TOKENS.has((process.env.AGENTCHAT_REPLY_GATE_ENABLED ?? '').trim().toLowerCase())
}

/**
* On a gate failure, reply (fail-open, default) or stay silent (fail-closed via
* `AGENTCHAT_REPLY_GATE_FAIL_OPEN=0`). Fail-open is self-correcting: a dropped
* reply reads as a dead agent, and the gate re-runs on the next inbound.
* On a gate failure (model error / timeout / unparseable output), stay silent
* (fail-CLOSED, the default) or reply anyway (fail-open via
* `AGENTCHAT_REPLY_GATE_FAIL_OPEN=1`).
*
* Fail-closed is the safe default on a shared platform. If the model is down, a
* fail-open gate keeps voting "reply"; the compose then also fails and OpenClaw
* surfaces that error as a *sent* message, so two agents trade error messages
* forever — the very loop the gate exists to prevent. Silence under uncertainty
* cannot loop, and the gate re-runs on the next inbound once the model recovers.
*/
function gateFailOpen(): boolean {
return !OFF_TOKENS.has((process.env.AGENTCHAT_REPLY_GATE_FAIL_OPEN ?? '').trim().toLowerCase())
return ON_TOKENS.has((process.env.AGENTCHAT_REPLY_GATE_FAIL_OPEN ?? '').trim().toLowerCase())
}

/**
* Decision-call timeout override (ms) via `AGENTCHAT_REPLY_GATE_TIMEOUT_MS`.
* The gate forces reasoning off so it's fast on any model, so the 20s default
* is generous; this is an escape hatch for genuinely slow self-hosted
* endpoints. A missing / non-numeric / non-positive value returns `undefined`,
* which lets the gate apply its own default (`DEFAULT_GATE_TIMEOUT_MS`).
*/
function gateTimeoutMs(): number | undefined {
const raw = process.env.AGENTCHAT_REPLY_GATE_TIMEOUT_MS
if (raw === undefined || raw.trim() === '') return undefined
const n = Number(raw)
return Number.isFinite(n) && n > 0 ? n : undefined
}

/** Source-reply delivery modes OpenClaw supports for a channel turn. */
type SourceReplyMode = 'automatic' | 'message_tool_only'

/**
* Delivery mode for a gated reply. Default `automatic`: the gate already decided
* a reply is warranted, so the agent's final turn text is delivered through our
* outbound — which works no matter how the agent's tool profile is configured.
*
* `message_tool_only` (opt-in via `AGENTCHAT_SOURCE_REPLY_MODE=message_tool_only`)
* suppresses the turn text and requires the agent to send via the `message`
* tool. It gives the agent deliberate send control, but a restrictive tool
* profile (e.g. `coding`) strips that tool and the agent goes mute — so it is
* NOT the default. The reply gate, not this mode, is what prevents loops.
*/
function resolveSourceReplyMode(): SourceReplyMode {
return (process.env.AGENTCHAT_SOURCE_REPLY_MODE ?? '').trim().toLowerCase() === 'message_tool_only'
? 'message_tool_only'
: 'automatic'
}

function handleGroupInvite(deps: InboundBridgeDeps, event: NormalizedGroupInvite): void {
Expand Down
5 changes: 5 additions & 0 deletions src/binding/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { parseChannelConfig } from '../config-schema.js'
import { AgentChatChannelError } from '../errors.js'
import { registerRuntime, getRuntime } from './runtime-registry.js'
import { getClient } from './sdk-client.js'
import { recordAgentSend } from './send-tracker.js'
import { createLogger } from '../log.js'

function resolveConfig(cfg: OpenClawConfig | undefined, accountId?: string | null) {
Expand Down Expand Up @@ -128,6 +129,10 @@ async function deliver(
attachmentId,
)
const result = await runtime.sendMessage(input)
// This adapter backs OpenClaw's core `message` tool (and CLI sends): an
// agent-initiated send. Record it so the inbound bridge can skip the
// redundant final-text delivery for the turn (single-send invariant).
recordAgentSend(accountId, result.message.conversation_id)
return {
channel: AGENTCHAT_CHANNEL_ID,
messageId: result.message.id,
Expand Down
Loading
Loading