feat: Add inter-agent conversation system (listen, ask, answer)

Enables parallel agents to communicate through a CLI-based conversation
mechanism coordinated via tRPC. Agents can ask questions to peers and
receive answers, with target resolution by agent ID, task ID, or phase ID.
This commit is contained in:
Lukas May
2026-02-10 13:43:30 +01:00
parent 270a5cb21d
commit a6371e156a
29 changed files with 632 additions and 46 deletions

View File

@@ -206,43 +206,50 @@ export class CleanupManager {
}
/**
* Check if all project worktrees for an agent are clean (no uncommitted/untracked files).
* Get the relative subdirectory names of dirty worktrees for an agent.
* Returns an empty array if all worktrees are clean or the workdir doesn't exist.
*/
async isWorkdirClean(alias: string, initiativeId: string | null): Promise<boolean> {
async getDirtyWorktreePaths(alias: string, initiativeId: string | null): Promise<string[]> {
const agentWorkdir = this.getAgentWorkdir(alias);
try {
await readdir(agentWorkdir);
} catch {
// Workdir doesn't exist — treat as clean
return true;
return [];
}
const worktreePaths: string[] = [];
const worktreePaths: { absPath: string; name: string }[] = [];
if (initiativeId) {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
for (const project of projects) {
worktreePaths.push(join(agentWorkdir, project.name));
worktreePaths.push({ absPath: join(agentWorkdir, project.name), name: project.name });
}
} else {
worktreePaths.push(join(agentWorkdir, 'workspace'));
worktreePaths.push({ absPath: join(agentWorkdir, 'workspace'), name: 'workspace' });
}
for (const wtPath of worktreePaths) {
const dirty: string[] = [];
for (const { absPath, name } of worktreePaths) {
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: wtPath });
if (stdout.trim().length > 0) {
log.info({ alias, worktree: wtPath }, 'workdir has uncommitted changes');
return false;
}
} catch (err) {
log.warn({ alias, worktree: wtPath, err: err instanceof Error ? err.message : String(err) }, 'git status failed, treating as dirty');
return false;
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: absPath });
if (stdout.trim().length > 0) dirty.push(name);
} catch {
dirty.push(name);
}
}
return dirty;
}
return true;
/**
* Check if all project worktrees for an agent are clean (no uncommitted/untracked files).
*/
async isWorkdirClean(alias: string, initiativeId: string | null): Promise<boolean> {
const dirty = await this.getDirtyWorktreePaths(alias, initiativeId);
if (dirty.length > 0) {
log.info({ alias, dirtyWorktrees: dirty }, 'workdir has uncommitted changes');
}
return dirty.length === 0;
}
/**

View File

@@ -259,7 +259,12 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
// Write manifest listing exactly which files were created
writeFileSync(
join(inputDir, 'manifest.json'),
JSON.stringify({ files: manifestFiles, contextFiles }) + '\n',
JSON.stringify({
files: manifestFiles,
contextFiles,
agentId: options.agentId ?? null,
agentName: options.agentName ?? null,
}) + '\n',
'utf-8',
);
}

View File

@@ -255,13 +255,7 @@ export class MultiProviderAgentManager implements AgentManager {
initiativeBasedAgent: !!initiativeId
}, 'agent workdir setup completed');
// 2b. Write input files
if (options.inputContext) {
writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext });
log.debug({ alias }, 'input files written');
}
// 2c. Append workspace layout to prompt now that worktrees exist
// 2b. Append workspace layout to prompt now that worktrees exist
const workspaceSection = buildWorkspaceLayout(agentCwd);
if (workspaceSection) {
prompt = prompt + workspaceSection;
@@ -281,6 +275,12 @@ export class MultiProviderAgentManager implements AgentManager {
});
const agentId = agent.id;
// 3b. Write input files (after agent creation so we can include agentId/agentName)
if (options.inputContext) {
writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
log.debug({ alias }, 'input files written');
}
// 4. Build spawn command
const { command, args, env: providerEnv } = this.processManager.buildSpawnCommand(provider, prompt);
const finalCwd = cwd ?? agentCwd;
@@ -418,7 +418,6 @@ export class MultiProviderAgentManager implements AgentManager {
}
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'auto-cleanup failed');
} finally {
this.commitRetryCount.delete(agentId);
}
}
@@ -434,10 +433,16 @@ export class MultiProviderAgentManager implements AgentManager {
const provider = getProvider(agent.provider);
if (!provider || provider.resumeStyle === 'none') return false;
// Check which specific worktrees are dirty — skip resume if all clean
const dirtyPaths = await this.cleanupManager.getDirtyWorktreePaths(agent.name, agent.initiativeId);
if (dirtyPaths.length === 0) return false;
const dirtyList = dirtyPaths.map(p => `- \`${p}/\``).join('\n');
const commitPrompt =
'You have uncommitted changes in your working directory. ' +
'Stage everything with `git add -A` and commit with an appropriate message describing your work. ' +
'Do not make any other changes.';
'You have uncommitted changes in the following project directories:\n' +
dirtyList + '\n\n' +
'For each directory listed above, `cd` into it, then run `git add -A && git commit -m "<message>"` ' +
'with an appropriate commit message describing the work. Do not make any other changes.';
await this.repository.update(agentId, { status: 'running', pendingQuestions: null, result: null });

View File

@@ -2,7 +2,7 @@
* Detail mode prompt — break a phase into executable tasks.
*/
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT, INTER_AGENT_COMMUNICATION } from './shared.js';
export function buildDetailPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DETAIL mode.
@@ -41,5 +41,6 @@ ${ID_GENERATION}
- Break work into 3-8 tasks per phase
- Order tasks logically (foundational work first)
- Make each task self-contained with enough context
- Include test/verify tasks where appropriate`;
- Include test/verify tasks where appropriate
${INTER_AGENT_COMMUNICATION}`;
}

