v0.12.0 — Bounded loops, durable static plans, crash-replay streaming
Close every open-ended / half-built feature surfaced by the v0.11.0 audit: memory completeness, audit fidelity, durable/streaming execution, and contract surface.
Breaking for coordinator agents.
BuiltByBerry\LaravelSwarm\Contracts\HasStructuredOutputis removed — switch theusestatement toLaravel\Ai\Contracts\HasStructuredOutput. Method signatures andinstanceofchecks are identical; only the import namespace changes. See UPGRADING.md.
Added
-
Bounded loops in hierarchical route plans (#190). Hierarchical and static-hierarchical plans were acyclic-only —
HierarchicalRoutePlannerandHierarchicalRoutePlanboth rejected any cycle ("Loops are not supported in this release" / "…in durable recovery"). A worker node may now declare a boundedloopback-edge —{"loop": {"to": "<ancestor worker node>", "max_iterations": <positive int>}}— to revisit an earlier node under an explicit iteration guard. After the worker runs, control routes back toloop.towhile its iteration count is belowmax_iterations, then falls through to the worker'snext(a looping worker must definenextas the exit). The bound is the termination condition:loop.tomust be a worker node that is a genuine ancestor on the looping node's own path,max_iterationsmust be a positive integer, and a missing/non-positive bound is rejected as unbounded — cycles formed by plainnext/branchedges are still rejected as before.#[MaxAgentSteps]is checked against the worst case (first pass plus every bounded replay of the loop body), so a loop that could exceed the limit fails before any worker runs.dispatchDurable()resumes a looped plan correctly: the persisted route cursor rewinds to the loop target while the durable step index advances monotonically, so a resumed loop never re-dispatches infinitely and each iteration is counted exactly once (idempotent across crash-replay). Each iteration is its own persisted step carrying aloop_iterationvalue in step metadata. Loops are hierarchical-only — non-hierarchical topologies are unaffected. Loops compose: a loop body may contain another bounded loop (each outer back-edge resets every inner loop's counter, so an outer boundMoover an inner boundMiruns the innermost worker up toMo * Mitimes), and a parallel group may sit inside a loop (the fan-out and join re-run every iteration, with each branch step carrying the enclosing loop'sloop_iteration— across sync, queued, and durable).#[MaxAgentSteps]is therefore budgeted as the product of the enclosing bounds, not their sum (reachableWorkerCount()folds each loop body in asmax_iterations * bodyCost), so an over-budget nested plan is rejected before any worker runs. Nested loops must be fully contained — an inner back-edge that escapes its enclosing loop's body is rejected at plan time. Loweringmax_iterationsin a redeploy that lands mid-run is safe: when the freshly-read bound is already at or below the persisted iteration count, the loop clamps and falls through to itsnextexit (no rewind, no crash, no extra pass beyond the one in flight). The build (HierarchicalRoutePlanner) and durable-rehydrate (HierarchicalRoutePlan) paths now share a single internalLoopRuleValidator, so both surfaces enforce identical loop/acyclicity/containment invariants with identical diagnostics. See Bounded Loops. -
dispatchDurable()now supported for static-hierarchical swarms (#191). Previously threwSwarmException. Static-hierarchical swarms now execute durably: the plan is built and validated at dispatch time (fail-fast — no LLM coordinator step), and execution starts directly with the first worker node. The plan-build step is step-0 (no agent call); subsequent steps advance the declared DAG exactly as in the sync/queued runners, including parallel-branch fan-out and join.coordinator_agent_classin the storedroute_cursoris''(empty string) for these runs. If you had acatchblock guarding against the old exception, remove it. See UPGRADING.md. -
Crash-replay durability for non-durable streamed runs (#192). A streamed sequential run that is abandoned mid-stream — a worker crash, a dropped connection, an early
breakbeforeswarm_stream_end— can now resume and replay byte-identically from its frozen memory snapshot. Two changes close the gap. Crash-safe tool-call capture: the streamed final agent's pending tool calls are now flushed into the snapshot in afinally, so aswarm_tool_callstill in flight when the generator is torn down is persisted with anullresult instead of being lost (previously the flush only ran on the happy path afterStreamEnd, so an abandoned stream dropped the in-flight pair). Snapshot-backed resume: the final streamed step now opens aMemoryReplayCoordinatorboundary — the same primitiveDurableBranchAdvanceruses — so re-running the swarm with the same run id detects the prior frozen snapshot, serves the agent the frozen memory view (drift in live memory after the crash cannot leak in), and rebuilds the tool-call record from scratch.SequentialRunnernow receivesMemoryReplayCoordinatorvia constructor injection. The happy-path stream event sequence and final snapshot byte-content are unchanged — the replay boundary is a no-op on first execution. Resume is governed by the memory replay mode (#[MemoryReplay]/swarm.memory.replay_mode, defaultfrozen_view;fresh_executionopts out) and requires the database persistence driver. Scope — terminal step only: only the final, streamed step replays byte-identically from its frozen snapshot. Non-final steps in a multi-step swarm re-execute against live memory on resume — their providers are re-invoked and any tool side effects re-fire (the primer step that seeds memory runs again). This is safe when earlier steps are idempotent; applications that need idempotent multi-step resume should usedispatchDurable(), which checkpoints every step across processes. Closing this gap forstream()(per-step replay of non-terminal steps) is tracked as #202. This is not full durable-mode streaming — usedispatchDurable()for checkpointed cross-process execution; #192 makes the non-durablestream()path's terminal step recoverable rather than silently lost. The frozen view is scoped per-invocation on the run'sActiveRunContextframe (not rebound on the container), so concurrent in-process streams under Octane each replay against their own snapshot with no cross-run bleed. See Streaming — Crash-Replay Durability. -
Streamed static-hierarchical runs honor bounded loops (#203). Bounded loops (#190) reached
prompt(),queue(), anddispatchDurable(), butStaticHierarchicalStreamRunnerwalked the plan with its own advance that set the next node tonextunconditionally — it never consultedhasLoop()/loopTo/loopMaxIterationsand kept no iteration counter, so a streamed looped plan ran the loop body once and exited instead of iterating.stream()(andbroadcast()/broadcastNow(), which wrap it) now re-streams the loop body up tomax_iterationsbefore falling through tonext, emitting a freshswarm_step_start…swarm_step_endgroup per pass with a monotonically increasingstepIndex, and records each looped step with itsloop_iterationin run history — parity with the other execution modes. A parallel group inside a loop body re-runs on every pass, and each branch step carries the enclosing loop'sloop_iteration. Unbounded loops are still rejected at plan build, before any worker streams. To remove the drift that caused this gap, the loop-continuation decision now lives on the route node itself —HierarchicalWorkerNode::successor($iteration)(and itsloopsBackAt($iteration)predicate) is the single authority that the sync, queued, durable, and streamed runners all consult, replacing the per-runner copies of the back-edge rule. No new loop semantics and no persisted-format change; this is a streamed-path parity fix for an already-supported plan shape. The non-durablestream()path keeps no route cursor, so loop state is purely in-flight and replay (#192) re-emits the recorded per-iteration events without re-running the loop. See Bounded loops and Streaming. -
Idempotent multi-step resume for non-durable streamed runs (#202). #192 made the terminal streamed step replay byte-identically on resume, but non-final steps re-executed against live memory — their providers were re-invoked and tool side effects (memory writes, external calls) re-fired. A streamed sequential run abandoned after step N and re-run with the same run id now skips every completed non-final step instead: its provider is not re-invoked and its tool side effects do not re-fire, and its output + usage are rehydrated so the downstream prompt — and therefore the terminal streamed step's events — is byte-identical to the original run. The terminal step still replays from its frozen snapshot (the #192 guarantee, unchanged). The blocker that made this its own component: a
MemorySnapshotfreezes the agent-visible memory view + tool calls, not the agent's output string, so the value fed into the next step's prompt was not recoverable. A newStreamStepCheckpointStorecontract (withDatabaseStreamStepCheckpointStore/NullStreamStepCheckpointStoreimplementations, aStreamStepCheckpointvalue object, and aswarm_stream_step_checkpointstable keyed by(run_id, step_index), overridable viaswarm.tables.stream_step_checkpoints/SWARM_STREAM_STEP_CHECKPOINTS_TABLE) records each completed non-final step's raw output + usage — the non-durable analogue of the durable runtime'sswarm_durable_node_outputs. The checkpoint is written only after the step completes and its guardrails pass, so "checkpoint present ⟹ step fully completed"; a step that crashed before completion leaves no checkpoint and re-executes. On the skip path the recorded guardrails still run against the rehydrated output (parity with a fresh run). The checkpoint store is bound in lockstep withSnapshotsMemory: resume is governed by the memory replay mode (#[MemoryReplay]/swarm.memory.replay_mode, defaultfrozen_view;fresh_executionwrites and reads nothing) and requires the database persistence driver — under the cache driver both resolve to their no-op implementations and every step re-executes on resume as before. It is Octane-safe (a stateless(run_id, step_index)store, no process-global rebind) and is operational resume state (parallel toswarm_contexts.input), not an evidence surface: the raw output is encrypted at rest throughSwarmPersistenceCipher(likeswarm_contexts.input/swarm_run_steps.output, on by default under the database driver) so the resumed prompt stays byte-identical, it is notswarm.capture.*-gated or audited, and it is pruned with the run — early-pruned alongside snapshots byswarm:memory:purge(retained together with--keep-snapshots) and cascade-deleted via theswarm_run_historiesforeign key as the backstop. The checkpoint write is best-effort: a store failure — or a missing table in a deploy-before-migrate window — logs a warning and the step still completes (resume just re-executes that step).swarm:install:memoryverifies the table alongside the snapshot table. Exactly-once execution of arbitrary external side effects across process boundaries remainsdispatchDurable()'s job — #202 closes the same-processstream()gap. Behavior change: a 2-step crashed-then-resumedstream()run now runs its non-final primer once, not twice; applications that assert a non-final step re-executes on resume must be updated.SequentialRunnernow receivesStreamStepCheckpointStorevia constructor injection. See Streaming — Crash-Replay Durability. -
Conversation-scoped memory surfaces to agents via a run conversation handle (#186).
MemoryScope::Conversationwas always storable and queryable (SwarmMemory::all(Conversation, $id)) but never reached an agent at runtime:AgentVisibleMemoryViewandMemoryToolScopeResolverboth resolved the Conversation scope id tonullbecause the runtime exposed no conversation handle, andRecall/Rememberdeclined it as "not addressable in this run".RunContext::withConversationId()now binds an opaque conversation id to a run (with aconversationId()accessor), stored in the run metadata so it threads through every execution path — sync, queued, durable, and the parallel/hierarchical branches — without a new column, mirroring howwithActor()is carried. With a conversation id bound,AgentVisibleMemoryViewgathersConversation-scoped entries under that id (so aMemoryPropagationPolicydeclaring the scope surfaces them, isolated per conversation), and theRecall/Remembertools read and writeconversationscope; with none bound the scope stays unaddressable and those call sites skip or decline it gracefully, exactly as before.ConversationRunResolveris promoted out of@experimental(itsstring -> list<string>signature is now stable);NullConversationRunResolverremains the default no-op binding, since Swarm still records no queryable conversation→run link in its own tables forswarm:memory:dumpexpansion. Deriving the conversation id from request/session state is application wiring and is left to the operator.conversation_idis added toEvidenceEnvelope::RESERVED_METADATA_KEYS, so its value is always emitted on audit and telemetry payloads as run provenance regardless of the metadata allowlist (noschema_versionbump — the addition is shape-preserving); keep conversation ids opaque (non-PII), since reserved values bypass the allowlist. See Conversation-scoped memory and the audit evidence contract.
Changed
-
BREAKING (evidence-surface shape):
CaptureDecision::Skipnow omits fields entirely instead of collapsing to[redacted](#187). This pairs with theEvidenceEnvelope::SCHEMA_VERSIONbump below — together they change the audit payload contract for installs returningSkip. Previously aCapturePolicyreturningCaptureDecision::Skipfor a category behaved identically toRedact— the value was persisted and emitted as the[redacted]string.Skipnow means true omission on the evidence surfaces (run history, lifecycle/stream events, audit envelopes): the field is absent from persisted/emitted arrays (run history steps, history context, lifecycle and stream events) andNULLon the nullable evidence columns. A migration makesswarm_run_steps.input/outputnullable;swarm_run_histories.outputwas already nullable. The operational active-context store (swarm_contexts.input) is runtime state required for durable resume and always retains the encrypted input —Skipnever nulls it; durable runs additionally require aFullactive-context decision at dispatch. Failures underSkipomit theerror.messagekey while keepingclass. New Skip-awareSwarmCapturehelpers (applyInput()/applyOutput()/applyFailureMessage()returning?string, plusstepToPersistedArray(),omitSkippedHistoryContextKeys(), andfailureArray()) implement the omission shape; lifecycle and stream event I/O fields are now nullable (?string). AStreamedSwarmResponserebuilt from Skipped stream events coerces the omitted output to''and setsmetadata['output_skipped'] = true(on the response and each affected step) so consumers can distinguish a deliberate omission from a genuinely empty output. The boolean path is frozen: the defaultBooleanCapturePolicyonly ever returnsFullorRedact, so every existingswarm.capture.*=falseinstall still sees[redacted]— only an explicitSkipfrom a custom policy changes the shape. ArtifactsRedact/Skipcollapse is pre-existing and unchanged (out of scope).RedactingMemoryStoreSkip behavior is unchanged. See UPGRADING.md and the audit evidence contract. -
BREAKING (audit evidence schema):
EvidenceEnvelope::SCHEMA_VERSIONis bumped to"3"(#187). TheSkipomission shape (absent keys,NULLcolumns, omittederror.message) changes the audit payload contract, so consumers pinning the schema version must accept"3". No payload-shape change occurs for installs that never returnCaptureDecision::Skip. See UPGRADING.md. -
BREAKING (contract surface):
BuiltByBerry\LaravelSwarm\Contracts\HasStructuredOutputis removed (#189). The interface was a zero-value marker — it extendedLaravel\Ai\Contracts\HasStructuredOutputverbatim and added no methods or behavior. Switch anyuse BuiltByBerry\LaravelSwarm\Contracts\HasStructuredOutputimport touse Laravel\Ai\Contracts\HasStructuredOutput; theinstanceofcheck and all method signatures are identical. This reverses the v0.5.0 upgrade guide instruction, which asked coordinators to adopt the Swarm marker — the upstream contract was always the runtime requirement. See UPGRADING.md.
Documentation
- Four durable-runtime extension points added to the public surface contract (#189).
ConfiguresDurableRetries,RoutesDurableBranches,RoutesDurableWaits, andDispatchesChildSwarmsare now listed in docs/public-surface.md. These contracts have been available since v0.10.0/v0.11.0; this corrects an omission in the surface documentation. No behavior change; no migration required.
Fixed
-
MemoryScope::Agententries were silently omitted from memory snapshots on hierarchical-parallel branches (#198).HierarchicalRunner::executePlan()discarded the return value ofresolveParallelWorker()and passednulltoAgentVisibleMemoryView::present(), causing the Agent scope to be skipped at snapshot time for concurrent branch workers. Agents could still read Agent-scoped entries live (e.g. via theRecalltool), but the frozen snapshot — the audit and replay record — omitted them, creating a gap between whatswarm:memory:inspectshowed and what the agent actually saw. The return value is now captured and forwarded; snapshots for hierarchical-parallel branches are now byte-identical to the agent's live view for any propagation policy that declaresMemoryScope::Agent. Behavior change: if your swarm uses aMemoryPropagationPolicythat declaresAgentscope and runs the hierarchical topology with parallel branches, those branch snapshots now include Agent-scoped entries where they previously did not — update any assertions that expect a zero Agent-scope count in hierarchical-parallel snapshots. -
StaticHierarchicalStreamRunnernow freezes aMemorySnapshotbefore every worker invocation (#159). Streamed static-hierarchical runs previously produced noswarm_memory_snapshotsrows — a gap that leftswarm:memory:inspectempty for those runs and gave the streaming-crash-replay path (#192) no snapshot anchor. A snapshot is now frozen immediately before each worker node executes, capturing the propagation-policy-filtered entry view the agent will see, and tool-call pairs are appended live as they stream in — matching the behavior ofSequentialStreamRunnerand all other runners. All three execution sub-paths are covered: sequential worker nodes, sequential parallel branches, and concurrent parallel branches (which pre-freeze their snapshots beforeConcurrencyManagerdispatches the work, then append tool-call pairs from the serialisable response payload after the concurrent run completes).StaticHierarchicalStreamRunnernow receivesSnapshotsMemoryandAgentVisibleMemoryViewvia constructor injection, mirroring the pattern used onSequentialRunner. Behavior change: streamed static-hierarchical runs now emitswarm_memory_snapshotsrows where they previously emitted none; applications that assert a zero-row count for these runs must be updated — see UPGRADING.md.
Full entry in the CHANGELOG · upgrade notes in UPGRADING.md.