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
This commit is contained in:
zhi
2026-03-09 12:00:36 +00:00
parent 68c13d9eef
commit 1f846fa7aa
3 changed files with 24 additions and 6 deletions

View File

@@ -62,9 +62,25 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo
let content = "";
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | 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<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") {
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<string, any>);
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(

View File

@@ -74,13 +74,13 @@ export function registerMessageSentHook(deps: MessageSentDeps): void {
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
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}`,
);