Summary
With a dispatch-workflow safe-output, gh-aw registers two MCP tools: the typed per-workflow tool (e.g. my_fixer, with a real schema derived from the target's workflow_dispatch inputs) and an untyped catch-all dispatch_workflow. Three behaviours compound into a hard run failure:
- The injected
<safe-output-tools> prompt manifest advertises only dispatch_workflow and omits the typed tool — so the agent is steered to the untyped one.
dispatch_workflow's MCP schema is empty and unvalidated, so a {} call is accepted and answered with a success message, recording a real safe-output item.
max: truncation keeps the first item, so when the agent notices its mistake and re-calls the typed tool correctly, the malformed item wins and the good one is discarded.
Net effect: an agent that self-corrects still fails the run.
Reproduction
Frontmatter:
safe-outputs:
dispatch-workflow:
workflows: [my-fixer]
max: 1
Have the agent call dispatch_workflow with {} (e.g. to probe the schema), then call the typed my_fixer tool correctly.
Observed:
- The MCP server replies to the empty call with
Safe-job 'dispatch_workflow' executed successfully with arguments: {}.
safeoutputs.jsonl records a bare {"type":"dispatch_workflow"} item, followed by the correct typed item.
- Truncation to
max: 1 keeps the bare item and drops the correct one.
- The
safe_outputs job then fails: POST /repos/.../actions/workflows/my-fixer.lock.yml/dispatches - 422 → Failed to dispatch workflow "my-fixer": Required input 'pr_number' not provided.
Note the 422 names the target workflow, so workflow_name was resolved despite being absent from the item — presumably defaulted from the single entry in workflows:. GH_AW_VALIDATION_JSON marks workflow_name as required: true for dispatch_workflow, yet the bare item still reached the dispatch step, so that validation appears not to be enforced on this path (or is satisfied by the defaulting). Either way the empty call should not have become a dispatch item.
Root cause: the manifest omission is a systematic gap
generateDynamicTools (pkg/workflow/safe_outputs_tools_generation.go) generates typed MCP tools for six categories:
SafeOutputs.Jobs
SafeOutputs.Scripts
SafeOutputs.Actions
SafeOutputs.DispatchWorkflow
SafeOutputs.DispatchRepository
SafeOutputs.CallWorkflow
But buildSafeOutputsSections — which produces the <safe-output-tools> Tools: line the agent actually reads — only enumerates the first three. TestBuildSafeOutputsSectionsCustomToolsConsistency in pkg/workflow/safe_outputs_prompt_tools_test.go asserts exactly that set, and its doc comment states the intent:
verifies that every custom tool type registered in the runtime configuration has a corresponding entry in the compiled <safe-output-tools> prompt block — preventing silent drift.
So the invariant is already established and deliberately tested; dispatch_workflow, dispatch_repository, and call_workflow are simply outside its coverage.
dispatch-workflow is the worst of the three, because the static safe_outputs_tools.json also contributes a generic dispatch_workflow entry, enabled whenever the config key is present. So for this category the manifest doesn't merely omit the typed tool — it advertises an untyped one in its place. That is what actively steers the agent to the unvalidated call rather than merely leaving the right one undiscovered.
The two registrations, as they appear in a compiled lock:
{ "name": "my_fixer", "inputSchema": { "type": "object", "required": ["pr_number"], "properties": { ... } } }
versus dispatch_workflow, registered as a "Custom safe-job" with "properties": {}, "additionalProperties": true, and nothing required.
The same lock file contains, several hundred lines earlier, the prompt chunk the agent reads:
<safe-output-tools>
Tools: add_comment, create_pull_request_review_comment(max:15), add_labels, remove_labels, dispatch_workflow, missing_tool, missing_data, noop
my_fixer is registered and callable, but absent from that list. A single generated file both registers the typed tool and tells the agent it doesn't exist.
This repo's own workflows are exposed to the same path: squad.md, squad-implement-worker.md, and the smoke-copilot* workflows all configure dispatch-workflow:, so their agents see a Tools: line naming dispatch_workflow while the typed squad_implement_worker / haiku_printer tools go unadvertised.
Impact
The failure is not self-healing. In a review-loop setup where a merge gate requires the critic run to succeed, this fails the gate closed and strands the PR until a human dispatches the downstream workflow by hand.
It is also probabilistic — it depends on the agent choosing to probe — so it presents as a rare, hard-to-reproduce flake rather than a deterministic bug.
Suggested fixes
- Close the manifest gap. Extend
buildSafeOutputsSections to enumerate all six dynamic tool categories, and widen TestBuildSafeOutputsSectionsCustomToolsConsistency to assert over them, so the existing anti-drift invariant covers the remaining three. Additionally, suppress the generic dispatch_workflow from the manifest when typed per-workflow tools exist, so the agent isn't pointed at the unvalidated tool.
- Reject empty-argument calls on custom safe-job tools. Built-ins already carry an explicit anti-probing guard in their descriptions (e.g.
add_comment's "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe"). Custom safe-jobs get neither the guard nor schema enforcement.
- Make
max: truncation prefer schema-valid items. Dropping a well-formed item in favour of a malformed one converts a recoverable agent mistake into a run failure. Validating before truncating, or preferring valid items when truncating, would make this class of error self-correcting.
Any one of the three breaks the chain; (1) is the most targeted and (3) is the most general.
Environment
Reproduced on gh-aw v0.86.2. The dual registration, the empty schema, and the manifest omission are byte-identical in locks compiled with v0.83.4 and v0.85.4, so this is longstanding behaviour rather than a recent regression.
Summary
With a
dispatch-workflowsafe-output, gh-aw registers two MCP tools: the typed per-workflow tool (e.g.my_fixer, with a real schema derived from the target'sworkflow_dispatchinputs) and an untyped catch-alldispatch_workflow. Three behaviours compound into a hard run failure:<safe-output-tools>prompt manifest advertises onlydispatch_workflowand omits the typed tool — so the agent is steered to the untyped one.dispatch_workflow's MCP schema is empty and unvalidated, so a{}call is accepted and answered with a success message, recording a real safe-output item.max:truncation keeps the first item, so when the agent notices its mistake and re-calls the typed tool correctly, the malformed item wins and the good one is discarded.Net effect: an agent that self-corrects still fails the run.
Reproduction
Frontmatter:
Have the agent call
dispatch_workflowwith{}(e.g. to probe the schema), then call the typedmy_fixertool correctly.Observed:
Safe-job 'dispatch_workflow' executed successfully with arguments: {}.safeoutputs.jsonlrecords a bare{"type":"dispatch_workflow"}item, followed by the correct typed item.max: 1keeps the bare item and drops the correct one.safe_outputsjob then fails:POST /repos/.../actions/workflows/my-fixer.lock.yml/dispatches - 422→Failed to dispatch workflow "my-fixer": Required input 'pr_number' not provided.Note the 422 names the target workflow, so
workflow_namewas resolved despite being absent from the item — presumably defaulted from the single entry inworkflows:.GH_AW_VALIDATION_JSONmarksworkflow_nameasrequired: truefordispatch_workflow, yet the bare item still reached the dispatch step, so that validation appears not to be enforced on this path (or is satisfied by the defaulting). Either way the empty call should not have become a dispatch item.Root cause: the manifest omission is a systematic gap
generateDynamicTools(pkg/workflow/safe_outputs_tools_generation.go) generates typed MCP tools for six categories:SafeOutputs.JobsSafeOutputs.ScriptsSafeOutputs.ActionsSafeOutputs.DispatchWorkflowSafeOutputs.DispatchRepositorySafeOutputs.CallWorkflowBut
buildSafeOutputsSections— which produces the<safe-output-tools>Tools:line the agent actually reads — only enumerates the first three.TestBuildSafeOutputsSectionsCustomToolsConsistencyinpkg/workflow/safe_outputs_prompt_tools_test.goasserts exactly that set, and its doc comment states the intent:So the invariant is already established and deliberately tested;
dispatch_workflow,dispatch_repository, andcall_workfloware simply outside its coverage.dispatch-workflowis the worst of the three, because the staticsafe_outputs_tools.jsonalso contributes a genericdispatch_workflowentry, enabled whenever the config key is present. So for this category the manifest doesn't merely omit the typed tool — it advertises an untyped one in its place. That is what actively steers the agent to the unvalidated call rather than merely leaving the right one undiscovered.The two registrations, as they appear in a compiled lock:
{ "name": "my_fixer", "inputSchema": { "type": "object", "required": ["pr_number"], "properties": { ... } } }versus
dispatch_workflow, registered as a"Custom safe-job"with"properties": {},"additionalProperties": true, and nothing required.The same lock file contains, several hundred lines earlier, the prompt chunk the agent reads:
my_fixeris registered and callable, but absent from that list. A single generated file both registers the typed tool and tells the agent it doesn't exist.This repo's own workflows are exposed to the same path:
squad.md,squad-implement-worker.md, and thesmoke-copilot*workflows all configuredispatch-workflow:, so their agents see aTools:line namingdispatch_workflowwhile the typedsquad_implement_worker/haiku_printertools go unadvertised.Impact
The failure is not self-healing. In a review-loop setup where a merge gate requires the critic run to succeed, this fails the gate closed and strands the PR until a human dispatches the downstream workflow by hand.
It is also probabilistic — it depends on the agent choosing to probe — so it presents as a rare, hard-to-reproduce flake rather than a deterministic bug.
Suggested fixes
buildSafeOutputsSectionsto enumerate all six dynamic tool categories, and widenTestBuildSafeOutputsSectionsCustomToolsConsistencyto assert over them, so the existing anti-drift invariant covers the remaining three. Additionally, suppress the genericdispatch_workflowfrom the manifest when typed per-workflow tools exist, so the agent isn't pointed at the unvalidated tool.add_comment's "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe"). Custom safe-jobs get neither the guard nor schema enforcement.max:truncation prefer schema-valid items. Dropping a well-formed item in favour of a malformed one converts a recoverable agent mistake into a run failure. Validating before truncating, or preferring valid items when truncating, would make this class of error self-correcting.Any one of the three breaks the chain; (1) is the most targeted and (3) is the most general.
Environment
Reproduced on gh-aw v0.86.2. The dual registration, the empty schema, and the manifest omission are byte-identical in locks compiled with v0.83.4 and v0.85.4, so this is longstanding behaviour rather than a recent regression.