Repository navigation
Conversation
Compiled expressions are Python source, but the compiler recovered grouping by looking for parenthesized_expression nodes around each operand in the CST. That missed ternary operands and the targets of member access, subscript and call, and it cannot work for ASTs built without a CST. Add parentheses purely from the shape of the tree, using Python's precedence table: ternary < or < and < not < comparison < additive < multiplicative < unary sign < primary. - Binary operators are left-associative, so an equal-precedence right operand is wrapped (1 - (2 - 3)). - Python chains comparisons, so a comparison that is an operand of a comparison is always wrapped on either side. - Ternaries are right-associative: only the alternative may be an unparenthesized ternary; any other ternary operand is wrapped. - not wraps operands that bind more loosely than not; unary +/- wrap anything below unary. - Targets of member access, subscript and call must be primary. The comparison path also copied the source whitespace around the operator by slicing CST text. Nothing depends on it, so comparisons are now emitted as `left op right`. Redundant source parentheses are no longer echoed, e.g. `(1 + 2)` now compiles to `1 + 2`. Supersedes the CST-based checks from #95; follows up #94.
…igns A number literal before `.` lexes as a float (`1.real`), so it is now parenthesized. Negative literals built in code print with a leading `-` and now rank as unary, and a signed operand of unary +/- is always wrapped so `-(-1)` no longer prints as `--1`. Regenerate the two golden fixtures whose compiled conditions change: one drops parentheses made redundant by and-over-or precedence, the other drops doubled spaces before `is not`. Reword a compile-collect comment that said the expression emitter never adds parentheses.
BichengWang
left a comment
There was a problem hiding this comment.
[P1] Preserve the existing behavior of unparenthesized comparison chains
At packages/compiler/src/expressions/compile-expression.ts:558–560, always wrapping a comparison operand changes accepted source programs. I saw the behavior-change note in the description, but this also silently enables out-of-range guards: 0 < @variables.amount < 10 compiles to (0 < state.amount) < 10, which evaluates to True for amount = 100 (and also for negative amounts). The base emits 0 < state.amount < 10, which correctly evaluates to False for those values.
I reproduced this through parse → lint → compile with no diagnostics:
config:
agent_name: "TestBot"
agent_type: "AgentforceServiceAgent"
access:
default_agent_user: "test@example.com"
variables:
amount: mutable number = 100
allowed: mutable boolean = False
start_agent main:
description: "Test"
reasoning:
instructions: ->
if 0 < @variables.amount < 10:
set @variables.allowed = True
| TestThe emitted AgentScriptInternal_condition_1 is (0 < state.amount) < 10, so the runtime takes the branch and sets allowed to True. There is a second failure mode: "a" < "b" < "c" changes from True to a Python TypeError because the outer comparison receives a boolean and a string.
Please preserve unparenthesized chains while keeping explicit grouping such as (3 > 2) > 1. The current AST shape conflates these cases, but the original CST still distinguishes them; retaining that distinction in parsing or emission would avoid changing the language behavior as part of this printing fix. Add source-to-runtime regression cases for numeric range guards and string chains; comparing emitted code only against a fully parenthesized rendering of the AST cannot catch this loss of source semantics.
Reviewed 0498226aaad0ff7232b16fb40d2a2b60c8138824 against ace15eb2dc75564fd9e214385ac045c539056770. Both JavaScript and native tree-sitter parsing reproduced the expression regressions, with Python evaluating the base and head outputs; explicitly parenthesized comparison controls retained their behavior. Offline validation: compiler tests 1,167 passed / 3 skipped, SDK tests 320 passed, compiler typecheck and changed-TypeScript ESLint passed. The normal Turbo build was blocked by the missing tree-sitter CLI; the compiler dependencies and SDK were built separately, and native checks used the installed binding directly.
The AST nests a source chain like `0 < a < 10` the same way as an explicit `(0 < a) < 10`, so always parenthesizing a comparison operand turned Python chains into nested comparisons and changed their meaning. Use the CST to tell them apart: an operand that is part of an unparenthesized chain in the source is emitted as a chain, and any other comparison operand of a comparison (explicit grouping, or a tree built in code) is still parenthesized. Adds source-to-Python regression tests for a numeric range guard and a string chain.
|
[AI] Thanks, you're right. Always wrapping a comparison operand changed what existing programs mean. aa5ee1a fixes it in emission. The AST and the parsers are unchanged.
Tests added:
Results:
|
[AI] This PR was drafted by an AI agent on behalf of @awli and reviewed before posting.
What
The compiler now adds grouping parentheses based on operator precedence, instead of looking for
parenthesized_expressionnodes in the CST. Grouping comes from the shape of the AST and Python's precedence rules, because the compiled expression runs as Python. The one exception is a comparison inside a comparison. There the CST decides whether the source wrote a chain (a < b < c) or explicit grouping ((a < b) < c), because the AST nests both the same way.getParenthesizedCstand the CST-based comparison spacing logic are removed.Why
Follow-up to #94 / #95. #95 fixed binary, unary and comparison operands by climbing the CST from each operand to find the source parentheses. That approach needs every compile function to remember the check, and some didn't. These still produce wrong or invalid output on
main(ace15eb2):main(1 if True else 2) if False else 31 if True else 2 if False else 3(evaluates to 1 instead of 3)(1 if True else 2) if False else 31 if (2 if True else 3) else 41 if 2 if True else 3 else 4(SyntaxError)1 if (2 if True else 3) else 4(1 + 2)[0]1 + 2[0](1 + 2)[0](@variables.a or @variables.b).xstate.a or state.b.x(state.a or state.b).x(a + b)()a + b()(a + b)()(1).real1.real(SyntaxError:1.lexes as a float)(1).realBinaryExpression(BinaryExpression(1,'+',2),'*',3)1 + 2 * 3(no CST to inspect)(1 + 2) * 3The #94/#95 cases stay correct:
(1 + 2) * (3 + 4),not (True and False),-(1 + 2),(True or False) == False, and@variables.a and not (@variables.b or @variables.c)→state.a and not (state.b or state.c).How
packages/compiler/src/expressions/compile-expression.ts:or<and<not< comparison <+ -<* /< unary+ -< primary (literal, reference, call, subscript, member access).compileOperand(expr, minPrecedence)wraps an operand that binds more loosely than its position allows. The rules:1 - (2 - 3),1 / (2 * 3).a < b < cmeansa < b and b < c). A chain written without parentheses in the source stays a chain, so0 < @variables.amount < 10compiles to0 < state.amount < 10, as onmain. Every other comparison operand of a comparison is wrapped, on either side: explicit grouping such as(3 > 2) > 1or1 < (2 < 3 < 4), and comparisons nested in an AST built in code.isSourceChainLinkmakes the call: the operand counts as a chain link only when its CST node sits directly under the parent comparison's CST node, with onlyexpressionwrappers and noparenthesized_expressionin between.notwraps operands looser thannot, sonot (a or b)is wrapped, whilenot a == banda == (not b)need no extra handling. Unary+/-wrap any non-primary operand, including a nested sign, so-(-1)stays-(-1)rather than--1..lengthbecomeslen(...), which needs no extra parentheses. A number literal before.is always wrapped, because1.reallexes as the float1..-. SoMemberExpression(NumberLiteral(-1), 'real')compiles to(-1).real.(1 + 2)compiles to1 + 2, and(a == 1) and (b == 2)toa == 1 and b == 2. This is intentional. It replaces fix(compiler): preserve parentheses on both binary operands #95's behavior of copying source parentheses into the output.left op right. The old code copied the source whitespace around the operator by slicing CST text, so1 == 1came out as1 == 1. That logic dates from the original compiler. The unit tests from fix(compiler): preserve parentheses on both binary operands #95 that pinned the spacing now expect the normalized form..snakeand.camelfor each), regenerated withpnpm fixtures:regen.connection_with_unified_emaildrops parentheses thatand-over-orprecedence makes redundant, e.g.X or (A and B and C)becomesX or A and B and C. This fixture was already stale onmainafter fix(compiler): preserve parentheses on both binary operands #95.post_action_conditionalsonly loses the doubled space instate.email is not None. No test in this repo reads these fixtures, so they had drifted silently.compilerandagentforcethat combines compiled conditions withand/or/not(compile-tool,compile-directives,else-if-chain,compile-workflow-node,compile-collect,file-reference-capture). Each already parenthesizes the parts it joins, so none needed changes.Test Plan
agentforce320 passed. Golden fixtures regenerated as described above. A freshpnpm fixtures:regenwith chain handling makes no further changes.Parser-backed
it.eachcases intest/expressions.test.tsfor every grouping case found during [Bug] Compiler drops left operand grouping when both binary operands are parenthesized #94/fix(compiler): preserve parentheses on both binary operands #95 and their follow-ups: unary, comparison, ternary, access targets, associativity, and controls with no parentheses.ASTs built in code with no CST.
New
test/operator-grouping.test.tswith four end-to-end compiles:(1 + 2) * (3 + 4));if not (@variables.a and @variables.b):→not (state.a and state.b);available when (@variables.a or @variables.b) and @variables.c→(state.a or state.b) and state.c;{!(@variables.total + 1) * 2}→{{(state.total + 1) * 2}}.Comparison chains, from source to runtime. Parser-backed cases cover
3 > 2 > 1,0 < @variables.a < 10,"a" < "b" < "c",1 < 2 <= 3 == 3,not 0 < @variables.a < 10, and mixes of chains with explicit grouping ((1 < 2) < 3 < 4,1 < (2 < 3 < 4),(1 < 2 < 3) < 4). A code-builtComparison(Comparison(3, >, 2), >, 1)still gives(3 > 2) > 1. Intest/operator-grouping.test.ts,ifconditions are compiled from source and the emitted condition is then evaluated withpython3:0 < @variables.amount < 10is False for amounts 100, -5, 0 and 10, and True for 5;"a" < "b" < "c"evaluates to True;3 > 2 > 1evaluates to True and(3 > 2) > 1to False.The Python checks are skipped when
python3isn't onPATH. The emitted strings are asserted either way. Without the chain fix, 18 of these tests fail.Grouping of number literals before
.and of nested signs:(1).real,-(-1),-(-@variables.a),(-@variables.a).x, and the negative-literal ASTs built in code.On
main, 28 of these tests fail. Some fail becausemain's output is wrong or invalid, the rest because the output is now normalized. All pass with this change.not, unary signs, member access (.real,.numerator, …) and method calls (.bit_length()). For each tree, Python evaluated the compiled output against a fully parenthesized rendering of the same tree: 0 mismatches. These trees have no CST, so this check covers tree-shaped grouping only. Source chains are covered by the source-to-runtime tests above. As a check on the harness itself, the first commit of this PR fails about 2,100 trees per seed on the number-before-.bug, andmainfails the two ternary rows above.pnpm lint,pnpm typecheck,tsc --noEmit, eslint and prettier.adaptNode) on 5,063 inputs, plus 11 chain and grouping cases after the chain fix. Two unrelated issues already onmaingot in the way. The parser's built-in tree-sitter entry can't load on Node 22: itrequire()s an ESM package that uses top-level await. AndFORCE_COLOR=3in the shell breaksbinding.gyp'shas_scannercheck.Checklist