Compare commits

..

4 Commits

Author SHA1 Message Date
13d5b95081 Merge pull request 'fix/no-reply-empty-content-detection' (#18) from fix/no-reply-empty-content-detection into main
Reviewed-on: #18
2026-03-09 21:49:59 +00:00
zhi
08f42bfd92 fix: skip rules-based no-reply override when turn manager allows agent
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.
2026-03-09 21:16:53 +00:00
zhi
bddc7798d8 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'.
2026-03-09 13:12:35 +00:00
zhi
1f846fa7aa fix: stop treating empty content as NO_REPLY, detect tool calls
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
2026-03-09 12:00:36 +00:00
5 changed files with 34 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
import http from "node:http"; import http from "node:http";
const port = Number(process.env.PORT || 8787); 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 || ""; const authToken = process.env.AUTH_TOKEN || "";
function sendJson(res, status, payload) { function sendJson(res, status, payload) {

View File

@@ -62,9 +62,25 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo
let content = ""; let content = "";
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined; const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
const msgContent = msg?.content;
if (msg) { if (msg) {
const role = msg.role as string | undefined; const role = msg.role as string | undefined;
if (role && role !== "assistant") return; 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<string, unknown>[]).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") { if (typeof msg.content === "string") {
content = msg.content; content = msg.content;
} else if (Array.isArray(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<string, any>); const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
const trimmed = content.trim(); const trimmed = content.trim();
const isEmpty = trimmed.length === 0;
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
const waitId = live.waitIdentifier || "👤"; const waitId = live.waitIdentifier || "👤";
const hasWaitIdentifier = !!lastChar && lastChar === waitId; 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); const turnDebug = getTurnDebugInfo(channelId);
api.logger.info( api.logger.info(

View File

@@ -126,6 +126,10 @@ export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): vo
}; };
} }
sessionAllowed.set(key, true); sessionAllowed.set(key, true);
api.logger.info(
`dirigent: turn allowed, skipping rules override session=${key} accountId=${accountId}`,
);
return;
} }
} }

View File

@@ -74,13 +74,13 @@ export function registerMessageSentHook(deps: MessageSentDeps): void {
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>); const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
const trimmed = content.trim(); const trimmed = content.trim();
const isEmpty = trimmed.length === 0;
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
const waitId = live.waitIdentifier || "👤"; const waitId = live.waitIdentifier || "👤";
const hasWaitIdentifier = !!lastChar && lastChar === waitId; 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)) { if (key && sessionTurnHandled.has(key)) {
sessionTurnHandled.delete(key); sessionTurnHandled.delete(key);
@@ -100,7 +100,7 @@ export function registerMessageSentHook(deps: MessageSentDeps): void {
if (wasNoReply || hasEndSymbol) { if (wasNoReply || hasEndSymbol) {
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); 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( api.logger.info(
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, `dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
); );

View File

@@ -81,7 +81,7 @@ const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent"); 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_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_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort); 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`; 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); 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"); step(6, 6, "enable plugin in allowlist");
const allow = getJson("plugins.allow") || []; const allow = getJson("plugins.allow") || [];
if (!allow.includes("dirigent")) { if (!allow.includes("dirigent")) {