diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd5912..967baf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - Agents cycle in their original turn-order position - After all mentioned agents have spoken, original order restores and state goes dormant - Handles edge cases: all NO_REPLY, reset, non-agent mentions +- **Wait for human reply**: New `waitIdentifier` (default: `👤`): + - Agent ends with `👤` to signal it needs a human response + - All agents go silent (routed to no-reply model) until a human speaks + - Prompt injection warns agents to use sparingly (only when human is actively participating) - **Installer improvements**: - Dynamic OpenClaw dir resolution (`$OPENCLAW_DIR` → `openclaw` CLI → `~/.openclaw`) - Plugin installed to `$(openclaw_dir)/plugins/dirigent` diff --git a/FEAT.md b/FEAT.md index 3ae31d5..76b2f6e 100644 --- a/FEAT.md +++ b/FEAT.md @@ -52,6 +52,18 @@ All implemented features across all versions. - Group chat prompts include: agent name, Discord accountId, Discord userId - userId resolved from bot token (base64 first segment) +## Wait for Human Reply *(v0.3.0)* +- Configurable wait identifier (default: `👤`) +- Agent ends message with `👤` instead of `🔚` when it needs a human to reply +- Triggers "waiting for human" state: + - All agents routed to no-reply model + - Turn manager goes dormant + - State clears automatically when a human sends a message +- Prompt injection tells agents: + - Use wait identifier only when confident the human is actively participating + - Do NOT use it speculatively +- Works with mention override: wait identifier during override also triggers waiting state + ## Scheduling Identifier - Configurable identifier (default: `➡️`) used for moderator handoff - Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic scheduling signal) diff --git a/README.md b/README.md index bde054b..24c3fdc 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Common options (see `docs/INTEGRATION.md`): - `humanList`, `agentList` - `endSymbols` - `schedulingIdentifier` (default `➡️`) +- `waitIdentifier` (default `👤`) — agent ends with this to pause all agents until human replies - `channelPoliciesFile` (per-channel overrides) - `moderatorBotToken` (handoff messages) - `enableDebugLogs`, `debugLogChannelIds` diff --git a/TASKLIST.md b/TASKLIST.md index 86e78cb..7e7395a 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -69,6 +69,17 @@ - New helpers in `index.ts`: `buildUserIdToAccountIdMap()`, `extractMentionedUserIds()`. - **Done**: Override logic integrated in `message_received` handler. +## 8) Wait for Human Reply ✅ +- Added configurable `waitIdentifier` (default: `👤`) to config and config schema. +- Prompt injection in group chats: tells agents to end with `👤` instead of end symbol when they need a human response. Warns to use sparingly — only when human is actively participating. +- Detection in `before_message_write` and `message_sent`: if last char matches wait identifier → `setWaitingForHuman(channelId)`. +- Turn manager `waitingForHuman` state: + - `checkTurn()` blocks all agents (`reason: "waiting_for_human"`) → routed to no-reply model. + - `onNewMessage()` clears `waitingForHuman` on human message → normal flow resumes. + - Non-human messages ignored while waiting. + - `resetTurn()` also clears waiting state. +- **Done**: Full lifecycle implemented across turn-manager, rules, and index. + --- ## Open Items / Notes diff --git a/plugin/index.ts b/plugin/index.ts index 394a5bf..f6a4779 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -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(); // 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"; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index ae77c34..b901262 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -17,6 +17,7 @@ "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] }, "schedulingIdentifier": { "type": "string", "default": "➡️" }, + "waitIdentifier": { "type": "string", "default": "👤" }, "noReplyProvider": { "type": "string" }, "noReplyModel": { "type": "string" }, "enableDiscordControlTool": { "type": "boolean", "default": true }, diff --git a/plugin/rules.ts b/plugin/rules.ts index 26f181b..aa1dd64 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -10,6 +10,8 @@ export type DirigentConfig = { endSymbols?: string[]; /** Scheduling identifier sent by moderator to activate agents (default: ➡️) */ schedulingIdentifier?: string; + /** Wait identifier: agent ends with this when waiting for a human reply (default: 👤) */ + waitIdentifier?: string; noReplyProvider: string; noReplyModel: string; /** Discord bot token for the moderator bot (used for turn handoff messages) */ diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 52e510d..0fc9410 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -25,6 +25,9 @@ export type ChannelTurnState = { savedTurnOrder?: string[]; /** First agent in override cycle; used to detect cycle completion */ overrideFirstAgent?: string; + // ── Wait-for-human state ── + /** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */ + waitingForHuman: boolean; }; const channelTurns = new Map(); @@ -64,6 +67,7 @@ export function initTurnOrder(channelId: string, botAccountIds: string[]): void currentSpeaker: null, // start dormant noRepliedThisCycle: new Set(), lastChangedAt: Date.now(), + waitingForHuman: false, }); } @@ -80,6 +84,11 @@ export function checkTurn(channelId: string, accountId: string): { return { allowed: true, currentSpeaker: null, reason: "no_turn_state" }; } + // Waiting for human → block all agents + if (state.waitingForHuman) { + return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" }; + } + // Not in turn order (human or unknown) → always allowed if (!state.turnOrder.includes(accountId)) { return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" }; @@ -122,7 +131,8 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi if (!state || state.turnOrder.length === 0) return; if (isHuman) { - // Human message without @mentions: restore original order if overridden, activate from first + // Human message: clear wait-for-human, restore original order if overridden, activate from first + state.waitingForHuman = false; restoreOriginalOrder(state); state.currentSpeaker = state.turnOrder[0]; state.noRepliedThisCycle = new Set(); @@ -130,6 +140,11 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi return; } + if (state.waitingForHuman) { + // Waiting for human — ignore non-human messages + return; + } + if (state.currentSpeaker !== null) { // Already active, no change needed from incoming message return; @@ -204,6 +219,27 @@ export function hasMentionOverride(channelId: string): boolean { return !!state?.savedTurnOrder; } +/** + * Set the channel to "waiting for human" state. + * All agents will be routed to no-reply until a human sends a message. + */ +export function setWaitingForHuman(channelId: string): void { + const state = channelTurns.get(channelId); + if (!state) return; + state.waitingForHuman = true; + state.currentSpeaker = null; + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); +} + +/** + * Check if the channel is waiting for a human reply. + */ +export function isWaitingForHuman(channelId: string): boolean { + const state = channelTurns.get(channelId); + return !!state?.waitingForHuman; +} + /** * Called when the current speaker finishes (end symbol detected) or says NO_REPLY. * @param wasNoReply - true if the speaker said NO_REPLY (empty/silent) @@ -288,6 +324,7 @@ export function resetTurn(channelId: string): void { restoreOriginalOrder(state); state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); + state.waitingForHuman = false; state.lastChangedAt = Date.now(); } } @@ -306,6 +343,7 @@ export function getTurnDebugInfo(channelId: string): Record { noRepliedThisCycle: [...state.noRepliedThisCycle], lastChangedAt: state.lastChangedAt, dormant: state.currentSpeaker === null, + waitingForHuman: state.waitingForHuman, hasOverride: !!state.savedTurnOrder, overrideFirstAgent: state.overrideFirstAgent || null, savedTurnOrder: state.savedTurnOrder || null,