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 express from 'express'
import collaborativeOrchestrator from '../services/collaborativeOrchestrator.js'; import collaborativeOrchestrator from '../services/collaborativeOrchestrator.js'
const router = express.Router(); const router = express.Router()
/** /**
* POST /api/collaborate * POST /api/collaborate
@ -9,73 +9,74 @@ const router = express.Router();
*/ */
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { try {
const { prompt, documentFormat = 'md', agentCount = 7 } = req.body; const { prompt, documentFormat = 'md', agentCount = 7 } = req.body
if (!prompt || prompt.trim().length === 0) { 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)) { 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( const sessionId = collaborativeOrchestrator.createSession(
prompt, prompt,
documentFormat, documentFormat,
Math.min(agentCount, 7) validAgentCount
); )
const session = collaborativeOrchestrator.getSessionDetails(sessionId); const sessionInfo = collaborativeOrchestrator.getSessionInfo(sessionId)
res.json({ res.json({
sessionId, sessionId,
prompt, prompt,
documentFormat, documentFormat,
agentCount: validAgentCount,
status: 'created', status: 'created',
agents: session.agents.map(a => a.role), agents: sessionInfo.agents,
message: 'Collaborative session created. Start the session to begin collaboration.' message: 'Collaborative session created. Start the session to begin collaboration.'
}); })
} catch (error) { } catch (error) {
console.error('Error creating collaborative session:', error); console.error('Error creating collaborative session:', error)
res.status(500).json({ error: 'Failed to create collaborative session' }); res.status(500).json({ error: 'Failed to create collaborative session' })
} }
}); })
/** /**
* POST /api/collaborate/:id/start * 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) => { router.post('/:id/start', async (req, res) => {
try { try {
const sessionId = parseInt(req.params.id); const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId); const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) { if (!session) {
return res.status(404).json({ error: 'Session not found' }); return res.status(404).json({ error: 'Session not found' })
} }
if (session.status !== 'created') { 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({ res.json({
sessionId, sessionId,
status: 'starting', status: 'starting',
message: 'Session is starting. Initial document is being created...' 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) { } catch (error) {
console.error('Error starting collaborative session:', error); console.error('Error starting session:', error)
res.status(500).json({ error: 'Failed to start collaborative session' }); res.status(500).json({ error: 'Failed to start session' })
} }
}); })
/** /**
* POST /api/collaborate/:id/round * POST /api/collaborate/:id/round
@ -83,85 +84,80 @@ router.post('/:id/start', async (req, res) => {
*/ */
router.post('/:id/round', async (req, res) => { router.post('/:id/round', async (req, res) => {
try { try {
const sessionId = parseInt(req.params.id); const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId); const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) { if (!session) {
return res.status(404).json({ error: 'Session not found' }); return res.status(404).json({ error: 'Session not found' })
} }
if (session.status !== 'ongoing') { 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) { 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({ res.json({
sessionId, sessionId,
roundNumber: activeSession.currentRound + 1, roundNumber,
status: 'running', status: 'running',
message: 'Review round in progress...' message: 'Review round in progress...'
}); })
// Run asynchronously without waiting // Run asynchronously
collaborativeOrchestrator.runRound(sessionId).catch(error => { collaborativeOrchestrator.runRound(sessionId).catch(error => {
console.error('Error running round:', error); console.error('Error running round:', error)
collaborativeOrchestrator.broadcast(sessionId, { collaborativeOrchestrator.broadcast(sessionId, {
type: 'round_error', type: 'round_error',
sessionId, sessionId,
error: error.message error: error.message
}); })
}); })
} catch (error) { } catch (error) {
console.error('Error running round:', error); console.error('Error running round:', error)
res.status(500).json({ error: 'Failed to run round' }); res.status(500).json({ error: 'Failed to run round' })
} }
}); })
/** /**
* GET /api/collaborate/:id * GET /api/collaborate/:id
* Get session details with full history and current document * Get session details
*/ */
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
try { try {
const sessionId = parseInt(req.params.id); const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSessionDetails(sessionId); const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) { if (!session) {
return res.status(404).json({ error: 'Session not found' }); return res.status(404).json({ error: 'Session not found' })
} }
res.json({ const sessionInfo = collaborativeOrchestrator.getSessionInfo(sessionId)
sessionId: session.id, const versions = collaborativeOrchestrator.getDocumentVersions(sessionId)
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
}))
});
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) { } catch (error) {
console.error('Error fetching session details:', error); console.error('Error fetching session details:', error)
res.status(500).json({ error: 'Failed to fetch session details' }); res.status(500).json({ error: 'Failed to fetch session details' })
} }
}); })
/** /**
* GET /api/collaborate/:id/document * GET /api/collaborate/:id/document
@ -169,29 +165,27 @@ router.get('/:id', (req, res) => {
*/ */
router.get('/:id/document', (req, res) => { router.get('/:id/document', (req, res) => {
try { try {
const sessionId = parseInt(req.params.id); const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId); const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) { 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 activeSession = collaborativeOrchestrator.activeSessions.get(sessionId)
const currentDocument = activeSession?.currentDocument || ''; const currentDocument = activeSession?.currentDocument || ''
// Determine content type based on format
const contentType = session.document_format === 'md' const contentType = session.document_format === 'md'
? 'text/markdown; charset=utf-8' ? 'text/markdown; charset=utf-8'
: 'text/plain; charset=utf-8'; : 'text/plain; charset=utf-8'
res.set('Content-Type', contentType);
res.send(currentDocument);
res.set('Content-Type', contentType)
res.send(currentDocument)
} catch (error) { } catch (error) {
console.error('Error fetching document:', error); console.error('Error fetching document:', error)
res.status(500).json({ error: 'Failed to fetch document' }); res.status(500).json({ error: 'Failed to fetch document' })
} }
}); })
/** /**
* GET /api/collaborate/:id/versions/:versionNumber * GET /api/collaborate/:id/versions/:versionNumber
@ -199,26 +193,21 @@ router.get('/:id/document', (req, res) => {
*/ */
router.get('/:id/versions/:versionNumber', (req, res) => { router.get('/:id/versions/:versionNumber', (req, res) => {
try { try {
const sessionId = parseInt(req.params.id); const sessionId = parseInt(req.params.id)
const versionNumber = parseInt(req.params.versionNumber); const versionNumber = parseInt(req.params.versionNumber)
const session = collaborativeOrchestrator.getSession(sessionId); const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) { 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 versions = collaborativeOrchestrator.getDocumentVersions(sessionId)
const version = versions.find(v => v.version_number === versionNumber); const version = versions.find(v => v.version_number === versionNumber)
if (!version) { 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({ res.json({
versionNumber: version.version_number, versionNumber: version.version_number,
modifiedBy: version.modified_by, modifiedBy: version.modified_by,
@ -226,13 +215,12 @@ router.get('/:id/versions/:versionNumber', (req, res) => {
roundNumber: version.round_number, roundNumber: version.round_number,
createdAt: version.created_at, createdAt: version.created_at,
content: version.content content: version.content
}); })
} catch (error) { } catch (error) {
console.error('Error fetching version:', error); console.error('Error fetching version:', error)
res.status(500).json({ error: 'Failed to fetch version' }); res.status(500).json({ error: 'Failed to fetch version' })
} }
}); })
/** /**
* POST /api/collaborate/:id/complete * POST /api/collaborate/:id/complete
@ -240,25 +228,24 @@ router.get('/:id/versions/:versionNumber', (req, res) => {
*/ */
router.post('/:id/complete', (req, res) => { router.post('/:id/complete', (req, res) => {
try { try {
const sessionId = parseInt(req.params.id); const sessionId = parseInt(req.params.id)
const session = collaborativeOrchestrator.getSession(sessionId); const session = collaborativeOrchestrator.getSession(sessionId)
if (!session) { 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({ res.json({
sessionId, sessionId,
status: 'completed', status: 'completed',
message: 'Session completed successfully' message: 'Session completed successfully'
}); })
} catch (error) { } catch (error) {
console.error('Error completing session:', error); console.error('Error completing session:', error)
res.status(500).json({ error: 'Failed to complete session' }); 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 db from '../db/schema.js'
import { generateAgentResponse } from './mistralClient.js'; import { generateAgentResponseSync, extractSection, extractThinking } from './mistralClient.js'
import { getRandomNames } from './nameGenerator.js'
class CollaborativeOrchestrator { class CollaborativeOrchestrator {
constructor() { constructor() {
this.activeSessions = new Map(); this.activeSessions = new Map()
this.wsClients = new Map(); // sessionId -> Set of WebSocket clients 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 }
];
} }
/** /**
@ -23,9 +13,9 @@ class CollaborativeOrchestrator {
*/ */
registerWSClient(sessionId, ws) { registerWSClient(sessionId, ws) {
if (!this.wsClients.has(sessionId)) { 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) { unregisterWSClient(sessionId, ws) {
if (this.wsClients.has(sessionId)) { 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) { broadcast(sessionId, message) {
if (this.wsClients.has(sessionId)) { if (this.wsClients.has(sessionId)) {
const data = JSON.stringify(message); const data = JSON.stringify(message)
this.wsClients.get(sessionId).forEach(ws => { this.wsClients.get(sessionId).forEach(ws => {
if (ws.readyState === 1) { // OPEN 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) { createSession(initialPrompt, documentFormat = 'md', agentCount = 7) {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO collaborative_sessions (initial_prompt, document_format, status) VALUES (?, ?, ?)' 'INSERT INTO collaborative_sessions (initial_prompt, document_format, status) VALUES (?, ?, ?)'
); )
const result = stmt.run(initialPrompt, documentFormat, 'created'); const result = stmt.run(initialPrompt, documentFormat, 'created')
const sessionId = result.lastInsertRowid; const sessionId = result.lastInsertRowid
// Select the agents to use // Generate random names for agents
const selectedAgents = this.agents.slice(0, Math.min(agentCount, this.agents.length)); const agentNames = getRandomNames(Math.min(agentCount, 50))
this.activeSessions.set(sessionId, { this.activeSessions.set(sessionId, {
id: sessionId, id: sessionId,
initialPrompt, initialPrompt,
documentFormat, documentFormat,
agents: selectedAgents, agents: agentNames, // Array of agent names
currentRound: 0, agentCount,
currentAgentIndex: 0,
currentDocument: null, currentDocument: null,
versionNumber: 0, versionNumber: 0,
conversationHistory: [], conversationHistory: [],
started: false started: false,
}); consecutiveNoChanges: 0, // Counter for convergence
lastModifiedAgent: null
})
return sessionId; return sessionId
} }
/** /**
* Get session by ID * Get session by ID
*/ */
getSession(sessionId) { getSession(sessionId) {
const stmt = db.prepare('SELECT * FROM collaborative_sessions WHERE id = ?'); const stmt = db.prepare('SELECT * FROM collaborative_sessions WHERE id = ?')
return stmt.get(sessionId); return stmt.get(sessionId)
} }
/** /**
* Get all versions of a document * Start a session - create initial document with first agent
*/ */
getDocumentVersions(sessionId) { async startSession(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) {
try { try {
const session = this.getSession(sessionId); const session = this.activeSessions.get(sessionId)
if (!session) { if (!session || session.started) return
throw new Error('Session not found');
}
const activeSession = this.activeSessions.get(sessionId); const firstAgent = session.agents[0]
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);
// Generate initial document // Generate initial document
const response = await generateAgentResponse( const initialResponse = await generateAgentResponseSync(
leadArchitect.role, firstAgent,
`${session.initial_prompt}\n\nYou are the Lead Architect. Create the INITIAL version of a comprehensive project document in ${session.document_format} format. 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: // Save to DB
- Project overview and goals const insertStmt = db.prepare(
- Architecture overview 'INSERT INTO document_versions (session_id, version_number, content, modified_by, modification_reason, round_number) VALUES (?, ?, ?, ?, ?, ?)'
- Technology stack decisions )
- Project structure insertStmt.run(sessionId, 0, initialDocument, firstAgent, 'Initial document creation', 0)
- Key features
- Non-functional requirements
- Timeline and phases
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 // Update DB status
let documentContent = response.proposal || response; const updateStmt = db.prepare('UPDATE collaborative_sessions SET status = ? WHERE id = ?')
if (typeof documentContent === 'object') { updateStmt.run('ongoing', sessionId)
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
});
// Broadcast initial document
this.broadcast(sessionId, { this.broadcast(sessionId, {
type: 'initial_document_created', type: 'initial_document_created',
sessionId, content: initialDocument,
agent: leadArchitect.role, agentName: firstAgent,
documentVersion: 1, thinking,
document: documentContent, roundNumber: 0
message: `Lead Architect (${leadArchitect.role}) created initial document. Starting review rounds...` })
});
return {
sessionId,
documentVersion: 1,
document: documentContent,
agents: activeSession.agents
};
// Auto-start first round
setTimeout(() => this.runRound(sessionId), 2000)
} catch (error) { } catch (error) {
console.error('Error starting collaborative session:', error); console.error('Error starting session:', error)
this.failSession(sessionId);
this.broadcast(sessionId, { this.broadcast(sessionId, {
type: 'session_error', type: 'session_error',
sessionId,
error: error.message 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) { async runRound(sessionId) {
try { try {
const session = this.getSession(sessionId); const session = this.activeSessions.get(sessionId)
if (!session) { if (!session) return
throw new Error('Session not found');
}
const activeSession = this.activeSessions.get(sessionId); const roundNumber = session.conversationHistory.length + 1
if (!activeSession) { const agentsInRound = session.agents
throw new Error('Active session not found'); const modifiedAgents = []
}
if (!activeSession.currentDocument) { // Each agent reviews the document
throw new Error('No document to review'); for (let i = 0; i < agentsInRound.length; i++) {
} const agentName = agentsInRound[i]
activeSession.currentRound += 1; // Broadcast that this agent is working
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) {
this.broadcast(sessionId, { this.broadcast(sessionId, {
type: 'agent_reviewing', type: 'agent_working',
sessionId, agentName,
agent: agent.role, roundNumber
roundNumber, })
message: `${agent.role} is reviewing the document...`
});
// Call agent to review and potentially modify document try {
const response = await generateAgentResponse( const response = await generateAgentResponseSync(
agent.role, agentName,
`${session.initial_prompt}\n\n session.initialPrompt,
CURRENT DOCUMENT (${session.document_format} format): session.currentDocument
\`\`\`${session.document_format} )
${activeSession.currentDocument}
\`\`\`
You are the ${agent.role}. Review this document from your perspective. Your task is to: const thinking = extractThinking(response)
1. Read and understand the entire document const section = extractSection(response)
2. Identify areas that need improvement or modifications from your expertise area
3. Provide improvements, additions, or modifications
IMPORTANT: // Broadcast agent's thinking in real-time
- If you decide to modify the document, output ONLY the complete modified document in ${session.document_format} format this.broadcast(sessionId, {
- If the document is already excellent and needs no changes, output: NO_CHANGES type: 'agent_thinking',
- Do not include explanations, just the document or NO_CHANGES agentName,
thinking,
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,
roundNumber roundNumber
); })
activeSession.currentDocument = responseText; // Check if agent made changes
activeSession.versionNumber += 1; if (section !== 'Section is good, no changes needed' && !section.includes('no changes needed')) {
agentsMadeChanges.push(agent.role); // Merge section into document
const updatedDocument = this.mergeSection(session.currentDocument, section)
this.broadcast(sessionId, { if (updatedDocument !== session.currentDocument) {
type: 'document_modified', session.currentDocument = updatedDocument
sessionId, session.versionNumber++
agent: agent.role, modifiedAgents.push(agentName)
roundNumber,
documentVersion: activeSession.versionNumber, // Save version to DB
document: responseText, const insertStmt = db.prepare(
changeSummary: diff.summary, 'INSERT INTO document_versions (session_id, version_number, content, modified_by, modification_reason, round_number) VALUES (?, ?, ?, ?, ?, ?)'
message: `${agent.role} modified the document` )
}); insertStmt.run(
} else { sessionId,
this.broadcast(sessionId, { session.versionNumber,
type: 'agent_no_changes', updatedDocument,
sessionId, agentName,
agent: agent.role, `Round ${roundNumber} modifications`,
roundNumber, roundNumber
message: `${agent.role} reviewed the document and found no changes needed` )
});
// 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( const roundStmt = db.prepare(
`INSERT INTO document_rounds 'INSERT INTO document_rounds (session_id, round_number, agents_in_round, agents_made_changes) VALUES (?, ?, ?, ?)'
(session_id, round_number, agents_in_round, agents_made_changes, completed_at) )
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`
);
roundStmt.run( roundStmt.run(
sessionId, sessionId,
roundNumber, roundNumber,
roundAgents, JSON.stringify(agentsInRound),
agentsMadeChanges.join(',') JSON.stringify(modifiedAgents)
); )
const conversationEntry = { // Add to history
session.conversationHistory.push({
roundNumber, roundNumber,
agentsMadeChanges: agentsMadeChanges.length > 0 ? agentsMadeChanges : 'none' agentsMadeChanges: modifiedAgents,
}; timestamp: Date.now()
})
activeSession.conversationHistory.push(conversationEntry);
// Check for convergence
const hasConverged = agentsMadeChanges.length === 0;
// Broadcast round complete
const hasConverged = session.consecutiveNoChanges >= session.agentCount
this.broadcast(sessionId, { this.broadcast(sessionId, {
type: 'round_complete', type: 'round_complete',
sessionId,
roundNumber, roundNumber,
agentsMadeChanges: agentsMadeChanges.length, agentsMadeChanges: modifiedAgents,
agentsWhoModified: agentsMadeChanges,
hasConverged, hasConverged,
message: hasConverged consecutiveNoChanges: session.consecutiveNoChanges
? 'Convergence reached! No more changes needed.' })
: `Round ${roundNumber} complete. ${agentsMadeChanges.length} agent(s) made changes.`
});
return {
roundNumber,
agentsMadeChanges,
hasConverged,
documentVersion: activeSession.versionNumber
};
// 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) { } catch (error) {
console.error('Error running round:', error); console.error('Error running round:', error)
throw 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) { mergeSection(document, newSection) {
const focusAreas = { if (!document || !newSection) return document
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';
}
/** // Extract header from new section
* Calculate diff between two document versions (simplified) const headerMatch = newSection.match(/^(#{1,4})\s+(.+)/)
*/ if (!headerMatch) return document
calculateDiff(oldContent, newContent) {
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
const linesAdded = Math.max(0, newLines.length - oldLines.length); const headerLevel = headerMatch[1].length
const linesRemoved = Math.max(0, oldLines.length - newLines.length); const headerText = headerMatch[2]
const changes = Math.abs(linesAdded) + Math.abs(linesRemoved); const headerRegex = new RegExp(`^${'#'.repeat(headerLevel)}\\s+${headerText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'gm')
const summary = `Modified document: +${linesAdded} lines, -${linesRemoved} lines (${changes} total changes)`; // Find and replace the section
if (headerRegex.test(document)) {
return { summary, linesAdded, linesRemoved }; // 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 * Complete a session
*/ */
completeSession(sessionId) { async completeSession(sessionId) {
const session = this.activeSessions.get(sessionId); try {
const document = session?.currentDocument || ''; 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( this.broadcast(sessionId, {
'UPDATE collaborative_sessions SET status = ?, completed_at = CURRENT_TIMESTAMP, final_document = ? WHERE id = ?' type: 'session_completed',
); finalDocument: session?.currentDocument
stmt.run('completed', document, sessionId); })
} catch (error) {
this.activeSessions.delete(sessionId); console.error('Error completing session:', error)
}
} }
/** /**
* Fail a session * Get document versions
*/ */
failSession(sessionId) { getDocumentVersions(sessionId) {
const stmt = db.prepare( const stmt = db.prepare(
'UPDATE collaborative_sessions SET status = ? WHERE id = ?' 'SELECT * FROM document_versions WHERE session_id = ? ORDER BY version_number ASC'
); )
stmt.run('failed', sessionId); return stmt.all(sessionId)
this.activeSessions.delete(sessionId);
} }
/** /**
* Get session details with full history * Get session info for API response
*/ */
getSessionDetails(sessionId) { getSessionInfo(sessionId) {
const session = this.getSession(sessionId); const session = this.activeSessions.get(sessionId)
const versions = this.getDocumentVersions(sessionId); const dbSession = this.getSession(sessionId)
const activeSession = this.activeSessions.get(sessionId);
if (!session) return null
return { return {
...session, id: sessionId,
versions, status: dbSession?.status,
currentRound: activeSession?.currentRound || 0, agents: session.agents,
currentDocument: activeSession?.currentDocument || null, agentCount: session.agentCount,
agents: activeSession?.agents || [], currentRound: session.conversationHistory.length,
conversationHistory: activeSession?.conversationHistory || [] 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_KEY = process.env.MISTRAL_API_KEY
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; 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 = { function getAgentPrompt(agentName) {
architect: `You are a Software Architect AI. Your role is to: return `You are an AI assistant named ${agentName} collaborating on a technical document design.
- Design high-level system architecture
- Make technology stack decisions
- Define project structure and modules
- Consider scalability and maintainability
- Provide clear technical justifications
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: IMPORTANT RULES:
- Design API endpoints and data models - Only modify ONE section header and its content
- Suggest backend technologies and frameworks - Never modify the entire document
- Plan database schema - Return only the section you're working on, not the whole document
- Consider performance and security - If section is good, respond: "Section is good, no changes needed"
- Provide implementation guidelines - Think step-by-step about what could be improved
- Share your reasoning process
Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`, Format your response as:
THINKING: [Your analysis and reasoning]
frontend_engineer: `You are a Frontend Engineer AI. Your role is to: DECISION: [What you'll modify or if keeping as-is]
- Design user interface structure SECTION:
- Suggest frontend frameworks and libraries [The modified or confirmed section with header]`
- 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: []}`
};
/** /**
* Call Mistral AI API * Call Mistral AI API with streaming
*/ */
async function callMistralAPI(messages, options = {}) { async function callMistralAPI(messages, options = {}) {
const { maxTokens, ...otherOptions } = options; const { maxTokens, stream = false, ...otherOptions } = options
const response = await fetch(MISTRAL_API_URL, { const response = await fetch(MISTRAL_API_URL, {
method: 'POST', method: 'POST',
@ -63,108 +50,132 @@ async function callMistralAPI(messages, options = {}) {
messages, messages,
temperature: options.temperature || 0.7, temperature: options.temperature || 0.7,
max_tokens: maxTokens || 2048, max_tokens: maxTokens || 2048,
stream,
...otherOptions ...otherOptions
}) })
}); })
if (!response.ok) { if (!response.ok) {
const error = await response.text(); const error = await response.text()
throw new Error(`Mistral API error: ${error}`); 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 = []) { async function* parseStreamResponse(reader) {
const systemPrompt = AGENT_PROMPTS[agentRole] || AGENT_PROMPTS.architect; 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 = [ const messages = [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: `Project prompt: ${prompt}` } { role: 'user', content: `Project description: ${prompt}\n\nCurrent document:\n${currentDocument}` }
]; ]
// 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.`
});
}
try { try {
const result = await callMistralAPI(messages, { const response = await callMistralAPI(messages, { stream: true })
temperature: 0.7, const reader = response.body.getReader()
maxTokens: 2048 let fullContent = ''
});
const content = result.choices[0].message.content; for await (const chunk of parseStreamResponse(reader)) {
fullContent += chunk
// Try to parse as JSON if (onThought) {
let parsedContent; onThought(chunk)
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);
} }
} catch (parseError) { yield chunk
// If not valid JSON, create structured response
parsedContent = {
proposal: content,
justification: `Analysis from ${agentRole}`,
confidence: 0.7,
dependencies: []
};
} }
// Ensure required fields return fullContent
return {
proposal: parsedContent.proposal || content,
justification: parsedContent.justification || '',
confidence: parsedContent.confidence || 0.7,
dependencies: parsedContent.dependencies || [],
mermaid: parsedContent.mermaid || null
};
} catch (error) { } catch (error) {
console.error(`Error generating response for ${agentRole}:`, error); console.error(`Error generating response from ${agentName}:`, error)
return `Error: ${error.message}`
// Return mock response on error
return {
proposal: `Error generating response: ${error.message}`,
justification: 'Failed to get AI response',
confidence: 0.5,
dependencies: [],
error: true
};
} }
} }
/** /**
* Generate responses from multiple agents in parallel * Generate agent response (non-streaming version for simpler integration)
*/ */
export async function generateMultiAgentResponses(agents, prompt, context = []) { export async function generateAgentResponseSync(agentName, prompt, currentDocument = '') {
const promises = agents.map(agent => const systemPrompt = getAgentPrompt(agentName)
generateAgentResponse(agent, prompt, context)
.then(response => ({ agent, response }))
);
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, * Extract section from AI response
generateMultiAgentResponses */
}; 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 ''
}