Skip to content

v0.12.0 — Bounded loops, durable static plans, crash-replay streaming

Choose a tag to compare

@dberry37388 dberry37388 released this 18 Jun 05:31
dc4ab58

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\HasStructuredOutput is removed — switch the use statement to Laravel\Ai\Contracts\HasStructuredOutput. Method signatures and instanceof checks 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 — HierarchicalRoutePlanner and HierarchicalRoutePlan both rejected any cycle ("Loops are not supported in this release" / "…in durable recovery"). A worker node may now declare a bounded loop back-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 to loop.to while its iteration count is below max_iterations, then falls through to the worker's next (a looping worker must define next as the exit). The bound is the termination condition: loop.to must be a worker node that is a genuine ancestor on the looping node's own path, max_iterations must be a positive integer, and a missing/non-positive bound is rejected as unbounded — cycles formed by plain next/branch edges 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 a loop_iteration value 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 bound Mo over an inner bound Mi runs the innermost worker up to Mo * Mi times), 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's loop_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 as max_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. Lowering max_iterations in 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 its next exit (no rewind, no crash, no extra pass beyond the one in flight). The build (HierarchicalRoutePlanner) and durable-rehydrate (HierarchicalRoutePlan) paths now share a single internal LoopRuleValidator, so both surfaces enforce identical loop/acyclicity/containment invariants with identical diagnostics. See Bounded Loops.

  • dispatchDurable() now supported for static-hierarchical swarms (#191). Previously threw SwarmException. 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_class in the stored route_cursor is '' (empty string) for these runs. If you had a catch block 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 break before swarm_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 a finally, so a swarm_tool_call still in flight when the generator is torn down is persisted with a null result instead of being lost (previously the flush only ran on the happy path after StreamEnd, so an abandoned stream dropped the in-flight pair). Snapshot-backed resume: the final streamed step now opens a MemoryReplayCoordinator boundary — the same primitive DurableBranchAdvancer uses — 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. SequentialRunner now receives MemoryReplayCoordinator via 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, default frozen_view; fresh_execution opts 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 use dispatchDurable(), which checkpoints every step across processes. Closing this gap for stream() (per-step replay of non-terminal steps) is tracked as #202. This is not full durable-mode streaming — use dispatchDurable() for checkpointed cross-process execution; #192 makes the non-durable stream() path's terminal step recoverable rather than silently lost. The frozen view is scoped per-invocation on the run's ActiveRunContext frame (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(), and dispatchDurable(), but StaticHierarchicalStreamRunner walked the plan with its own advance that set the next node to next unconditionally — it never consulted hasLoop()/loopTo/loopMaxIterations and kept no iteration counter, so a streamed looped plan ran the loop body once and exited instead of iterating. stream() (and broadcast()/broadcastNow(), which wrap it) now re-streams the loop body up to max_iterations before falling through to next, emitting a fresh swarm_step_startswarm_step_end group per pass with a monotonically increasing stepIndex, and records each looped step with its loop_iteration in 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's loop_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 its loopsBackAt($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-durable stream() 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 MemorySnapshot freezes 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 new StreamStepCheckpointStore contract (with DatabaseStreamStepCheckpointStore / NullStreamStepCheckpointStore implementations, a StreamStepCheckpoint value object, and a swarm_stream_step_checkpoints table keyed by (run_id, step_index), overridable via swarm.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's swarm_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 with SnapshotsMemory: resume is governed by the memory replay mode (#[MemoryReplay] / swarm.memory.replay_mode, default frozen_view; fresh_execution writes 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 to swarm_contexts.input), not an evidence surface: the raw output is encrypted at rest through SwarmPersistenceCipher (like swarm_contexts.input / swarm_run_steps.output, on by default under the database driver) so the resumed prompt stays byte-identical, it is not swarm.capture.*-gated or audited, and it is pruned with the run — early-pruned alongside snapshots by swarm:memory:purge (retained together with --keep-snapshots) and cascade-deleted via the swarm_run_histories foreign 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:memory verifies the table alongside the snapshot table. Exactly-once execution of arbitrary external side effects across process boundaries remains dispatchDurable()'s job — #202 closes the same-process stream() gap. Behavior change: a 2-step crashed-then-resumed stream() run now runs its non-final primer once, not twice; applications that assert a non-final step re-executes on resume must be updated. SequentialRunner now receives StreamStepCheckpointStore via constructor injection. See Streaming — Crash-Replay Durability.

  • Conversation-scoped memory surfaces to agents via a run conversation handle (#186). MemoryScope::Conversation was always storable and queryable (SwarmMemory::all(Conversation, $id)) but never reached an agent at runtime: AgentVisibleMemoryView and MemoryToolScopeResolver both resolved the Conversation scope id to null because the runtime exposed no conversation handle, and Recall/Remember declined it as "not addressable in this run". RunContext::withConversationId() now binds an opaque conversation id to a run (with a conversationId() 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 how withActor() is carried. With a conversation id bound, AgentVisibleMemoryView gathers Conversation-scoped entries under that id (so a MemoryPropagationPolicy declaring the scope surfaces them, isolated per conversation), and the Recall/Remember tools read and write conversation scope; with none bound the scope stays unaddressable and those call sites skip or decline it gracefully, exactly as before. ConversationRunResolver is promoted out of @experimental (its string -> list<string> signature is now stable); NullConversationRunResolver remains the default no-op binding, since Swarm still records no queryable conversation→run link in its own tables for swarm:memory:dump expansion. Deriving the conversation id from request/session state is application wiring and is left to the operator. conversation_id is added to EvidenceEnvelope::RESERVED_METADATA_KEYS, so its value is always emitted on audit and telemetry payloads as run provenance regardless of the metadata allowlist (no schema_version bump — 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::Skip now omits fields entirely instead of collapsing to [redacted] (#187). This pairs with the EvidenceEnvelope::SCHEMA_VERSION bump below — together they change the audit payload contract for installs returning Skip. Previously a CapturePolicy returning CaptureDecision::Skip for a category behaved identically to Redact — the value was persisted and emitted as the [redacted] string. Skip now 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) and NULL on the nullable evidence columns. A migration makes swarm_run_steps.input/output nullable; swarm_run_histories.output was already nullable. The operational active-context store (swarm_contexts.input) is runtime state required for durable resume and always retains the encrypted input — Skip never nulls it; durable runs additionally require a Full active-context decision at dispatch. Failures under Skip omit the error.message key while keeping class. New Skip-aware SwarmCapture helpers (applyInput()/applyOutput()/applyFailureMessage() returning ?string, plus stepToPersistedArray(), omitSkippedHistoryContextKeys(), and failureArray()) implement the omission shape; lifecycle and stream event I/O fields are now nullable (?string). A StreamedSwarmResponse rebuilt from Skipped stream events coerces the omitted output to '' and sets metadata['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 default BooleanCapturePolicy only ever returns Full or Redact, so every existing swarm.capture.*=false install still sees [redacted] — only an explicit Skip from a custom policy changes the shape. Artifacts Redact/Skip collapse is pre-existing and unchanged (out of scope). RedactingMemoryStore Skip behavior is unchanged. See UPGRADING.md and the audit evidence contract.

  • BREAKING (audit evidence schema): EvidenceEnvelope::SCHEMA_VERSION is bumped to "3" (#187). The Skip omission shape (absent keys, NULL columns, omitted error.message) changes the audit payload contract, so consumers pinning the schema version must accept "3". No payload-shape change occurs for installs that never return CaptureDecision::Skip. See UPGRADING.md.

  • BREAKING (contract surface): BuiltByBerry\LaravelSwarm\Contracts\HasStructuredOutput is removed (#189). The interface was a zero-value marker — it extended Laravel\Ai\Contracts\HasStructuredOutput verbatim and added no methods or behavior. Switch any use BuiltByBerry\LaravelSwarm\Contracts\HasStructuredOutput import to use Laravel\Ai\Contracts\HasStructuredOutput; the instanceof check 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, and DispatchesChildSwarms are 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::Agent entries were silently omitted from memory snapshots on hierarchical-parallel branches (#198). HierarchicalRunner::executePlan() discarded the return value of resolveParallelWorker() and passed null to AgentVisibleMemoryView::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 the Recall tool), but the frozen snapshot — the audit and replay record — omitted them, creating a gap between what swarm:memory:inspect showed 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 declares MemoryScope::Agent. Behavior change: if your swarm uses a MemoryPropagationPolicy that declares Agent scope 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.

  • StaticHierarchicalStreamRunner now freezes a MemorySnapshot before every worker invocation (#159). Streamed static-hierarchical runs previously produced no swarm_memory_snapshots rows — a gap that left swarm:memory:inspect empty 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 of SequentialStreamRunner and 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 before ConcurrencyManager dispatches the work, then append tool-call pairs from the serialisable response payload after the concurrent run completes). StaticHierarchicalStreamRunner now receives SnapshotsMemory and AgentVisibleMemoryView via constructor injection, mirroring the pattern used on SequentialRunner. Behavior change: streamed static-hierarchical runs now emit swarm_memory_snapshots rows 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.