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,
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user