Skip to content

fix(plan-level): AB#3156 — research path StrictUndefined + PowerShell bareword - #354

Merged
PolyphonyRequiem merged 1 commit into
mainfrom
fix/ab-3156-plan-level-research-path
May 13, 2026
Merged

fix(plan-level): AB#3156 — research path StrictUndefined + PowerShell bareword#354
PolyphonyRequiem merged 1 commit into
mainfrom
fix/ab-3156-plan-level-research-path

Conversation

@PolyphonyRequiem

Copy link
Copy Markdown
Owner

Summary

Fixes AB#3156 — two latent bugs in plan-level.yaml's research path that PR #351 introduced and never caught in CI (harness Integration scenarios are excluded from the fast loop). Both fired in the AB#3127 dogfood at 14:42 PT today, killing the second relaunch attempt after PR #353 fixed the architect-prompt regression.

Bug 1: research_loop_counter $hasTopics = true bareword

The PowerShell script rendered the Jinja boolean unquoted, so PowerShell parsed true as a cmdlet name and left $hasTopics = $null. JSON-serialized as null, which jinja treats as null != false, routing to research_dispatch instead of open_questions_policy even when the architect emitted empty topics.

Fix: string-compare the rendered token.

$hasTopics = '{{ ... }}' -eq 'true'

Bug 2: research_dispatch escalation_cap StrictUndefined

The architect output schema declares research_needs as a typed object but doesn't always populate every key. escalation_cap was a newer optional addition (AB#3134) the architect never emits.

Under StrictUndefined (conductor's default), dict.missing_attr raises UndefinedError BEFORE the default() filter sees the value, so the filter never fires.

Fix: use Python dict.get() for all optional research_needs keys — escalation_cap, budget_hint, and the nested context.plan_excerpt / archive_scope.paths / archive_scope.tags.

{{ research_needs.get('escalation_cap', 1) }}
{{ research_needs.get('context', {}).get('plan_excerpt', '') }}

Bug 1 chain-fires Bug 2 — even when topics is empty (no-research path), the broken $hasTopics routing forwards to research_dispatch, which then hits escalation_cap. Both must be fixed together.

Harness scenario adjustment

Updated the architect_research_loop scenario fixture to emit the full research_needs shape (topics + context + budget_hint + archive_scope) on the second architect call, matching what the real architect emits. The minimal {topics: []} shape hit conductor's recursive required-by-default validator (orthogonal to the bugs above).

Verification

  • conductor validate plan-level.yaml — clean
  • architect_research_loop harness scenario — passes end-to-end, full loop executes to workflow_completed. Was failing on main with both bugs visible in output.
  • AB#3127 dogfood relaunch will be the live verification (next step after merge).

Follow-up

The recursive required-by-default behavior of conductor's output validator is a deeper design point worth revisiting (every architect emit must perfectly match the schema's recursive shape, including nested objects). Out of scope here.

… bareword

Two latent bugs in the research path of plan-level.yaml (introduced by
PR #351, never caught in CI because harness Integration scenarios are
excluded from the fast loop). Both fired in the AB#3127 dogfood at
14:42 PT today, killing the second relaunch attempt.

## Bug 1: research_loop_counter $hasTopics = true (bareword)

The PowerShell script rendered the Jinja boolean unquoted. PowerShell
parsed the bareword `true` as a cmdlet name, errored, and left
$hasTopics = $null. JSON-serialized as null, which jinja treats as
null != false, routing to research_dispatch instead of
open_questions_policy even when the architect emitted empty topics.

Fix: string-compare the rendered token.

## Bug 2: research_dispatch escalation_cap StrictUndefined

