Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/AGENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,22 @@ See [docs/API-REFERENCE.md](./API-REFERENCE.md) for the complete endpoint docume
| `POST /api/agents/:id/messages/:msgId/reply` | HTTP Sig (self only) | Reply |
| `GET /api/messages/:msgId/status` | API Key | Delivery status |

### Round Tables (ephemeral multi-agent deliberation)
| Endpoint | Auth | Description |
|----------|------|-------------|
| `POST /api/round-tables` | Agent ID | Create session |
| `GET /api/round-tables` | Agent ID | List my sessions |
| `GET /api/round-tables/:id` | Agent ID (participants only) | Get session |
| `POST /api/round-tables/:id/speak` | Agent ID (participants only) | Add message |
| `POST /api/round-tables/:id/resolve` | Agent ID (facilitator only) | Close session |

**Round Table behavior notes:**

- **`excluded_participants`** — When some requested participants cannot be enrolled in the backing ADMP group (e.g. unregistered agent IDs), the create response includes an `excluded_participants` array listing those that were dropped. This field is only present when at least one participant was excluded; it is absent in the happy path. The stored session record does not carry this field — it is returned at create time only.
- **Expiry notifications** — When a session times out, the server automatically sends a `notification` message (type `notification`, body `{ reason: "timeout" }`) to the facilitator and all participants. The notification envelope has `from` set to the facilitator's agent ID (the logical author of the session). Facilitators receive a self-addressed copy; agents should not rely on `from === self` as a filter to suppress expiry notifications.
- **Partial enrollment** — If only some participants enroll successfully, the session is created with the enrolled subset. `rt.participants` and the backing group membership are kept in sync. The group's `max_members` is updated to reflect the actual enrolled count after partial enrollment.
- **Storage growth** — Resolved and expired sessions accumulate in storage. The server periodically purges records older than `ROUND_TABLE_PURGE_TTL_MS` (default: 7 days). Set this env var to control retention. Custom storage adapters must implement `purgeStaleRoundTables(olderThanMs)`.

---

## 7. Error Handling
Expand Down
15 changes: 7 additions & 8 deletions src/routes/round-tables.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ import { authenticateAgent } from '../middleware/auth.js';
const router = express.Router();

function getErrorStatusCode(error) {
const msg = error.message || '';
if (msg.includes('not found')) return 404;
if (msg.includes('Not a participant') || msg.includes('Only the facilitator')) return 403;
if (msg.includes('already resolved') || msg.includes('has expired') || msg.includes('maximum of 200')) return 409;
return 400;
return error.statusCode || 400;
}

