From 1f846fa7aa06a645c37effc0304cbca05780e47c Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 9 Mar 2026 12:00:36 +0000 Subject: [PATCH 1/3] fix: stop treating empty content as NO_REPLY, detect tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problems fixed: 1. before_message_write treated empty content (isEmpty) as NO_REPLY. Tool-call-only assistant messages (thinking + toolCall, no text) had empty extracted text, causing false NO_REPLY detection. In single-agent channels this immediately set turn to dormant, blocking all subsequent responses for the entire model run. 2. Added explicit toolCall/tool_call/tool_use detection in before_message_write — skip turn processing for intermediate tool-call messages entirely. 3. no-reply-api/server.mjs: default model name changed from 'dirigent-no-reply-v1' to 'no-reply' to match the configured model id in openclaw.json, fixing model list discovery. Changes: - plugin/hooks/before-message-write.ts: toolCall detection + remove isEmpty - plugin/hooks/message-sent.ts: remove isEmpty from wasNoReply - no-reply-api/server.mjs: fix default model name - dist/dirigent/index.ts: same fixes applied to monolithic build - dist/no-reply-api/server.mjs: same model name fix --- no-reply-api/server.mjs | 2 +- plugin/hooks/before-message-write.ts | 22 ++++++++++++++++++++-- plugin/hooks/message-sent.ts | 6 +++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/no-reply-api/server.mjs b/no-reply-api/server.mjs index c04927a..da9dd70 100644 --- a/no-reply-api/server.mjs +++ b/no-reply-api/server.mjs @@ -1,7 +1,7 @@ import http from "node:http"; const port = Number(process.env.PORT || 8787); -const modelName = process.env.NO_REPLY_MODEL || "dirigent-no-reply-v1"; +const modelName = process.env.NO_REPLY_MODEL || "no-reply"; const authToken = process.env.AUTH_TOKEN || ""; function sendJson(res, status, payload) { diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts index faee8ba..b2422c0 100644 --- a/plugin/hooks/before-message-write.ts +++ b/plugin/hooks/before-message-write.ts @@ -62,9 +62,25 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo let content = ""; const msg = (event as Record).message as Record | undefined; + const msgContent = msg?.content; if (msg) { const role = msg.role as string | undefined; if (role && role !== "assistant") return; + + // Detect tool calls — intermediate model step, not a final response. + // Skip turn processing entirely to avoid false NO_REPLY detection. + if (Array.isArray(msgContent)) { + const hasToolCalls = (msgContent as Record[]).some( + (part) => part?.type === "toolCall" || part?.type === "tool_call" || part?.type === "tool_use", + ); + if (hasToolCalls) { + api.logger.info( + `dirigent: before_message_write skipping tool-call message session=${key ?? "undefined"} channel=${channelId ?? "undefined"}`, + ); + return; + } + } + if (typeof msg.content === "string") { content = msg.content; } else if (Array.isArray(msg.content)) { @@ -99,13 +115,15 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); const trimmed = content.trim(); - const isEmpty = trimmed.length === 0; const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const waitId = live.waitIdentifier || "👤"; const hasWaitIdentifier = !!lastChar && lastChar === waitId; - const wasNoReply = isEmpty || isNoReply; + // Only treat explicit NO_REPLY/HEARTBEAT_OK keywords as no-reply. + // Empty content is NOT treated as no-reply — it may come from intermediate + // model responses (e.g. thinking-only blocks) that are not final answers. + const wasNoReply = isNoReply; const turnDebug = getTurnDebugInfo(channelId); api.logger.info( diff --git a/plugin/hooks/message-sent.ts b/plugin/hooks/message-sent.ts index 2c87284..20b2f7e 100644 --- a/plugin/hooks/message-sent.ts +++ b/plugin/hooks/message-sent.ts @@ -74,13 +74,13 @@ export function registerMessageSentHook(deps: MessageSentDeps): void { const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); const trimmed = content.trim(); - const isEmpty = trimmed.length === 0; const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const waitId = live.waitIdentifier || "👤"; const hasWaitIdentifier = !!lastChar && lastChar === waitId; - const wasNoReply = isEmpty || isNoReply; + // Only treat explicit NO_REPLY/HEARTBEAT_OK keywords as no-reply. + const wasNoReply = isNoReply; if (key && sessionTurnHandled.has(key)) { sessionTurnHandled.delete(key); @@ -100,7 +100,7 @@ export function registerMessageSentHook(deps: MessageSentDeps): void { if (wasNoReply || hasEndSymbol) { const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); - const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol"; + const trigger = wasNoReply ? "no_reply_keyword" : "end_symbol"; api.logger.info( `dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, ); From bddc7798d8dc182a2117848d61b08f499b31e346 Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 9 Mar 2026 12:50:51 +0000 Subject: [PATCH 2/3] fix: add no-reply model to agents.defaults.models allowlist Changes: - Change default provider ID from 'dirigentway' to 'dirigent' - Add no-reply model to agents.defaults.models allowlist during install - Fix no-reply-api server default model name from 'dirigent-no-reply-v1' to 'no-reply' This fixes the issue where dirigent/no-reply model was not showing in 'openclaw models list' and was being rejected as 'not allowed'. --- scripts/install.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/install.mjs b/scripts/install.mjs index 4f70595..7478e94 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -81,7 +81,7 @@ const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins"); const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent"); const NO_REPLY_INSTALL_DIR = path.join(PLUGIN_INSTALL_DIR, "no-reply-api"); -const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigentway"; +const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent"; const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply"; const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort); const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`; @@ -223,6 +223,11 @@ if (mode === "install") { }; setJson("models.providers", providers); + // Add no-reply model to agents.defaults.models allowlist + const agentsDefaultsModels = getJson("agents.defaults.models") || {}; + agentsDefaultsModels[`${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`] = {}; + setJson("agents.defaults.models", agentsDefaultsModels); + step(6, 6, "enable plugin in allowlist"); const allow = getJson("plugins.allow") || []; if (!allow.includes("dirigent")) { From 08f42bfd9228cfe3223f149a9e7e5ff497884567 Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 9 Mar 2026 21:16:53 +0000 Subject: [PATCH 3/3] fix: skip rules-based no-reply override when turn manager allows agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the turn manager determines it's an agent's turn (checkTurn.allowed), the rules engine's evaluateDecision() could still override the model to no-reply with reason 'rule_match_no_end_symbol'. This happened because: 1. The sender of the triggering message (another agent) was not in the humanList, so the rules fell through to the end-symbol check. 2. getLastChar() operates on the full prompt (including system content like Runtime info), so it never found the end symbol even when the actual message ended with one. Fix: return early from before_model_resolve after the turn check passes, skipping the rules-based no-reply override entirely. The turn manager is the authoritative source for multi-agent turn coordination. Tested: 3-agent counting chain ran successfully (3→11) with correct NO_REPLY handling when count exceeded threshold. --- plugin/hooks/before-model-resolve.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/hooks/before-model-resolve.ts b/plugin/hooks/before-model-resolve.ts index 0b551f6..85447e5 100644 --- a/plugin/hooks/before-model-resolve.ts +++ b/plugin/hooks/before-model-resolve.ts @@ -126,6 +126,10 @@ export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): vo }; } sessionAllowed.set(key, true); + api.logger.info( + `dirigent: turn allowed, skipping rules override session=${key} accountId=${accountId}`, + ); + return; } }