Releases: timmcleod/agent-workflows
Release list
v0.12.0 — static Workflow::start()
Feature release: class-based workflows start themselves. No schema changes, no migration.
Added
- Static
Workflow::start()—ContractReview::start(['contract' => $text], participant: $user)is equivalent toAgentWorkflow::start(ContractReview::class, ...). The static resolves the manager through the container, soAgentWorkflow::fake()records these runs too. The facade remains the way to start string-named workflows.
v0.11.0 — Multi-agent debate
Feature release: durable multi-agent debate. No schema changes, no migration.
Added
debate()— two or more debater agents argue a topic in rounds (openings, then rebuttals) while a structured-output judge rules on the transcript after each round; the loop stops onjudge.consensus === true(or a customuntil:predicate) or at theroundscap, which is an outcome (satisfied: false), not a failure. Compiles toevaluate()+ a package-shipped callback body, so each round is one checkpoint and one audit row, drift hashing and the dashboard treat it as machinery they already know, and the graph stays static.as:is required. Costs grow quadratically withrounds;transcriptWindow:bounds the debaters' prompts to the last N rounds (the judge always sees the full transcript). Full guide: docs/agent-debate.md.Support\Transcript— a JSON-safe view oversteps.{id}.transcript(append(),entries(),bySpeaker(),round(),render(lastRounds:)).AgentStepResult::sum()— key-wise usage aggregation for multi-call step bodies.- Callback steps now receive their
StepDefinitionas a second argument and may return aStepResultto report token usage on the audit row.
Guard rails, priced in tokens
- A judge whose verdict lacks a
consensusboolean fails the debate loudly after one round under the default predicate; with a customuntil:the check is waived. Debater count/type, duplicate speaker names,rounds, andtranscriptWindoware validated at definition time. - A debater (or judge) pausing on SDK tool approvals mid-round fails the round loudly naming the participant — per-speaker decision replay doesn't exist yet. Move approval-gated tools outside the debate.
- The shipped protocol prompts are versioned into the definition hash (
DebateRoundStep::PROTOCOL_VERSION), so a package upgrade that changes them refuses to resume an in-flight debate under strict drift mode.
Compatibility note on the callback signature: every single-parameter callback (__invoke(WorkflowState $state)) keeps working unchanged. A callback that declares an optional second parameter of another type (__invoke(WorkflowState $state, ?Foo $extra = null)) now receives the StepDefinition there and will TypeError; rename or retype that parameter.
Pair with agent-workflows-ui v0.4.0 for debate round-progress in the dashboard.
v0.10.0 — hardening release
Hardening release from a full-package review: parallel × agents, parallel × crash recovery, interrupt payload integrity, and operational scale. Run php artisan migrate after upgrading — schema changes now always ship as additive migrations (this release adds hot-path indexes, drops the foreign key constraints, and repairs the v0.8 → v0.9 timeout_at gap).
Breaking (flagged per 0.x policy):
- Unaliased
evaluate()steps are now id'd by the bare class basename (wasevaluate:{Basename}), sooutput(Target::class)addresses the loop's checkpoints like any other step's. This changes those workflows' definition hashes — in strict drift mode, in-flight runs refuse to resume after deploying the upgrade (passas: 'evaluate:{Basename}'to keep the old id). awaitHuman()schemas containing closures or non-Stringable rule objects (Rule::enum,Password::min) now throw at definition time — they silently degraded to empty constraints through JSON persistence. Stringable rules (Rule::in(...)) are cast to their string form, which also corrects those definitions' hashes.- Duplicate explicit
as:aliases throw instead of being silently renamed. - Registering a different definition under an existing workflow name throws;
WorkflowRegistry::forget()is the explicit escape hatch. resume()/deliverEvent()payloads may no longer contain the engine-reservedstepskey.- Step targets must be real classes at definition time; a callback step returning non-null that isn't a
WorkflowStatenow fails instead of silently discarding the value. - An agent pausing on tool approvals inside a
parallel()branch fails the run with an explicit error (was silently recorded as completed); insideevaluate()bodies the pause now correctly parks the run andresume()continues the loop. - Lifecycle events implement
ShouldDispatchAfterCommitandSerializesModels;WorkflowFaileddrops its Throwable at the serialization boundary (queued listeners readfailure_reason). - The
run_idforeign key constraints are removed; deleting aWorkflowRunmodel still cascades to its steps and interrupts (query-builder mass deletes bypass model events).
Fixed:
- The default parallel merge deep-merges the engine-owned
stepssubtree per step id — two or more agent branches without amerge:closure always conflicted and could never complete. - Crashed parallel fan-outs are recoverable:
retry()supersedes all in-flight audit rows, the duplicate-delivery guard throws instead of returning the input snapshot as a branch result (sync mode silently completed with a crashed branch's work missing), and the batch completer only merges the current fan-out generation. cancel()(or a failure) stops queued branches from executing full agent turns against a dead run.- Strict definition drift is enforced in branch jobs, the batch completer, and in
resume()/deliverEvent()before the response is consumed. - Queue redeliveries of steps that are still executing (
retry_aftershorter than the step) no longer fail the run and discard the original attempt's result.
Added:
awaitEvent($event, schema:)— validates delivered payloads and strips undeclared fields.assertStepRanTimes()on the fake; all workflow-name assertions accept class names likestart()does.audit.step_outputconfig (full|minimal) for step audit snapshot size.- The sweeper chunks its scans, batches in-flight checks, and debounces re-dispatch to once per staleness window.
Full details in the CHANGELOG. 145 tests, Pint + PHPStan clean.
v0.9.0 — human gates get SLAs
Additive release. Existing installs must re-run the migration to pick up the interrupts table's timeout_at column.
Real processes have deadlines — a run shouldn't wait for sign-off forever. awaitHuman() now takes a timeout, enforced by the scheduled agent-workflows:sweep command you already run:
->awaitHuman(reason: 'Final sign-off required', schema: [
'approved' => 'required|boolean',
'notes' => 'nullable|string',
], timeout: CarbonInterval::days(3), timeoutResponse: [
'approved' => false,
'notes' => 'Auto-rejected: sign-off timed out.',
])timeout:— seconds or anyDateInterval. Recorded astimeout_aton the interrupt row, so approval UIs can show the deadline.- With a
timeoutResponse:, the run resumes with that payload past the deadline — an auto-decision, validated against the schema like any human answer. - Without one, the run fails at the gate — and
$run->retry()re-arms the same wait with a fresh deadline, so "give them another three days" is one call.
The timeout and response count toward the definition hash; editing the human-facing reason remains free.
v0.8.0 — typed workflow state
Additive release, no breaking changes.
Typed state classes. A workflow can now declare a WorkflowState subclass and the engine hydrates it for every step, prompt closure, condition, predicate, and post-resume continuation:
public function stateClass(): string
{
return ContractReviewState::class;
}Typed accessors, IDE completion, and one class that knows where things live — while the storage stays the same schemaless JSON checkpoint. Fully opt-in: workflows without stateClass() behave exactly as before, the base get()/set() API still works on subclasses, and the state class is excluded from the definition hash so adopting one never strands in-flight runs.
WorkflowState::output(). Step outputs addressed by class instead of structural paths, on every state instance:
$state->output(RiskAnalysisAgent::class)?->structured('riskScore');
$state->output(DraftReplyAgent::class)?->text();Full write-up: docs/typed-state.md.
v0.7.0 — definitions render themselves
Additive release, no breaking changes.
WorkflowDefinition::toGraph() — every definition can now serialize itself into a renderable graph: rows of typed nodes plus labelled edges (yes/no on condition branches, fan-out on parallel branches), with node details for display (agent targets, prompt previews, await reasons and schemas, evaluator budgets). The payload includes the definition hash, so consumers can detect drift against a run's pinned version.
First consumer: the new timmcleod/agent-workflows-ui dashboard package, which renders runs as live flowcharts over this graph.
Also: created_at/updated_at are now declared on the WorkflowRun and WorkflowInterrupt docblocks, so static analysis in consuming apps no longer flags reads of them.
v0.6.0 — remove the handoffs feature
Breaking change: the conversation-handoffs feature is removed. The package is now purely about durable workflow processes: steps, checkpoints, interrupts, and retries.
Removed: HasHandoffs / HasHandoffTools, the synthetic transfer_to_* tools, AgentWorkflow::resolveAgentFor() / transferConversation(), the ConversationTransferred event, and the agent_conversation_owners table (drop it if you migrated it).
Why: conversation-ownership routing was mechanically independent of the workflow engine — a chat-shaped concern living in a pipeline-shaped package. Removing it keeps one concept per package.
If you were using it: stay on v0.5.1 or vendor the handoff classes from that tag — the feature was self-contained (a contract, a trait, a tool, a listener, a model, and two facade methods) and composes with any laravel/ai app without this package.
v0.5.1 — leaner checkpoints for structured agents
Agents that declare a response schema no longer checkpoint the text form of their response alongside structured — it was the same JSON stored twice, doubling the checkpoint size for no information.
Adjust if needed: code reading steps.{id}.text from a structured agent should read steps.{id}.structured (or a key within it) instead. Plain agents keep steps.{id}.text exactly as before.
v0.5.0 — correctness and liveness hardening
This release makes the durability claims provable rather than aspirational. No API changes to workflow definitions; two additions to the run model and one new command.
Correctness (the engine's contract, now enforced and tested):
- Idempotent step claims. Every step execution is claimed in a locked transaction that checks the run's status, the cursor, and in-flight attempts; a unique
(run_id, step_id, attempt)index settles simultaneous claims. Duplicate queue deliveries no-op instead of re-executing steps. - Atomic transitions everywhere. Cursor advances, failures, resumes, retries, and event deliveries are all conditional on their expected source state. Two concurrent
resume()calls (a double-clicked approve button) yield exactly one resumption; the loser gets aWorkflowException. - Documented semantics: step bodies are at-least-once; checkpoints and cursor advances are exactly-once. Keep step side effects idempotent or isolate them in their own step.
Liveness:
php artisan agent-workflows:sweeprecovers runs stranded by hard-killed workers (OOM, SIGKILL, deploys) or lost dispatches — re-dispatching from the checkpoint, or marking failed persweep.action. Schedule it every few minutes; see the new README Operations section.$run->cancel()from any non-terminal state: resolves open interrupts, firesWorkflowCancelled, and discards an in-flight step's result at the step boundary.
Docs: full lifecycle-events list, an Operations section (queue isolation, worker timeouts, sweeper, semantics), and a fix for the evaluate() example, whose predicate previously read a step that never ran.
Schema note: adds a unique index on (run_id, step_id, attempt). Fresh installs get it via the migration; existing pre-release installs should re-migrate (migrate:fresh) or add the index manually.
v0.4.0 — workflows are classes
Breaking change: AgentWorkflow::define() is removed. Every workflow is now a dedicated class, generated with php artisan make:agent-workflow and registered in the config workflows array — which loads at boot on every process, so queue workers always know your definitions (the failure mode where define() ran somewhere workers never boot is no longer possible to write).
class TicketReply extends Workflow
{
public function build(WorkflowDefinition $workflow): WorkflowDefinition
{
return $workflow
->step(DraftReplyAgent::class,
prompt: fn ($state) => 'Draft a reply to: '.$state->get('ticket_message'))
->awaitHuman(reason: 'Review the drafted reply', schema: ['final_reply' => 'required|string'])
->step(SendReply::class);
}
}// config/agent-workflows.php
'workflows' => [App\AgentWorkflows\TicketReply::class],
// start by class (type-safe) or by registered name:
AgentWorkflow::start(TicketReply::class, input: [...]);Migration: move each define() chain into a Workflow class's build() method and list the class in config. AgentWorkflow::register() remains for runtime registration (tests, packages).