Root cause of a spawned agent's failed first send: fleet agents are born not knowing how to reach their peers. Inject a comms cheat-sheet + peer roster into each fleet agent's system prompt via composeContract (the runtime-agnostic path every `mosaic yolo <runtime>` agent hits), so it can reach the orchestrator and peers from its first turn. - src/fleet/comms-onboarding.ts (standalone, no fleet.ts coupling): parseRosterAgents (name/class/host/ssh/socket), renderPeerReach (data-driven agent-send command), buildFleetCommsBlock (own [host:session] identity + agent-send path + peer table + FLIP-to-reply + `agent send --verify`=ACCEPTED), readFleetCommsBlock (reads roster.yaml; '' when not a member). - launch.ts composeContract appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster (no-op for non-fleet launches). - roster.schema.json: optional per-agent host/ssh/socket (cross-host + socket addressing; manual cross-host listing is a pre-federation stopgap). Address rendering is fully data-driven per Mos's acceptance criteria: - cross-host: local → `-s <session>` (no -H); remote → `-H <ssh> -s <session>`. - socket: named → `-L <socket>`; unset → default socket (no -L) — matches the LIVE socket, never blindly inherits the roster's tmux.socket_name. Verified: 14 onboarding + 9 composeContract tests (parse, render local/remote/ fallback/equal-host/socket-none/named/combined, build, situational read + composeContract injection w/ correct cross-host+socket addrs, no-op when MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean. Refs #620 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
/**
|
|
* Fleet onboarding-injection (#620).
|
|
*
|
|
* Fleet agents are born not knowing how to reach their peers — the root cause of
|
|
* a spawned agent's failed first send. When an agent boots via `mosaic yolo
|
|
* <runtime>` (→ composeContract → system prompt), we append a comms cheat-sheet
|
|
* + peer roster so it can talk to the orchestrator and other agents immediately.
|
|
*
|
|
* Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation
|
|
* stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a
|
|
* w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin
|
|
* peer. Same-host peers render the short form.
|
|
*
|
|
* Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the
|
|
* heavy fleet command module. The roster is parsed leniently — the cheat-sheet
|
|
* is best-effort onboarding, never a hard dependency.
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { homedir, hostname } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
export interface CommsPeer {
|
|
name: string;
|
|
/** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
|
|
className: string;
|
|
/** Host the peer runs on; absent ⇒ the fleet host (same host). */
|
|
host?: string;
|
|
/** SSH target (user@host) for a cross-host peer; renders the `-H` form. */
|
|
ssh?: string;
|
|
/** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */
|
|
socket?: string;
|
|
}
|
|
|
|
/**
|
|
* Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a
|
|
* dependency on the full fleet roster parser; the format is `- name:` list items
|
|
* with `class:`/`host:`/`ssh:` siblings under `agents:`.
|
|
*/
|
|
export function parseRosterAgents(yamlText: string): CommsPeer[] {
|
|
const peers: CommsPeer[] = [];
|
|
let current: CommsPeer | null = null;
|
|
let inAgents = false;
|
|
const scalar = (line: string, key: string): string | null => {
|
|
const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`));
|
|
return m ? (m[1] as string).trim() : null;
|
|
};
|
|
for (const rawLine of yamlText.split('\n')) {
|
|
const line = rawLine.replace(/\s+$/, '');
|
|
if (/^agents:\s*$/.test(line)) {
|
|
inAgents = true;
|
|
continue;
|
|
}
|
|
if (!inAgents) continue;
|
|
// A new top-level key (no leading space) ends the agents block.
|
|
if (/^\S/.test(line)) break;
|
|
|
|
const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
|
if (nameMatch) {
|
|
if (current) peers.push(current);
|
|
current = { name: nameMatch[1] as string, className: 'worker' };
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
const cls = scalar(line, 'class');
|
|
if (cls) current.className = cls;
|
|
const host = scalar(line, 'host');
|
|
if (host) current.host = host;
|
|
const ssh = scalar(line, 'ssh');
|
|
if (ssh) current.ssh = ssh;
|
|
const socket = scalar(line, 'socket');
|
|
if (socket) current.socket = socket;
|
|
}
|
|
if (current) peers.push(current);
|
|
return peers;
|
|
}
|
|
|
|
export interface FleetCommsOptions {
|
|
/** This agent's name (it is excluded from its own peer list). */
|
|
selfName: string;
|
|
/** All roster agents (including self; filtered out internally). */
|
|
agents: CommsPeer[];
|
|
/** Host the fleet runs on (short hostname) — the same-host baseline. */
|
|
fleetHost: string;
|
|
/** Absolute path to agent-send.sh in this install. */
|
|
agentSendPath: string;
|
|
}
|
|
|
|
/** Is this peer on a different host than the fleet baseline? */
|
|
function isRemote(peer: CommsPeer, fleetHost: string): boolean {
|
|
return peer.host !== undefined && peer.host !== fleetHost;
|
|
}
|
|
|
|
/**
|
|
* Render the exact agent-send command to reach a peer (session = agent name).
|
|
* Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
|
|
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
|
*/
|
|
export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string {
|
|
const parts = [agentSendPath];
|
|
if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L
|
|
if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string));
|
|
parts.push('-s', peer.name, '-m', '"…"');
|
|
return parts.join(' ');
|
|
}
|
|
|
|
/**
|
|
* Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when
|
|
* the agent has no peers (a single-agent roster has no one to talk to).
|
|
*/
|
|
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
|
const peers = opts.agents.filter((a) => a.name !== opts.selfName);
|
|
if (peers.length === 0) return '';
|
|
|
|
const orchestrator = peers.find((p) => p.className === 'orchestrator');
|
|
const rows = peers
|
|
.map((p) => {
|
|
const where = isRemote(p, opts.fleetHost)
|
|
? `${p.className} · host \`${p.host}\``
|
|
: p.className;
|
|
const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where;
|
|
return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`;
|
|
})
|
|
.join('\n');
|
|
|
|
const orchLine = orchestrator
|
|
? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` +
|
|
`status, and decisions there.`
|
|
: `This fleet has no orchestrator in its roster; coordinate with your peers directly.`;
|
|
|
|
return `# Fleet Comms — reach your peers
|
|
|
|
You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` —
|
|
that is the \`<src>\` other agents see and reply to. Reach other agents (durable tmux sessions) with the
|
|
Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer:
|
|
same-host peers use the short form (no \`-H\`); cross-host peers include \`-H <user@host>\`.
|
|
|
|
## Peers
|
|
|
|
| Agent | Role | Reach (session = agent name) |
|
|
| ----- | ---- | ---------------------------- |
|
|
${rows}
|
|
|
|
${orchLine}
|
|
|
|
## Conventions
|
|
|
|
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`agent-send.sh\` adds it automatically.
|
|
- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`).
|
|
- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`;
|
|
}
|
|
|
|
/**
|
|
* Read the fleet roster from `mosaicHome` and build the comms block for
|
|
* `selfName`. Returns '' when there is no roster, the agent is not in it, or
|
|
* there are no peers — onboarding is best-effort and never throws.
|
|
*/
|
|
export function readFleetCommsBlock(
|
|
mosaicHome: string,
|
|
selfName: string | undefined,
|
|
fleetHost: string = hostname().split('.')[0] || 'localhost',
|
|
): string {
|
|
if (!selfName) return '';
|
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
if (!existsSync(rosterPath)) return '';
|
|
let text: string;
|
|
try {
|
|
text = readFileSync(rosterPath, 'utf-8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
const agents = parseRosterAgents(text);
|
|
if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet
|
|
return buildFleetCommsBlock({
|
|
selfName,
|
|
agents,
|
|
fleetHost,
|
|
agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
|
|
});
|
|
}
|
|
|
|
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
|
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|