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:
zhi
2026-03-07 17:32:28 +00:00
parent e4454bfc1a
commit 211ad9246f
8 changed files with 101 additions and 4 deletions

View File

@@ -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<string, ChannelTurnState>();
@@ -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<string, unknown> {
noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null,
waitingForHuman: state.waitingForHuman,
hasOverride: !!state.savedTurnOrder,
overrideFirstAgent: state.overrideFirstAgent || null,
savedTurnOrder: state.savedTurnOrder || null,