feat: Discord-independent inter-agent communication via paired_turn_outputs

- New paired_turn_outputs table stores agent output directly in DB
- Owner/reviewer prompts assembled from DB instead of Discord messages
- Arbiter context includes human messages + turn outputs
- Falls back to Discord messages for pre-migration tasks
- 50k char storage limit with truncation warning
This commit is contained in:
Eyejoker
2026-03-31 03:38:53 +09:00
parent dddf18428e
commit efb5710aad
4 changed files with 183 additions and 11 deletions

View File

@@ -25,7 +25,9 @@ import {
NewMessage, NewMessage,
AgentType, AgentType,
PairedProject, PairedProject,
PairedRoomRole,
PairedTask, PairedTask,
PairedTurnOutput,
PairedWorkspace, PairedWorkspace,
RegisteredGroup, RegisteredGroup,
ScheduledTask, ScheduledTask,
@@ -276,6 +278,17 @@ function createSchema(database: Database): void {
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
ON paired_workspaces(task_id, role); ON paired_workspaces(task_id, role);
CREATE TABLE IF NOT EXISTS paired_turn_outputs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
turn_number INTEGER NOT NULL,
role TEXT NOT NULL,
output_text TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(task_id, turn_number, role)
);
CREATE INDEX IF NOT EXISTS idx_paired_turn_outputs_task
ON paired_turn_outputs(task_id, turn_number);
CREATE TABLE IF NOT EXISTS channel_owner ( CREATE TABLE IF NOT EXISTS channel_owner (
chat_jid TEXT PRIMARY KEY, chat_jid TEXT PRIMARY KEY,
owner_service_id TEXT NOT NULL, owner_service_id TEXT NOT NULL,
@@ -2443,3 +2456,55 @@ function migrateJsonState(): void {
} }
} }
} }
// ── Paired turn outputs ──────────────────────────────────────────
const MAX_TURN_OUTPUT_CHARS = 50_000;
export function insertPairedTurnOutput(
taskId: string,
turnNumber: number,
role: PairedRoomRole,
outputText: string,
): void {
if (outputText.length > MAX_TURN_OUTPUT_CHARS) {
logger.warn(
{ taskId, turnNumber, role, originalLen: outputText.length, maxLen: MAX_TURN_OUTPUT_CHARS },
'Paired turn output truncated — agent output exceeds storage limit',
);
}
db.prepare(
`INSERT OR REPLACE INTO paired_turn_outputs
(task_id, turn_number, role, output_text, created_at)
VALUES (?, ?, ?, ?, ?)`,
).run(
taskId,
turnNumber,
role,
outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
new Date().toISOString(),
);
}
export function getPairedTurnOutputs(
taskId: string,
): PairedTurnOutput[] {
return db
.prepare(
`SELECT * FROM paired_turn_outputs
WHERE task_id = ?
ORDER BY turn_number ASC`,
)
.all(taskId) as PairedTurnOutput[];
}
export function getLatestTurnNumber(taskId: string): number {
const row = db
.prepare(
`SELECT MAX(turn_number) as max_turn
FROM paired_turn_outputs
WHERE task_id = ?`,
)
.get(taskId) as { max_turn: number | null } | undefined;
return row?.max_turn ?? 0;
}

View File

@@ -15,6 +15,8 @@ import {
createServiceHandoff, createServiceHandoff,
getAllTasks, getAllTasks,
getLatestOpenPairedTaskForChat, getLatestOpenPairedTaskForChat,
getLatestTurnNumber,
insertPairedTurnOutput,
} from './db.js'; } from './db.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -226,6 +228,7 @@ export async function runAgentForGroup(
const effectivePrompt = moaEnrichedPrompt; const effectivePrompt = moaEnrichedPrompt;
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null; let pairedExecutionSummary: string | null = null;
let pairedFullOutput: string | null = null;
let pairedExecutionCompleted = false; let pairedExecutionCompleted = false;
let pairedSawOutput = false; let pairedSawOutput = false;
@@ -420,6 +423,7 @@ export async function runAgentForGroup(
const outputText = getAgentOutputText(output); const outputText = getAgentOutputText(output);
if (typeof outputText === 'string' && outputText.length > 0) { if (typeof outputText === 'string' && outputText.length > 0) {
pairedExecutionSummary = outputText.slice(0, 500); pairedExecutionSummary = outputText.slice(0, 500);
pairedFullOutput = outputText;
} else if ( } else if (
typeof output.error === 'string' && typeof output.error === 'string' &&
output.error.length > 0 output.error.length > 0
@@ -1019,6 +1023,25 @@ export async function runAgentForGroup(
status: effectiveStatus, status: effectiveStatus,
summary: pairedExecutionSummary, summary: pairedExecutionSummary,
}); });
// Store full output for direct inter-agent data passing (Discord-independent).
if (pairedFullOutput && effectiveStatus === 'succeeded') {
try {
const turnNumber =
getLatestTurnNumber(pairedExecutionContext.task.id) + 1;
insertPairedTurnOutput(
pairedExecutionContext.task.id,
turnNumber,
completedRole,
pairedFullOutput,
);
} catch (err) {
logger.warn(
{ taskId: pairedExecutionContext.task.id, err },
'Failed to store paired turn output',
);
}
}
} }
// After owner/reviewer completes, enqueue the next turn so // After owner/reviewer completes, enqueue the next turn so

View File

