feat: split dirigent_tools into individual tools + human @mention override

Feature 1: Split dirigent_tools
- Replace monolithic dirigent_tools (9 actions) with 9 individual tools
- Discord: dirigent_channel_create, dirigent_channel_update, dirigent_member_list
- Policy: dirigent_policy_get, dirigent_policy_set, dirigent_policy_delete
- Turn: dirigent_turn_status, dirigent_turn_advance, dirigent_turn_reset
- Extract shared executeDiscordAction() helper

Feature 2: Human @mention override
- When humanList user @mentions agents, temporarily override turn order
- Only mentioned agents cycle, ordered by their turn order position
- Original order restores when cycle returns to first agent or all NO_REPLY
- New: setMentionOverride(), hasMentionOverride(), extractMentionedUserIds()
- New: buildUserIdToAccountIdMap() for reverse userId→accountId resolution

Bump version to 0.3.0
This commit is contained in:
zhi
2026-03-07 16:55:01 +00:00
parent bbd18cd90c
commit 0729e83b38
7 changed files with 436 additions and 113 deletions

View File

@@ -20,6 +20,11 @@ export type ChannelTurnState = {
noRepliedThisCycle: Set<string>;
/** Timestamp of last state change */
lastChangedAt: number;
// ── Mention override state ──
/** Original turn order saved when override is active */
savedTurnOrder?: string[];
/** First agent in override cycle; used to detect cycle completion */
overrideFirstAgent?: string;
};
const channelTurns = new Map<string, ChannelTurnState>();
@@ -107,6 +112,8 @@ export function checkTurn(channelId: string, accountId: string): {
* Called when a new message arrives in the channel.
* Handles reactivation from dormant state and human-triggered resets.
*
* NOTE: For human messages with @mentions, call setMentionOverride() instead.
*
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
* @param isHuman - whether the sender is in the humanList
*/
@@ -115,7 +122,8 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi
if (!state || state.turnOrder.length === 0) return;
if (isHuman) {
// Human message: activate, start from first in order
// Human message without @mentions: restore original order if overridden, activate from first
restoreOriginalOrder(state);
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
@@ -141,6 +149,61 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi
state.lastChangedAt = Date.now();
}
/**
* Restore original turn order if an override is active.
*/
function restoreOriginalOrder(state: ChannelTurnState): void {
if (state.savedTurnOrder) {
state.turnOrder = state.savedTurnOrder;
state.savedTurnOrder = undefined;
state.overrideFirstAgent = undefined;
}
}
/**
* Set a temporary mention override for the turn order.
* When a human @mentions specific agents, only those agents speak (in their
* relative order from the current turn order). After the cycle returns to the
* first agent, the original order is restored.
*
* @param channelId - Discord channel ID
* @param mentionedAccountIds - accountIds of @mentioned agents, ordered by
* their position in the current turn order
* @returns true if override was set, false if no valid agents
*/
export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean {
const state = channelTurns.get(channelId);
if (!state || mentionedAccountIds.length === 0) return false;
// Restore any existing override first
restoreOriginalOrder(state);
// Filter to agents actually in the turn order
const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id));
if (validIds.length === 0) return false;
// Order by their position in the current turn order
validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b));
// Save original and apply override
state.savedTurnOrder = [...state.turnOrder];
state.turnOrder = validIds;
state.overrideFirstAgent = validIds[0];
state.currentSpeaker = validIds[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return true;
}
/**
* Check if a mention override is currently active.
*/
export function hasMentionOverride(channelId: string): boolean {
const state = channelTurns.get(channelId);
return !!state?.savedTurnOrder;
}
/**
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
@@ -157,6 +220,8 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply:
// Check if ALL agents have NO_REPLY'd this cycle
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
if (allNoReplied) {
// If override active, restore original order before going dormant
restoreOriginalOrder(state);
// Go dormant
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
@@ -168,7 +233,18 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply:
state.noRepliedThisCycle = new Set();
}
return advanceTurn(channelId);
const next = advanceTurn(channelId);
// Check if override cycle completed (returned to first agent)
if (state.overrideFirstAgent && next === state.overrideFirstAgent) {
restoreOriginalOrder(state);
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return null; // go dormant after override cycle completes
}
return next;
}
/**
@@ -209,6 +285,7 @@ export function advanceTurn(channelId: string): string | null {
export function resetTurn(channelId: string): void {
const state = channelTurns.get(channelId);
if (state) {
restoreOriginalOrder(state);
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
@@ -229,5 +306,8 @@ export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null,
hasOverride: !!state.savedTurnOrder,
overrideFirstAgent: state.overrideFirstAgent || null,
savedTurnOrder: state.savedTurnOrder || null,
};
}