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:
@@ -26,7 +26,14 @@ import type {
|
||||
AgentResumedEvent,
|
||||
AgentWaitingEvent,
|
||||
} 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
|
||||
@@ -66,12 +73,28 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
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.
|
||||
* Creates isolated worktree, starts Claude CLI, persists state.
|
||||
*/
|
||||
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
|
||||
const { name, taskId, prompt, cwd } = options;
|
||||
const { name, taskId, prompt, cwd, mode = 'execute' } = options;
|
||||
const worktreeId = randomUUID();
|
||||
const branchName = `agent/${name}`;
|
||||
|
||||
@@ -91,12 +114,14 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
sessionId: null,
|
||||
worktreeId: worktree.id,
|
||||
status: 'running',
|
||||
mode,
|
||||
});
|
||||
|
||||
// Use agent.id from repository for all tracking
|
||||
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(
|
||||
'claude',
|
||||
[
|
||||
@@ -105,7 +130,7 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
'--output-format',
|
||||
'json',
|
||||
'--json-schema',
|
||||
JSON.stringify(agentOutputJsonSchema),
|
||||
JSON.stringify(jsonSchema),
|
||||
],
|
||||
{
|
||||
cwd: cwd ?? worktree.path,
|
||||
@@ -139,7 +164,7 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
agentId: string,
|
||||
@@ -158,95 +183,296 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
await this.repository.updateSessionId(agentId, cliResult.session_id);
|
||||
}
|
||||
|
||||
// Parse the agent's structured output from result field
|
||||
const agentOutput = agentOutputSchema.parse(JSON.parse(cliResult.result));
|
||||
const rawOutput = JSON.parse(cliResult.result);
|
||||
const active = this.activeAgents.get(agentId);
|
||||
|
||||
switch (agentOutput.status) {
|
||||
case 'done': {
|
||||
// Success path
|
||||
if (active) {
|
||||
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);
|
||||
}
|
||||
// Parse output based on agent mode
|
||||
switch (agent.mode) {
|
||||
case 'discuss':
|
||||
await this.handleDiscussOutput(agent, rawOutput, cliResult.session_id);
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
case 'breakdown':
|
||||
await this.handleBreakdownOutput(agent, rawOutput);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unrecoverable_error': {
|
||||
// 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);
|
||||
}
|
||||
case 'execute':
|
||||
default:
|
||||
await this.handleExecuteOutput(agent, rawOutput, cliResult.session_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (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).
|
||||
* 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');
|
||||
|
||||
// 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(
|
||||
'claude',
|
||||
[
|
||||
@@ -388,7 +615,7 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
'--output-format',
|
||||
'json',
|
||||
'--json-schema',
|
||||
JSON.stringify(agentOutputJsonSchema),
|
||||
JSON.stringify(jsonSchema),
|
||||
],
|
||||
{
|
||||
cwd: worktree.path,
|
||||
|
||||
Reference in New Issue
Block a user