Skip to content

feat: workflow-program IR Phase 1 — typed params, guards, wait_until (additive, back-compatible)#71

Merged
abrichr merged 2 commits into
mainfrom
feat/workflow-program-ir-phase1
Jul 13, 2026
Merged

feat: workflow-program IR Phase 1 — typed params, guards, wait_until (additive, back-compatible)#71
abrichr merged 2 commits into
mainfrom
feat/workflow-program-ir-phase1

Conversation

@abrichr

@abrichr abrichr commented Jul 13, 2026

Copy link
Copy Markdown
Member

Implements Phase 1 of the merged workflow-program RFC (docs/design/WORKFLOW_PROGRAM_IR.md §6) — the additive, backward-compatible first step from the linear macro IR toward a parameterized program. "A demonstration is evidence, not a specification." This is the compiler-vs-recorder gap both external reviews flagged.

What ships (all additive & optional — a bundle with none of these replays byte-identically to today; $0 / zero model calls)

  • Typed parametersWorkflow.param_specs: dict[str, ParamSpec] alongside the frozen Workflow.params: dict[str, str] (NOT a replacement — back-compat). ParamSpec carries type (string/date/enum/number/entity_ref), example (the recorded demo value, also the replay default), required, and enum choices. At replay each spec's example folds in as a default; caller-supplied values win; a required spec with neither a value nor an example → fail fast at run start naming it (before any step executes).
  • Step.wait_until: Optional[Predicate] — a bounded readiness predicate the replayer polls before proceeding; timeout → HALT (never proceed-anyway). Subsumes the existing SCROLL closed-loop: a SCROLL step's default readiness is now the ANCHOR_RESOLVES predicate on the next anchored step's anchor — today's closed loop expressed as the first concrete predicate rather than a special case. An explicit wait_until on a SCROLL overrides that default.
  • Step.guard: Optional[Guard] — a deterministic precondition over the entry frame. Semantics chosen (RFC left the single-guard skip-vs-halt open): Guard.on_unmet defaults to "halt" (the safe direction for an unmet precondition, per the repo's refuse-rather-than-guess posture); "skip" turns the step into a no-op success (the expected-but-optional case, e.g. dismiss a survey modal only when present — a guarded branch without the Phase-2 state machine). Full multi-way branches/loops/subflows are Phase 2, intentionally not built here.

Predicate kinds (model-free, evaluated by replayer._predicate_holds): anchor_resolves, text_present, text_absent, param_equals, and and/or/not composition. worklist_nonempty (loops) is deferred to Phase 2.

Orthogonality / concurrency (this PR merges LAST, after #64 secret / #69 StructuralLocator / #70 healing / #66 effects)

New IR fields are independent additive fieldsparams/param_specs, wait_until, guard do not restructure Step/Workflow and are orthogonal to Step.secret (#64), Step.effects (#66), Step.risk, and StructuralLocator (#69). Reconcile by keeping both sets of fields.

Exact touch-points for reconciliation

  • openadapt_flow/ir.py (+148): new classes ParamKind, ParamSpec, PredicateKind, Predicate, Guard (defined before Step; Predicate.model_rebuild() for the self-referential operands). New optional fields: Step.wait_until, Step.guard; Workflow.param_specs; StepResult.skipped. No existing field changed; schema_version left at 1 deliberately (the fields load additively regardless of version, and keeping it unbumped preserves byte-identical output for featureless bundles — bump can be a follow-up).
  • openadapt_flow/runtime/replayer.py (+284, localized): run() param merge now folds param_specs examples in as defaults + fail-fast required-param check. _run_step() calls the new _apply_step_gates() (guard then wait_until) before resolve/act, early-returning on skip/halt. New methods _apply_step_gates, _predicate_holds, _describe_predicate. _act_scroll() re-expressed to poll a wait_until predicate as its stop condition (default = next-anchor ANCHOR_RESOLVES); the old _probe_anchor was folded into _predicate_holds and removed. _act()/scroll call threads params. Identity ladder, resolution ladder, effects, postconditions, heal all untouched.
  • openadapt_flow/compiler/compile.py (+14): emits a ParamSpec(type=string, example=<demo value>) per recorded param alongside params (generalizes the recorder's single note value). No other compiler behavior changed.
  • tests/test_program_ir_phase1.py (new, +336, 12 tests): worked add-patient-note-style example (typed param + wait_until + guard) round-tripping through bundle save/load; param defaulted-from-example and caller-override at replay; missing required param → fail fast naming it; wait_until holds→proceed and timeout→HALT; guard halt-default, skip-noop, and met-executes; wait_until subsumes SCROLL (explicit predicate + default next-anchor); a no-fields bundle replaying identically.

Tests

Full suite green: 1008 passed, 16 skipped (clean py3.12 venv, pip install -e '.[dev]'). The 12 new Phase-1 tests + all existing replayer/compiler/emit tests pass unchanged (back-compat).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ

…(additive, back-compatible)

Implements the RFC's Phase 1 (docs/design/WORKFLOW_PROGRAM_IR.md): the first
additive, backward-compatible step from a linear macro IR toward a parameterized
program. Typed parameters on Workflow (substituted at replay), an optional
per-step guard (deterministic precondition; fail-safe), and wait_until (bounded
readiness predicate that subsumes the SCROLL closed-loop). A bundle with none of
these replays byte-identically to today. $0 / zero model calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ
@abrichr abrichr changed the title feat: workflow-program IR Phase 1 — typed params, guards, wait_until (additive) feat: workflow-program IR Phase 1 — typed params, guards, wait_until (additive, back-compatible) Jul 13, 2026
…-ir-phase1

# Conflicts:
#	openadapt_flow/compiler/compile.py
#	openadapt_flow/ir.py
@abrichr abrichr marked this pull request as ready for review July 13, 2026 17:04
@abrichr abrichr merged commit 8bfcffe into main Jul 13, 2026
2 checks passed
abrichr added a commit that referenced this pull request Jul 13, 2026
…ons → guards/params (ask, don't guess) (#74)

* feat: workflow-program IR Phase 1 — typed params, guards, wait_until (additive, back-compatible)

Implements the RFC's Phase 1 (docs/design/WORKFLOW_PROGRAM_IR.md): the first
additive, backward-compatible step from a linear macro IR toward a parameterized
program. Typed parameters on Workflow (substituted at replay), an optional
per-step guard (deterministic precondition; fail-safe), and wait_until (bounded
readiness predicate that subsumes the SCROLL closed-loop). A bundle with none of
these replays byte-identically to today. $0 / zero model calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ

* feat: interactive disambiguation — Socrates-style compile-time questions → guards/params (ask, don't guess)

Implements the RFC (docs/design/WORKFLOW_PROGRAM_IR.md §3 [3]) induction stage:
where a single demonstration under-specifies intent, surface CONCRETE
multiple-choice questions and apply the answer deterministically as a Phase-1
guard/param — instead of silently freezing an accidental interpretation.

New module openadapt_flow/compiler/disambiguation.py detects three ambiguity
kinds structurally (ZERO model calls):
- parameter candidate — an untagged typed value → ParamSpec + param binding
- absent-result handling — an identity-armed entity selection after a search
  with no 0/>1-match branch → Guard(ANCHOR_RESOLVES, on_unmet="halt")
- optional dialog — a once-handled popup → Guard(TEXT_PRESENT, on_unmet="skip")

Answers map to #71's Guard/Predicate/ParamSpec types verbatim (no new IR
fields). Refuse-rather-than-guess (mirrors runtime.identity): an UNANSWERED
consequential ambiguity (one gating an irreversible write) is flagged and the
resolved skill is marked NOT certified until answered — never silently
defaulted. Non-consequential unanswered ambiguities fall back to a safe no-op
default.

Core is a pure, testable API — detect_ambiguities(workflow) and
apply_answers(workflow, answers) — plus a thin `disambiguate` CLI subcommand
(interactive prompt or --answers JSON). compile.py is UNCHANGED; disambiguation
is an opt-in pass over a compiled bundle.

Stacks on #71 (feat/workflow-program-ir-phase1); retarget base to main after
#71 merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.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