@@ -16,6 +16,7 @@ import {
getRecentChatMessages, getRecentChatMessages,
isPairedRoomJid, isPairedRoomJid,
getLatestOpenPairedTaskForChat, getLatestOpenPairedTaskForChat,
getPairedTurnOutputs,
updatePairedTask, updatePairedTask,
type ServiceHandoff, type ServiceHandoff,
type WorkItem, type WorkItem,
@@ -160,6 +161,53 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}); });
}; };
/** Convert paired turn outputs to NewMessage format for formatMessages(). */
const turnOutputsToMessages = (
outputs: import('./types.js').PairedTurnOutput[],
chatJid: string,
): NewMessage[] =>
outputs.map((t) => ({
id: `turn-${t.task_id}-${t.turn_number}`,
chat_jid: chatJid,
sender: t.role,
sender_name: t.role,
content: t.output_text,
timestamp: t.created_at,
is_bot_message: true as const,
is_from_me: false as const,
}));
/**
* Build a prompt from paired_turn_outputs (Discord-independent) + human messages.
* Falls back to the legacy labelPairedSenders path when no turn outputs exist.
*/
const buildPairedTurnPrompt = (
taskId: string,
chatJid: string,
timezone: string,
missedMessages: NewMessage[],
): string => {
const turnOutputs = getPairedTurnOutputs(taskId);
if (turnOutputs.length === 0) {
// No stored outputs yet — fall back to Discord messages
return formatMessages(
labelPairedSenders(chatJid, missedMessages),
timezone,
);
}
// Human messages from the missed messages (exclude bot messages)
const humanMessages = missedMessages.filter((m) => !m.is_bot_message);
const agentMessages = turnOutputsToMessages(turnOutputs, chatJid);
// Merge human + agent messages chronologically
const allMessages = [...humanMessages, ...agentMessages].sort(
(a, b) => a.timestamp.localeCompare(b.timestamp),
);
return formatMessages(allMessages, timezone);
};
const isBotOnlyPairedRoomTurn = ( const isBotOnlyPairedRoomTurn = (
chatJid: string, chatJid: string,
messages: NewMessage[], messages: NewMessage[],
@@ -655,10 +703,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
); );
} }
const arbiterMessages = labelPairedSenders( const arbiterTurnOutputs = getPairedTurnOutputs(
chatJid, pendingReviewTask.id,
getRecentChatMessages(chatJid, 20),
); );
const recentMsgs = getRecentChatMessages(chatJid, 20);
const arbiterMessages =
arbiterTurnOutputs.length > 0
? [
...recentMsgs.filter((m) => !m.is_bot_message),
...turnOutputsToMessages(arbiterTurnOutputs, chatJid),
].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
: labelPairedSenders(chatJid, recentMsgs);
const arbiterPrompt = buildArbiterContextPrompt({ const arbiterPrompt = buildArbiterContextPrompt({
chatJid, chatJid,
taskId: pendingReviewTask.id, taskId: pendingReviewTask.id,
@@ -820,10 +875,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const turnRole = resolveActiveRole(taskStatus); const turnRole = resolveActiveRole(taskStatus);
let prompt: string; let prompt: string;
if (turnRole === 'arbiter' && pendingTaskForChannel) { if (turnRole === 'arbiter' && pendingTaskForChannel) {
const arbiterMsgs = labelPairedSenders( // Prefer direct turn outputs; fall back to Discord messages
chatJid, const turnOutputs = getPairedTurnOutputs(pendingTaskForChannel.id);
getRecentChatMessages(chatJid, 20), const arbiterRecentMsgs = getRecentChatMessages(chatJid, 20);
); const arbiterMsgs =
turnOutputs.length > 0
? [
...arbiterRecentMsgs.filter((m) => !m.is_bot_message),
...turnOutputsToMessages(turnOutputs, chatJid),
].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
: labelPairedSenders(chatJid, arbiterRecentMsgs);
prompt = buildArbiterContextPrompt({ prompt = buildArbiterContextPrompt({
chatJid, chatJid,
taskId: pendingTaskForChannel.id, taskId: pendingTaskForChannel.id,
@@ -831,6 +892,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
timezone: deps.timezone, timezone: deps.timezone,
messages: arbiterMsgs, messages: arbiterMsgs,
}); });
} else if (pendingTaskForChannel) {
prompt = buildPairedTurnPrompt(
pendingTaskForChannel.id,
chatJid,
deps.timezone,
missedMessages,
);
} else { } else {
prompt = formatMessages( prompt = formatMessages(
labelPairedSenders(chatJid, missedMessages), labelPairedSenders(chatJid, missedMessages),
@@ -1042,10 +1110,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
pendingMessages.length > 0 pendingMessages.length > 0
? pendingMessages ? pendingMessages
: processableGroupMessages; : processableGroupMessages;
const formatted = formatMessages( const formatted = loopPendingTask
labelPairedSenders(chatJid, messagesToSend), ? buildPairedTurnPrompt(
deps.timezone, loopPendingTask.id,
); chatJid,
deps.timezone,
messagesToSend,
)
: formatMessages(
labelPairedSenders(chatJid, messagesToSend),
deps.timezone,
);
const isBotOnlyPairedFollowUp = isBotOnlyPairedRoomTurn( const isBotOnlyPairedFollowUp = isBotOnlyPairedRoomTurn(
chatJid, chatJid,
messagesToSend, messagesToSend,

View File

@@ -75,6 +75,15 @@ export interface PairedTask {
updated_at: string; updated_at: string;
} }
export interface PairedTurnOutput {
id: number;
task_id: string;
turn_number: number;
role: PairedRoomRole;
output_text: string;
created_at: string;
}
export interface PairedWorkspace { export interface PairedWorkspace {
id: string; id: string;
task_id: string; task_id: string;