Skip to content

feat(workflow): tail-only loop break, final-step outcome report, artifact handoff - #141

Merged
Cidan merged 2 commits into
mainfrom
feat/workflow-loop-guard-artifacts
Aug 21, 2026
Merged

feat(workflow): tail-only loop break, final-step outcome report, artifact handoff#141
Cidan merged 2 commits into
mainfrom
feat/workflow-loop-guard-artifacts

Conversation

@Cidan

@Cidan Cidan commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Restores three workflow behaviors that the graph migration (#136) dropped or weakened, each done the ADK-native way.

1. Only the tail step of a loop can break out

exit_loop was attached to every inner step, so any step could end the loop. Now it's attached to the last inner step only.

// pkg/workflow/compile.go
if role.InLoop && role.IsTail {
    tools, err = withExitLoopTool(tools)
}

exit_loop sets Actions.Escalate — the only way an ask tool ends a loopagent — and no other tool touches Actions. So withholding the tool makes an early break structurally impossible for the non-tail steps. This is your old end_turn decision guard, except the old one detected the violation after the fact and re-prompted; this one makes it unrepresentable. The non-tail loop instruction is reworded to say the step cannot end the loop.

2. finish_workflow is attached again (regression fix)

The tool and the Progress capture both survived #136, but nothing attached the tool to a step once the graph became one session (the old IsWorkflowFinalStep flag was per-session). So runs reported no artifacts to the user. It's now attached to the final step, which is how the user learns what a run produced — PR links, tickets. Not optional.

3. Artifacts pass structured data between steps

Every step gets save_artifact and load_artifacts. This works now where it didn't before #136: the whole graph runs as one runner invocation, so the runner's ArtifactService (from RunnerBuilder) spans every step — a save in step 1 is visible to a load in step 3.

ADK ships load_artifacts but no save tool, so SaveArtifactTool is the missing half:

resp, err := ctx.Artifacts().Save(ctx, name, genai.NewPartFromText(p.Content))

This is the ADK-native replacement for the old ask/plans/ notes directories.

Mechanics

CompileWorkflow hands each step a StepRole{InLoop, IsTail, IsFinal} through its ToolsBuilder. IsTail gates exit_loop, IsFinal gates finish_workflow. The TUI builder (cmd/ask/workflow_graph.go) appends tools.WorkflowStepTools directly; the headless builder passes WorkflowStep/WorkflowFinalStep flags to the tool factory, which appends them in BuildCoreTools (pkg/engine can't import pkg/tools, hence the flag indirection).

Tests

  • StepRole is computed correctly for linear / loop / final-loop shapes (the tail of a final loop is both IsTail and IsFinal)
  • WorkflowStepTools attaches save+load always, finish only on the final step
  • save_artifact writes name+content through ctx.Artifacts().Save (verified with a fake store), and returns a real Go error — so retryandreflect sees it — when there's no service or no name
  • the loop instruction tells only the tail step how to break, and only the final step to call finish_workflow

make test green across all 9 packages.

Note

I did not touch docs/ — it was intentionally removed in 3683f9d. All doc updates are in CLAUDE.md.

🤖 Generated with Claude Code

Cidan and others added 2 commits August 21, 2026 16:24
…fact handoff

Restores three workflow behaviors and does each the ADK-native way.

1. Only the tail step of a loop can break out early. exit_loop is now
   attached to the last inner step only, not every inner step. It sets
   Actions.Escalate — the only way an ask tool ends a loopagent — and no
   other tool touches Actions, so withholding it makes an early break
   structurally impossible for the non-tail steps. This replaces the old
   end_turn decision guard, which detected the violation after the fact
   and re-prompted; now it cannot happen. The non-tail loop instruction
   is reworded to say the step cannot end the loop.

2. finish_workflow is attached again. The tool and the Progress capture
   both survived #136, but nothing attached the tool to a step once the
   graph became one session, so a run reported no artifacts. It is now
   attached to the final step (WorkflowStepTools). This is how the user
   learns what a run produced — PR links, tickets — so it is not
   optional.

3. Artifacts pass structured data between steps. Every step gets
   save_artifact (native, writes through ctx.Artifacts().Save — ADK
   ships only load_artifacts) and load_artifacts. This works now, where
   it did not before #136: the whole graph runs as one runner
   invocation, so the runner's ArtifactService spans every step. This is
   the ADK-native replacement for the ask/plans notes directories.

Mechanics: CompileWorkflow hands each step a StepRole{InLoop, IsTail,
IsFinal} through its ToolsBuilder. IsTail gates exit_loop, IsFinal gates
finish_workflow. The TUI builder (cmd/ask/workflow_graph.go) appends
tools.WorkflowStepTools directly; the headless builder passes
WorkflowStep/WorkflowFinalStep flags to the tool factory, which appends
them in BuildCoreTools (pkg/engine can't import pkg/tools).

Tests: StepRole is computed correctly for linear/loop/final-loop shapes;
WorkflowStepTools attaches save+load always and finish only on the final
step; save_artifact writes name+content through ctx.Artifacts().Save and
errors (a real Go error, so retryandreflect sees it) with no service or
no name; the loop instruction tells only the tail step how to break, and
only the final step to call finish_workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A two-step workflow on the real ADK runner: step 1 saves an artifact,
step 2 loads it. Proves the whole seam the earlier tests only covered in
pieces — the tool factory attaches save_artifact/load_artifacts to every
step, the graph runs as one runner invocation so the ArtifactService
spans both steps, and the saved content reaches step 2's model.

Lives in the external engine_test package so it can import pkg/tools
(whose init registers the tool factory) without the tools -> engine
import cycle. A scripted model.LLM drives one step at a time by the
marker in each step's system instruction.

The assertion is airtight against a false pass: the secret must be ABSENT
from the reader's step handoff (the saver's node output is 'saved the
plan', and IncludeContentsNone keeps its tool calls out of the reader's
context) and PRESENT only after load_artifacts resolves. A negative
control with a mismatched artifact name fails as expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Cidan

Cidan commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Added the real end-to-end test: TestWorkflowArtifactHandoff in pkg/engine/workflow_artifact_integration_test.go.

A two-step workflow runs on the actual ADK graph — step 1 calls save_artifact, step 2 calls load_artifacts — and the test asserts the saved content reaches step 2's model. It's in the external engine_test package so it can import pkg/tools (registering the real tool factory) without the tools -> engine cycle.

It's airtight against a false pass: the secret must be absent from the reader's step handoff (proving IncludeContentsNone keeps the saver's tool calls out) and present only after load_artifacts resolves. Verified with a negative control — a mismatched artifact name fails as expected.

@Cidan
Cidan merged commit 742682a into main Aug 21, 2026
@Cidan
Cidan deleted the feat/workflow-loop-guard-artifacts branch August 21, 2026 23:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant