Major refactor: Replace fixed roles with N named AI agents

Backend changes:
- Refactor mistralClient to generic agent prompts (not role-based)
- Implement streaming responses with thinking extraction
- Create nameGenerator service for random AI names
- Refactor collaborativeOrchestrator for N agents
- Implement true convergence (N agents with no changes)
- Add section merging for partial document updates
- Each AI modifies only ONE section, not entire document
- Broadcast agent_working and agent_thinking events in real-time
- Update routes for new orchestrator API

Features:
- Support 3-50 AI agents instead of fixed 7 roles
- Real-time thinking/reasoning streaming
- Partial document updates (section-based)
- True convergence tracking
- Automatic round progression
- Section extraction and merging

Next: Frontend enhancements for visualization
This commit is contained in:
Augustin ROUX 2025-10-18 23:01:43 +02:00
parent 97c4ad9f6c
commit b566671ea4
3 changed files with 450 additions and 571 deletions

View File

@ -1,7 +1,7 @@
import express from 'express';
import collaborativeOrchestrator from '../services/collaborativeOrchestrator.js';
import express from 'express'
import collaborativeOrchestrator from '../services/collaborativeOrchestrator.js'
const router = express.Router();
const router = express.Router()
/**
* POST /api/collaborate
@ -9,73 +9,74 @@ const router = express.Router();
*/
router.post('/', async (req, res) => {
try {
const { prompt, documentFormat = 'md', agentCount = 7 } = req.body;
const { prompt, documentFormat = 'md', agentCount = 7 } = req.body
if (!prompt || prompt.trim().length === 0) {
return res.status(400).json({ error: 'Prompt is required' });
return res.status(400).json({ error: 'Prompt is required' })
}
if (!['md', 'txt'].includes(documentFormat)) {
return res.status(400).json({ error: 'Document format must be "md" or "txt"' });
return res.status(400).json({ error: 'Document format must be "md" or "txt"' })
}
// Validate agent count
const validAgentCount = Math.min(Math.max(agentCount, 3), 50)
const sessionId = collaborativeOrchestrator.createSession(
prompt,
documentFormat,
Math.min(agentCount, 7)
);
validAgentCount
)
const session = collaborativeOrchestrator.getSessionDetails(sessionId);
const sessionInfo = collaborativeOrchestrator.getSessionInfo(sessionId)
res.json({
sessionId,
prompt,
documentFormat,
agentCount: validAgentCount,
status: 'created',
agents: session.agents.map(a => a.role),
agents: sessionInfo.agents,
message: 'Collaborative session created. Start the session to begin collaboration.'
});
})
} catch (error) {
console.error('Error creating collaborative session:', error);
res.status(500).json({ error: 'Failed to create collaborative session' });
console.error('Error creating collaborative session:', error)
res.status(500).json({ error: 'Failed to create collaborative session' })
}
});
})
/**
* POST /api/collaborate/:id/start
* Start the collaborative session (Lead Architect creates initial document)
* Start the collaborative session
*/
router.post('/:id/start', async (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const session = collaborativeOrchestrator.getSession(sessionId);
const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) {
return res.status(404).json({ error: 'Session not found' });
return res.status(404).json({ error: 'Session not found' })
}
if (session.status !== 'created') {
return res.status(400).json({ error: 'Session has already been started or is no longer available' });
return res.status(400).json({ error: 'Session has already been started' })
}
// Start the session asynchronously
res.json({
sessionId,
status: 'starting',
message: 'Session is starting. Initial document is being created...'
});
// Start asynchronously without waiting
collaborativeOrchestrator.startCollaborativeSession(sessionId).catch(error => {
console.error('Error starting collaborative session:', error);
});
})
// Start asynchronously
collaborativeOrchestrator.startSession(sessionId).catch(error => {
console.error('Error starting session:', error)
})
} catch (error) {
console.error('Error starting collaborative session:', error);
res.status(500).json({ error: 'Failed to start collaborative session' });
console.error('Error starting session:', error)
res.status(500).json({ error: 'Failed to start session' })
}
});
})
/**
* POST /api/collaborate/:id/round
@ -83,85 +84,80 @@ router.post('/:id/start', async (req, res) => {
*/
router.post('/:id/round', async (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const session = collaborativeOrchestrator.getSession(sessionId);
const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) {
return res.status(404).json({ error: 'Session not found' });
return res.status(404).json({ error: 'Session not found' })
}
if (session.status !== 'ongoing') {
return res.status(400).json({ error: 'Session is not in ongoing status' });
return res.status(400).json({ error: 'Session is not in ongoing status' })
}
const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId);
const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId)
if (!activeSession?.started) {
return res.status(400).json({ error: 'Session has not been started yet' });
return res.status(400).json({ error: 'Session has not been started yet' })
}
// Run round asynchronously
const roundNumber = activeSession.conversationHistory.length + 1
res.json({
sessionId,
roundNumber: activeSession.currentRound + 1,
roundNumber,
status: 'running',
message: 'Review round in progress...'
});
})
// Run asynchronously without waiting
// Run asynchronously
collaborativeOrchestrator.runRound(sessionId).catch(error => {
console.error('Error running round:', error);
console.error('Error running round:', error)
collaborativeOrchestrator.broadcast(sessionId, {
type: 'round_error',
sessionId,
error: error.message
});
});
})
})
} catch (error) {
console.error('Error running round:', error);
res.status(500).json({ error: 'Failed to run round' });
console.error('Error running round:', error)
res.status(500).json({ error: 'Failed to run round' })
}
});
})
/**
* GET /api/collaborate/:id
* Get session details with full history and current document
* Get session details
*/
router.get('/:id', (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const session = collaborativeOrchestrator.getSessionDetails(sessionId);
const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) {
return res.status(404).json({ error: 'Session not found' });
return res.status(404).json({ error: 'Session not found' })
}
res.json({
sessionId: session.id,
initialPrompt: session.initial_prompt,
documentFormat: session.document_format,
status: session.status,
createdAt: session.created_at,
completedAt: session.completed_at,
currentRound: session.currentRound,
agents: session.agents.map(a => a.role),
currentDocument: session.currentDocument,
documentVersionCount: session.versions.length,
conversationHistory: session.conversationHistory,
versions: session.versions.map(v => ({
versionNumber: v.version_number,
modifiedBy: v.modified_by,
modificationReason: v.modification_reason,
roundNumber: v.round_number,
createdAt: v.created_at
}))
});
const sessionInfo = collaborativeOrchestrator.getSessionInfo(sessionId)
const versions = collaborativeOrchestrator.getDocumentVersions(sessionId)
res.json({
sessionId: sessionInfo.id,
status: sessionInfo.status,
agents: sessionInfo.agents,
agentCount: sessionInfo.agentCount,
currentRound: sessionInfo.currentRound,
currentDocument: sessionInfo.currentDocument,
versionNumber: sessionInfo.versionNumber,
documentVersionCount: versions.length,
conversationHistory: sessionInfo.conversationHistory,
createdAt: session.created_at,
completedAt: session.completed_at
})
} catch (error) {
console.error('Error fetching session details:', error);
res.status(500).json({ error: 'Failed to fetch session details' });
console.error('Error fetching session details:', error)
res.status(500).json({ error: 'Failed to fetch session details' })
}
});
})
/**
* GET /api/collaborate/:id/document
@ -169,29 +165,27 @@ router.get('/:id', (req, res) => {
*/
router.get('/:id/document', (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const session = collaborativeOrchestrator.getSession(sessionId);
const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) {
return res.status(404).json({ error: 'Session not found' });
return res.status(404).json({ error: 'Session not found' })
}
const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId);
const currentDocument = activeSession?.currentDocument || '';
const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId)
const currentDocument = activeSession?.currentDocument || ''
// Determine content type based on format
const contentType = session.document_format === 'md'
? 'text/markdown; charset=utf-8'
: 'text/plain; charset=utf-8';
res.set('Content-Type', contentType);
res.send(currentDocument);
: 'text/plain; charset=utf-8'
res.set('Content-Type', contentType)
res.send(currentDocument)
} catch (error) {
console.error('Error fetching document:', error);
res.status(500).json({ error: 'Failed to fetch document' });
console.error('Error fetching document:', error)
res.status(500).json({ error: 'Failed to fetch document' })
}
});
})
/**
* GET /api/collaborate/:id/versions/:versionNumber
@ -199,26 +193,21 @@ router.get('/:id/document', (req, res) => {
*/
router.get('/:id/versions/:versionNumber', (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const versionNumber = parseInt(req.params.versionNumber);
const sessionId = parseInt(req.params.id)
const versionNumber = parseInt(req.params.versionNumber)
const session = collaborativeOrchestrator.getSession(sessionId);
const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) {
return res.status(404).json({ error: 'Session not found' });
return res.status(404).json({ error: 'Session not found' })
}
const versions = collaborativeOrchestrator.getDocumentVersions(sessionId);
const version = versions.find(v => v.version_number === versionNumber);
const versions = collaborativeOrchestrator.getDocumentVersions(sessionId)
const version = versions.find(v => v.version_number === versionNumber)
if (!version) {
return res.status(404).json({ error: 'Version not found' });
return res.status(404).json({ error: 'Version not found' })
}
const contentType = session.document_format === 'md'
? 'text/markdown; charset=utf-8'
: 'text/plain; charset=utf-8';
res.set('Content-Type', contentType);
res.json({
versionNumber: version.version_number,
modifiedBy: version.modified_by,
@ -226,13 +215,12 @@ router.get('/:id/versions/:versionNumber', (req, res) => {
roundNumber: version.round_number,
createdAt: version.created_at,
content: version.content
});
})
} catch (error) {
console.error('Error fetching version:', error);
res.status(500).json({ error: 'Failed to fetch version' });
console.error('Error fetching version:', error)
res.status(500).json({ error: 'Failed to fetch version' })
}
});
})
/**
* POST /api/collaborate/:id/complete
@ -240,25 +228,24 @@ router.get('/:id/versions/:versionNumber', (req, res) => {
*/
router.post('/:id/complete', (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const session = collaborativeOrchestrator.getSession(sessionId);
const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) {
return res.status(404).json({ error: 'Session not found' });
return res.status(404).json({ error: 'Session not found' })
}
collaborativeOrchestrator.completeSession(sessionId);
collaborativeOrchestrator.completeSession(sessionId)
res.json({
sessionId,
status: 'completed',
message: 'Session completed successfully'
});
})
} catch (error) {
console.error('Error completing session:', error);
res.status(500).json({ error: 'Failed to complete session' });
console.error('Error completing session:', error)
res.status(500).json({ error: 'Failed to complete session' })
}
});
})
export default router;
export default router

