Pure-graph edge evaluator does not implement content_not_contains, causing mutually-exclusive branch conditions to both fire #95

Closed
opened 2026-08-03 21:48:47 +00:00 by CoreRasurae · 0 comments
Member

Metadata

  • Commit Message: fix(langgraph): implement content_not_contains condition in pure graph edge evaluator
  • Branch: bugfix/m1-pure-graph-content-not-contains

Background and context

The Actor Configuration Standard defines content_not_contains as a condition
type that MUST be honored by "Stream router, conditional nodes, bridge router"
(§5.4). §5.4.1 additionally specifies that for pure-graph edge conditions,
an unknown condition type defaults to True (with a warning emitted).

PureLangGraph._evaluate_edge_condition()
(src/cleveractors/langgraph/pure_graph.py, starting around line 2013)
implements always, content_contains, context_value, output_pattern, and
message_pattern — but has no branch for content_not_contains. Any edge
configured with content_not_contains therefore falls into the catch-all:

else:
    # Unknown condition type - default to true
    self.logger.warning(f"Unknown condition type: {condition_type}")
    return True

This produces the log line Unknown condition type: content_not_contains,
reproduced verbatim (three times) during a real run against
packages/porting-actor-tools-graph-no_prune-single_thread-skills_b.yaml.

That graph route encodes a classic exclusive if/else branch via a pair of
sibling edges out of each classifier node, e.g.:

- source: bl_classifier1
  target: bl_fixes1
  condition: {type: content_not_contains, text: NO_FIXES}
- source: bl_classifier1
  target: ui_coder
  condition: {type: content_contains, text: NO_FIXES}

Because content_not_contains always evaluates to True regardless of
message content, whenever the classifier output does contain NO_FIXES,
both edges are satisfied simultaneously: content_contains correctly matches
(→ ui_coder), and the broken content_not_contains also returns True
(→ bl_fixes1). _get_next_nodes() (pure_graph.py:1990-2011) returns both
targets instead of the single one the author intended.

Combined with a second, independently-filed defect in parallel-dispatch
gating (companion issue), this causes both branches to execute concurrently
via asyncio.gather, even though the graph draws them as a mutually
exclusive choice.

Current behavior

  • An edge condition {type: content_not_contains, text: "<X>"} in a
    pure-graph route always evaluates to True, regardless of whether the
    message content actually contains <X>.
  • A warning Unknown condition type: content_not_contains is logged every
    time such an edge is evaluated.
  • When paired with a complementary content_contains edge on a sibling
    branch (the common if/else idiom), both edges can be satisfied at once, so
    _get_next_nodes() returns more candidate next-nodes than intended.

Expected behavior

  • _evaluate_edge_condition() MUST implement content_not_contains per
    §5.4: true if the substring is NOT present in the (stringified) message
    content, matching the existing correct implementations in nodes.py:812,
    bridge.py:468, and reactive/stream_router.py:550.
  • A classifier node with two sibling edges — one content_contains: X, one
    content_not_contains: X — MUST yield exactly one next-node, whichever
    matches the actual message content.
  • No spurious Unknown condition type: content_not_contains warning should be
    logged for a correctly configured edge.

Acceptance criteria

  • _evaluate_edge_condition() returns True when the configured text is
    absent from the message content, and False when present, for
    type: content_not_contains.
  • Given a graph with sibling edges content_contains: X /
    content_not_contains: X from the same source node, _get_next_nodes()
    returns exactly one target for any message content (containing or not
    containing X).
  • No Unknown condition type: content_not_contains warning is logged for
    a correctly configured edge.
  • Existing content_contains, always, context_value,
    output_pattern, message_pattern behavior is unchanged (no
    regression).

