feat(11-03): update ClaudeAgentManager for mode-specific schemas

- Import all mode-specific JSON schemas (discuss, breakdown)
- Add getJsonSchemaForMode() helper to select schema by mode
- Update spawn() to pass mode to repository and use mode-specific schema
- Refactor handleAgentCompletion() to route to mode-specific handlers
- Add handleExecuteOutput() for execute mode (existing behavior)
- Add handleDiscussOutput() for discuss mode (context_complete status)
- Add handleBreakdownOutput() for breakdown mode (breakdown_complete status)
- Update resume() to use mode-specific JSON schema
This commit is contained in:
Lukas May
2026-01-31 19:12:31 +01:00
parent d865330f85
commit 937d24eca5

View File

@@ -26,7 +26,14 @@ import type {
AgentResumedEvent, AgentResumedEvent,
AgentWaitingEvent, AgentWaitingEvent,
} from '../events/index.js'; } from '../events/index.js';
import { agentOutputSchema, agentOutputJsonSchema } from './schema.js'; import {
agentOutputSchema,
agentOutputJsonSchema,
discussOutputSchema,
discussOutputJsonSchema,
breakdownOutputSchema,
breakdownOutputJsonSchema,
} from './schema.js';
/** /**
* Result structure from Claude CLI with --output-format json * Result structure from Claude CLI with --output-format json
@@ -66,12 +73,28 @@ export class ClaudeAgentManager implements AgentManager {
private eventBus?: EventBus private eventBus?: EventBus
) {} ) {}
/**
* Get the appropriate JSON schema for a given agent mode.
* Each mode has its own output schema for Claude CLI --json-schema flag.
*/
private getJsonSchemaForMode(mode: AgentMode): object {
switch (mode) {
case 'discuss':
return discussOutputJsonSchema;
case 'breakdown':
return breakdownOutputJsonSchema;
case 'execute':
default:
return agentOutputJsonSchema;
}
}
/** /**
* Spawn a new agent to work on a task. * Spawn a new agent to work on a task.
* Creates isolated worktree, starts Claude CLI, persists state. * Creates isolated worktree, starts Claude CLI, persists state.
*/ */
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> { async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
const { name, taskId, prompt, cwd } = options; const { name, taskId, prompt, cwd, mode = 'execute' } = options;
const worktreeId = randomUUID(); const worktreeId = randomUUID();
const branchName = `agent/${name}`; const branchName = `agent/${name}`;
@@ -91,12 +114,14 @@ export class ClaudeAgentManager implements AgentManager {
sessionId: null, sessionId: null,
worktreeId: worktree.id, worktreeId: worktree.id,
status: 'running', status: 'running',
mode,
}); });
// Use agent.id from repository for all tracking // Use agent.id from repository for all tracking
const agentId = agent.id; const agentId = agent.id;
// 3. Start Claude CLI in background with JSON schema for structured output // 3. Start Claude CLI in background with mode-specific JSON schema
const jsonSchema = this.getJsonSchemaForMode(mode);
const subprocess = execa( const subprocess = execa(
'claude', 'claude',
[ [
@@ -105,7 +130,7 @@ export class ClaudeAgentManager implements AgentManager {
'--output-format', '--output-format',
'json', 'json',
'--json-schema', '--json-schema',
JSON.stringify(agentOutputJsonSchema), JSON.stringify(jsonSchema),
], ],
{ {
cwd: cwd ?? worktree.path, cwd: cwd ?? worktree.path,
@@ -139,7 +164,7 @@ export class ClaudeAgentManager implements AgentManager {
/** /**
* Handle agent subprocess completion. * Handle agent subprocess completion.
* Parses structured JSON result with discriminated union, updates session ID, emits events. * Parses structured JSON result with mode-specific schema, updates session ID, emits events.
*/ */
private async handleAgentCompletion( private async handleAgentCompletion(
agentId: string, agentId: string,
@@ -158,95 +183,296 @@ export class ClaudeAgentManager implements AgentManager {
await this.repository.updateSessionId(agentId, cliResult.session_id); await this.repository.updateSessionId(agentId, cliResult.session_id);
} }
// Parse the agent's structured output from result field const rawOutput = JSON.parse(cliResult.result);
const agentOutput = agentOutputSchema.parse(JSON.parse(cliResult.result));
const active = this.activeAgents.get(agentId); const active = this.activeAgents.get(agentId);
switch (agentOutput.status) { // Parse output based on agent mode
case 'done': { switch (agent.mode) {
// Success path case 'discuss':
if (active) { await this.handleDiscussOutput(agent, rawOutput, cliResult.session_id);
active.result = {
success: true,
message: agentOutput.result,
filesModified: agentOutput.filesModified,
};
}
await this.repository.updateStatus(agentId, 'idle');
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
taskId: agent.taskId ?? '',
reason: 'task_complete',
},
};
this.eventBus.emit(event);
}
break; break;
} case 'breakdown':
await this.handleBreakdownOutput(agent, rawOutput);
case 'questions': {
// Questions path - agent needs input (one or more questions)
if (active) {
active.pendingQuestions = {
questions: agentOutput.questions,
};
}
await this.repository.updateStatus(agentId, 'waiting_for_input');
if (this.eventBus) {
const event: AgentWaitingEvent = {
type: 'agent:waiting',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
taskId: agent.taskId ?? '',
sessionId: cliResult.session_id ?? agent.sessionId ?? '',
questions: agentOutput.questions,
},
};
this.eventBus.emit(event);
}
break; break;
} case 'execute':
default:
case 'unrecoverable_error': { await this.handleExecuteOutput(agent, rawOutput, cliResult.session_id);
// Error path
if (active) {
active.result = {
success: false,
message: agentOutput.error,
};
}
await this.repository.updateStatus(agentId, 'crashed');
if (this.eventBus) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
taskId: agent.taskId ?? '',
error: agentOutput.error,
},
};
this.eventBus.emit(event);
}
break; break;
}
} }
} catch (error) { } catch (error) {
await this.handleAgentError(agentId, error); await this.handleAgentError(agentId, error);
} }
} }
/**
* Handle output for execute mode (default mode).
*/
private async handleExecuteOutput(
agent: { id: string; name: string; taskId: string | null; sessionId: string | null },
rawOutput: unknown,
sessionId: string | undefined
): Promise<void> {
const agentOutput = agentOutputSchema.parse(rawOutput);
const active = this.activeAgents.get(agent.id);
switch (agentOutput.status) {
case 'done': {
if (active) {
active.result = {
success: true,
message: agentOutput.result,
filesModified: agentOutput.filesModified,
};
}
await this.repository.updateStatus(agent.id, 'idle');
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
reason: 'task_complete',
},
};
this.eventBus.emit(event);
}
break;
}
case 'questions': {
if (active) {
active.pendingQuestions = {
questions: agentOutput.questions,
};
}
await this.repository.updateStatus(agent.id, 'waiting_for_input');
if (this.eventBus) {
const event: AgentWaitingEvent = {
type: 'agent:waiting',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
sessionId: sessionId ?? agent.sessionId ?? '',
questions: agentOutput.questions,
},
};
this.eventBus.emit(event);
}
break;
}
case 'unrecoverable_error': {
if (active) {
active.result = {
success: false,
message: agentOutput.error,
};
}
await this.repository.updateStatus(agent.id, 'crashed');
if (this.eventBus) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
error: agentOutput.error,
},
};
this.eventBus.emit(event);
}
break;
}
}
}
/**
* Handle output for discuss mode.
* Outputs decisions array when context gathering is complete.
*/
private async handleDiscussOutput(
agent: { id: string; name: string; taskId: string | null; sessionId: string | null },
rawOutput: unknown,
sessionId: string | undefined
): Promise<void> {
const discussOutput = discussOutputSchema.parse(rawOutput);
const active = this.activeAgents.get(agent.id);
switch (discussOutput.status) {
case 'context_complete': {
if (active) {
active.result = {
success: true,
message: discussOutput.summary,
};
}
await this.repository.updateStatus(agent.id, 'idle');
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
reason: 'context_complete',
},
};
this.eventBus.emit(event);
}
break;
}
case 'questions': {
if (active) {
active.pendingQuestions = {
questions: discussOutput.questions,
};
}
await this.repository.updateStatus(agent.id, 'waiting_for_input');
if (this.eventBus) {
const event: AgentWaitingEvent = {
type: 'agent:waiting',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
sessionId: sessionId ?? agent.sessionId ?? '',
questions: discussOutput.questions,
},
};
this.eventBus.emit(event);
}
break;
}
case 'unrecoverable_error': {
if (active) {
active.result = {
success: false,
message: discussOutput.error,
};
}
await this.repository.updateStatus(agent.id, 'crashed');
if (this.eventBus) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
error: discussOutput.error,
},
};
this.eventBus.emit(event);
}
break;
}
}
}
/**
* Handle output for breakdown mode.
* Outputs phases array when initiative decomposition is complete.
*/
private async handleBreakdownOutput(
agent: { id: string; name: string; taskId: string | null },
rawOutput: unknown
): Promise<void> {
const breakdownOutput = breakdownOutputSchema.parse(rawOutput);
const active = this.activeAgents.get(agent.id);
switch (breakdownOutput.status) {
case 'breakdown_complete': {
if (active) {
active.result = {
success: true,
message: `Breakdown complete with ${breakdownOutput.phases.length} phases`,
};
}
await this.repository.updateStatus(agent.id, 'idle');
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
reason: 'breakdown_complete',
},
};
this.eventBus.emit(event);
}
break;
}
case 'questions': {
if (active) {
active.pendingQuestions = {
questions: breakdownOutput.questions,
};
}
await this.repository.updateStatus(agent.id, 'waiting_for_input');
if (this.eventBus) {
const event: AgentWaitingEvent = {
type: 'agent:waiting',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
sessionId: '',
questions: breakdownOutput.questions,
},
};
this.eventBus.emit(event);
}
break;
}
case 'unrecoverable_error': {
if (active) {
active.result = {
success: false,
message: breakdownOutput.error,
};
}
await this.repository.updateStatus(agent.id, 'crashed');
if (this.eventBus) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId: agent.id,
name: agent.name,
taskId: agent.taskId ?? '',
error: breakdownOutput.error,
},
};
this.eventBus.emit(event);
}
break;
}
}
}
/** /**
* Handle agent errors - actual crashes (not waiting for input). * Handle agent errors - actual crashes (not waiting for input).
* With structured output via --json-schema, question status is handled in * With structured output via --json-schema, question status is handled in
@@ -377,7 +603,8 @@ export class ClaudeAgentManager implements AgentManager {
await this.repository.updateStatus(agentId, 'running'); await this.repository.updateStatus(agentId, 'running');
// Start CLI with --resume flag and same JSON schema // Start CLI with --resume flag and mode-specific JSON schema
const jsonSchema = this.getJsonSchemaForMode(agent.mode as AgentMode);
const subprocess = execa( const subprocess = execa(
'claude', 'claude',
[ [
@@ -388,7 +615,7 @@ export class ClaudeAgentManager implements AgentManager {
'--output-format', '--output-format',
'json', 'json',
'--json-schema', '--json-schema',
JSON.stringify(agentOutputJsonSchema), JSON.stringify(jsonSchema),
], ],
{ {
cwd: worktree.path, cwd: worktree.path,