View File

@ -1,21 +1,11 @@
import db from '../db/schema.js';
import { generateAgentResponse } from './mistralClient.js';
import db from '../db/schema.js'
import { generateAgentResponseSync, extractSection, extractThinking } from './mistralClient.js'
import { getRandomNames } from './nameGenerator.js'
class CollaborativeOrchestrator {
constructor() {
this.activeSessions = new Map();
this.wsClients = new Map(); // sessionId -> Set of WebSocket clients
// Define collaborative agents with specialized roles
this.agents = [
{ role: 'lead_architect', isLead: true },
{ role: 'backend_engineer', isLead: false },
{ role: 'frontend_engineer', isLead: false },
{ role: 'ui_designer', isLead: false },
{ role: 'devops_engineer', isLead: false },
{ role: 'product_manager', isLead: false },
{ role: 'security_specialist', isLead: false }
];
this.activeSessions = new Map()
this.wsClients = new Map() // sessionId -> Set of WebSocket clients
}
/**
@ -23,9 +13,9 @@ class CollaborativeOrchestrator {
*/
registerWSClient(sessionId, ws) {
if (!this.wsClients.has(sessionId)) {
this.wsClients.set(sessionId, new Set());
this.wsClients.set(sessionId, new Set())
}
this.wsClients.get(sessionId).add(ws);
this.wsClients.get(sessionId).add(ws)
}
/**
@ -33,7 +23,7 @@ class CollaborativeOrchestrator {
*/
unregisterWSClient(sessionId, ws) {
if (this.wsClients.has(sessionId)) {
this.wsClients.get(sessionId).delete(ws);
this.wsClients.get(sessionId).delete(ws)
}
}
@ -42,432 +32,323 @@ class CollaborativeOrchestrator {
*/
broadcast(sessionId, message) {
if (this.wsClients.has(sessionId)) {
const data = JSON.stringify(message);
const data = JSON.stringify(message)
this.wsClients.get(sessionId).forEach(ws => {
if (ws.readyState === 1) { // OPEN
ws.send(data);
ws.send(data)
}
});
})
}
}
/**
* Create a new collaborative session
* Create a new collaborative session with N random-named agents
*/
createSession(initialPrompt, documentFormat = 'md', agentCount = 7) {
const stmt = db.prepare(
'INSERT INTO collaborative_sessions (initial_prompt, document_format, status) VALUES (?, ?, ?)'
);
const result = stmt.run(initialPrompt, documentFormat, 'created');
const sessionId = result.lastInsertRowid;
)
const result = stmt.run(initialPrompt, documentFormat, 'created')
const sessionId = result.lastInsertRowid
// Select the agents to use
const selectedAgents = this.agents.slice(0, Math.min(agentCount, this.agents.length));
// Generate random names for agents
const agentNames = getRandomNames(Math.min(agentCount, 50))
this.activeSessions.set(sessionId, {
id: sessionId,
initialPrompt,
documentFormat,
agents: selectedAgents,
currentRound: 0,
agents: agentNames, // Array of agent names
agentCount,
currentAgentIndex: 0,
currentDocument: null,
versionNumber: 0,
conversationHistory: [],
started: false
});
started: false,
consecutiveNoChanges: 0, // Counter for convergence
lastModifiedAgent: null
})
return sessionId;
return sessionId
}
/**
* Get session by ID
*/
getSession(sessionId) {
const stmt = db.prepare('SELECT * FROM collaborative_sessions WHERE id = ?');
return stmt.get(sessionId);
const stmt = db.prepare('SELECT * FROM collaborative_sessions WHERE id = ?')
return stmt.get(sessionId)
}
/**
* Get all versions of a document
* Start a session - create initial document with first agent
*/
getDocumentVersions(sessionId) {
const stmt = db.prepare(
`SELECT * FROM document_versions
WHERE session_id = ?
ORDER BY version_number ASC`
);
return stmt.all(sessionId);
}
/**
* Get latest document version
*/
getLatestDocument(sessionId) {
const stmt = db.prepare(
`SELECT * FROM document_versions
WHERE session_id = ?
ORDER BY version_number DESC
LIMIT 1`
);
return stmt.get(sessionId);
}
/**
* Save a document version
*/
saveDocumentVersion(sessionId, content, modifiedBy, reason, roundNumber) {
// Get current version number
const lastVersion = this.getLatestDocument(sessionId);
const versionNumber = (lastVersion?.version_number || 0) + 1;
const stmt = db.prepare(
`INSERT INTO document_versions
(session_id, version_number, content, modified_by, modification_reason, round_number)
VALUES (?, ?, ?, ?, ?, ?)`
);
const result = stmt.run(sessionId, versionNumber, content, modifiedBy, reason, roundNumber);
return result.lastInsertRowid;
}
/**
* Start the collaborative session - Lead Architect creates first version
*/
async startCollaborativeSession(sessionId) {
async startSession(sessionId) {
try {
const session = this.getSession(sessionId);
if (!session) {
throw new Error('Session not found');
}
const session = this.activeSessions.get(sessionId)
if (!session || session.started) return
const activeSession = this.activeSessions.get(sessionId);
if (!activeSession) {
throw new Error('Active session not found');
}
// Update session status from 'created' to 'ongoing'
const updateStmt = db.prepare('UPDATE collaborative_sessions SET status = ? WHERE id = ?');
updateStmt.run('ongoing', sessionId);
activeSession.started = true;
// Broadcast session start
this.broadcast(sessionId, {
type: 'session_start',
sessionId,
message: 'Collaborative session started. Lead Architect is creating initial document...'
});
// Get Lead Architect (first agent)
const leadArchitect = activeSession.agents.find(a => a.isLead);
const firstAgent = session.agents[0]
// Generate initial document
const response = await generateAgentResponse(
leadArchitect.role,
`${session.initial_prompt}\n\nYou are the Lead Architect. Create the INITIAL version of a comprehensive project document in ${session.document_format} format.
const initialResponse = await generateAgentResponseSync(
firstAgent,
session.initialPrompt,
''
)
This document will be reviewed and modified by other team members (Backend Engineer, Frontend Engineer, UI Designer, DevOps Engineer, Product Manager, Security Specialist).
const initialDocument = extractSection(initialResponse)
const thinking = extractThinking(initialResponse)
Create a structured, complete document that outlines:
- Project overview and goals
- Architecture overview
- Technology stack decisions
- Project structure
- Key features
- Non-functional requirements
- Timeline and phases
// Save to DB
const insertStmt = db.prepare(
'INSERT INTO document_versions (session_id, version_number, content, modified_by, modification_reason, round_number) VALUES (?, ?, ?, ?, ?, ?)'
)
insertStmt.run(sessionId, 0, initialDocument, firstAgent, 'Initial document creation', 0)
Output ONLY the raw document content in ${session.document_format} format, nothing else.`
);
// Update session
session.currentDocument = initialDocument
session.versionNumber = 0
session.started = true
session.consecutiveNoChanges = 0
// Extract document from response
let documentContent = response.proposal || response;
if (typeof documentContent === 'object') {
documentContent = JSON.stringify(documentContent, null, 2);
}
// Save as first version
this.saveDocumentVersion(
sessionId,
documentContent,
leadArchitect.role,
'Initial document creation',
1
);
// Update in-memory session
activeSession.currentDocument = documentContent;
activeSession.versionNumber = 1;
activeSession.currentRound = 1;
activeSession.conversationHistory.push({
agent: leadArchitect.role,
action: 'created_initial_document',
documentVersion: 1
});
// Update DB status
const updateStmt = db.prepare('UPDATE collaborative_sessions SET status = ? WHERE id = ?')
updateStmt.run('ongoing', sessionId)
// Broadcast initial document
this.broadcast(sessionId, {
type: 'initial_document_created',
sessionId,
agent: leadArchitect.role,
documentVersion: 1,
document: documentContent,
message: `Lead Architect (${leadArchitect.role}) created initial document. Starting review rounds...`
});
return {
sessionId,
documentVersion: 1,
document: documentContent,
agents: activeSession.agents
};
content: initialDocument,
agentName: firstAgent,
thinking,
roundNumber: 0
})
// Auto-start first round
setTimeout(() => this.runRound(sessionId), 2000)
} catch (error) {
console.error('Error starting collaborative session:', error);
this.failSession(sessionId);
console.error('Error starting session:', error)
this.broadcast(sessionId, {
type: 'session_error',
sessionId,
error: error.message
});
throw error;
})
}
}
/**
* Run one round of review (each agent reviews and potentially modifies)
* Run a round of collaborative review
*/
async runRound(sessionId) {
try {
const session = this.getSession(sessionId);
if (!session) {
throw new Error('Session not found');
}
const session = this.activeSessions.get(sessionId)
if (!session) return
const activeSession = this.activeSessions.get(sessionId);
if (!activeSession) {
throw new Error('Active session not found');
}
const roundNumber = session.conversationHistory.length + 1
const agentsInRound = session.agents
const modifiedAgents = []
if (!activeSession.currentDocument) {
throw new Error('No document to review');
}
// Each agent reviews the document
for (let i = 0; i < agentsInRound.length; i++) {
const agentName = agentsInRound[i]
activeSession.currentRound += 1;
const roundNumber = activeSession.currentRound;
this.broadcast(sessionId, {
type: 'round_start',
sessionId,
roundNumber,
message: `Starting review round ${roundNumber}. Agents will review and modify the document...`
});
const agentsMadeChanges = [];
const roundAgents = activeSession.agents.map(a => a.role).join(',');
// Process each agent sequentially
for (const agent of activeSession.agents) {
// Broadcast that this agent is working
this.broadcast(sessionId, {
type: 'agent_reviewing',
sessionId,
agent: agent.role,
roundNumber,
message: `${agent.role} is reviewing the document...`
});
type: 'agent_working',
agentName,
roundNumber
})
// Call agent to review and potentially modify document
const response = await generateAgentResponse(
agent.role,
`${session.initial_prompt}\n\n
CURRENT DOCUMENT (${session.document_format} format):
\`\`\`${session.document_format}
${activeSession.currentDocument}
\`\`\`
try {
const response = await generateAgentResponseSync(
agentName,
session.initialPrompt,
session.currentDocument
)
You are the ${agent.role}. Review this document from your perspective. Your task is to:
1. Read and understand the entire document
2. Identify areas that need improvement or modifications from your expertise area
3. Provide improvements, additions, or modifications
const thinking = extractThinking(response)
const section = extractSection(response)
IMPORTANT:
- If you decide to modify the document, output ONLY the complete modified document in ${session.document_format} format
- If the document is already excellent and needs no changes, output: NO_CHANGES
- Do not include explanations, just the document or NO_CHANGES
Modification focus for ${agent.role}:
${this.getAgentFocusArea(agent.role)}`
);
let responseText = response.proposal || response;
if (typeof responseText === 'object') {
responseText = JSON.stringify(responseText, null, 2);
}
// Check if agent made changes
if (responseText.trim() !== 'NO_CHANGES') {
// Calculate diff
const diff = this.calculateDiff(activeSession.currentDocument, responseText);
// Save new version
this.saveDocumentVersion(
sessionId,
responseText,
agent.role,
diff.summary,
// Broadcast agent's thinking in real-time
this.broadcast(sessionId, {
type: 'agent_thinking',
agentName,
thinking,
roundNumber
);
})
activeSession.currentDocument = responseText;
activeSession.versionNumber += 1;
agentsMadeChanges.push(agent.role);
// Check if agent made changes
if (section !== 'Section is good, no changes needed' && !section.includes('no changes needed')) {
// Merge section into document
const updatedDocument = this.mergeSection(session.currentDocument, section)
this.broadcast(sessionId, {
type: 'document_modified',
sessionId,
agent: agent.role,
roundNumber,
documentVersion: activeSession.versionNumber,
document: responseText,
changeSummary: diff.summary,
message: `${agent.role} modified the document`
});
} else {
this.broadcast(sessionId, {
type: 'agent_no_changes',
sessionId,
agent: agent.role,
roundNumber,
message: `${agent.role} reviewed the document and found no changes needed`
});
if (updatedDocument !== session.currentDocument) {
session.currentDocument = updatedDocument
session.versionNumber++
modifiedAgents.push(agentName)
// Save version to DB
const insertStmt = db.prepare(
'INSERT INTO document_versions (session_id, version_number, content, modified_by, modification_reason, round_number) VALUES (?, ?, ?, ?, ?, ?)'
)
insertStmt.run(
sessionId,
session.versionNumber,
updatedDocument,
agentName,
`Round ${roundNumber} modifications`,
roundNumber
)
// Broadcast modification
this.broadcast(sessionId, {
type: 'document_modified',
content: updatedDocument,
modifiedBy: agentName,
section,
roundNumber
})
}
}
} catch (error) {
console.error(`Error with agent ${agentName}:`, error)
}
}
// Save round completion
// Track convergence
if (modifiedAgents.length === 0) {
session.consecutiveNoChanges++
} else {
session.consecutiveNoChanges = 0
}
// Save round to DB
const roundStmt = db.prepare(
`INSERT INTO document_rounds
(session_id, round_number, agents_in_round, agents_made_changes, completed_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`
);
'INSERT INTO document_rounds (session_id, round_number, agents_in_round, agents_made_changes) VALUES (?, ?, ?, ?)'
)
roundStmt.run(
sessionId,
roundNumber,
roundAgents,
agentsMadeChanges.join(',')
);
JSON.stringify(agentsInRound),
JSON.stringify(modifiedAgents)
)
const conversationEntry = {
// Add to history
session.conversationHistory.push({
roundNumber,
agentsMadeChanges: agentsMadeChanges.length > 0 ? agentsMadeChanges : 'none'
};
activeSession.conversationHistory.push(conversationEntry);
// Check for convergence
const hasConverged = agentsMadeChanges.length === 0;
agentsMadeChanges: modifiedAgents,
timestamp: Date.now()
})
// Broadcast round complete
const hasConverged = session.consecutiveNoChanges >= session.agentCount
this.broadcast(sessionId, {
type: 'round_complete',
sessionId,
roundNumber,
agentsMadeChanges: agentsMadeChanges.length,
agentsWhoModified: agentsMadeChanges,
agentsMadeChanges: modifiedAgents,
hasConverged,
message: hasConverged
? 'Convergence reached! No more changes needed.'
: `Round ${roundNumber} complete. ${agentsMadeChanges.length} agent(s) made changes.`
});
return {
roundNumber,
agentsMadeChanges,
hasConverged,
documentVersion: activeSession.versionNumber
};
consecutiveNoChanges: session.consecutiveNoChanges
})
// Auto-schedule next round if not converged
if (!hasConverged && session.consecutiveNoChanges < session.agentCount) {
setTimeout(() => this.runRound(sessionId), 2000)
} else if (hasConverged) {
// All agents agreed, auto-complete
setTimeout(() => this.completeSession(sessionId), 2000)
}
} catch (error) {
console.error('Error running round:', error);
throw error;
console.error('Error running round:', error)
this.broadcast(sessionId, {
type: 'session_error',
error: error.message
})
}
}
/**
* Get focus area for each agent
* Merge a modified section into the document
*/
getAgentFocusArea(agentRole) {
const focusAreas = {
lead_architect: 'High-level architecture, technology stack, system design, scalability',
backend_engineer: 'APIs, databases, backend services, data models, performance, security',
frontend_engineer: 'UI components, state management, performance, user interactions, frameworks',
ui_designer: 'User experience, accessibility, visual design, UI patterns, usability',
devops_engineer: 'Deployment, infrastructure, CI/CD, monitoring, containers, scalability',
product_manager: 'Features, user needs, roadmap, priorities, market fit, business goals',
security_specialist: 'Security architecture, data protection, authentication, compliance'
};
return focusAreas[agentRole] || 'General improvements';
}
mergeSection(document, newSection) {
if (!document || !newSection) return document
/**
* Calculate diff between two document versions (simplified)
*/
calculateDiff(oldContent, newContent) {
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
// Extract header from new section
const headerMatch = newSection.match(/^(#{1,4})\s+(.+)/)
if (!headerMatch) return document
const linesAdded = Math.max(0, newLines.length - oldLines.length);
const linesRemoved = Math.max(0, oldLines.length - newLines.length);
const changes = Math.abs(linesAdded) + Math.abs(linesRemoved);
const headerLevel = headerMatch[1].length
const headerText = headerMatch[2]
const headerRegex = new RegExp(`^${'#'.repeat(headerLevel)}\\s+${headerText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'gm')
const summary = `Modified document: +${linesAdded} lines, -${linesRemoved} lines (${changes} total changes)`;
return { summary, linesAdded, linesRemoved };
// Find and replace the section
if (headerRegex.test(document)) {
// Replace existing section
const sections = document.split(/\n(?=^#{1,4}\s+)/m)
let merged = sections
.map(section => {
if (headerRegex.test(section)) {
return newSection
}
return section
})
.join('\n')
return merged
} else {
// Append new section
return document + '\n\n' + newSection
}
}
/**
* Complete a session
*/
completeSession(sessionId) {
const session = this.activeSessions.get(sessionId);
const document = session?.currentDocument || '';
async completeSession(sessionId) {
try {
const stmt = db.prepare('UPDATE collaborative_sessions SET status = ?, completed_at = CURRENT_TIMESTAMP, final_document = ? WHERE id = ?')
const session = this.activeSessions.get(sessionId)
if (session) {
stmt.run('completed', session.currentDocument, sessionId)
}
const stmt = db.prepare(
'UPDATE collaborative_sessions SET status = ?, completed_at = CURRENT_TIMESTAMP, final_document = ? WHERE id = ?'
);
stmt.run('completed', document, sessionId);
this.activeSessions.delete(sessionId);
this.broadcast(sessionId, {
type: 'session_completed',
finalDocument: session?.currentDocument
})
} catch (error) {
console.error('Error completing session:', error)
}
}
/**
* Fail a session
* Get document versions
*/
failSession(sessionId) {
getDocumentVersions(sessionId) {
const stmt = db.prepare(
'UPDATE collaborative_sessions SET status = ? WHERE id = ?'
);
stmt.run('failed', sessionId);
this.activeSessions.delete(sessionId);
'SELECT * FROM document_versions WHERE session_id = ? ORDER BY version_number ASC'
)
return stmt.all(sessionId)
}
/**
* Get session details with full history
* Get session info for API response
*/
getSessionDetails(sessionId) {
const session = this.getSession(sessionId);
const versions = this.getDocumentVersions(sessionId);
const activeSession = this.activeSessions.get(sessionId);
getSessionInfo(sessionId) {
const session = this.activeSessions.get(sessionId)
const dbSession = this.getSession(sessionId)
if (!session) return null
return {
...session,
versions,
currentRound: activeSession?.currentRound || 0,
currentDocument: activeSession?.currentDocument || null,
agents: activeSession?.agents || [],
conversationHistory: activeSession?.conversationHistory || []
};
id: sessionId,
status: dbSession?.status,
agents: session.agents,
agentCount: session.agentCount,
currentRound: session.conversationHistory.length,
currentDocument: session.currentDocument,
versionNumber: session.versionNumber,
conversationHistory: session.conversationHistory
}
}
}
export default new CollaborativeOrchestrator();
export default new CollaborativeOrchestrator()

View File

@ -1,56 +1,43 @@
import dotenv from 'dotenv';
import dotenv from 'dotenv'
import { Readable } from 'stream'
dotenv.config();
dotenv.config()
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY;
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'
/**
* Agent role system prompts
* Generic AI prompt for collaborative document editing
*/
const AGENT_PROMPTS = {
architect: `You are a Software Architect AI. Your role is to:
- Design high-level system architecture
- Make technology stack decisions
- Define project structure and modules
- Consider scalability and maintainability
- Provide clear technical justifications
function getAgentPrompt(agentName) {
return `You are an AI assistant named ${agentName} collaborating on a technical document design.
Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`,
Your responsibilities:
1. Review the current document structure
2. Select ONE section to improve or modify (identified by #, ##, ###, #### headers)
3. Provide your thinking process and reasoning
4. Return ONLY the modified section with its header, or confirm it's good as-is
backend_engineer: `You are a Backend Engineer AI. Your role is to:
- Design API endpoints and data models
- Suggest backend technologies and frameworks
- Plan database schema
- Consider performance and security
- Provide implementation guidelines
IMPORTANT RULES:
- Only modify ONE section header and its content
- Never modify the entire document
- Return only the section you're working on, not the whole document
- If section is good, respond: "Section is good, no changes needed"
- Think step-by-step about what could be improved
- Share your reasoning process
Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`,
frontend_engineer: `You are a Frontend Engineer AI. Your role is to:
- Design user interface structure
- Suggest frontend frameworks and libraries
- Plan component architecture
- Consider UX and performance
- Provide implementation guidelines
Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`,
designer: `You are a UI/UX Designer AI. Your role is to:
- Design user experience flows
- Suggest UI patterns and layouts
- Consider accessibility and usability
- Provide visual design guidelines
- Think about user interactions
Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`
};
Format your response as:
THINKING: [Your analysis and reasoning]
DECISION: [What you'll modify or if keeping as-is]
SECTION:
[The modified or confirmed section with header]`
}
/**
* Call Mistral AI API
* Call Mistral AI API with streaming
*/
async function callMistralAPI(messages, options = {}) {
const { maxTokens, ...otherOptions } = options;
const { maxTokens, stream = false, ...otherOptions } = options
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
@ -63,108 +50,132 @@ async function callMistralAPI(messages, options = {}) {
messages,
temperature: options.temperature || 0.7,
max_tokens: maxTokens || 2048,
stream,
...otherOptions
})
});
})
if (!response.ok) {
const error = await response.text();
throw new Error(`Mistral API error: ${error}`);
const error = await response.text()
throw new Error(`Mistral API error: ${error}`)
}
return await response.json();
return response
}
/**
* Generate agent response for a debate
* Parse streaming response
*/
export async function generateAgentResponse(agentRole, prompt, context = []) {
const systemPrompt = AGENT_PROMPTS[agentRole] || AGENT_PROMPTS.architect;
async function* parseStreamResponse(reader) {
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const json = JSON.parse(data)
if (json.choices?.[0]?.delta?.content) {
yield json.choices[0].delta.content
}
} catch (e) {
// Skip invalid JSON
}
}
}
}
}
/**
* Generate agent response with streaming thoughts
*/
export async function generateAgentResponse(agentName, prompt, currentDocument = '', onThought = null) {
const systemPrompt = getAgentPrompt(agentName)
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Project prompt: ${prompt}` }
];
// Add context from previous responses
if (context.length > 0) {
const contextStr = context
.slice(-3) // Last 3 responses to avoid token bloat
.map(r => `${r.agent_role}: ${JSON.stringify(r.content)}`)
.join('\n');
messages.push({
role: 'user',
content: `Previous discussion:\n${contextStr}\n\nProvide your analysis and proposal.`
});
}
{ role: 'user', content: `Project description: ${prompt}\n\nCurrent document:\n${currentDocument}` }
]
try {
const result = await callMistralAPI(messages, {
temperature: 0.7,
maxTokens: 2048
});
const response = await callMistralAPI(messages, { stream: true })
const reader = response.body.getReader()
let fullContent = ''
const content = result.choices[0].message.content;
// Try to parse as JSON
let parsedContent;
try {
// Extract JSON from markdown code blocks if present
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) ||
content.match(/(\{[\s\S]*\})/);
if (jsonMatch) {
parsedContent = JSON.parse(jsonMatch[1]);
} else {
parsedContent = JSON.parse(content);
for await (const chunk of parseStreamResponse(reader)) {
fullContent += chunk
if (onThought) {
onThought(chunk)
}
} catch (parseError) {
// If not valid JSON, create structured response
parsedContent = {
proposal: content,
justification: `Analysis from ${agentRole}`,
confidence: 0.7,
dependencies: []
};
yield chunk
}
// Ensure required fields
return {
proposal: parsedContent.proposal || content,
justification: parsedContent.justification || '',
confidence: parsedContent.confidence || 0.7,
dependencies: parsedContent.dependencies || [],
mermaid: parsedContent.mermaid || null
};
return fullContent
} catch (error) {
console.error(`Error generating response for ${agentRole}:`, error);
// Return mock response on error
return {
proposal: `Error generating response: ${error.message}`,
justification: 'Failed to get AI response',
confidence: 0.5,
dependencies: [],
error: true
};
console.error(`Error generating response from ${agentName}:`, error)
return `Error: ${error.message}`
}
}
/**
* Generate responses from multiple agents in parallel
* Generate agent response (non-streaming version for simpler integration)
*/
export async function generateMultiAgentResponses(agents, prompt, context = []) {
const promises = agents.map(agent =>
generateAgentResponse(agent, prompt, context)
.then(response => ({ agent, response }))
);
export async function generateAgentResponseSync(agentName, prompt, currentDocument = '') {
const systemPrompt = getAgentPrompt(agentName)
return await Promise.all(promises);
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Project description: ${prompt}\n\nCurrent document:\n${currentDocument}` }
]
try {
const response = await callMistralAPI(messages)
const data = await response.json()
if (!data.choices?.[0]?.message?.content) {
throw new Error('Invalid response from Mistral API')
}
return data.choices[0].message.content
} catch (error) {
console.error(`Error generating response from ${agentName}:`, error)
return {
proposal: `Error generating response: ${error.message}`,
justification: 'Failed to get AI response',
confidence: 0,
dependencies: [],
error: true
}
}
}
export default {
generateAgentResponse,
generateMultiAgentResponses
};
/**
* Extract section from AI response
*/
export function extractSection(aiResponse) {
const sectionMatch = aiResponse.match(/SECTION:\s*([\s\S]*?)(?:$|THINKING:|DECISION:)/)
if (sectionMatch) {
return sectionMatch[1].trim()
}
return aiResponse
}
/**
* Extract thinking from AI response
*/
export function extractThinking(aiResponse) {
const thinkingMatch = aiResponse.match(/THINKING:\s*([\s\S]*?)(?:DECISION:|SECTION:)/)
if (thinkingMatch) {
return thinkingMatch[1].trim()
}
return ''
}