From 18894e2587b0cd810071ddc765053e6f8dfccb80 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:21:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20workflow=E3=81=AB=E3=81=A6agent?= =?UTF-8?q?=E6=9C=AA=E6=8C=87=E5=AE=9A=E3=81=A7=E3=82=82=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E3=81=AB=E3=81=99=E3=82=8B=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent フィールドを optional に変更 - agent未指定時は instruction_template のみで実行(システムプロンプトなし) - agentSpec文字列をインラインシステムプロンプトとして扱う機能を追加 - セッションキーを agent ?? step.name に変更してagent未指定に対応 - README/README.ja.mdにエージェントレスステップの説明を追加 --- README.md | 25 ++++++++ docs/README.ja.md | 25 ++++++++ src/__tests__/parallel-and-loader.test.ts | 12 ++-- src/agents/runner.ts | 66 +++++++++++++++++----- src/core/models/schemas.ts | 9 +-- src/core/models/workflow-types.ts | 4 +- src/core/workflow/engine/OptionsBuilder.ts | 2 +- src/core/workflow/engine/ParallelRunner.ts | 3 +- src/core/workflow/engine/StepExecutor.ts | 7 ++- src/core/workflow/engine/WorkflowEngine.ts | 2 +- src/core/workflow/phase-runner.ts | 14 +++-- src/infra/config/loaders/workflowParser.ts | 14 ++++- 12 files changed, 140 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 1917f6f..0762d9f 100644 --- a/README.md +++ b/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: diff --git a/docs/README.ja.md b/docs/README.ja.md index e9c22a4..cc89101 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -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: | + コード品質をレビューしてください。 +``` + ### パラレルステップ ステップ内でサブステップを並列実行し、集約条件で評価できます: diff --git a/src/__tests__/parallel-and-loader.test.ts b/src/__tests__/parallel-and-loader.test.ts index bf3e244..1222833 100644 --- a/src/__tests__/parallel-and-loader.test.ts +++ b/src/__tests__/parallel-and-loader.test.ts @@ -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); }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index b57bcb6..5ff393c 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -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 { - 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 { diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index d604d96..bd6960f 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -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({ diff --git a/src/core/models/workflow-types.ts b/src/core/models/workflow-types.ts index 32eda9c..0e1f9a1 100644 --- a/src/core/models/workflow-types.ts +++ b/src/core/models/workflow-types.ts @@ -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 */ diff --git a/src/core/workflow/engine/OptionsBuilder.ts b/src/core/workflow/engine/OptionsBuilder.ts index 26d2313..81093e6 100644 --- a/src/core/workflow/engine/OptionsBuilder.ts +++ b/src/core/workflow/engine/OptionsBuilder.ts @@ -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, }; } diff --git a/src/core/workflow/engine/ParallelRunner.ts b/src/core/workflow/engine/ParallelRunner.ts index d4f489b..0102091 100644 --- a/src/core/workflow/engine/ParallelRunner.ts +++ b/src/core/workflow/engine/ParallelRunner.ts @@ -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) { diff --git a/src/core/workflow/engine/StepExecutor.ts b/src/core/workflow/engine/StepExecutor.ts index 1d085df..7aff644 100644 --- a/src/core/workflow/engine/StepExecutor.ts +++ b/src/core/workflow/engine/StepExecutor.ts @@ -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); diff --git a/src/core/workflow/engine/WorkflowEngine.ts b/src/core/workflow/engine/WorkflowEngine.ts index e49c29f..0b6ed95 100644 --- a/src/core/workflow/engine/WorkflowEngine.ts +++ b/src/core/workflow/engine/WorkflowEngine.ts @@ -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(), diff --git a/src/core/workflow/phase-runner.ts b/src/core/workflow/phase-runner.ts index cef02d8..63c9e87 100644 --- a/src/core/workflow/phase-runner.ts +++ b/src/core/workflow/phase-runner.ts @@ -130,9 +130,10 @@ export async function runReportPhase( stepIteration: number, ctx: PhaseRunnerContext, ): Promise { - 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 { - 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; diff --git a/src/infra/config/loaders/workflowParser.ts b/src/infra/config/loaders/workflowParser.ts index 58fd6e3..ced11a4 100644 --- a/src/infra/config/loaders/workflowParser.ts +++ b/src/infra/config/loaders/workflowParser.ts @@ -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,