View File

@@ -2,7 +2,7 @@
* Discuss mode prompt — clarifying questions and decision capture.
*/
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT, INTER_AGENT_COMMUNICATION } from './shared.js';
export function buildDiscussPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DISCUSS mode.
@@ -30,5 +30,6 @@ ${ID_GENERATION}
- Ask 2-4 questions at a time, not more
- Provide options when choices are clear
- Capture every decision with rationale
- Don't proceed until ambiguities are resolved`;
- Don't proceed until ambiguities are resolved
${INTER_AGENT_COMMUNICATION}`;
}

View File

@@ -2,7 +2,7 @@
* Execute mode prompt — standard worker agent.
*/
import { INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
import { INPUT_FILES, SIGNAL_FORMAT, INTER_AGENT_COMMUNICATION } from './shared.js';
export function buildExecutePrompt(): string {
return `You are a Worker agent in the Codewalk multi-agent system.
@@ -16,5 +16,6 @@ ${SIGNAL_FORMAT}
- Complete the task as specified in .cw/input/task.md
- Ask questions if requirements are unclear
- Report errors honestly — don't guess
- Focus on writing clean, tested code`;
- Focus on writing clean, tested code
${INTER_AGENT_COMMUNICATION}`;
}

View File

@@ -5,7 +5,7 @@
* input files, ID generation) are in shared.ts.
*/
export { SIGNAL_FORMAT, INPUT_FILES, ID_GENERATION } from './shared.js';
export { SIGNAL_FORMAT, INPUT_FILES, ID_GENERATION, INTER_AGENT_COMMUNICATION } from './shared.js';
export { buildExecutePrompt } from './execute.js';
export { buildDiscussPrompt } from './discuss.js';
export { buildPlanPrompt } from './plan.js';

View File

@@ -2,7 +2,7 @@
* Plan mode prompt — plan initiative into phases.
*/
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT, INTER_AGENT_COMMUNICATION } from './shared.js';
export function buildPlanPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in PLAN mode.
@@ -36,5 +36,6 @@ ${ID_GENERATION}
- Start with foundation/infrastructure phases
- Group related work together
- Make dependencies explicit using phase IDs
- Each task should be completable in one session`;
- Each task should be completable in one session
${INTER_AGENT_COMMUNICATION}`;
}

View File

@@ -2,7 +2,7 @@
* Refine mode prompt — review and propose edits to initiative pages.
*/
import { INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
import { INPUT_FILES, SIGNAL_FORMAT, INTER_AGENT_COMMUNICATION } from './shared.js';
export function buildRefinePrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in REFINE mode.
@@ -24,5 +24,6 @@ Write one file per modified page to \`.cw/output/pages/{pageId}.md\`:
- Each output page's body is the FULL new content (not a diff)
- Preserve [[page:\$id|title]] cross-references in your output
- Focus on clarity, completeness, and consistency
- Do not invent new page IDs — only reference existing ones from .cw/input/pages/`;
- Do not invent new page IDs — only reference existing ones from .cw/input/pages/
${INTER_AGENT_COMMUNICATION}`;
}

View File

@@ -43,3 +43,57 @@ When creating new entities (phases, tasks, decisions), generate a unique ID by r
cw id
\`\`\`
Use the output as the filename (e.g., \`{id}.md\`).`;
export const INTER_AGENT_COMMUNICATION = `
## Inter-Agent Communication
You are working in a multi-agent parallel environment. Other agents may be working on related tasks simultaneously.
### Your Identity
Read \`.cw/input/manifest.json\` — it contains \`agentId\` and \`agentName\` fields identifying you.
### Listening for Questions
At the START of your session, start a background listener:
\`\`\`bash
cw listen --agent-id <YOUR_AGENT_ID> &
LISTEN_PID=$!
\`\`\`
When the listener prints JSON to stdout, another agent is asking you a question:
\`\`\`json
{ "conversationId": "...", "fromAgentId": "...", "question": "..." }
\`\`\`
Answer it:
\`\`\`bash
cw answer "<your answer>" --conversation-id <conversationId>
\`\`\`
Then restart the listener:
\`\`\`bash
cw listen --agent-id <YOUR_AGENT_ID> &
LISTEN_PID=$!
\`\`\`
### Asking Questions
To ask another agent a question (blocks until answered):
\`\`\`bash
cw ask "What interface does the user service expose?" --from <YOUR_AGENT_ID> --agent-id <TARGET_AGENT_ID>
\`\`\`
You can also target by task or phase:
\`\`\`bash
cw ask "What port does the API run on?" --from <YOUR_AGENT_ID> --task-id <TASK_ID>
cw ask "What schema are you using?" --from <YOUR_AGENT_ID> --phase-id <PHASE_ID>
\`\`\`
### When to Communicate
- You need interface/schema/contract info from another agent's work
- You're about to modify a shared resource and want to coordinate
- You have a dependency on work another agent is doing
### Cleanup
Before writing \`.cw/output/signal.json\`, kill your listener:
\`\`\`bash
kill $LISTEN_PID 2>/dev/null
\`\`\``;

View File

@@ -29,6 +29,10 @@ export interface AgentInputContext {
phases?: Array<import('../db/schema.js').Phase & { dependsOn?: string[] }>;
/** All tasks for the initiative (read-only context for agents) */
tasks?: import('../db/schema.js').Task[];
/** Agent ID for inter-agent communication */
agentId?: string;
/** Agent name for inter-agent communication */
agentName?: string;
}
/**