Agent Waves: Implementation Plan
- [ ] Step 1: Event model extensions (wave metadata) - [ ] Step 2: HatConfig extensions (concurrency, aggregate) - [ ] Step 3: WaveTracker state machine - [ ] Step 4: Wave CLI tool (`ralph wave emit`) - [ ] Step 5: Wave worker prompt builder - [ ] Step 6: Loop runner wave execution - [ ] Step 7: Context injection for NL dispatch - [ ] Step 8: Nested wave prevention - [ ] Step 9: Diagnostics and ob
Overview
Agent Waves: Implementation Plan
Checklist
- Step 1: Event model extensions (wave metadata)
- Step 2: HatConfig extensions (concurrency, aggregate)
- Step 3: WaveTracker state machine
- Step 4: Wave CLI tool (
ralph wave emit) - Step 5: Wave worker prompt builder
- Step 6: Loop runner wave execution
- Step 7: Context injection for NL dispatch
- Step 8: Nested wave prevention
- Step 9: Diagnostics and observability
- Step 10: Smoke tests and E2E
- Step 11: Documentation and example presets
Step 1: Event Model Extensions
Objective: Add optional wave correlation metadata to the event system so wave events can be identified, tracked, and correlated throughout the pipeline.
Implementation guidance:
- Add
wave_id: Option,wave_index: Option<u32>,wave_total: Option<u32>toEventstruct incrates/ralph-proto/src/event.rs - Add builder methods:
with_wave(wave_id, index, total),is_wave_event() - Add same fields to
EventRecordincrates/ralph-core/src/event_logger.rswith#[serde(skip_serializing_if = "Option::is_none")] - Update
EventReaderincrates/ralph-core/src/event_reader.rsto parse wave fields from JSONL using#[serde(default)]for backwards compatibility - Update
EventRecord::new()andEventRecord::from_agent_event()to propagate wave fields fromEvent
Test requirements:
- Unit: wave metadata round-trips through serialize/deserialize
- Unit: events without wave fields parse correctly (backwards compat)
- Unit:
is_wave_event()returns correct results - Unit:
EventReaderparses JSONL with and without wave fields
Integration notes: This is the foundation — every subsequent step depends on these fields existing. No behavioral changes yet; existing functionality is unaffected.
Demo: cargo test -p ralph-proto and cargo test -p ralph-core pass. Manually write a JSONL line with wave fields, verify EventReader parses it.
Step 2: HatConfig Extensions
Objective: Add concurrency and aggregate configuration fields to hat definitions so preset authors can declare wave-capable and aggregator hats.
Implementation guidance:
- Add
concurrency: u32(default 1) toHatConfigincrates/ralph-core/src/config.rs - Add
aggregate: OptiontoHatConfig - Define
AggregateConfig { mode: AggregateMode, timeout: u32 }andAggregateMode::WaitForAll - Add validation in
RalphConfig::validate():concurrency >= 1- Error if
aggregateset on hat withconcurrency > 1 - Warn if
concurrency > 1but no downstream hat hasaggregate
- Propagate
concurrencytoHatstruct incrates/ralph-proto/src/hat.rsif needed for runtime access
Test requirements:
- Unit: YAML with
concurrency: 3andaggregate: { mode: wait_for_all, timeout: 600 }parses correctly - Unit: YAML without new fields parses with defaults (concurrency=1, aggregate=None)
- Unit: validation rejects
concurrency: 0 - Unit: validation rejects aggregate on concurrent hat
- Unit: existing preset YAML files still parse correctly
Integration notes: Pure config — no runtime behavior changes. Existing presets are unaffected because defaults preserve current behavior.
Future failure modes (v2+): v1 is hardcoded best-effort (continue-on-failure) — partial results are almost always useful, so the wave continues when instances fail and the aggregator gets structured failure metadata. on_failure: fail_fast is worth adding in v2 for all-or-nothing use cases (e.g., parallel builds where one failure invalidates everything). Failure thresholds and must-pass hats are better handled by aggregator instructions — the aggregator already sees which instances failed and can decide how to react. If we see patterns where people keep writing the same "abort if X failed" logic in aggregator instructions, that's the signal to promote it to config.
Demo: Write a test hat collection YAML with wave config, verify it parses and validates.
Step 3: WaveTracker State Machine
Objective: Build the core state machine that tracks active waves, records results and failures, manages timeouts, and determines when aggregation gates should open.
Implementation guidance:
- New file:
crates/ralph-core/src/wave_tracker.rs - Core structs:
WaveTracker,WaveState,WaveInstance,InstanceStatus,WaveResult,WaveFailure,CompletedWave,WaveProgress - Key methods:
register_wave(wave_id, events, worker_hat, timeout)— creates new wave staterecord_result(wave_id, event)→WaveProgress(InProgress or Complete)record_failure(wave_id, index, error, duration)— records instance failureis_complete(wave_id)— all results + failures == expected totalcheck_timeouts()→Vec— returns timed-out wave IDstake_wave_results(wave_id)→CompletedWave— consumes completed wavehas_active_waves()— any waves in progress
- Add
mod wave_trackertocrates/ralph-core/src/lib.rsand export
Test requirements:
- Unit: register wave, record results one by one, verify Complete on last
- Unit: register wave, record some results + failure, verify completion accounting
- Unit: timeout detection with mocked time
- Unit:
take_wave_resultsreturns all results and failures, removes wave - Unit: multiple concurrent waves tracked independently
Integration notes: Pure data structure — no I/O, no async. Can be tested entirely with synchronous unit tests. Will be integrated into the loop runner in Step 6.
Demo: cargo test -p ralph-core wave_tracker — all state transitions exercised.
Step 4: Wave CLI Tool
Objective: Build the ralph wave emit command for atomic batch wave dispatch, and enhance ralph emit to support wave worker env vars.
Implementation guidance:
- New file:
crates/ralph-cli/src/wave.rs - Add
Wave(wave::WaveArgs)toCommandsenum incrates/ralph-cli/src/main.rs - Wire up
wave::execute()in the command dispatch match - v1 only supports batch emission — no
start/endsubcommands (deferred to v2) ralph wave emit <topic> --payloads "a" "b" "c":- Check
RALPH_WAVE_WORKERenv var — if set, exit with error (nested wave prevention) - Generate wave ID (timestamp-based hex:
w-{:08x}from nanos mod0xFFFF_FFFF) - Resolve events file from
.ralph/current-eventsmarker (falling back to.ralph/events.jsonl) - Write N events to JSONL, each with
wave_id,wave_index: 0..N-1,wave_total: N - Print wave ID to stdout
- Check
- Enhance existing
ralph emit(inmain.rs:emit_command):- Check
RALPH_EVENTS_FILEenv var — if set, write to that file instead of default - Check
RALPH_WAVE_ID+RALPH_WAVE_INDEXenv vars — if set, auto-tag events with wave metadata - When no wave env vars are present, behavior is unchanged (backwards compatible)
- Check
Test requirements:
- Unit/CLI:
ralph wave emit topic --payloads a b cwrites 3 tagged events atomically - Unit/CLI:
ralph emitwithRALPH_WAVE_IDandRALPH_WAVE_INDEXenv vars tags events correctly - Unit/CLI:
ralph emitwithRALPH_EVENTS_FILEenv var writes to specified file - Unit/CLI:
ralph emitwithout wave env vars works unchanged - Unit/CLI:
RALPH_WAVE_WORKER=1 ralph wave emitfails with error
Integration notes: This is the agent-facing interface. The events file is the handoff — CLI writes tagged JSONL, loop runner reads and detects waves in Step 6. The env var approach keeps ralph emit backwards compatible while transparently supporting wave workers.
Demo: Run ralph wave emit review.file --payloads "src/main.rs" "src/lib.rs" "src/config.rs". Inspect events file — three events with matching wave_id, sequential indices, wave_total=3.
Step 5: Wave Worker Prompt Builder
Objective: Build the prompt constructor for wave worker instances — each worker gets focused context with the hat's instructions and its specific event payload.
Implementation guidance:
- New file:
crates/ralph-core/src/wave_prompt.rs - Add
mod wave_prompttocrates/ralph-core/src/lib.rsand export build_wave_worker_prompt(HatConfig, Event, WaveWorkerContext) -> StringWaveWorkerContextcontains:wave_id,wave_index,wave_total,result_topics(from hat'spublishes)- Prompt sections:
- Hat instructions (from config)
- Wave context metadata (wave_id, your index, total instances)
- Event payload (the work item)
- Event writing guide (how to emit results — topic from
publishes, env vars handle correlation transparently) - Nested wave guard ("Do NOT use
ralph wavecommands")
- Keep it simple — no HATS table, no objective, no scratchpad. Workers are focused executors.
- The events file path and wave correlation metadata are communicated via env vars (Step 6), not embedded in the prompt. The prompt only includes what the agent needs to understand its task.
Test requirements:
- Unit: prompt includes hat instructions
- Unit: prompt includes event payload
- Unit: prompt includes wave metadata
- Unit: prompt includes nested wave prohibition
- Unit: prompt includes correct result topic from hat's
publishes
Integration notes: Used by the loop runner (Step 6) when spawning wave backends. Pure string construction — no I/O.
Demo: Call build_wave_worker_prompt() with test data, inspect output string for all required sections.
Step 6: Loop Runner Wave Execution
Objective: The core integration — detect wave events after a normal iteration, spawn concurrent backends for wave workers, collect results, merge into the main events file, and resume the normal loop. The loop runner owns the entire wave lifecycle; the event loop remains wave-agnostic.
Implementation guidance:
- In
crates/ralph-cli/src/loop_runner.rs, afterprocess_events_from_jsonl():- New struct
DetectedWave { wave_id, target_hat: HatId, hat_config: HatConfig, events: Vec, total: u32 } - New function
detect_wave_events(events, registry) -> Option— groups events bywave_id, validates consistency, resolves target hat from event topic viaHatRegistry - When wave detected, enter wave execution mode:
- Create per-worker events files (
.ralph/wave-{wave_id}-{index}.jsonl) - Register wave in
WaveTracker - Spawn concurrent backends using
tokio::sync::Semaphorefor concurrency limiting - Each backend gets env vars:
RALPH_WAVE_WORKER=1,RALPH_WAVE_ID,RALPH_WAVE_INDEX,RALPH_EVENTS_FILE(pointing to per-worker file) - Each backend uses worker hat's backend config, prompt from
build_wave_worker_prompt() - Collect results as instances complete — read events from each per-worker file
- Handle failures — call
wave_tracker.record_failure() - Race against aggregate timeout (resolved from downstream aggregator hat's config)
- On timeout: cancel running instances (SIGTERM, then SIGKILL after 250ms)
- Merge all result events from per-worker files into the main events file
- Clean up per-worker files
- Increment worker hat's activation count by number of instances
- Accumulate costs into global
max_costcheck
- Create per-worker events files (
- Resume normal loop — aggregator hat sees all results as pending events on next iteration
- New struct
WaveInstanceResult { index, status, events, cost, tokens, duration }— returned by each instance- The event loop never sees partial wave results. By the time it processes events on the next iteration, all results have been merged.
Test requirements:
- Integration: mock backend that emits result events, verify wave lifecycle end-to-end
- Integration: concurrency limiting — 5 instances, concurrency=2, verify max 2 concurrent
- Integration: timeout fires, verify partial results collected, instances terminated
- Integration: instance failure, verify wave continues, failure recorded
- Integration: activation counts incremented per instance
- Integration: cost accumulated across all instances
- Integration: per-worker event files created, read, merged, and cleaned up
- Integration: main events file not written to during wave execution
Integration notes: This is the largest and most complex step. It touches the main loop's execution path. Consider implementing incrementally: first get sequential wave execution working (concurrency=1), then add the semaphore-based concurrency. The aggregator gate is implicit — by merging all results at once, the event loop's existing determine_active_hats() naturally picks up the aggregator hat.
Demo: Create a test hat collection with dispatcher + worker (concurrency=2) + aggregator. Run a small wave (3 items) with a mock backend. Verify concurrent execution, result collection, and aggregator activation.
Step 7: Context Injection for NL Dispatch
Objective: Enhance the HATS table in Ralph's prompt to include downstream hat descriptions and wave dispatch instructions, enabling natural language wave dispatch.
Implementation guidance:
- In
crates/ralph-core/src/hatless_ralph.rs, in the HATS table generation (hats_section):- When the active hat has
publishestargeting wave-capable hats (concurrency > 1):- Add "Available Downstream Hats" section with topic, name, description, concurrency
- Add wave emission instructions (brief
ralph wave emitusage)
- When the active hat
publishestarget multiple different hats (scatter-gather):- Same enrichment, showing each target hat
- When the active hat has
- Use existing
HatInfoand topology resolution — this already resolvespublishes→ downstream hats - Keep it concise — a few-line table + one-liner wave instruction, not a tutorial
Test requirements:
- Unit: dispatcher hat with wave-capable downstream → prompt includes downstream table
- Unit: dispatcher hat with non-wave downstream (concurrency=1) → no wave context injected
- Unit: scatter-gather hat with multiple downstream hats → all listed
- Unit: prompt includes wave emission instructions
- Unit: hat with no publishes → no downstream section
Integration notes: Pure prompt construction — extends existing HATS table logic. No runtime behavior changes. This is what makes NL dispatch possible: the model sees what's available and decides what to fan out.
Demo: Build prompt for a dispatcher hat in a wave-capable collection. Inspect prompt for downstream hat table and wave instructions.
Step 8: Nested Wave Prevention
Objective: Prevent wave workers from emitting further waves, avoiding complexity explosion in v1.
Implementation guidance:
- Hard enforcement: In
ralph wave emit(wave.rs), checkRALPH_WAVE_WORKERenv var. If set, print error and exit with non-zero status. (This check is already partially implemented in Step 4, but verify it's in place.) - Soft enforcement: In
build_wave_worker_prompt()(Step 5), include "Do NOT useralph wavecommands. Nested waves are not suppo