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:
65
src/db.ts
65
src/db.ts
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user