feat: workflowにてagent未指定でも起動可能にする (#71)
- agent フィールドを optional に変更 - agent未指定時は instruction_template のみで実行(システムプロンプトなし) - agentSpec文字列をインラインシステムプロンプトとして扱う機能を追加 - セッションキーを agent ?? step.name に変更してagent未指定に対応 - README/README.ja.mdにエージェントレスステップの説明を追加
This commit is contained in:
parent
b944349d8f
commit
18894e2587
25
README.md
25
README.md
@ -209,6 +209,31 @@ steps:
|
||||
Review the implementation for architecture and code quality.
|
||||
```
|
||||
|
||||
### Agent-less Steps
|
||||
|
||||
The `agent` field is optional. When omitted, the step executes using only the `instruction_template` without a system prompt. This is useful for simple tasks where agent behavior customization is not needed.
|
||||
|
||||
```yaml
|
||||
- name: summarize
|
||||
# No agent specified — uses instruction_template only
|
||||
edit: false
|
||||
rules:
|
||||
- condition: Summary complete
|
||||
next: COMPLETE
|
||||
instruction_template: |
|
||||
Read the reports and provide a brief summary.
|
||||
```
|
||||
|
||||
You can also provide an inline system prompt as the `agent` value (when the specified file does not exist):
|
||||
|
||||
```yaml
|
||||
- name: review
|
||||
agent: "You are a code reviewer. Focus on readability and maintainability."
|
||||
edit: false
|
||||
instruction_template: |
|
||||
Review the code for quality.
|
||||
```
|
||||
|
||||
### Parallel Steps
|
||||
|
||||
Steps can execute sub-steps concurrently with aggregate evaluation:
|
||||
|
||||
@ -207,6 +207,31 @@ steps:
|
||||
アーキテクチャとコード品質の観点で実装をレビューしてください。
|
||||
```
|
||||
|
||||
### エージェントレスステップ
|
||||
|
||||
`agent` フィールドは省略可能です。省略した場合、ステップはシステムプロンプトなしで `instruction_template` のみを使って実行されます。これはエージェントの動作カスタマイズが不要なシンプルなタスクに便利です。
|
||||
|
||||
```yaml
|
||||
- name: summarize
|
||||
# agent未指定 — instruction_templateのみを使用
|
||||
edit: false
|
||||
rules:
|
||||
- condition: 要約完了
|
||||
next: COMPLETE
|
||||
instruction_template: |
|
||||
レポートを読んで簡潔な要約を提供してください。
|
||||
```
|
||||
|
||||
また、`agent` の値としてインラインシステムプロンプトを記述することもできます(指定されたファイルが存在しない場合):
|
||||
|
||||
```yaml
|
||||
- name: review
|
||||
agent: "あなたはコードレビュアーです。可読性と保守性に焦点を当ててください。"
|
||||
edit: false
|
||||
instruction_template: |
|
||||
コード品質をレビューしてください。
|
||||
```
|
||||
|
||||
### パラレルステップ
|
||||
|
||||
ステップ内でサブステップを並列実行し、集約条件で評価できます:
|
||||
|
||||
@ -22,14 +22,14 @@ describe('ParallelSubStepRawSchema', () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a sub-step without agent', () => {
|
||||
it('should accept a sub-step without agent (instruction_template only)', () => {
|
||||
const raw = {
|
||||
name: 'no-agent-step',
|
||||
instruction_template: 'Do something',
|
||||
};
|
||||
|
||||
const result = ParallelSubStepRawSchema.safeParse(raw);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept optional fields', () => {
|
||||
@ -90,14 +90,14 @@ describe('WorkflowStepRawSchema with parallel', () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a step with neither agent nor parallel', () => {
|
||||
it('should accept a step with neither agent nor parallel (instruction_template only)', () => {
|
||||
const raw = {
|
||||
name: 'orphan-step',
|
||||
instruction_template: 'Do something',
|
||||
};
|
||||
|
||||
const result = WorkflowStepRawSchema.safeParse(raw);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept a step with agent (no parallel)', () => {
|
||||
@ -111,14 +111,14 @@ describe('WorkflowStepRawSchema with parallel', () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a step with empty parallel array', () => {
|
||||
it('should accept a step with empty parallel array (no agent, no parallel content)', () => {
|
||||
const raw = {
|
||||
name: 'empty-parallel',
|
||||
parallel: [],
|
||||
};
|
||||
|
||||
const result = WorkflowStepRawSchema.safeParse(raw);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -154,15 +154,15 @@ export class AgentRunner {
|
||||
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
|
||||
}
|
||||
|
||||
/** Run an agent by name or path */
|
||||
/** Run an agent by name, path, inline prompt string, or no agent at all */
|
||||
async run(
|
||||
agentSpec: string,
|
||||
agentSpec: string | undefined,
|
||||
task: string,
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const agentName = AgentRunner.extractAgentName(agentSpec);
|
||||
const agentName = agentSpec ? AgentRunner.extractAgentName(agentSpec) : 'default';
|
||||
log.debug('Running agent', {
|
||||
agentSpec,
|
||||
agentSpec: agentSpec ?? '(none)',
|
||||
agentName,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
@ -171,11 +171,8 @@ export class AgentRunner {
|
||||
permissionMode: options.permissionMode,
|
||||
});
|
||||
|
||||
// If agentPath is provided (from workflow), use it to load prompt
|
||||
// 1. If agentPath is provided (resolved file exists), load prompt from file
|
||||
if (options.agentPath) {
|
||||
if (!existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent file not found: ${options.agentPath}`);
|
||||
}
|
||||
const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath);
|
||||
|
||||
const providerType = AgentRunner.resolveProvider(options.cwd, options);
|
||||
@ -198,15 +195,54 @@ export class AgentRunner {
|
||||
return provider.call(agentName, task, callOptions);
|
||||
}
|
||||
|
||||
// Fallback: Look for custom agent by name
|
||||
// 2. If agentSpec is provided but no agentPath (file not found), try custom agent first,
|
||||
// then use the string as inline system prompt
|
||||
if (agentSpec) {
|
||||
const customAgents = loadCustomAgents();
|
||||
const agentConfig = customAgents.get(agentName);
|
||||
|
||||
if (agentConfig) {
|
||||
return this.runCustom(agentConfig, task, options);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown agent: ${agentSpec}`);
|
||||
// Use agentSpec string as inline system prompt
|
||||
const providerType = AgentRunner.resolveProvider(options.cwd, options);
|
||||
const provider = getProvider(providerType);
|
||||
|
||||
const callOptions: ProviderCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: AgentRunner.resolveModel(options.cwd, options),
|
||||
systemPrompt: agentSpec,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
|
||||
return provider.call(agentName, task, callOptions);
|
||||
}
|
||||
|
||||
// 3. No agent specified — run with instruction_template only (no system prompt)
|
||||
const providerType = AgentRunner.resolveProvider(options.cwd, options);
|
||||
const provider = getProvider(providerType);
|
||||
|
||||
const callOptions: ProviderCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: AgentRunner.resolveModel(options.cwd, options),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
|
||||
return provider.call(agentName, task, callOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,7 +251,7 @@ export class AgentRunner {
|
||||
const defaultRunner = new AgentRunner();
|
||||
|
||||
export async function runAgent(
|
||||
agentSpec: string,
|
||||
agentSpec: string | undefined,
|
||||
task: string,
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentResponse> {
|
||||
|
||||
@ -113,10 +113,10 @@ export const WorkflowRuleSchema = z.object({
|
||||
interactive_only: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Sub-step schema for parallel execution (agent is required) */
|
||||
/** Sub-step schema for parallel execution */
|
||||
export const ParallelSubStepRawSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
agent: z.string().min(1),
|
||||
agent: z.string().optional(),
|
||||
agent_name: z.string().optional(),
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||
@ -155,10 +155,7 @@ export const WorkflowStepRawSchema = z.object({
|
||||
pass_previous_response: z.boolean().optional().default(true),
|
||||
/** Sub-steps to execute in parallel */
|
||||
parallel: z.array(ParallelSubStepRawSchema).optional(),
|
||||
}).refine(
|
||||
(data) => data.agent || (data.parallel && data.parallel.length > 0),
|
||||
{ message: 'Step must have either an agent or parallel sub-steps' },
|
||||
);
|
||||
});
|
||||
|
||||
/** Workflow configuration schema - raw YAML format */
|
||||
export const WorkflowConfigRawSchema = z.object({
|
||||
|
||||
@ -50,8 +50,8 @@ export interface ReportObjectConfig {
|
||||
/** Single step in a workflow */
|
||||
export interface WorkflowStep {
|
||||
name: string;
|
||||
/** Agent name or path as specified in workflow YAML */
|
||||
agent: string;
|
||||
/** Agent name, path, or inline prompt as specified in workflow YAML. Undefined when step runs without an agent. */
|
||||
agent?: string;
|
||||
/** Session handling for this step */
|
||||
session?: 'continue' | 'refresh';
|
||||
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
|
||||
|
||||
@ -45,7 +45,7 @@ export class OptionsBuilder {
|
||||
|
||||
return {
|
||||
...this.buildBaseOptions(step),
|
||||
sessionId: step.session === 'refresh' ? undefined : this.getSessionId(step.agent),
|
||||
sessionId: step.session === 'refresh' ? undefined : this.getSessionId(step.agent ?? step.name),
|
||||
allowedTools,
|
||||
};
|
||||
}
|
||||
|
||||
@ -92,8 +92,9 @@ export class ParallelRunner {
|
||||
? { ...baseOptions, onStream: parallelLogger.createStreamHandler(subStep.name, index) }
|
||||
: baseOptions;
|
||||
|
||||
const subSessionKey = subStep.agent ?? subStep.name;
|
||||
const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions);
|
||||
updateAgentSession(subStep.agent, subResponse.sessionId);
|
||||
updateAgentSession(subSessionKey, subResponse.sessionId);
|
||||
|
||||
// Phase 2: report output for sub-step
|
||||
if (subStep.report) {
|
||||
|
||||
@ -85,18 +85,19 @@ export class StepExecutor {
|
||||
? state.stepIterations.get(step.name) ?? 1
|
||||
: incrementStepIteration(state, step.name);
|
||||
const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration, state, task, maxIterations);
|
||||
const sessionKey = step.agent ?? step.name;
|
||||
log.debug('Running step', {
|
||||
step: step.name,
|
||||
agent: step.agent,
|
||||
agent: step.agent ?? '(none)',
|
||||
stepIteration,
|
||||
iteration: state.iteration,
|
||||
sessionId: state.agentSessions.get(step.agent) ?? 'new',
|
||||
sessionId: state.agentSessions.get(sessionKey) ?? 'new',
|
||||
});
|
||||
|
||||
// Phase 1: main execution (Write excluded if step has report)
|
||||
const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step);
|
||||
let response = await runAgent(step.agent, instruction, agentOptions);
|
||||
updateAgentSession(step.agent, response.sessionId);
|
||||
updateAgentSession(sessionKey, response.sessionId);
|
||||
|
||||
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession);
|
||||
|
||||
|
||||
@ -426,7 +426,7 @@ export class WorkflowEngine extends EventEmitter {
|
||||
this.state.status = 'aborted';
|
||||
return {
|
||||
response: {
|
||||
agent: step.agent,
|
||||
agent: step.agent ?? step.name,
|
||||
status: 'blocked',
|
||||
content: ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count),
|
||||
timestamp: new Date(),
|
||||
|
||||
@ -130,9 +130,10 @@ export async function runReportPhase(
|
||||
stepIteration: number,
|
||||
ctx: PhaseRunnerContext,
|
||||
): Promise<void> {
|
||||
const sessionId = ctx.getSessionId(step.agent);
|
||||
const sessionKey = step.agent ?? step.name;
|
||||
const sessionId = ctx.getSessionId(sessionKey);
|
||||
if (!sessionId) {
|
||||
throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${step.agent}" in step "${step.name}"`);
|
||||
throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in step "${step.name}"`);
|
||||
}
|
||||
|
||||
log.debug('Running report phase', { step: step.name, sessionId });
|
||||
@ -156,7 +157,7 @@ export async function runReportPhase(
|
||||
}
|
||||
|
||||
// Update session (phase 2 may update it)
|
||||
ctx.updateAgentSession(step.agent, reportResponse.sessionId);
|
||||
ctx.updateAgentSession(sessionKey, reportResponse.sessionId);
|
||||
|
||||
log.debug('Report phase complete', { step: step.name, status: reportResponse.status });
|
||||
}
|
||||
@ -170,9 +171,10 @@ export async function runStatusJudgmentPhase(
|
||||
step: WorkflowStep,
|
||||
ctx: PhaseRunnerContext,
|
||||
): Promise<string> {
|
||||
const sessionId = ctx.getSessionId(step.agent);
|
||||
const sessionKey = step.agent ?? step.name;
|
||||
const sessionId = ctx.getSessionId(sessionKey);
|
||||
if (!sessionId) {
|
||||
throw new Error(`Status judgment phase requires a session to resume, but no sessionId found for agent "${step.agent}" in step "${step.name}"`);
|
||||
throw new Error(`Status judgment phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in step "${step.name}"`);
|
||||
}
|
||||
|
||||
log.debug('Running status judgment phase', { step: step.name, sessionId });
|
||||
@ -190,7 +192,7 @@ export async function runStatusJudgmentPhase(
|
||||
const judgmentResponse = await runAgent(step.agent, judgmentInstruction, judgmentOptions);
|
||||
|
||||
// Update session (phase 3 may update it)
|
||||
ctx.updateAgentSession(step.agent, judgmentResponse.sessionId);
|
||||
ctx.updateAgentSession(sessionKey, judgmentResponse.sessionId);
|
||||
|
||||
log.debug('Status judgment phase complete', { step: step.name, status: judgmentResponse.status });
|
||||
return judgmentResponse.content;
|
||||
|
||||
@ -148,14 +148,24 @@ function normalizeRule(r: {
|
||||
/** Normalize a raw step into internal WorkflowStep format. */
|
||||
function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep {
|
||||
const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule);
|
||||
const agentSpec: string = step.agent ?? '';
|
||||
const agentSpec: string | undefined = step.agent || undefined;
|
||||
|
||||
// Resolve agent path: if the resolved path exists on disk, use it; otherwise leave agentPath undefined
|
||||
// so that the runner treats agentSpec as an inline system prompt string.
|
||||
let agentPath: string | undefined;
|
||||
if (agentSpec) {
|
||||
const resolved = resolveAgentPathForWorkflow(agentSpec, workflowDir);
|
||||
if (existsSync(resolved)) {
|
||||
agentPath = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
const result: WorkflowStep = {
|
||||
name: step.name,
|
||||
agent: agentSpec,
|
||||
session: step.session,
|
||||
agentDisplayName: step.agent_name || (agentSpec ? extractAgentDisplayName(agentSpec) : step.name),
|
||||
agentPath: agentSpec ? resolveAgentPathForWorkflow(agentSpec, workflowDir) : undefined,
|
||||
agentPath,
|
||||
allowedTools: step.allowed_tools,
|
||||
provider: step.provider,
|
||||
model: step.model,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user