Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/Workflow/WorkflowAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/Workflow/WorkflowAbstractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading