From 8aaebe0ce213e271998119a20c99b98404e6b668 Mon Sep 17 00:00:00 2001 From: Coston Perkins Date: Tue, 8 Sep 2026 10:21:21 -0500 Subject: [PATCH] feat(workflow): append_system_prompt per prompt step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quality-critical rule (e.g. "never narrate before the deliverable") placed in a prompt step's `prompt:` text competes with everything else in a long user turn and is unreliable in practice — even a sharply worded rule can keep failing an llm_as_judge check on leaked preamble, because wording strength doesn't fix where the instruction lives. buildClaudeArgs already supports --append-system-prompt via ClaudeTask.appendSystemPrompt, but only internal planning/refine/eval invocations ever set it — no YAML-authored prompt step could reach it. Adds append_system_prompt as a per-step field, threaded through RawStepSchema -> convertInnerStep -> ClaudeTask, with {{var}} substitution like prompt: already has. --- AGENTS.md | 1 + README.md | 13 ++++++++++ src/load-workflow.ts | 9 +++++++ src/tests/load-workflow.test.ts | 43 +++++++++++++++++++++++++++++++++ src/types.ts | 2 ++ 5 files changed, 68 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c923b41..2da7b6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Executant is a TypeScript CLI tool (`src/`) that executes YAML-defined workflows - `self_healing: true` - Optional (defaults to `false`; opt-in per step), automatically passes script failures to Claude for fixing - `llm_as_judge: true` - Optional, evaluates step quality and retries up to 5 times if needed - `allowed_tools` - Optional list restricting which tools are available for a prompt step. Applies to both Claude and OpenCode providers. Omit entirely for no restrictions (default — all tools available). `[]` = text-only mode (no tools). `[bash, read]` = only those tools. Tool names are case-insensitive (`Bash` and `bash` both work). + - `append_system_prompt` - Optional, prompt steps only. Appended to the CLI's own system prompt for this step via `--append-system-prompt`, rather than living inside the user-turn `prompt:` text. Use it for a rule the model must not trade off against everything else in a long prompt (e.g. "never narrate outside the deliverable") — system-level instructions get materially stronger adherence than the same words placed in the user turn. Supports `{{var}}` substitution like `prompt:` does. - `context` - Optional list of var names whose values are file paths; file contents are prepended to the prompt at runtime - `forEach` - Optional inline array or shell command (newline-split stdout); runs the inner step once per item with `{{item}}` substituted - `repeat: N` - Runs the step N times sequentially (compiles to a ForEachTask at load time); mutually exclusive with `forEach`; `{{item}}` is the 1-based iteration number diff --git a/README.md b/README.md index a167982..00288fc 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,11 @@ Step-level `provider`, `model`, and `agent` fields take priority over env vars. - Omit entirely → all tools available (default) - `allowed_tools: []` → text-only mode, no tools - `allowed_tools: [Bash, Read, Write]` → only those tools; names are case-insensitive +- **`append_system_prompt`** — prompt steps only; appended to the CLI's own system prompt + via `--append-system-prompt` instead of living inside `prompt:`'s user-turn text. A rule + placed here holds up far better than the same words buried in a long prompt — reach for + it for anything the model must never trade off against the rest of the instructions + (e.g. "never narrate outside the deliverable"). Supports `{{var}}` substitution. ```yaml steps: @@ -306,6 +311,14 @@ steps: - name: summarise prompt: Write a one-paragraph summary. allowed_tools: [] # no tools — pure text generation + + - name: pr_summary + prompt: | + Write the pull-request description... deliver it wrapped in markers. + append_system_prompt: >- + You are running non-interactively and unattended. Never emit narration, + commentary, or a preamble in your text output — reason and decide silently + via tool calls; the only text you output is the exact deliverable requested. ``` ```yaml diff --git a/src/load-workflow.ts b/src/load-workflow.ts index 70c49ae..3ed4aa2 100644 --- a/src/load-workflow.ts +++ b/src/load-workflow.ts @@ -52,6 +52,7 @@ export const RawStepSchema: z.ZodType = z.lazy(() => timeout_seconds: z.number().positive().optional(), provider: z.enum(["claude", "opencode"]).optional(), model: z.string().optional(), + append_system_prompt: z.string().optional(), agent: z.string().optional(), workflow: z.string().optional(), vars: z.record(z.string(), z.string()).optional(), @@ -316,6 +317,14 @@ function convertInnerStep( model: step.model ?? DEFAULT_MODEL, ...(step.provider && { provider: step.provider }), ...(step.agent && { agent: step.agent }), + ...(step.append_system_prompt && { + appendSystemPrompt: substituteVars( + step.append_system_prompt, + vars, + name, + "append_system_prompt", + ), + }), ...(contextFiles.length > 0 && { contextFiles }), ...(step.output && { output: resolveOutputFile(step.output, vars, name), diff --git a/src/tests/load-workflow.test.ts b/src/tests/load-workflow.test.ts index 95f0768..b529125 100644 --- a/src/tests/load-workflow.test.ts +++ b/src/tests/load-workflow.test.ts @@ -729,6 +729,49 @@ steps: assert.equal(task.model, "llama-qwen7b/qwen2.5-coder-7b"); }); + test("append_system_prompt is passed through to ClaudeTask", () => { + const file = tmpYaml(` +goal: test +steps: + - name: implement + append_system_prompt: Never narrate. Reply with only the deliverable. + prompt: Do the work +`); + const wf = loadWorkflow(file); + const task = wf.tasks[0] as ClaudeTask; + assert.equal( + task.appendSystemPrompt, + "Never narrate. Reply with only the deliverable.", + ); + }); + + test("append_system_prompt is omitted from ClaudeTask when not set", () => { + const file = tmpYaml(` +goal: test +steps: + - name: implement + prompt: Do the work +`); + const wf = loadWorkflow(file); + const task = wf.tasks[0] as ClaudeTask; + assert.equal(task.appendSystemPrompt, undefined); + }); + + test("append_system_prompt substitutes vars like prompt does", () => { + const file = tmpYaml(` +goal: test +vars: + role: reviewer +steps: + - name: implement + append_system_prompt: You are acting as a {{role}}. + prompt: Do the work +`); + const wf = loadWorkflow(file); + const task = wf.tasks[0] as ClaudeTask; + assert.equal(task.appendSystemPrompt, "You are acting as a reviewer."); + }); + test("agent field is passed through to ClaudeTask", () => { const file = tmpYaml(` goal: test diff --git a/src/types.ts b/src/types.ts index 9b69ccd..0550847 100644 --- a/src/types.ts +++ b/src/types.ts @@ -687,6 +687,8 @@ export type RawStep = { provider?: AgentProvider; /** Model override for this step. */ model?: string; + /** Appended to the CLI's own system prompt for this step only. */ + append_system_prompt?: string; /** OpenCode agent name. */ agent?: string; /** Local path or URL to another workflow, run as a nested sub-run. */