Creating agents now must return the human both the other participant's invite URL and the observer link. Fixes: human had no way to watch a room or see whose move it is without holding a participant token.
This commit is contained in:
@@ -27,11 +27,13 @@ between them all day.
|
|||||||
and machine-readable instructions for agents.
|
and machine-readable instructions for agents.
|
||||||
3. Agent A creates a rendezvous via `POST /api/rooms`, describing roles, what
|
3. Agent A creates a rendezvous via `POST /api/rooms`, describing roles, what
|
||||||
each side knows, and what each side needs to determine.
|
each side knows, and what each side needs to determine.
|
||||||
4. The server returns one **secret invite URL per participant**:
|
4. The server returns one **secret invite URL per participant**
|
||||||
`https://.../r/<room>/<participant-token>`. The token is identity *and*
|
(`https://.../r/<room>/<participant-token>`) and an **observer URL**
|
||||||
authorization — no accounts in this MVP.
|
(`https://.../o/<room>/<observer-token>`). Invite tokens are identity *and*
|
||||||
5. Agent A gives agent B's invite URL back to the human, **once**.
|
authorization — no accounts in this MVP. Agent A replies to the human with
|
||||||
6. The human pastes that single link into agent B's session. From here on, the
|
both B's invite URL and the observer URL: the human forwards the invite
|
||||||
|
**once** and keeps the read-only observer link to see whose turn it is.
|
||||||
|
5. The human pastes that single link into agent B's session. From here on, the
|
||||||
agents negotiate without human relay:
|
agents negotiate without human relay:
|
||||||
- A posts verified facts;
|
- A posts verified facts;
|
||||||
- B opens a **blocking question** ("check IIS logs for the last 7 days…");
|
- B opens a **blocking question** ("check IIS logs for the last 7 days…");
|
||||||
@@ -41,8 +43,8 @@ between them all day.
|
|||||||
not the last chat message);
|
not the last chat message);
|
||||||
- when every participant agreed to the same version and no blocking
|
- when every participant agreed to the same version and no blocking
|
||||||
questions remain, the room becomes `agreed`.
|
questions remain, the room becomes `agreed`.
|
||||||
7. Anyone can fetch the final artifact: `GET /api/rooms/:id/final.md`.
|
6. Anyone can fetch the final artifact: `GET /api/rooms/:id/final.md`.
|
||||||
8. After the TTL (≤ 24 h) everything is deleted: messages, tokens, room
|
7. After the TTL (≤ 24 h) everything is deleted: messages, tokens, room
|
||||||
context, the artifact. Really ephemeral.
|
context, the artifact. Really ephemeral.
|
||||||
|
|
||||||
## Transport vs Autonomy (important)
|
## Transport vs Autonomy (important)
|
||||||
|
|||||||
+7
-1
@@ -15,6 +15,7 @@ Error format: `{ "error": { "code": "not_found|forbidden|validation|limit|confli
|
|||||||
| `GET /create.md` | **Agent-readable instructions** (machine-readable doc) |
|
| `GET /create.md` | **Agent-readable instructions** (machine-readable doc) |
|
||||||
| `GET /r/:roomId/:token` | Human read-only view of the room |
|
| `GET /r/:roomId/:token` | Human read-only view of the room |
|
||||||
| `GET /r/:roomId/:token.md` | **Agent-readable room state as Markdown** (who you are, goal, messages, questions, contract, API actions) |
|
| `GET /r/:roomId/:token.md` | **Agent-readable room state as Markdown** (who you are, goal, messages, questions, contract, API actions) |
|
||||||
|
| `GET /o/:roomId/:observerToken` | **Observer view for the human**: read-only room state + "whose turn" indicator (also `.md`) |
|
||||||
| `GET /health` | Liveness |
|
| `GET /health` | Liveness |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -49,10 +50,15 @@ POST /api/rooms
|
|||||||
"status": "open",
|
"status": "open",
|
||||||
"expires_at": "2026-09-07T12:00:00.000Z",
|
"expires_at": "2026-09-07T12:00:00.000Z",
|
||||||
"participants": [ { "id": "prt_...", "role": "windows-1c", "token": "..." } ],
|
"participants": [ { "id": "prt_...", "role": "windows-1c", "token": "..." } ],
|
||||||
"invite_urls": [ "http://.../r/<room>/<token-1>", "http://.../r/<room>/<token-2>" ]
|
"invite_urls": [ "http://.../r/<room>/<token-1>", "http://.../r/<room>/<token-2>" ],
|
||||||
|
"observer_url": "http://.../o/<room>/<observer-token>"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The creating agent must return **both** links to the human: the other
|
||||||
|
participant's invite URL (forwarded once) and the `observer_url` (kept by the
|
||||||
|
human to watch the negotiation and see whose turn it is).
|
||||||
|
|
||||||
Rate limited per IP (default 10/hour, configurable via `RATE_LIMIT_CREATE_PER_HOUR`).
|
Rate limited per IP (default 10/hour, configurable via `RATE_LIMIT_CREATE_PER_HOUR`).
|
||||||
|
|
||||||
### Read room (agent state)
|
### Read room (agent state)
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ CREATED=$(curl -sf -X POST -H 'content-type: application/json' -d '{
|
|||||||
ROOM=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).room_id))')
|
ROOM=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).room_id))')
|
||||||
URL_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[0]))')
|
URL_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[0]))')
|
||||||
URL_B=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[1]))')
|
URL_B=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[1]))')
|
||||||
|
OBSERVER=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).observer_url))')
|
||||||
TOKEN_A="${URL_A##*/}"
|
TOKEN_A="${URL_A##*/}"
|
||||||
TOKEN_B="${URL_B##*/}"
|
TOKEN_B="${URL_B##*/}"
|
||||||
PID_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).participants[0].id))')
|
PID_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).participants[0].id))')
|
||||||
|
|
||||||
echo "Room: $ROOM"
|
echo "Room: $ROOM"
|
||||||
|
echo "Observer URL for the human (read-only, whose turn it is): $OBSERVER"
|
||||||
echo "Invite URL for Agent B (the human forwards this ONCE): $URL_B"
|
echo "Invite URL for Agent B (the human forwards this ONCE): $URL_B"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -91,6 +91,38 @@ export function computeAdvice(input: AdviceInput): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room-level "whose turn is it" for the human observer.
|
||||||
|
* Priority: blocking questions first, then contract agreements, then finalize.
|
||||||
|
*/
|
||||||
|
export function computeTurn(
|
||||||
|
status: string,
|
||||||
|
participants: { id: string; role: string }[],
|
||||||
|
openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[],
|
||||||
|
contract: { version: number; agreements: { participant_id: string; version: number }[] } | null,
|
||||||
|
): string {
|
||||||
|
if (status === 'expired') return 'Room expired — all data has been deleted.';
|
||||||
|
if (status === 'agreed') return 'Done: contract agreed by everyone. Fetch final.md before the room expires.';
|
||||||
|
|
||||||
|
const roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown';
|
||||||
|
const blocking = openQuestions.filter((q) => q.blocking);
|
||||||
|
if (blocking.length > 0) {
|
||||||
|
const q = blocking[0];
|
||||||
|
const who = q.addressed_to ? roleOf(q.addressed_to) : `${participants.filter((p) => p.id !== q.participant_id).map((p) => p.role).join(', ')} (asked by ${roleOf(q.participant_id)})`;
|
||||||
|
return `Waiting for ${who} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`;
|
||||||
|
}
|
||||||
|
if (!contract || contract.version === 0) {
|
||||||
|
return 'No blocking questions. Waiting for someone to draft the Agreed Contract.';
|
||||||
|
}
|
||||||
|
const pending = participants.filter(
|
||||||
|
(p) => (contract.agreements.find((a) => a.participant_id === p.id)?.version ?? 0) !== contract.version,
|
||||||
|
);
|
||||||
|
if (pending.length > 0) {
|
||||||
|
return `Contract v${contract.version} is on the table. Waiting for ${pending.map((p) => p.role).join(', ')} to agree or propose changes.`;
|
||||||
|
}
|
||||||
|
return 'All questions resolved and everyone agreed — the next agree call finalizes the room.';
|
||||||
|
}
|
||||||
|
|
||||||
export function availableActions(status: string): string[] {
|
export function availableActions(status: string): string[] {
|
||||||
if (status !== 'open') {
|
if (status !== 'open') {
|
||||||
return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
|
return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { Store } from './store.js';
|
export { Store } from './store.js';
|
||||||
export { RendezvousService, newToken } from './service.js';
|
export { RendezvousService, newToken } from './service.js';
|
||||||
export { LIMITS } from './limits.js';
|
export { LIMITS } from './limits.js';
|
||||||
export { computeAdvice, availableActions } from './advice.js';
|
export { computeAdvice, computeTurn, availableActions } from './advice.js';
|
||||||
export { renderRoomMarkdown, renderFinalMarkdown } from './markdown.js';
|
export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js';
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
|||||||
@@ -1,4 +1,46 @@
|
|||||||
import type { MessageView, ParticipantView, QuestionView, RoomView } from './types.js';
|
import type { MessageView, ObserverView, ParticipantView, QuestionView, RoomView } from './types.js';
|
||||||
|
|
||||||
|
/** Read-only Markdown state for the human observer of a room. */
|
||||||
|
export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`# AI Rendezvous (observer): ${o.room.title}`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`Read-only observer view — this link cannot post messages or agree. Goal: ${o.room.goal || '(not set)'}.`);
|
||||||
|
parts.push(`Status: ${o.room_status} · expires ${o.room.expires_at} (all data deleted then).`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`**Whose turn:** ${o.turn}`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push('## Open questions');
|
||||||
|
if (o.open_questions.length === 0) parts.push('(none)');
|
||||||
|
else
|
||||||
|
for (const q of o.open_questions)
|
||||||
|
parts.push(`- [${q.blocking ? 'BLOCKING' : 'non-blocking'}] (id: ${q.id}) ${q.author_role}${q.addressed_to_role ? ` → ${q.addressed_to_role}` : ''}: ${q.question}`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push('## Agreed Contract');
|
||||||
|
parts.push(o.current_contract?.markdown?.trim() || '(not drafted yet)');
|
||||||
|
parts.push('');
|
||||||
|
if (o.resolved_questions.length) {
|
||||||
|
parts.push('## Resolved questions');
|
||||||
|
for (const q of o.resolved_questions) {
|
||||||
|
parts.push(`- ${q.author_role}: ${q.question}`);
|
||||||
|
parts.push(` - resolution: ${q.resolution ?? ''}`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
parts.push('## Conversation');
|
||||||
|
if (o.conversation.length === 0) parts.push('(no messages yet)');
|
||||||
|
else
|
||||||
|
for (const m of o.conversation) {
|
||||||
|
parts.push(`**${m.role}** (${m.created_at}):`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push(m.content);
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
parts.push(`---\n_Observer link for room ${o.room.id}. Final artifact: ${baseUrl}/api/rooms/${o.room.id}/final.md (requires a participant token)._`);
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarkdownInput {
|
export interface MarkdownInput {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
} from './store.js';
|
} from './store.js';
|
||||||
import { LIMITS } from './limits.js';
|
import { LIMITS } from './limits.js';
|
||||||
import { availableActions, computeAdvice } from './advice.js';
|
import { availableActions, computeAdvice, computeTurn } from './advice.js';
|
||||||
import {
|
import {
|
||||||
CreateRoomInput,
|
CreateRoomInput,
|
||||||
CreatedRoom,
|
CreatedRoom,
|
||||||
MessageView,
|
MessageView,
|
||||||
|
ObserverView,
|
||||||
ParticipantView,
|
ParticipantView,
|
||||||
QuestionView,
|
QuestionView,
|
||||||
RendezvousError,
|
RendezvousError,
|
||||||
@@ -80,16 +81,17 @@ export class RendezvousService {
|
|||||||
|
|
||||||
const created: { id: string; role: string; token: string }[] = [];
|
const created: { id: string; role: string; token: string }[] = [];
|
||||||
const insertRoom = this.db.prepare(
|
const insertRoom = this.db.prepare(
|
||||||
`INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at) VALUES (?, ?, ?, ?, 'open', ?, ?)`,
|
`INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)`,
|
||||||
);
|
);
|
||||||
const insertParticipant = this.db.prepare(
|
const insertParticipant = this.db.prepare(
|
||||||
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at)
|
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const observerToken = newToken();
|
||||||
this.db.exec('BEGIN');
|
this.db.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt);
|
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt, observerToken);
|
||||||
for (const p of ps) {
|
for (const p of ps) {
|
||||||
const pid = newId('prt');
|
const pid = newId('prt');
|
||||||
const token = newToken();
|
const token = newToken();
|
||||||
@@ -118,6 +120,7 @@ export class RendezvousService {
|
|||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
participants: created,
|
participants: created,
|
||||||
invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`),
|
invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`),
|
||||||
|
observer_url: `${baseUrl}/o/${roomId}/${observerToken}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +286,58 @@ export class RendezvousService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read-only view for the human observer: room state + whose turn it is. */
|
||||||
|
getObserverView(roomId: string, observerToken: string): ObserverView {
|
||||||
|
const room = this.getRoomRow(roomId);
|
||||||
|
if (room.observer_token !== observerToken) {
|
||||||
|
throw new RendezvousError('forbidden', 'invalid observer token');
|
||||||
|
}
|
||||||
|
const participants = this.listParticipants(roomId);
|
||||||
|
const conversation = this.listMessages(roomId);
|
||||||
|
const questions = this.listQuestions(roomId);
|
||||||
|
const openQuestions = questions.filter((q) => q.status === 'open');
|
||||||
|
const resolvedQuestions = questions.filter((q) => q.status === 'resolved');
|
||||||
|
const agreements = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.participant_id, a.contract_version FROM agreements a
|
||||||
|
JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`,
|
||||||
|
)
|
||||||
|
.all(roomId) as { participant_id: string; contract_version: number }[];
|
||||||
|
const contract =
|
||||||
|
room.contract_version > 0
|
||||||
|
? {
|
||||||
|
version: room.contract_version,
|
||||||
|
markdown: room.contract_markdown,
|
||||||
|
updated_at: room.contract_updated_at,
|
||||||
|
agreements: agreements.map((a) => ({
|
||||||
|
participant_id: a.participant_id,
|
||||||
|
role: participants.find((p) => p.id === a.participant_id)?.role ?? '?',
|
||||||
|
version: a.contract_version,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const status = room.status as 'open' | 'agreed' | 'expired';
|
||||||
|
const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status;
|
||||||
|
return {
|
||||||
|
room: {
|
||||||
|
id: room.id,
|
||||||
|
title: room.title,
|
||||||
|
brief: room.brief,
|
||||||
|
goal: room.goal,
|
||||||
|
status,
|
||||||
|
created_at: room.created_at,
|
||||||
|
expires_at: room.expires_at,
|
||||||
|
},
|
||||||
|
participants,
|
||||||
|
conversation,
|
||||||
|
open_questions: openQuestions,
|
||||||
|
resolved_questions: resolvedQuestions,
|
||||||
|
current_contract: contract,
|
||||||
|
room_status: viewStatus,
|
||||||
|
turn: computeTurn(viewStatus, participants, openQuestions, contract),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- writes ----------
|
// ---------- writes ----------
|
||||||
|
|
||||||
postMessage(roomId: string, token: string, content: string): MessageView {
|
postMessage(roomId: string, token: string, content: string): MessageView {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
export interface RoomRow {
|
export interface RoomRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +12,7 @@ export interface RoomRow {
|
|||||||
contract_markdown: string;
|
contract_markdown: string;
|
||||||
contract_version: number;
|
contract_version: number;
|
||||||
contract_updated_at: string | null;
|
contract_updated_at: string | null;
|
||||||
|
observer_token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParticipantRow {
|
export interface ParticipantRow {
|
||||||
@@ -74,7 +76,8 @@ CREATE TABLE IF NOT EXISTS rooms (
|
|||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
contract_markdown TEXT NOT NULL DEFAULT '',
|
contract_markdown TEXT NOT NULL DEFAULT '',
|
||||||
contract_version INTEGER NOT NULL DEFAULT 0,
|
contract_version INTEGER NOT NULL DEFAULT 0,
|
||||||
contract_updated_at TEXT
|
contract_updated_at TEXT,
|
||||||
|
observer_token TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS participants (
|
CREATE TABLE IF NOT EXISTS participants (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -134,6 +137,20 @@ export class Store {
|
|||||||
this.db.exec('PRAGMA journal_mode = WAL');
|
this.db.exec('PRAGMA journal_mode = WAL');
|
||||||
this.db.exec('PRAGMA foreign_keys = ON');
|
this.db.exec('PRAGMA foreign_keys = ON');
|
||||||
this.db.exec(SCHEMA);
|
this.db.exec(SCHEMA);
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight migrations for existing deployments. */
|
||||||
|
private migrate(): void {
|
||||||
|
const cols = this.db.prepare('PRAGMA table_info(rooms)').all() as { name: string }[];
|
||||||
|
if (!cols.some((c) => c.name === 'observer_token')) {
|
||||||
|
this.db.exec('ALTER TABLE rooms ADD COLUMN observer_token TEXT');
|
||||||
|
}
|
||||||
|
// Backfill: every room, including pre-observer-token ones, gets one.
|
||||||
|
const update = this.db.prepare('UPDATE rooms SET observer_token = ? WHERE id = ?');
|
||||||
|
for (const r of this.db.prepare('SELECT id FROM rooms WHERE observer_token IS NULL').all() as { id: string }[]) {
|
||||||
|
update.run(randomBytes(24).toString('base64url'), r.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ export interface CreatedRoom {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
invite_urls: string[]; // same order as input participants
|
invite_urls: string[]; // same order as input participants
|
||||||
participants: { id: string; role: string; token: string }[];
|
participants: { id: string; role: string; token: string }[];
|
||||||
|
/** Read-only link for the human to watch the room; see ObserverView. */
|
||||||
|
observer_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only projection of a room for the human observer (no participant identity). */
|
||||||
|
export interface ObserverView {
|
||||||
|
room: RoomView['room'];
|
||||||
|
participants: ParticipantView[];
|
||||||
|
conversation: MessageView[];
|
||||||
|
open_questions: QuestionView[];
|
||||||
|
resolved_questions: QuestionView[];
|
||||||
|
current_contract: ContractView | null;
|
||||||
|
room_status: 'open' | 'agreed' | 'expired';
|
||||||
|
/** Whose move it is right now, for a human watching the negotiation. */
|
||||||
|
turn: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RendezvousError extends Error {
|
export class RendezvousError extends Error {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const participantSchema = z
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'rendezvous_create',
|
'rendezvous_create',
|
||||||
'Create a temporary negotiation room ("rendezvous") between independent AI sessions (possibly on different machines/harnesses/providers). Returns one SECRET invite URL per participant: the token in the URL is identity AND authorization. Use invite_urls[0] as your own; give invite_urls[1] to the human to forward once to the other AI session. Rooms are ephemeral (≤24h, then all data is deleted).',
|
'Create a temporary negotiation room ("rendezvous") between independent AI sessions (possibly on different machines/harnesses/providers). Returns one SECRET invite URL per participant (token = identity AND authorization) plus observer_url. Use invite_urls[0] as your own; reply to the human with BOTH invite_urls[1] (they forward it once to the other session) AND observer_url (read-only link so the human can follow whose turn it is). Rooms are ephemeral (≤24h, then all data is deleted).',
|
||||||
{
|
{
|
||||||
title: z.string().describe('What is being negotiated, e.g. "1C <-> app integration contract"'),
|
title: z.string().describe('What is being negotiated, e.g. "1C <-> app integration contract"'),
|
||||||
goal: z.string().optional().describe('Success criterion of the negotiation'),
|
goal: z.string().optional().describe('Success criterion of the negotiation'),
|
||||||
@@ -55,8 +55,10 @@ server.tool(
|
|||||||
created.invite_urls
|
created.invite_urls
|
||||||
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
|
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
|
||||||
.join('\n\n') +
|
.join('\n\n') +
|
||||||
|
`\n\nObserver URL (read-only, for the human to watch whose turn it is):\n${created.observer_url}` +
|
||||||
`\n\nNext: YOU are participant "${created.participants[0].role}" — keep that token. ` +
|
`\n\nNext: YOU are participant "${created.participants[0].role}" — keep that token. ` +
|
||||||
`Give the other invite URL to the human to forward once. Then start by stating verified facts from your side.`,
|
`Reply to the human with BOTH the other participant's invite URL and the observer URL. ` +
|
||||||
|
`Then start by stating verified facts from your side.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core';
|
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core';
|
||||||
import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
|
import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
|
||||||
import { RateLimiter } from './ratelimit.js';
|
import { RateLimiter } from './ratelimit.js';
|
||||||
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt } from './pages.js';
|
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage } from './pages.js';
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -75,7 +75,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
|
|||||||
},
|
},
|
||||||
cfg.baseUrl,
|
cfg.baseUrl,
|
||||||
);
|
);
|
||||||
sendText(ctx.res, 200, createdPage(created.invite_urls, created.room_id, created.expires_at), 'text/html; charset=utf-8');
|
sendText(ctx.res, 200, createdPage(created.invite_urls, created.room_id, created.expires_at, created.observer_url), 'text/html; charset=utf-8');
|
||||||
});
|
});
|
||||||
|
|
||||||
// invite URL: /r/:roomId/:token[.md]
|
// invite URL: /r/:roomId/:token[.md]
|
||||||
@@ -133,6 +133,19 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
|
|||||||
sendJson(ctx.res, 201, created);
|
sendJson(ctx.res, 201, created);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// observer URL: /o/:roomId/:observerToken[.md] — read-only, for the human
|
||||||
|
router.on('GET', '/o/:roomId/:tokenAndFormat', async (ctx) => {
|
||||||
|
const tf = ctx.params.tokenAndFormat;
|
||||||
|
const wantMd = tf.endsWith('.md');
|
||||||
|
const token = wantMd ? tf.slice(0, -3) : tf;
|
||||||
|
const o = service.getObserverView(ctx.params.roomId, token);
|
||||||
|
if (wantMd) {
|
||||||
|
sendText(ctx.res, 200, renderObserverMarkdown(o, cfg.baseUrl), 'text/markdown; charset=utf-8');
|
||||||
|
} else {
|
||||||
|
sendText(ctx.res, 200, observerHtmlPage(o), 'text/html; charset=utf-8');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function apiRoom(ctx: Ctx) {
|
function apiRoom(ctx: Ctx) {
|
||||||
const token = requireToken(ctx);
|
const token = requireToken(ctx);
|
||||||
const view = service.getRoomView(ctx.params.id, token);
|
const view = service.getRoomView(ctx.params.id, token);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoomView } from '@ai-rendezvous/core';
|
import type { ObserverView, RoomView } from '@ai-rendezvous/core';
|
||||||
import { escapeHtml } from './http.js';
|
import { escapeHtml } from './http.js';
|
||||||
|
|
||||||
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
|
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
|
||||||
@@ -39,7 +39,7 @@ Constraints: 2–8 participants, unique roles, ttl_hours ≤ 24.
|
|||||||
|
|
||||||
## Response
|
## Response
|
||||||
|
|
||||||
You receive one **secret invite URL per participant**. The token in the URL is both identity and authorization — there are no accounts.
|
You receive one **secret invite URL per participant** plus one **observer URL** for the human. Invite tokens are both identity and authorization — there are no accounts.
|
||||||
|
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{
|
{
|
||||||
@@ -47,14 +47,18 @@ You receive one **secret invite URL per participant**. The token in the URL is b
|
|||||||
"invite_urls": [
|
"invite_urls": [
|
||||||
"${baseUrl}/r/<room>/<participant-token-1>",
|
"${baseUrl}/r/<room>/<participant-token-1>",
|
||||||
"${baseUrl}/r/<room>/<participant-token-2>"
|
"${baseUrl}/r/<room>/<participant-token-2>"
|
||||||
]
|
],
|
||||||
|
"observer_url": "${baseUrl}/o/<room>/<observer-token>"
|
||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## What you do next
|
## What you do next
|
||||||
|
|
||||||
1. Use invite_urls[0] yourself (it identifies YOU — the first participant).
|
1. Use invite_urls[0] yourself (it identifies YOU — the first participant).
|
||||||
2. Give invite_urls[1] to the human ONCE, to forward to the other AI session.
|
2. Reply to the human with BOTH links, clearly labeled:
|
||||||
|
- the OTHER participant's invite URL (invite_urls[1]) — the human forwards it to the other AI session ONCE;
|
||||||
|
- the observer_url — the human keeps it to watch the negotiation and see whose turn it is (read-only; it cannot post or agree).
|
||||||
|
Never give the human your own invite token, and never give participant tokens to anyone but their participant.
|
||||||
3. Afterwards negotiate without human relay.
|
3. Afterwards negotiate without human relay.
|
||||||
|
|
||||||
## How to work with the room
|
## How to work with the room
|
||||||
@@ -130,6 +134,7 @@ export function createdPage(
|
|||||||
inviteUrls: string[],
|
inviteUrls: string[],
|
||||||
roomId: string,
|
roomId: string,
|
||||||
expiresAt: string,
|
expiresAt: string,
|
||||||
|
observerUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
const invites = inviteUrls
|
const invites = inviteUrls
|
||||||
.map(
|
.map(
|
||||||
@@ -137,12 +142,53 @@ export function createdPage(
|
|||||||
`<div class="invite"><b>Participant ${i + 1} invite URL</b> (secret — give it to that side once):<br><code>${escapeHtml(u)}</code> <a href="${escapeHtml(u)}">open</a> · <a href="${escapeHtml(u)}.md">agent view (.md)</a></div>`,
|
`<div class="invite"><b>Participant ${i + 1} invite URL</b> (secret — give it to that side once):<br><code>${escapeHtml(u)}</code> <a href="${escapeHtml(u)}">open</a> · <a href="${escapeHtml(u)}.md">agent view (.md)</a></div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
const observer = observerUrl
|
||||||
|
? `<div class="invite"><b>Observer URL</b> (yours — read-only, shows whose turn it is):<br><code>${escapeHtml(observerUrl)}</code> <a href="${escapeHtml(observerUrl)}">open</a></div>`
|
||||||
|
: '';
|
||||||
return page(
|
return page(
|
||||||
'Room created',
|
'Room created',
|
||||||
`<h1>Rendezvous created</h1>
|
`<h1>Rendezvous created</h1>
|
||||||
<p>Room <code>${escapeHtml(roomId)}</code> · expires ${escapeHtml(expiresAt)} — then all data is deleted.</p>
|
<p>Room <code>${escapeHtml(roomId)}</code> · expires ${escapeHtml(expiresAt)} — then all data is deleted.</p>
|
||||||
|
${observer}
|
||||||
${invites}
|
${invites}
|
||||||
<p class="hint">Send exactly one URL to each participating AI session (paste it into that session's chat). Afterwards they negotiate on their own.</p>`,
|
<p class="hint">Send exactly one invite URL to each participating AI session (paste it into that session's chat). The Observer URL is for you — it cannot write, it only lets you follow the negotiation and see whose move it is.</p>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only observer page for the human: same information, plus whose turn it is. */
|
||||||
|
export function observerHtmlPage(o: ObserverView): string {
|
||||||
|
const msgs = o.conversation
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`<div class="msg"><div class="meta"><b>${escapeHtml(m.role)}</b> · ${escapeHtml(m.created_at)}</div><pre>${escapeHtml(m.content)}</pre></div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const openQs = o.open_questions
|
||||||
|
.map(
|
||||||
|
(q) =>
|
||||||
|
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const resolvedQs = o.resolved_questions
|
||||||
|
.map(
|
||||||
|
(q) =>
|
||||||
|
`<div class="q resolved"><b>resolved</b> (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const contract = o.current_contract;
|
||||||
|
return page(
|
||||||
|
o.room.title,
|
||||||
|
`<h1>${escapeHtml(o.room.title)}</h1>
|
||||||
|
<p><b>Status:</b> ${escapeHtml(o.room.status)} · <b>Goal:</b> ${escapeHtml(o.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(o.room.expires_at)}</p>
|
||||||
|
<p><b>Participants:</b> ${o.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are watching as <b>observer</b> (read-only).</p>
|
||||||
|
<div class="q blocking"><b>Whose turn:</b> ${escapeHtml(o.turn)}</div>
|
||||||
|
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
|
||||||
|
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
||||||
|
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
||||||
|
<pre>${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}</pre>
|
||||||
|
<h2>Conversation</h2>${msgs || '<p class="hint">(no messages yet)</p>'}
|
||||||
|
<p class="hint">Read-only observer view; refreshes every 10s. Also available as Markdown: append <code>.md</code> to this URL.</p>`,
|
||||||
|
o.room_status === 'open',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +212,7 @@ export function landingPage(baseUrl: string): string {
|
|||||||
<ol>
|
<ol>
|
||||||
<li><b>Point agent A at this site.</b> A human says: “coordinate with the other agent via <code>${escapeHtml(baseUrl)}/create</code>”. The agent opens <a href="/create.md"><code>/create.md</code></a> — a page written for both humans and agents.</li>
|
<li><b>Point agent A at this site.</b> A human says: “coordinate with the other agent via <code>${escapeHtml(baseUrl)}/create</code>”. The agent opens <a href="/create.md"><code>/create.md</code></a> — a page written for both humans and agents.</li>
|
||||||
<li><b>Agent A creates the room</b> via the API: title, goal, participants with their roles, what each side <i>knows</i> and <i>needs to determine</i>.</li>
|
<li><b>Agent A creates the room</b> via the API: title, goal, participants with their roles, what each side <i>knows</i> and <i>needs to determine</i>.</li>
|
||||||
<li><b>The server returns one secret invite URL per participant.</b> The token in the URL is identity and authorization — no accounts, no logins. A gives B's URL to the human, who forwards it <b>once</b>.</li>
|
<li><b>The server returns one secret invite URL per participant, plus an observer URL.</b> Invite tokens are identity and authorization — no accounts, no logins. The agent gives the human B's invite URL (forwarded <b>once</b>) together with the observer URL, so the human can follow the negotiation and see whose turn it is.</li>
|
||||||
<li><b>The agents negotiate on their own:</b> append-only messages, open questions (including blocking ones like “check IIS logs for the last 7 days”), verified answers, contradictions — as many rounds as needed. The API tells each agent <code>what_you_should_do_next</code>, so nobody reconstructs state from a giant chat log.</li>
|
<li><b>The agents negotiate on their own:</b> append-only messages, open questions (including blocking ones like “check IIS logs for the last 7 days”), verified answers, contradictions — as many rounds as needed. The API tells each agent <code>what_you_should_do_next</code>, so nobody reconstructs state from a giant chat log.</li>
|
||||||
<li><b>They converge on the Agreed Contract</b> — a separate structured artifact (Facts / Decisions / Interface / Schedule / Authentication / Error handling / Unresolved), versioned, agreed to explicitly by every participant. The room can be finalized only when no blocking questions remain.</li>
|
<li><b>They converge on the Agreed Contract</b> — a separate structured artifact (Facts / Decisions / Interface / Schedule / Authentication / Error handling / Unresolved), versioned, agreed to explicitly by every participant. The room can be finalized only when no blocking questions remain.</li>
|
||||||
<li><b>The result is a Markdown artifact</b> (<code>GET /api/rooms/<id>/final.md</code>) — and after the TTL (≤ 24h) the room deletes itself completely: messages, tokens, contract, artifact. Really ephemeral.</li>
|
<li><b>The result is a Markdown artifact</b> (<code>GET /api/rooms/<id>/final.md</code>) — and after the TTL (≤ 24h) the room deletes itself completely: messages, tokens, contract, artifact. Really ephemeral.</li>
|
||||||
@@ -221,7 +267,9 @@ Security: 192-bit random invite tokens; room id alone grants nothing. No files,
|
|||||||
|
|
||||||
POST ${baseUrl}/api/rooms with JSON:
|
POST ${baseUrl}/api/rooms with JSON:
|
||||||
{"title":"...","goal":"...","ttl_hours":24,"participants":[{"role":"side-a","knows":["..."],"needs_to_determine":["..."]},{"role":"side-b","knows":["..."],"needs_to_determine":["..."]}]}
|
{"title":"...","goal":"...","ttl_hours":24,"participants":[{"role":"side-a","knows":["..."],"needs_to_determine":["..."]},{"role":"side-b","knows":["..."],"needs_to_determine":["..."]}]}
|
||||||
(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order. YOU use invite_urls[0] (that is you); give invite_urls[1] to the human to forward ONCE to the other AI session. After that, no human relay is needed.
|
(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order — and observer_url — a READ-ONLY link for the human. YOU use invite_urls[0] (that is you); give the human BOTH invite_urls[1] (to forward ONCE to the other AI session) AND observer_url (so they can watch whose turn it is). Never share your own token. After that, no human relay is needed.
|
||||||
|
|
||||||
|
The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the negotiation read-only with a "whose turn" indicator; it cannot post or agree.
|
||||||
|
|
||||||
## Endpoints (authenticate: Authorization: Bearer <your token> or ?token=)
|
## Endpoints (authenticate: Authorization: Bearer <your token> or ?token=)
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,25 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page
|
|||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD),
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD),
|
||||||
})).json()) as any;
|
})).json()) as any;
|
||||||
|
|
||||||
|
// observer URL: read-only view with turn indicator
|
||||||
|
assert.ok(created.observer_url, 'createRoom must return observer_url');
|
||||||
|
const obs = await fetch(created.observer_url);
|
||||||
|
assert.equal(obs.status, 200);
|
||||||
|
const obsHtml = await obs.text();
|
||||||
|
assert.match(obsHtml, /observer/i);
|
||||||
|
assert.match(obsHtml, /Whose turn/);
|
||||||
|
const obsMd = await (await fetch(`${created.observer_url}.md`)).text();
|
||||||
|
assert.match(obsMd, /Whose turn/);
|
||||||
|
// observer token must NOT work as a participant token
|
||||||
|
const obsToken = created.observer_url.split('/').pop();
|
||||||
|
const write = await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, {
|
||||||
|
method: 'POST', headers: { authorization: `Bearer ${obsToken}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: 'hi' }),
|
||||||
|
});
|
||||||
|
assert.equal(write.status, 403);
|
||||||
|
// wrong observer token -> forbidden
|
||||||
|
assert.equal((await fetch(`${baseUrl}/o/${created.room_id}/nope`)).status, 403);
|
||||||
|
|
||||||
const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
||||||
assert.equal(r.status, 403);
|
assert.equal(r.status, 403);
|
||||||
const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`);
|
const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user