Supporting information

  • Spec: docs/index.md §5.4 (condition table, content_not_contains row),
    §5.4.1 (subsystem-specific defaults for pure-graph edges).
  • Reproduction: packages/porting-actor-tools-graph-no_prune-single_thread-skills_b.yaml,
    route main, edges out of bl_classifier1/bl_classifier2/bl_classifier3
    and project_classifier1/project_classifier2/project_classifier3.
  • Log evidence from a client/test_app.py run: three occurrences of
    Unknown condition type: content_not_contains, followed by interleaved
    tool calls from agents (code-review-fixes1, ui-coder-designer,
    code-adjustments-classifier1) that the graph places in strict sequence.
  • Related: this issue depends on its companion TDD issue, and is compounded
    by a separately-filed parallel-dispatch-gating defect in the same file
    (pure_graph.py lines 1078, 1860, 1965), which is what actually causes the
    two branches to run concurrently once both are returned as next-nodes.

Subtasks

  • Add a content_not_contains branch to
    PureLangGraph._evaluate_edge_condition() in
    src/cleveractors/langgraph/pure_graph.py, mirroring the existing
    logic in nodes.py, bridge.py, and reactive/stream_router.py.
  • Tests (Behave): scenarios for content_not_contains true/false cases,
    and a combined content_contains/content_not_contains sibling-edge
    scenario asserting exactly one next-node is selected.
  • Tests (Robot): integration scenario running a small pure-graph config
    with the sibling if/else pattern end-to-end, asserting only the
    correct branch executes.
  • Update CHANGELOG with a user-facing entry.
  • Verify coverage >= 97% via nox -s coverage_report.
  • Run nox (all default sessions), fix any errors.

Definition of Done

This issue is complete when:

  • All subtasks above are completed and checked off.
  • A Git commit is created where the first line matches the Commit Message in
    Metadata exactly.
  • The commit is pushed to the branch matching the Branch in Metadata exactly.
  • The companion TDD regression test (@tdd_issue / @tdd_issue_N) passes
    with @tdd_expected_fail removed.
  • The commit is submitted as a PR to master, reviewed, and merged.
