Clean Result Channel — Reference¶
The clean result channel is a dedicated, opt-in output path that carries an agent or recipe step's semantic answer on a channel that is separate from stdout. Stdout keeps carrying tracing, banners, progress heartbeats, and ANSI colour — the "firehose" a human reads. The clean channel carries only what the child chose to write as its result, captured verbatim.
It exists to end a recurring antipattern: downstream consumers (Simard
distillation, merge-verdict evaluation, and the in-repo orchestration patterns)
having to strip ANSI, brace-scan, and serde-parse a stream of interleaved logs
just to recover one agent's answer. When one agent's output feeds another, the
handoff should be semantic and clean.
Structured output is not required. The channel carries free text. The only guarantee is fidelity: what the child writes is what the consumer reads, byte for byte.
Contents¶
- The problem it replaces
- How it works
- Quick start
- API reference
RunOptions.result_sinkProcessResult.resultresult_sinkmodule- Configuration
AMPLIHACK_RESULT_SINK- The invocation contract
- Verbatim guarantee
- Fallback and back-compatibility
- Migration example: dropping stdout scraping
- Implementation notes
- Security notes
- FAQ
The problem it replaces¶
Today an agent's answer is embedded in ProcessResult.output — the child's full
stdout. To recover the answer, consumers reach for helpers like extract_json
(crates/amplihack-cli/src/commands/orch.rs): try ```json fences,
then untagged fences, then a left-to-right balanced-brace scan with serde_json.
Pattern consumers (patterns/debate.rs, patterns/n_version.rs,
patterns/expert_panel/) similarly read result.output and post-process it.
This is brittle for well-known reasons:
- ANSI noise. Colour escapes land in the middle of the payload.
- Banner / tracing bleed. Startup banners and
tracinglines interleave with the answer and can themselves contain{/}. - Ambiguous braces. A brace-scanner can lock onto a log line's JSON-looking fragment instead of the real answer.
- Lossy. Any answer that is not JSON (plain prose, a verdict word, a diff) has no reliable anchor to scrape at all.
The clean result channel removes the guessing: the child writes its answer to a runner-provided path, and the runner hands that file's exact contents to the consumer.
How it works¶
flowchart LR
subgraph Runner
A["run_delivered_command"]
A -->|"1. allocate sink path"| SINK["result-sink file<br/>(owner-only 0700 runtime dir)"]
A -->|"2. export AMPLIHACK_RESULT_SINK=path"| ENV["child env"]
end
subgraph Child["Spawned agent / step"]
ENV --> C["agent"]
C -->|"stdout: banner + tracing + ANSI"| OUT["stdout (noisy)"]
C -->|"final answer, verbatim"| SINK
end
OUT -->|"captured as"| RES1["ProcessResult.output"]
SINK -->|"read verbatim after exit"| RES2["ProcessResult.result = Some(...)"]
- A caller opts in by setting
RunOptions.result_sink = Some(path)(or by letting the runner allocate one — seeallocate_sink_path). - Before spawning, the runner exports
AMPLIHACK_RESULT_SINK=<path>into the child's environment. When the path came fromallocate_sink_path, its parent directory already exists with owner-only permissions; a caller that supplies its own path viawith_result_sinkowns that directory and its permissions. - The child runs. It writes human/log output to stdout as always, and writes
its final answer to the file named by
AMPLIHACK_RESULT_SINK. - After the child exits, the runner reads that file verbatim into
ProcessResult.result. - If the child never wrote the file (opted out, older agent, crash before
write),
resultisNoneand consumers fall back tooutput. The sink is read only on the normal-exit path: when the runner returns early (spawn failure, prompt-write failure, or timeout) it emits aProcessResult::errwithresult == Noneand never reads the sink.
stdout capture is completely unchanged. output and stderr mean exactly what
they meant before this feature existed.
Quick start¶
Opt in from a Rust caller that drives ProcessRunner:
use amplihack_orchestration::claude_process::{RunOptions, TokioProcessRunner, ProcessRunner};
use amplihack_orchestration::result_sink;
let runner = TokioProcessRunner::new();
// Allocate a runner-owned sink under the run's runtime directory.
let sink_path = result_sink::allocate_sink_path(&runtime_dir)?;
let opts = RunOptions::new(prompt, process_id)
.with_result_sink(sink_path);
let result = runner.run(opts).await;
// The clean answer — no stdout scraping, no strip_ansi, no brace scan.
let answer = result.result.as_deref().unwrap_or(&result.output);
From the child side (any agent honouring the contract), writing the answer is one line of shell:
# The runner sets this when the caller opts in. Empty/unset => legacy behavior.
if [ -n "${AMPLIHACK_RESULT_SINK:-}" ]; then
printf '%s' "$FINAL_ANSWER" > "$AMPLIHACK_RESULT_SINK"
fi
API reference¶
The public surface lives in the amplihack-orchestration crate.
RunOptions.result_sink¶
RunOptions gains one optional, additive field:
| Field | Type | Default | Meaning |
|---|---|---|---|
result_sink |
Option<PathBuf> |
None |
When Some(path), enable the clean channel for this run. |
RunOptions::new(prompt, process_id)setsresult_sink: None. Existing callers are unchanged — the field defaults off.RunOptions::with_result_sink(path)is a builder method that returns the options with the sink enabled:
All previously existing fields (prompt, process_id, timeout, model,
working_dir) keep their exact prior semantics.
ProcessResult.result¶
ProcessResult gains one optional, additive field:
| Field | Type | Default | Meaning |
|---|---|---|---|
result |
Option<String> |
None |
The clean channel contents, read verbatim. None = not written. |
Semantics:
Some(s)— the child wrote the sink;sis the file's exact contents.None— the caller did not opt in, or the child did not write the sink, or the contents were not valid UTF-8 (see Verbatim guarantee).
All existing fields (exit_code, output, stderr, duration, process_id)
are unchanged. The constructors ProcessResult::ok(...) and
ProcessResult::err(...) both initialise result to None, so callers that go
through those constructors compile without changes. Code that builds a
ProcessResult via a struct literal (e.g. the runner's own success arm)
adds result explicitly — see Implementation notes.
result_sink module¶
amplihack_orchestration::result_sink is the consumer-facing helper module.
| Item | Kind | Description |
|---|---|---|
RESULT_SINK_ENV |
const | The env-var name the runner exports to the child. Value: "AMPLIHACK_RESULT_SINK". |
allocate_sink_path(runtime_dir) |
fn | Allocate a fresh, unique, runner-owned sink path under runtime_dir. Creates the dir 0700 if needed. |
inject_sink_env(cmd, path) |
fn | Export AMPLIHACK_RESULT_SINK=<path> onto a Command's environment. |
read_sink_verbatim(path) |
fn | Read the sink file's bytes and return Some(String) verbatim, or None (unwritten / symlink / oversize / non-UTF-8). |
/// The environment variable name the runner exports to the child process.
pub const RESULT_SINK_ENV: &str = "AMPLIHACK_RESULT_SINK";
/// Allocate a unique, runner-owned result-sink path under `runtime_dir`.
/// The directory is created with owner-only permissions if it does not exist.
pub fn allocate_sink_path(runtime_dir: &Path) -> io::Result<PathBuf>;
/// Export `AMPLIHACK_RESULT_SINK=<path>` onto the environment of the command
/// that is about to be spawned (call before spawn).
pub fn inject_sink_env(cmd: &mut Command, path: &Path);
/// Read the sink verbatim. Returns:
/// - `Some(contents)` when the file exists and is valid UTF-8 within the cap,
/// - `None` when the file is absent, a symlink, empty-unwritten, oversize, or
/// not UTF-8.
/// Performs NO ANSI stripping, trimming, newline normalization, or JSON parsing.
/// A symlinked sink is refused rather than followed (SEC-13).
pub fn read_sink_verbatim(path: &Path) -> Option<String>;
Consumers that only need to read a captured result never call these directly —
they read ProcessResult.result. The module is exposed for callers that
allocate sinks themselves or that build child commands manually.
Concurrency. One sink serves exactly one run. Patterns that fan out many
ProcessRunner::runcalls in parallel (debate,n_version,expert_panel) must allocate a distinct sink per spawn — reusing one path across concurrent children races their writes.allocate_sink_pathreturns a unique path on each call for this reason.
Configuration¶
AMPLIHACK_RESULT_SINK¶
Type: absolute path
Set by: the runner (run_delivered_command) when the caller opts in via
RunOptions.result_sink
Read by: the spawned agent / recipe step
When present and non-empty, the child should write its final answer to this
path. When absent or empty, the child behaves exactly as before (answer on
stdout only). The runner captures the file's contents verbatim into
ProcessResult.result after the child exits.
Important runner behaviours:
- Opt-in only. The variable is exported only when
RunOptions.result_sinkisSome. - No stale inheritance. When
RunOptions.result_sinkisNone, the runner activelyenv_removesAMPLIHACK_RESULT_SINKfrom the child so an inherited value from an ancestor process can never silently redirect capture. - Runner-owned path. The path is chosen by the runner, not derived from untrusted context, and lives under the run's runtime directory (see Security notes).
See also Environment Variables — Reference.
The invocation contract¶
The clean channel is defined as a small, stable contract so that consumers
outside this crate — the external recipe-runner-rs binary and Simard — can
honour it without linking against amplihack-orchestration:
- Runner side. If a step requests the clean channel, the runner:
- allocates a private path it owns,
- exports
AMPLIHACK_RESULT_SINK=<path>into the child environment, -
after the child exits, reads that path verbatim and exposes it as the step's
result. If a step does not request the channel,AMPLIHACK_RESULT_SINKis not present in the child env (and any inherited value is removed). -
Agent / step side. A conforming agent:
- checks whether
AMPLIHACK_RESULT_SINKis set and non-empty, - if so, writes its final semantic answer (free text) to that path,
-
continues to emit human/log output to stdout as usual. If the variable is unset, the agent does nothing special — its answer remains on stdout for legacy capture.
-
Consumer side. A consumer reads the step's
result. Ifresultis present it is authoritative and requires no post-processing. Ifresultis absent, the consumer falls back to scrapingoutputexactly as it does today.
The contract is intentionally minimal: one env var, one file, verbatim capture. There is no schema, no framing, no handshake, and no dependency direction from the child to this crate.
Verbatim guarantee¶
ProcessResult.result is the sink file's contents byte for byte. The runner
performs none of the following:
- ANSI escape stripping
- leading/trailing whitespace trimming
- newline normalization (CRLF/LF)
- JSON or any other parsing / validation
- truncation of legitimately-sized content
This is the whole point: an answer that contains braces, JSON, ANSI-looking bytes, banners, or the literal text of a log line is returned unchanged. Nothing in the answer can be mistaken for framing, because there is no framing.
Two bounded exceptions, both of which yield None rather than a corrupted
string:
- Size cap. Reads are bounded. A sink larger than the cap yields
None(consumers fall back tooutput). This prevents a hostile or runaway child from forcing an unbounded allocation. - UTF-8.
resultis aString; non-UTF-8 sink bytes yieldNonerather than a lossy replacement. (Answers are text; binary payloads are out of scope for v1.)
Empty file vs. unwritten file: an existing zero-length sink is treated as "not a
meaningful answer" and reported as None, so a child that opts out by simply not
writing behaves identically to one that never touches the file. Consequently a
consumer never observes Some("") — an empty answer and an unwritten answer are
deliberately the same signal, and no consumer needs to distinguish them.
Fallback and back-compatibility¶
The feature is strictly additive and opt-in. The compatibility matrix:
| Caller opts in? | Child writes sink? | result |
output |
Consumer behaviour |
|---|---|---|---|---|
| No | — | None |
stdout (as before) | Legacy: scrape output. |
| Yes | No | None |
stdout | Falls back to scraping output. |
| Yes | Yes | Some(answer) |
stdout | Reads result; no scraping needed. |
Guarantees:
- Existing recipes and steps that read stdout keep working unchanged.
- Existing
ProcessResult/RunOptionsconstructors compile unchanged (resultandresult_sinkdefault off). - The
extract_json/ brace-scan helpers are not removed or deprecated; they remain the fallback path for the "no clean channel" case.
The recommended consumer idiom always degrades safely:
// Prefer the clean channel; fall back to stdout only if the child didn't write it.
let answer = result.result.as_deref().unwrap_or(&result.output);
Migration example: dropping stdout scraping¶
This is the concrete before/after a consumer follows to stop scraping. It is also exercised as an in-repo test proving the answer is recovered verbatim even when stdout is saturated with ANSI, tracing, and banner noise.
Before — scrape a noisy stdout:
// Answer is buried in `output` alongside banners, tracing, and ANSI colour.
// Must guess where the JSON is and strip escapes — brittle and lossy.
let raw = result.output;
let cleaned = strip_ansi(&raw);
let verdict = extract_json(&cleaned) // balanced-brace scan
.and_then(|v| v.get("verdict").cloned())
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default(); // silently empty on a bad scrape
After — read the clean channel:
// The step wrote its verdict to the sink. Read it verbatim. Done.
let verdict = result
.result
.clone()
.expect("step opted into the clean result channel");
// No strip_ansi. No extract_json. No brace scan. No serde. No guessing.
The proving test (shape, in amplihack-orchestration): a fake child emits a
storm of ANSI SGR sequences, tracing-style log lines, and a startup banner to
stdout, while writing an answer that itself contains { "verdict": "MERGE" }
plus stray ANSI-looking bytes to AMPLIHACK_RESULT_SINK. The test asserts:
result == Some(exact_answer_string)— byte-for-byte, including the braces and ANSI-looking bytes, with zero bleed from stdout;- the assertion is reached without calling
strip_ansi,extract_json, or anyserdehelper; outputstill contains the full noisy stdout (stdout capture unaffected);- the no-opt-in case yields
result == Nonewithoutputunchanged.
Implementation notes¶
The plumbing lives in crates/amplihack-orchestration/src/claude_process.rs.
Every execution path funnels through one shared executor,
run_delivered_command, which receives the opted-in sink alongside the
delivered command:
async fn run_delivered_command(
delivered: DeliveredProcessCommand,
timeout: Option<Duration>,
result_sink: Option<PathBuf>,
process_id: String,
start: Instant,
) -> ProcessResult
- Inject before spawn. When a sink is requested, the executor calls
result_sink::inject_sink_envon thestd::process::Commandbefore it is converted viaTokioCommand::from(command)and spawned. When none is requested itenv_removesRESULT_SINK_ENVso an inherited ancestor value cannot leak to the child (SEC-10). - Read on the normal-exit path only. The sink is read verbatim exactly where
the success
ProcessResultis built. Every early return (spawn, stdin-write, or timeout failure) usesProcessResult::err, which leavesresult = None, so a failed run never touches the sink — matching the error-path semantics above. - Additive fields.
ProcessResult.resultandRunOptions.result_sinkboth default toNoneinProcessResult::ok/ProcessResult::errandRunOptions::new, so existing constructors and call sites compile and behave unchanged. - Concurrency falls out naturally. The sink is per invocation of
run_delivered_command, so each parallelrun()indebate/n_version/expert_panelgets its own channel as long as callers allocate a distinct path per run (see the concurrency note underresult_sinkmodule).
Security notes¶
The clean channel is designed so that opting in never widens the trust boundary.
| ID | Control |
|---|---|
| SEC-1 | The sink path is runner-owned — allocated by allocate_sink_path, never taken from untrusted recipe context. Recipe-context injection drops AMPLIHACK_-prefixed keys, so a recipe cannot set the sink path itself. |
| SEC-3 | Reads are bounded by a size cap; an oversize sink yields None, never an unbounded allocation. |
| SEC-5 | result is treated as untrusted child output — same trust level as output. Consumers must not eval/execute it. |
| SEC-6 | The runner creates the run's runtime directory owner-only (0700 on Unix), keeping every sink inside it private to the owner. The child writes the sink file itself; the runner sets no mode on the file, relying on the owner-only directory to keep it private. |
| SEC-10 | When the caller does not opt in, the runner env_removes any inherited AMPLIHACK_RESULT_SINK so a stale ancestor value can never redirect capture. |
| SEC-12 | The capability is additive and opt-in; the default path (no sink) is byte-identical to prior behaviour. |
| SEC-13 | The reader refuses a symlinked sink: read_sink_verbatim lstats the path and returns None for a symlink rather than following it, so a sink swapped for a symlink can never redirect the runner into reading an arbitrary file (e.g. a secret) and handing it back as the "clean" answer. |
The sink carries a free-text answer, not code or a command. It is data, and consumers must handle it as data.
FAQ¶
Does my answer have to be JSON? No. The channel is free text. JSON, a single verdict word, prose, or a diff are all fine — whatever the child writes is returned verbatim.
What if the child crashes before writing the sink?
result is None and the consumer falls back to output. No regression versus
today.
Can I use a file descriptor instead of a path? Not in v1. FDs are cross-platform-fragile and cannot be consumed by a pinned-git library dependency. A runner-allocated file path is the contract; FD transport is explicitly out of scope.
Does this change the recipe schema?
No. v1 requires no recipe-schema change. If a per-step opt-in flag is added
later it will be #[serde(default)] and remain back-compatible.
Where does the sink file live?
Under the run's runtime directory (see
AMPLIHACK_RUNTIME_ROOT),
allocated by the runner with owner-only permissions.
Downstream consumers. After this ships, pinned consumers (Simard
distillation, merge-verdict evaluation) can bump their amplihack-rs dependency
and read ProcessResult.result / the AMPLIHACK_RESULT_SINK contract instead of
scraping stdout. See the PR body for the exact pinned-rev bump.