The architect output schema declares research_needs as a typed object
but doesn't always populate every key. escalation_cap was a newer
optional addition (AB#3134) the architect never emits.

Under StrictUndefined (conductor default), dict.missing_attr raises
UndefinedError BEFORE the default() filter sees the value, so the
filter never fires.

Fix: use Python dict.get() for all optional research_needs keys —
escalation_cap, budget_hint, and the nested context.plan_excerpt /
archive_scope.paths / archive_scope.tags.

Bug 1 chain-fires Bug 2: even when topics is empty (no research path),
the broken $hasTopics routing forwards to research_dispatch, which
then hits escalation_cap. Both must be fixed together.

## Verification

- conductor validate plan-level.yaml — clean
- harness scenario architect_research_loop — PASSES end-to-end (was
  failing on main with the same errors). Full loop now executes
  through to workflow_completed.

## Harness scenario adjustment

Updated the architect_research_loop scenario fixture to emit the full
research_needs shape (topics + context + budget_hint + archive_scope)
on the second architect call, matching what the real architect emits.
The minimal {topics: []} shape hit conductor's recursive
required-by-default validator (orthogonal to the bugs above).

The deeper output-schema-strictness issue is a conductor design point
worth revisiting in a follow-up.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PolyphonyRequiem
PolyphonyRequiem merged commit a0b9cb3 into main May 13, 2026
1 check passed
@PolyphonyRequiem
PolyphonyRequiem deleted the fix/ab-3156-plan-level-research-path branch May 13, 2026 21:54
PolyphonyRequiem pushed a commit that referenced this pull request May 14, 2026
…kind schema

Refactor the architect's research payload from a nested-optional
`research_needs` object to a flat discriminated-union shape with a
required `research_request_kind` discriminant and always-present
sibling `research_*` fields. Eliminates the AB#3156 trap at the
source: routing is now a string compare on a guaranteed-present field
(`architect.output.research_request_kind == 'request'`) and the
input_mapping reads flat top-level fields directly — no
`.get(key, default)` chains, no StrictUndefined hazard.

Why
---

Conductor enforces "all declared schema properties are required" at
runtime, but the previous schema declared `research_needs` as an
*optional* nested object whose own properties (`topics`, `context`,
`budget_hint`, `archive_scope`) were also optional. Every consumer
in plan-level.yaml had to defensively chain `.get(key, default)` and
a single missed `.attr | default()` (instead of `.get()`) crashed
the run via StrictUndefined (see AB#3156 / PR #354).

The fix is a discriminated-union lean: replace the nested-optional
shape with a flat top-level discriminant + sibling fields the architect
always emits.

Schema before / after
---------------------

Before (architect output, plan-level.yaml):

    research_needs:                # optional
      topics: [string]              # optional under it
      context: { ... }              # optional, nested deeper
      budget_hint: string           # optional
      archive_scope: { ... }        # optional

After:

    research_request_kind: "none" | "request"   # required, ALWAYS present
    research_topics: [string]                    # always emitted; [] when "none"
    research_context: object                     # always emitted; {} when "none"
    research_budget_hint: string                 # always emitted; "" when "none"
    research_archive_scope: object               # always emitted; {} when "none"
    research_escalation_cap: number              # always emitted; default 1

Routing before / after
----------------------

Before (research_loop_counter routes):

    when: ""{{ research_loop_counter.output.has_topics == false }}""

…where `has_topics` was computed by a PowerShell bareword guard:

    \ = '{{ (architect.output.research_needs.topics
        | default([]) | length > 0) | string | lower }}' -eq 'true'

After:

    when: ""{{ architect.output.research_request_kind == 'none' }}""

The PowerShell `has_topics` guard is gone — routing reads the
always-present discriminant directly. The `research_dispatch`
input_mapping now reads `architect.output.research_topics` /
`research_budget_hint` / `research_escalation_cap` directly;
`research_context` and `research_archive_scope` keep `.get()`
since their nested keys are described as opaque `type: object` in the
output schema (so consumers tolerate `{}` without recursive nested
validation rejecting it).

Files
-----

- `.conductor/registry/prompts/architect-plan-level.md` — schema
  example, `Identify research needs` table, and the `research_needs`
  field section all updated to the flat shape. Documents both
  `kind=
one` and `kind=
equest` examples explicitly so the LLM
  has a concrete pattern.

- `.conductor/registry/workflows/plan-level.yaml` — output schema
  flipped to the flat shape (`research_context` /
  `research_archive_scope` are opaque `type: object` so empty `{}`
  validates under conductor's recursive-required policy);
  `research_loop_counter` route condition simplified to a string
  compare; PowerShell `has_topics` guard removed; `research_dispatch`
  input_mapping reads flat fields.

- `tests/harness/scenarios/architect_research_loop/scenario.yaml` —
  both architect fixture calls updated. 1st call:
  `research_request_kind: ""request""` triggers research_dispatch.
  2nd call: `research_request_kind: ""none""` falls through to
  open_questions_policy (verified by the harness scenario passing
  end-to-end with all 5 expected agents executed in order).

Verification
------------

- `conductor validate .conductor/registry/workflows/plan-level.yaml`
  → Validation Successful.
- `Invoke-Pester tests/harness/run-scenarios.Tests.ps1` → all 5
  scenarios green (architect_research_loop, cascade_remedy_no_stale,
  close_out_happy_path, research_happy_path).
- `Invoke-Pester tests/lint-jinja-resolver.Tests.ps1` → 30/30 green.
- `Invoke-Pester .conductor/registry/tests/lint-strict-undefined.Tests.ps1`
  → 11/11 green.

The lint-plan-level.ps1 `missing-oq-answer-counter-node` failure is
pre-existing on `main` (verified) and unrelated to this change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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