feat: wait for human reply (waitIdentifier 👤)
- Add waitIdentifier config (default: 👤) to DirigentConfig and plugin schema - Prompt injection: tells agents to end with 👤 when they need a human reply, warns to use sparingly (only when human is actively participating) - Detection in before_message_write and message_sent hooks - Turn manager: new waitingForHuman state - checkTurn() blocks all agents when waiting - onNewMessage() clears state on human message - Non-human messages ignored while waiting - resetTurn() also clears waiting state - All agents routed to no-reply model during waiting state - Update docs (FEAT.md, CHANGELOG.md, TASKLIST.md, README.md)
This commit is contained in:
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js";
|
||||
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo, setMentionOverride, hasMentionOverride } from "./turn-manager.js";
|
||||
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo, setMentionOverride, hasMentionOverride, setWaitingForHuman, isWaitingForHuman } from "./turn-manager.js";
|
||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||
|
||||
// ── No-Reply API child process lifecycle ──────────────────────────────
|
||||
@@ -78,11 +78,12 @@ const sessionTurnHandled = new Set<string>(); // Track sessions where turn was a
|
||||
const MAX_SESSION_DECISIONS = 2000;
|
||||
const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string): string {
|
||||
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string {
|
||||
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
|
||||
let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK) must NOT include ${symbols}.`;
|
||||
if (isGroupChat) {
|
||||
instruction += `\n\nGroup chat rules: If this message is not relevant to you, does not need your response, or you have nothing valuable to add, reply with NO_REPLY. Do not speak just for the sake of speaking.`;
|
||||
instruction += `\n\nWait for human reply: If you need a human to respond to your message, end with ${waitIdentifier} instead of ${symbols}. This pauses all agents until a human speaks. Use this sparingly — only when you are confident the human is actively participating in the discussion (has sent a message recently). Do NOT use it speculatively.`;
|
||||
}
|
||||
return instruction;
|
||||
}
|
||||
@@ -237,6 +238,7 @@ function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig):
|
||||
enableDebugLogs: false,
|
||||
debugLogChannelIds: [],
|
||||
schedulingIdentifier: "➡️",
|
||||
waitIdentifier: "👤",
|
||||
...cfg,
|
||||
} as DirigentConfig;
|
||||
}
|
||||
@@ -539,6 +541,7 @@ export default {
|
||||
enableDirigentPolicyTool: true,
|
||||
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
||||
schedulingIdentifier: "➡️",
|
||||
waitIdentifier: "👤",
|
||||
...(api.pluginConfig || {}),
|
||||
} as DirigentConfig & {
|
||||
enableDiscordControlTool: boolean;
|
||||
@@ -1081,7 +1084,8 @@ export default {
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId);
|
||||
|
||||
// Inject agent identity for group chats (includes userId now)
|
||||
let identity = "";
|
||||
@@ -1208,6 +1212,8 @@ export default {
|
||||
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;
|
||||
|
||||
const turnDebug = getTurnDebugInfo(channelId);
|
||||
@@ -1215,6 +1221,17 @@ export default {
|
||||
`dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
|
||||
);
|
||||
|
||||
// Wait identifier: agent wants a human reply → all agents go silent
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write wait-for-human triggered session=${key} channel=${channelId} accountId=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAllowed = sessionAllowed.get(key);
|
||||
|
||||
if (wasNoReply) {
|
||||
@@ -1323,6 +1340,8 @@ export default {
|
||||
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;
|
||||
|
||||
// Skip if turn was already advanced in before_message_write
|
||||
@@ -1334,6 +1353,15 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait identifier detection (fallback if not caught in before_message_write)
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasNoReply || hasEndSymbol) {
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
|
||||
const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol";
|
||||
|
||||
Reference in New Issue
Block a user