## Metadata - **Commit Message:** `fix(langgraph): implement content_not_contains condition in pure graph edge evaluator` - **Branch:** `bugfix/m1-pure-graph-content-not-contains` ## Background and context The Actor Configuration Standard defines `content_not_contains` as a condition type that MUST be honored by "Stream router, conditional nodes, bridge router" (§5.4). §5.4.1 additionally specifies that for **pure-graph edge conditions**, an *unknown* condition type defaults to `True` (with a warning emitted). `PureLangGraph._evaluate_edge_condition()` (`src/cleveractors/langgraph/pure_graph.py`, starting around line 2013) implements `always`, `content_contains`, `context_value`, `output_pattern`, and `message_pattern` — but has no branch for `content_not_contains`. Any edge configured with `content_not_contains` therefore falls into the catch-all: ```python else: # Unknown condition type - default to true self.logger.warning(f"Unknown condition type: {condition_type}") return True ``` This produces the log line `Unknown condition type: content_not_contains`, reproduced verbatim (three times) during a real run against `packages/porting-actor-tools-graph-no_prune-single_thread-skills_b.yaml`. That graph route encodes a classic exclusive if/else branch via a pair of sibling edges out of each classifier node, e.g.: ```yaml - source: bl_classifier1 target: bl_fixes1 condition: {type: content_not_contains, text: NO_FIXES} - source: bl_classifier1 target: ui_coder condition: {type: content_contains, text: NO_FIXES} ``` Because `content_not_contains` always evaluates to `True` regardless of message content, whenever the classifier output *does* contain `NO_FIXES`, both edges are satisfied simultaneously: `content_contains` correctly matches (→ `ui_coder`), and the broken `content_not_contains` also returns `True` (→ `bl_fixes1`). `_get_next_nodes()` (`pure_graph.py:1990-2011`) returns both targets instead of the single one the author intended. Combined with a second, independently-filed defect in parallel-dispatch gating (companion issue), this causes both branches to execute concurrently via `asyncio.gather`, even though the graph draws them as a mutually exclusive choice. ## Current behavior - An edge condition `{type: content_not_contains, text: "<X>"}` in a pure-graph route always evaluates to `True`, regardless of whether the message content actually contains `<X>`. - A warning `Unknown condition type: content_not_contains` is logged every time such an edge is evaluated. - When paired with a complementary `content_contains` edge on a sibling branch (the common if/else idiom), both edges can be satisfied at once, so `_get_next_nodes()` returns more candidate next-nodes than intended. ## Expected behavior - `_evaluate_edge_condition()` MUST implement `content_not_contains` per §5.4: true if the substring is NOT present in the (stringified) message content, matching the existing correct implementations in `nodes.py:812`, `bridge.py:468`, and `reactive/stream_router.py:550`. - A classifier node with two sibling edges — one `content_contains: X`, one `content_not_contains: X` — MUST yield exactly one next-node, whichever matches the actual message content. - No spurious `Unknown condition type: content_not_contains` warning should be logged for a correctly configured edge. ## Acceptance criteria - [ ] `_evaluate_edge_condition()` returns `True` when the configured text is absent from the message content, and `False` when present, for `type: content_not_contains`. - [ ] Given a graph with sibling edges `content_contains: X` / `content_not_contains: X` from the same source node, `_get_next_nodes()` returns exactly one target for any message content (containing or not containing `X`). - [ ] No `Unknown condition type: content_not_contains` warning is logged for a correctly configured edge. - [ ] Existing `content_contains`, `always`, `context_value`, `output_pattern`, `message_pattern` behavior is unchanged (no regression). ## Supporting information - Spec: `docs/index.md` §5.4 (condition table, `content_not_contains` row), §5.4.1 (subsystem-specific defaults for pure-graph edges). - Reproduction: `packages/porting-actor-tools-graph-no_prune-single_thread-skills_b.yaml`, route `main`, edges out of `bl_classifier1`/`bl_classifier2`/`bl_classifier3` and `project_classifier1`/`project_classifier2`/`project_classifier3`. - Log evidence from a `client/test_app.py` run: three occurrences of `Unknown condition type: content_not_contains`, followed by interleaved tool calls from agents (`code-review-fixes1`, `ui-coder-designer`, `code-adjustments-classifier1`) that the graph places in strict sequence. - Related: this issue depends on its companion TDD issue, and is compounded by a separately-filed parallel-dispatch-gating defect in the same file (`pure_graph.py` lines 1078, 1860, 1965), which is what actually causes the two branches to run concurrently once both are returned as next-nodes. ## Subtasks - [ ] Add a `content_not_contains` branch to `PureLangGraph._evaluate_edge_condition()` in `src/cleveractors/langgraph/pure_graph.py`, mirroring the existing logic in `nodes.py`, `bridge.py`, and `reactive/stream_router.py`. - [ ] Tests (Behave): scenarios for `content_not_contains` true/false cases, and a combined `content_contains`/`content_not_contains` sibling-edge scenario asserting exactly one next-node is selected. - [ ] Tests (Robot): integration scenario running a small pure-graph config with the sibling if/else pattern end-to-end, asserting only the correct branch executes. - [ ] Update CHANGELOG with a user-facing entry. - [ ] Verify coverage >= 97% via `nox -s coverage_report`. - [ ] Run `nox` (all default sessions), fix any errors. ## Definition of Done This issue is complete when: - All subtasks above are completed and checked off. - A Git commit is created where the first line matches the Commit Message in Metadata exactly. - The commit is pushed to the branch matching the Branch in Metadata exactly. - The companion TDD regression test (`@tdd_issue` / `@tdd_issue_N`) passes with `@tdd_expected_fail` removed. - The commit is submitted as a PR to `master`, reviewed, and merged.
CoreRasurae added this to the v2.1.0 milestone 2026-08-03 21:48:47 +00:00
CoreRasurae added the
State
Unverified
Type
Bug
Priority
Critical
labels 2026-08-03 21:48:48 +00:00
CoreRasurae added
State
Verified
and removed
State
Unverified
labels 2026-08-03 21:56:30 +00:00
CoreRasurae added
State
In Review
and removed
State
Verified
labels 2026-08-04 07:31:14 +00:00
CoreRasurae added
State
Completed
and removed
State
In Review
labels 2026-08-04 12:40:17 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: cleveragents/cleveractors-core#95