/**
Expand Down Expand Up @@ -44,16 +40,19 @@ router.post('/', authenticateAgent, async (req, res) => {
if (invalidParticipant !== undefined) {
return res.status(400).json({ error: 'INVALID_PARTICIPANT_ID', message: 'each participant must be a non-empty string of 255 characters or less' });
}
if (timeout_minutes !== undefined && (typeof timeout_minutes !== 'number' || !Number.isFinite(timeout_minutes))) {
return res.status(400).json({ error: 'INVALID_TIMEOUT', message: 'timeout_minutes must be a number' });
if (timeout_minutes !== undefined && (typeof timeout_minutes !== 'number' || !Number.isFinite(timeout_minutes) || !Number.isInteger(timeout_minutes))) {
return res.status(400).json({ error: 'INVALID_TIMEOUT', message: 'timeout_minutes must be an integer' });
}
if (participants.includes(req.agent.agent_id)) {
return res.status(400).json({ error: 'FACILITATOR_IN_PARTICIPANTS', message: 'facilitator cannot be listed as a participant' });
}

const rt = await roundTableService.create({
topic: topic.trim(),
goal: goal.trim(),
facilitator: req.agent.agent_id,
participants,
timeout_minutes: timeout_minutes || 30
timeout_minutes: timeout_minutes ?? 30
});

res.status(201).json(rt);
Expand Down
10 changes: 8 additions & 2 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ config();

const PORT = process.env.PORT || 8080;
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS) || 60000;
const ROUND_TABLE_PURGE_TTL_MS = (() => {
const parsed = parseInt(process.env.ROUND_TABLE_PURGE_TTL_MS);
return Number.isNaN(parsed) ? 7 * 24 * 60 * 60 * 1000 : parsed;
})();

// Warn about insecure outbox webhook configuration
if (process.env.MAILGUN_API_KEY && !process.env.MAILGUN_WEBHOOK_SIGNING_KEY) {
Expand Down Expand Up @@ -221,14 +225,16 @@ function startBackgroundJobs() {
const messagesDeleted = await storage.cleanupExpiredMessages();
const ephemeralPurged = await inboxService.purgeExpiredEphemeralMessages();
const roundTablesExpired = await roundTableService.expireStale();
const roundTablesPurged = await roundTableService.purgeStale(ROUND_TABLE_PURGE_TTL_MS);

if (leasesReclaimed > 0 || messagesExpired > 0 || messagesDeleted > 0 || ephemeralPurged > 0 || roundTablesExpired > 0) {
if (leasesReclaimed > 0 || messagesExpired > 0 || messagesDeleted > 0 || ephemeralPurged > 0 || roundTablesExpired > 0 || roundTablesPurged > 0) {
logger.debug({
leasesReclaimed,
messagesExpired,
messagesDeleted,
ephemeralPurged,
roundTablesExpired
roundTablesExpired,
roundTablesPurged
}, 'Cleanup job completed');
}
} catch (error) {
Expand Down
230 changes: 230 additions & 0 deletions src/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { requireApiKey } from './middleware/auth.js';
import { webhookService } from './services/webhook.service.js';
import { outboxService } from './services/outbox.service.js';
import { storage } from './storage/index.js';
import { roundTableService } from './services/round-table.service.js';
import { groupService } from './services/group.service.js';

let createMechStorage = null;
try {
Expand Down Expand Up @@ -4359,3 +4361,231 @@ test('round table: missing required fields return 400', async () => {
.send({ topic: 'Missing participants', goal: 'Test', participants: [] });
assert.equal(noParticipants.status, 400);
});

test('round table: facilitator can speak in their own session', async () => {
const facilitator = await registerAgent('rt-fac-speak');
const participant = await registerAgent('rt-fac-speak-p');

const createRes = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', facilitator.agent_id)
.send({
topic: 'Facilitator speech test',
goal: 'Verify facilitator can speak',
participants: [participant.agent_id],
timeout_minutes: 30
});

assert.equal(createRes.status, 201);
const rtId = createRes.body.id;

const speakRes = await request(app)
.post(`/api/round-tables/${rtId}/speak`)
.set('X-Agent-ID', facilitator.agent_id)
.send({ message: 'I am the facilitator and I can speak.' });

assert.equal(speakRes.status, 201);
assert.equal(speakRes.body.thread_length, 1);
});

test('round table: expireStale marks session expired and notifies facilitator and participants', async () => {
const facilitator = await registerAgent('rt-expire-fac');
const participant = await registerAgent('rt-expire-p');

const createRes = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', facilitator.agent_id)
.send({
topic: 'Expiry test',
goal: 'Verify expiry',
participants: [participant.agent_id],
timeout_minutes: 60
});

assert.equal(createRes.status, 201);
const rtId = createRes.body.id;

// Drain the work_order invitation from participant inbox before backdating
await request(app)
.post(`/api/agents/${encodeURIComponent(participant.agent_id)}/inbox/pull`)
.set('X-Agent-ID', participant.agent_id);

// Backdate the expiry to force expiration
await storage.updateRoundTable(rtId, {
expires_at: new Date(Date.now() - 1000).toISOString()
});

const expired = await roundTableService.expireStale();
assert.ok(expired >= 1, 'at least one session should be expired');

// Confirm status is now expired via storage
const rt = await storage.getRoundTable(rtId);
assert.equal(rt.status, 'expired');

// Participant inbox should have an expiry notification
const participantPull = await request(app)
.post(`/api/agents/${encodeURIComponent(participant.agent_id)}/inbox/pull`)
.set('X-Agent-ID', participant.agent_id);
assert.equal(participantPull.status, 200);
assert.equal(participantPull.body.envelope.type, 'notification');
assert.equal(participantPull.body.envelope.body.round_table_id, rtId);
assert.equal(participantPull.body.envelope.body.reason, 'timeout');

// Facilitator inbox should also have an expiry notification
const facilitatorPull = await request(app)
.post(`/api/agents/${encodeURIComponent(facilitator.agent_id)}/inbox/pull`)
.set('X-Agent-ID', facilitator.agent_id);
assert.equal(facilitatorPull.status, 200);
assert.equal(facilitatorPull.body.envelope.type, 'notification');
assert.equal(facilitatorPull.body.envelope.body.round_table_id, rtId);

// Confirm speak returns 409
const lateSpeak = await request(app)
.post(`/api/round-tables/${rtId}/speak`)
.set('X-Agent-ID', participant.agent_id)
.send({ message: 'Too late.' });
assert.equal(lateSpeak.status, 409);
});

test('round table: duplicate participants are deduplicated', async () => {
const facilitator = await registerAgent('rt-dedup-fac');
const participant = await registerAgent('rt-dedup-p');

const createRes = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', facilitator.agent_id)
.send({
topic: 'Dedup test',
goal: 'Verify dedup',
participants: [participant.agent_id, participant.agent_id, participant.agent_id],
timeout_minutes: 30
});

assert.equal(createRes.status, 201);
assert.equal(createRes.body.participants.length, 1, 'duplicates should be removed');
assert.equal(createRes.body.participants[0], participant.agent_id);
});

test('round table: missing goal returns 400', async () => {
const agent = await registerAgent('rt-no-goal');

const res = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', agent.agent_id)
.send({ topic: 'No goal here', participants: ['other-agent'] });

assert.equal(res.status, 400);
assert.ok(res.body.error === 'INVALID_GOAL');
});

test('round table: non-integer timeout_minutes returns 400', async () => {
const agent = await registerAgent('rt-float-timeout');
const participant = await registerAgent('rt-float-timeout-p');

const res = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', agent.agent_id)
.send({
topic: 'Float timeout',
goal: 'Test integer validation',
participants: [participant.agent_id],
timeout_minutes: 1.5
});

assert.equal(res.status, 400);
assert.ok(res.body.error === 'INVALID_TIMEOUT');
});

test('round table: zero-enrollment returns 400 and leaves no orphaned groups', async () => {
const facilitator = await registerAgent('rt-zero-enroll-fac');
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

// Count groups the facilitator belongs to before the attempt
const groupsBefore = (await groupService.listForAgent(facilitator.agent_id)).length;

// All fake participants — addMember will throw "Agent not found" for each
const res = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', facilitator.agent_id)
.send({
topic: 'Zero enrollment test',
goal: 'All participants unknown',
participants: [`ghost-${unique}-1`, `ghost-${unique}-2`],
timeout_minutes: 30
});

assert.equal(res.status, 400);
assert.ok(res.body.message.toLowerCase().includes('no participants'));

// No orphaned round-table groups should remain
const groupsAfter = (await groupService.listForAgent(facilitator.agent_id)).length;
assert.equal(groupsAfter, groupsBefore, 'group created during enrollment should be cleaned up');
});

test('round table: partial enrollment — only enrolled participants stored, excluded_participants returned', async () => {
const facilitator = await registerAgent('rt-partial-fac');
const validParticipant = await registerAgent('rt-partial-p');
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

const createRes = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', facilitator.agent_id)
.send({
topic: 'Partial enrollment test',
goal: 'Verify split-brain prevention',
participants: [validParticipant.agent_id, `ghost-${unique}`],
timeout_minutes: 30
});

assert.equal(createRes.status, 201);

// Only the valid participant should be in participants
assert.equal(createRes.body.participants.length, 1);
assert.equal(createRes.body.participants[0], validParticipant.agent_id);

// The ghost agent should be in excluded_participants
assert.ok(Array.isArray(createRes.body.excluded_participants));
assert.equal(createRes.body.excluded_participants.length, 1);
assert.ok(createRes.body.excluded_participants[0].startsWith('ghost-'));

// The backing group's max_members should be aligned to enrolled count + 1 (= 2)
const groupRes = await request(app)
.get(`/api/groups/${encodeURIComponent(createRes.body.group_id)}`)
.set('X-Agent-ID', facilitator.agent_id);
assert.equal(groupRes.status, 200);
assert.equal(groupRes.body.settings.max_members, 2);

// Enrolled participant can speak
const rtId = createRes.body.id;
const speakRes = await request(app)
.post(`/api/round-tables/${rtId}/speak`)
.set('X-Agent-ID', validParticipant.agent_id)
.send({ message: 'I am enrolled and can speak.' });
assert.equal(speakRes.status, 201);

// A real registered agent that was not enrolled cannot speak (enforced by _requireParticipant)
const nonEnrolled = await registerAgent('rt-partial-non-enrolled');
const nonEnrolledSpeakRes = await request(app)
.post(`/api/round-tables/${rtId}/speak`)
.set('X-Agent-ID', nonEnrolled.agent_id)
.send({ message: 'I was not enrolled and should not speak.' });
assert.equal(nonEnrolledSpeakRes.status, 403);
});

test('round table: facilitator cannot be listed as a participant', async () => {
const facilitator = await registerAgent('rt-fac-as-participant');
const otherParticipant = await registerAgent('rt-fac-as-participant-p');

const res = await request(app)
.post('/api/round-tables')
.set('X-Agent-ID', facilitator.agent_id)
.send({
topic: 'Self-inclusion test',
goal: 'Verify facilitator cannot be a participant',
participants: [facilitator.agent_id, otherParticipant.agent_id],
timeout_minutes: 30
});

assert.equal(res.status, 400);
assert.equal(res.body.error, 'FACILITATOR_IN_PARTICIPANTS');
});
Loading