From 42edc3416df017a417de4167c77f21d9eb8895c6 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:34:52 +0000 Subject: [PATCH] feat(workflow): handoff formation is traced under its own 'handoff' role --- src/Workflow/WorkflowAbstract.php | 9 +++++++-- tests/Workflow/WorkflowAbstractTest.php | 27 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Workflow/WorkflowAbstract.php b/src/Workflow/WorkflowAbstract.php index 18da220..f1095d8 100644 --- a/src/Workflow/WorkflowAbstract.php +++ b/src/Workflow/WorkflowAbstract.php @@ -981,6 +981,11 @@ function (array $history): void { * formed it the instant it exists — so a resume in a fresh process, where that conversation is * gone, reads it back at construction ({@see loadHandoff()}) instead of re-forming it. Cleared * before the inner call so it never re-enters; a step that ran no model exchange hands on ''. + * + * The formation exchange is traced under its OWN role, 'handoff' — not 'worker'. It runs at the + * FIRST ai() of the NEXT step, so before this its span sat inside that step and read as the next + * step doing work it had not started ('assess' looked like it did nothing, its "work" being the + * PREVIOUS step's handoff). The role names the exchange for what it is. */ private function formPendingHandoff(): void { @@ -997,8 +1002,8 @@ private function formPendingHandoff(): void . 'attention to — decisions made, files/paths touched, what remains, gotchas. Pass on only ' . 'what matters, not everything. Reply with that handoff only.', [], - null, - $pending['history'], // continue the work conversation — the model still has the full context + 'handoff', // its own trace role, so the exchange is not read as the next step's work + $pending['history'], // continue the work conversation — the model still has the full context )); // Persist it the moment it is formed, keyed by the step that formed it. A resume that lands on diff --git a/tests/Workflow/WorkflowAbstractTest.php b/tests/Workflow/WorkflowAbstractTest.php index 2f6a27b..749364d 100644 --- a/tests/Workflow/WorkflowAbstractTest.php +++ b/tests/Workflow/WorkflowAbstractTest.php @@ -1434,6 +1434,33 @@ public function second(): void Assert::true(str_contains($worker->requests[2]->system, 'added subtract(); next, run the tests')); } + #[Test] + public function handoffFormationIsTracedUnderItsOwnRoleNotTheNextStepsWork(): void + { + // The handoff exchange runs at the FIRST ai() of the NEXT step, so its ai span sat inside that + // step's span under the 'worker' role — reading as the next step doing work it had not begun + // (Edmond flagged an 'assess' step as doing nothing, its only visible exchange being the PRIOR + // step's handoff). It is now traced as role 'handoff', distinct from the step's own work. + $worker = new ScriptedAgent( + $this->answer('did the work'), // first()'s own ai() + $this->answer('the baton'), // the handoff formation + $this->answer('ok'), // second()'s own ai() + ); + $sink = new ArrayTraceSink(); + $this->relay($this->config(worker: $worker, tracer: new Tracer('r1', $sink)))->run(); + + $aiRoles = []; + + foreach ($sink->records as $record) { + if ($record->event()->type === 'ai') { + $aiRoles[] = (string) $record->event()->data['role']; + } + } + + // first's work, then the handoff formation under its own role, then second's work + Assert::same($aiRoles, ['worker', 'handoff', 'worker']); + } + #[Test] public function aFormedHandoffIsSavedToTheStoreKeyedByTheStepThatFormedIt(): void {