feat: workflowにてagent未指定でも起動可能にする (#71)

- agent フィールドを optional に変更
- agent未指定時は instruction_template のみで実行(システムプロンプトなし)
- agentSpec文字列をインラインシステムプロンプトとして扱う機能を追加
- セッションキーを agent ?? step.name に変更してagent未指定に対応
- README/README.ja.mdにエージェントレスステップの説明を追加
This commit is contained in:
nrslib 2026-02-03 00:21:17 +09:00
parent b944349d8f
commit 18894e2587
12 changed files with 140 additions and 43 deletions

View File

@ -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:

View File

@ -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: |
コード品質をレビューしてください。
```
### パラレルステップ
ステップ内でサブステップを並列実行し、集約条件で評価できます:

View File

@ -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);
});
});

View File

@ -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
const customAgents = loadCustomAgents();
const agentConfig = customAgents.get(agentName);
// 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);
}
if (agentConfig) {
return this.runCustom(agentConfig, task, options);
// 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);
}
throw new Error(`Unknown agent: ${agentSpec}`);
// 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> {

View File

@ -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({

View File

@ -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 */

View File

@ -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,
};
}

View File

@ -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) {

View File

@ -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);

View File

@ -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(),

View File

@ -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;

View File

@ -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,