Skip to content

Commit 2f74a64

Browse files
authored
Fix: capture agent output for later workflow steps (#32) (#56)
* Fix: capture agent output for later workflow steps (#32) Agent steps currently discard their output after streaming it to the terminal, so later steps cannot inspect sentinel values like blocked status tags. Changes: - Add capture to AgentStep with shell-variable validation - Render captured agent output into a shell variable while preserving uncaptured streaming behavior - Add parser, schema, and runtime tests plus README coverage Fixes #32 * Archive investigation for issue #32 * Fix: preserve captured agent failures (#32)
1 parent 07e0f0d commit 2f74a64

5 files changed

Lines changed: 245 additions & 4 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Issue 32 Archive
2+
3+
## Issue
4+
5+
- #32: `[Important] capture: field on agent steps to expose output to subsequent steps`
6+
- Type: ENHANCEMENT
7+
8+
## Investigation Summary
9+
10+
Agent steps streamed their output to the terminal and discarded it, so later workflow steps could not inspect sentinel values such as blocked-status tags.
11+
12+
## Implementation Plan
13+
14+
- Add `capture: str | None = None` to `AgentStep` in `src/flowsh_cli/models.py`
15+
- Validate `capture` as an uppercase shell variable name
16+
- Render captured agent output into a shell variable in `src/flowsh_cli/render.py`
17+
- Add parser, schema, and end-to-end tests in `tests/test_workflow_to_harness.py`
18+
- Document `capture` in `README.md`
19+
20+
## Validation
21+
22+
- `make qa`
23+
24+
## Notes
25+
26+
The fix was implemented on branch `fix/issue-32-agent-capture`.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Harness paths are derived from workflow ids. `wf_example` becomes `example.sh` i
7979
|---|---|---|
8080
| `vars` | Execute shell commands and export their stdout into shell variables | Variable names must be uppercase shell identifiers. |
8181
| `bash` | Run shell commands | Runs with `bash -euo pipefail`. |
82-
| `agent` | Call OpenCode | Supports `agent`, `model`, `command`, `expandPrompt`, and `dangerouslySkipPermissions`. |
82+
| `agent` | Call OpenCode | Supports `agent`, `model`, `command`, `capture`, `expandPrompt`, and `dangerouslySkipPermissions`. |
8383
| `for` | Iterate over newline-delimited values from a previous `vars` step | Flat iteration only; nested `for` steps are not supported. |
8484
| `while` | Re-evaluate a Bash condition before each iteration | Use for dynamic queues or other stateful loops that must keep discovering new work. |
8585
| `parallel` | Run child steps concurrently | Children run as separate branches and the parent waits for all of them. |
@@ -90,6 +90,8 @@ Harness paths are derived from workflow ids. `wf_example` becomes `example.sh` i
9090

9191
`expandPrompt: true` does plain text replacement only. It does not evaluate shell expressions like `$(...)`, backticks, or globs.
9292

93+
Set `capture: VARIABLE_NAME` on an `agent` step when you want the full OpenCode output stored in a shell variable for later `vars` or `bash` steps.
94+
9395
Set `dangerouslySkipPermissions: true` only when you want the generated harness to pass `--dangerously-skip-permissions` to OpenCode. The YAML alias `dangerously-skip-permissions` is also accepted.
9496

9597
## Validation And Safety

src/flowsh_cli/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ class AgentStep(BaseStep):
9494
agent: str | None = None
9595
model: str | None = None
9696
command: str | None = None
97+
capture: str | None = Field(
98+
default=None,
99+
description="Name of a shell variable that receives the full agent output for later steps.",
100+
)
97101
dangerouslySkipPermissions: bool = Field(
98102
default=False,
99103
validation_alias=AliasChoices(
@@ -142,6 +146,13 @@ def validate_agent(cls, value: str | None) -> str | None:
142146
raise ValueError("must match ^[A-Za-z0-9_-]+$")
143147
return value
144148

149+
@field_validator("capture")
150+
@classmethod
151+
def validate_capture(cls, value: str | None) -> str | None:
152+
if value is not None and not re.fullmatch(r"[A-Z_][A-Z0-9_]*", value):
153+
raise ValueError("must match ^[A-Z_][A-Z0-9_]*$")
154+
return value
155+
145156

146157
class VarsStep(BaseStep):
147158
type: Literal["vars"]

src/flowsh_cli/render.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ def render_harness(workflow: Workflow) -> str:
180180
' local agent="${2:-}"',
181181
' local model="${3:-}"',
182182
' local command="${4:-}"',
183-
' local dangerously_skip_permissions="${5:-false}"',
183+
' local capture="${5:-}"',
184+
' local dangerously_skip_permissions="${6:-false}"',
184185
"",
185186
" local cmd=(opencode run --format json)",
186187
' if [[ -n "$agent" ]]; then',
@@ -206,7 +207,17 @@ def render_harness(workflow: Workflow) -> str:
206207
" return 127",
207208
" fi",
208209
"",
209-
' "${cmd[@]}" -- "$prompt"',
210+
' if [[ -n "$capture" ]]; then',
211+
" local output",
212+
" local status=0",
213+
' output="$("${cmd[@]}" -- "$prompt")"',
214+
" status=$?",
215+
" printf '%s\\n' \"$output\"",
216+
' printf -v "$capture" \'%s\' "$output"',
217+
' return "$status"',
218+
" else",
219+
' "${cmd[@]}" -- "$prompt"',
220+
" fi",
210221
"}",
211222
"",
212223
section(f"Starting workflow: {workflow.name}"),
@@ -376,10 +387,12 @@ def _render_step_body(step: Step, title: str) -> list[str]:
376387
lines.append(f" local agent={bash_quote(step.agent or '')}")
377388
lines.append(f" local model={bash_quote(step.model or '')}")
378389
lines.append(f" local command={bash_quote(step.command or '')}")
390+
lines.append(f" local capture={bash_quote(step.capture or '')}")
379391
dangerous_skip_permissions = "true" if step.dangerouslySkipPermissions else "false"
380392
lines.append(f" local dangerously_skip_permissions={dangerous_skip_permissions}")
381393
lines.append(
382-
' run_agent "$prompt" "$agent" "$model" "$command" "$dangerously_skip_permissions"'
394+
' run_agent "$prompt" "$agent" "$model" '
395+
'"$command" "$capture" "$dangerously_skip_permissions"'
383396
)
384397
elif isinstance(step, ForStep):
385398
raise AssertionError("nested for steps are not supported")

tests/test_workflow_to_harness.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,47 @@ def test_parse_workflows_accepts_agent_opencode_options(tmp_path: Path) -> None:
154154
assert step.dangerouslySkipPermissions is True
155155

156156

157+
def test_parse_workflows_accepts_agent_capture(tmp_path: Path) -> None:
158+
workflow_file = tmp_path / "workflows.yml"
159+
workflow_file.write_text(
160+
"""
161+
workflows:
162+
- id: wf_agent_capture
163+
name: Agent Capture
164+
steps:
165+
- type: agent
166+
capture: IMPLEMENT_OUTPUT
167+
prompt: Save the output.
168+
""".lstrip(),
169+
encoding="utf-8",
170+
)
171+
172+
step = parse_workflows(workflow_file)[0].steps[0]
173+
174+
assert isinstance(step, AgentStep)
175+
assert step.capture == "IMPLEMENT_OUTPUT"
176+
177+
178+
@pytest.mark.parametrize("value", ["implement_output", "1OUTPUT", "OUTPUT-NAME"])
179+
def test_parse_workflows_rejects_invalid_agent_capture(tmp_path: Path, value: str) -> None:
180+
workflow_file = tmp_path / "workflows.yml"
181+
workflow_file.write_text(
182+
f"""
183+
workflows:
184+
- id: wf_agent_capture_invalid
185+
name: Agent Capture Invalid
186+
steps:
187+
- type: agent
188+
capture: {value}
189+
prompt: Save the output.
190+
""".lstrip(),
191+
encoding="utf-8",
192+
)
193+
194+
with pytest.raises(WorkflowParseError, match="capture"):
195+
parse_workflows(workflow_file)
196+
197+
157198
def test_parse_workflows_accepts_dangerous_skip_flag_alias(tmp_path: Path) -> None:
158199
workflow_file = tmp_path / "workflows.yml"
159200
workflow_file.write_text(
@@ -1092,6 +1133,7 @@ def test_cli_exposes_schema_without_workflow_argument() -> None:
10921133
assert "model:" in result.output
10931134
assert "command:" in result.output
10941135
assert "dangerouslySkipPermissions:" in result.output
1136+
assert "capture:" in result.output
10951137
assert "Each value is a shell command." in result.output
10961138
assert "Name of a variable (defined by a preceding vars step)" in result.output
10971139
assert "Name of the shell variable exported into each iteration body." in result.output
@@ -1903,6 +1945,153 @@ def test_generated_harness_invokes_opencode_with_all_agent_options(tmp_path: Pat
19031945
assert '{"ok":true}' in executed.stdout
19041946

19051947

1948+
def test_generated_harness_captures_agent_output_for_later_steps(tmp_path: Path) -> None:
1949+
workflow_file = tmp_path / "workflows.yml"
1950+
workflow_file.write_text(
1951+
"""
1952+
workflows:
1953+
- id: wf_agent_capture
1954+
name: Agent Capture
1955+
steps:
1956+
- type: agent
1957+
capture: IMPLEMENT_OUTPUT
1958+
prompt: |
1959+
Print a sentinel.
1960+
- type: bash
1961+
run: |
1962+
if echo "$IMPLEMENT_OUTPUT" | grep -qF '<implement-status>blocked</implement-status>'; then
1963+
printf 'blocked\\n'
1964+
fi
1965+
""".lstrip(),
1966+
encoding="utf-8",
1967+
)
1968+
generated = subprocess.run(
1969+
[sys.executable, "-m", "flowsh_cli", str(workflow_file)],
1970+
check=False,
1971+
capture_output=True,
1972+
text=True,
1973+
cwd=tmp_path,
1974+
)
1975+
bin_dir = tmp_path / "bin"
1976+
bin_dir.mkdir()
1977+
fake_opencode = bin_dir / "opencode"
1978+
fake_opencode.write_text(
1979+
"""#!/usr/bin/env bash
1980+
printf '<implement-status>blocked</implement-status>\\n'
1981+
""",
1982+
encoding="utf-8",
1983+
)
1984+
fake_opencode.chmod(0o700)
1985+
1986+
assert generated.returncode == 0, generated.stderr
1987+
executed = subprocess.run(
1988+
["bash", str(tmp_path / "agent_capture.sh")],
1989+
check=False,
1990+
capture_output=True,
1991+
text=True,
1992+
cwd=tmp_path,
1993+
env={**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"},
1994+
)
1995+
1996+
assert executed.returncode == 0, executed.stderr
1997+
assert "blocked" in executed.stdout
1998+
1999+
2000+
def test_generated_harness_preserves_agent_capture_failure_status(tmp_path: Path) -> None:
2001+
workflow_file = tmp_path / "workflows.yml"
2002+
workflow_file.write_text(
2003+
"""
2004+
workflows:
2005+
- id: wf_agent_capture_failure
2006+
name: Agent Capture Failure
2007+
steps:
2008+
- type: agent
2009+
name: Ask OpenCode
2010+
capture: IMPLEMENT_OUTPUT
2011+
prompt: |
2012+
Print a sentinel and fail.
2013+
""".lstrip(),
2014+
encoding="utf-8",
2015+
)
2016+
generated = subprocess.run(
2017+
[sys.executable, "-m", "flowsh_cli", str(workflow_file)],
2018+
check=False,
2019+
capture_output=True,
2020+
text=True,
2021+
cwd=tmp_path,
2022+
)
2023+
bin_dir = tmp_path / "bin"
2024+
bin_dir.mkdir()
2025+
fake_opencode = bin_dir / "opencode"
2026+
fake_opencode.write_text(
2027+
"""#!/usr/bin/env bash
2028+
printf '<implement-status>blocked</implement-status>\n'
2029+
exit 17
2030+
""",
2031+
encoding="utf-8",
2032+
)
2033+
fake_opencode.chmod(0o700)
2034+
2035+
assert generated.returncode == 0, generated.stderr
2036+
executed = subprocess.run(
2037+
["bash", str(tmp_path / "agent_capture_failure.sh")],
2038+
check=False,
2039+
capture_output=True,
2040+
text=True,
2041+
cwd=tmp_path,
2042+
env={**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"},
2043+
)
2044+
2045+
assert executed.returncode == 17, executed.stderr
2046+
assert "blocked" in executed.stdout
2047+
2048+
2049+
def test_generated_harness_agent_without_capture_still_streams_output(tmp_path: Path) -> None:
2050+
workflow_file = tmp_path / "workflows.yml"
2051+
workflow_file.write_text(
2052+
"""
2053+
workflows:
2054+
- id: wf_agent_stream
2055+
name: Agent Stream
2056+
steps:
2057+
- type: agent
2058+
prompt: |
2059+
Stream the output.
2060+
""".lstrip(),
2061+
encoding="utf-8",
2062+
)
2063+
generated = subprocess.run(
2064+
[sys.executable, "-m", "flowsh_cli", str(workflow_file)],
2065+
check=False,
2066+
capture_output=True,
2067+
text=True,
2068+
cwd=tmp_path,
2069+
)
2070+
bin_dir = tmp_path / "bin"
2071+
bin_dir.mkdir()
2072+
fake_opencode = bin_dir / "opencode"
2073+
fake_opencode.write_text(
2074+
"""#!/usr/bin/env bash
2075+
printf '<streamed-output>\\n'
2076+
""",
2077+
encoding="utf-8",
2078+
)
2079+
fake_opencode.chmod(0o700)
2080+
2081+
assert generated.returncode == 0, generated.stderr
2082+
executed = subprocess.run(
2083+
["bash", str(tmp_path / "agent_stream.sh")],
2084+
check=False,
2085+
capture_output=True,
2086+
text=True,
2087+
cwd=tmp_path,
2088+
env={**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"},
2089+
)
2090+
2091+
assert executed.returncode == 0, executed.stderr
2092+
assert "<streamed-output>" in executed.stdout
2093+
2094+
19062095
def test_generated_harness_expands_agent_prompt_when_expand_prompt_enabled(tmp_path: Path) -> None:
19072096
workflow_file = tmp_path / "workflows.yml"
19082097
workflow_file.write_text(

0 commit comments

Comments
 (0)