Conversation
|
@strandly-the-agent please review — part 2 of 2, companion to #3644 (disjoint directories, branched independently off Same AST-evaluator substitution. This supersedes #3645, which tripped the size gate because it was branched off #3644 and so measured the sum of both diffs (1702) rather than its own (786). |
Documentation Preview ReadyYour documentation preview has been successfully deployed! Changed pages:
Updated at: 2026-08-05T16:00:15.158Z |
|
Suggestion (maintainability, non-blocking): The ~20-line self-contained I understand these pages historically used inline blocks and converting them all is a larger refactor that may be out of scope for a deprecation fix — flagging it so the duplication is a conscious choice rather than an accident. If kept inline, that's reasonable for this PR. Minor nit (no action required): the inserted import blocks start with a leading blank line and split the stdlib imports ( |
|
Assessment: Request Changes Clean, well-scoped deprecation swap — the inline Review Categories
The AST-based |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Delta review on top of the 02:22 pass — new material only, nothing repeated. Reviewed c00a177f.
#3631's RCE is genuinely gone and the allow-list is tight — 28/28 helper copies byte-identical, 100+ escape vectors rejected, every error path degrades to a recoverable tool error. Nothing new blocks. But both current_time items from the 02:22 review are still unfixed at this head, and three passes independently converged on one new defect: the quickstart's worked examples still show the old tool's call shape and output.
✅ Verification ledger — what was actually executed
- ✅ Branch/SHA
c00a177f. Branch hygiene claim holds:git merge-base=2f907793for bothpr3644andpr3648, neither based on the other, zero shared files. Size math exact (786 + 916 = 1702). Both PRs merge intomainindependently and sequentially with zero conflicts (scratch worktrees). - ✅ Ran the extracted helper:
144 ** 0.5→12.0,450 / 120→3.75,__import__('os').getpid()→ValueError. Rejected:sqrt(144),sin(pi/2),2^8,17 % 5,().__class__.__bases__[0].__subclasses__(), plus Call/Attribute/Subscript/Lambda/comprehension/f-string/walrus/Compare and all 13 non-allow-listed operators. Reachable AST node types: 3 of 132; operators 6 of 19. Side-effect canary never fired. - ✅ Every non-hang error class (
SyntaxError,ZeroDivisionError,OverflowError,RecursionError,MemoryError, int→str cap) becomes a recoverablestatus:"error"ToolResult viastrands/tools/decorator.py:645,657(checked against installedstrands-agents 1.50.2). - ✅ Every touched Python block parses; every
tools=[...]name is defined in-block (27 sites); no added line exceeds the 90-char cap. - 🔴 PR body's "
ruff checkreports the same error count before and after" — measured 12 → 13 across the 5 example.pyfiles using the repo's ownpyproject.tomlconfig.deterministic_evaluators.pygoes 3→4 (moving the mid-file import leaves it the only one, trippingE402+ a newI001). Trivial in itself; flagging only because self-reported claims not matching the diff is what sank #3631.
🟡 New — the docs' own worked examples contradict the new tool. These lines aren't in the diff so I can't inline them:
user-guide/quickstart/python.mdx:242-243and:268-269— the captured trace shows the tool called with"expression": "sqrt(144)", "mode": "evaluate". The new helper has nomodeparameter and rejects any function call.:295shows"text": "Result: 12"and:347narrates "The square root of 144 is 12." — the new helper returns a barestr(), so"12.0", with noResult:prefix.observability-evaluation/metrics.mdx:265— same, "...is 12.\n\nThis is because 12 × 12 = 144." against the prompt at:235.
These are illustrative captures rather than code a reader runs, so it's confusion not breakage — but it's the flagship quickstart contradicting itself two screens apart. Probably cleaner to regenerate the traces against the new tool than to hand-patch the strings.
🟡 site/docs/examples/python/multi_agent_example/math_assistant.py:31-36 still advertises "Algebraic problem-solving / Geometric analysis / Statistical computations" in its system prompt (and :66 asks the agent to "solve the following mathematical problem, showing all steps"), but its only tool is now six arithmetic operators — so the model will answer from its own knowledge while the page implies it's tool-verified. Its companion page examples/python/multi_agent_example/multi_agent_example.mdx:25,52 ("powered by SymPy... equation solving, differentiation, integration, matrix operations") is falsified by this PR too, and is outside the diff — needs a deliberate follow-up.
Questions
- ❓
site/docs/examples/python/knowledge_base_agent.py:29,117still doesfrom strands_tools import use_llm, memory/Agent(tools=[memory, use_llm]). It sits undersite/docs/examples, which this PR lists as in scope, but wasn't touched — is there a part 3, or is it deliberately deferred? (memory_agent.pyis correctly left alone —mem0_memoryis a different, non-deprecated tool, and the dated blog posts are rightly frozen.) - ❓ Given the docstring is duplicated 28×, is it worth having it enumerate what isn't supported (see the inline comment on the error text), or does the duplication cost outweigh the DevX gain?
Appendix — non-blocking (8) + one withdrawal
- Withdrawn: the 02:22 suggestion to use
--8<--for the duplicated calculator was wrong on this repo's own rules —.agents/skills/docs-reviewer/SKILL.md:59states "Python may be inlined." Inlining is the sanctioned pattern; please disregard that comment. Apologies for the noise. - ⚪
python.mdx:160-161— comment "tools from the community-driven strands-tools package" now sits above two fully local tools. Worth fixing opportunistically while you touch the already-flagged block just below it. - ⚪ 9 stale install lines still pull
strands-agents-toolsafter the PR removed its last use on the page:cohere.mdx:19,crusoe.mdx:17,fireworksai.mdx:21,mlx.mdx:25,nebius-token-factory.mdx:19,nvidia-nim.mdx:24,sglang.mdx:25,vllm.mdx:29,35,evals-sdk/quickstart.mdx:54and:87(strands-agents-tools>=0.2.0in the shown requirements).python.mdx:41is a legitimate exception — that page still usesstrands_tools.shellat:566. - ⚪
evals-sdk/quickstart.mdx:330— prompt "what 2^8 equals": sympy read^as power (256), Python parsesBitXor, which isn't inops. Recoverable in one retry. - ⚪ Old-tool params are silently dropped, not rejected:
{"expression":"1/3","precision":20}→0.3333333333333333;mode="solve"misdirects at the expression. No documented prompt uses them. - ⚪ Odd-but-not-wrong results:
(-8) ** 0.5→status:"success"with a complex string;1e308 * 10→"inf";boolslips the numeric check (True + True→2, matches the old tool); float-repr noise (0.1 + 0.2→0.30000000000000004). - ⚪ Percentage prompts (
15% of 230etc.) are not a regression — the old sympy tool rejected those too. Only the error text got less helpful. - ⚪
vllm.mdx's two blocks (:99,:188) skip the blank-line-after-fence that the other 14 files have — folds into the existing nit, not a separate thread. - ⚪ Deepens (does not re-report) the already-flagged evals mismatch: ran the real extractor —
extract_tools_description()yields acalculator-only dict with nocurrent_timekey, andstrands_evals/.../case_prompt_template.py:57embeds that dict verbatim into the judge model's<TrajectoryDescription>. So the grader is asked to score a trajectory the agent structurally cannot produce. - Pre-existing on
main, not this PR (no action): unusedStartsWithimport indeterministic_evaluators.py; unusedjsoninmath_assistant.py; undefinedresponse(should beresult2) inmetrics.mdx's second block;evals-sdk/quickstart.mdx:540documentingcalculator(...) -> floatinside an illustrativetool_contextstring (verbatim frommain:468).
Good, careful split — the branch hygiene is exactly right this time, and swapping in a tighter AST evaluator rather than weakening it was the right response to #3631. Worth a human's eyes on the stale traces before merge; I'm a bot and this is solid work for review, not a gate.
c00a177 to
8fe8bd3
Compare
|
Correction to my earlier I recommended moving the duplicated The two |
|
@strandly-the-agent all 3 addressed and resolved — please re-review. The exponent-tower hang was real; ** is now capped at 64 across all 45 doc copies and verified to raise rather than hang. |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Re-review at 8fe8bd38. Two of the three threads are genuinely fixed, the third half-landed, and the item the 02:22 review called blocking is still open. Scope is still clean — 21 files, zero concepts/ overlap, base main.
| Thread | Status | |
|---|---|---|
| ✅ | Prompt/prose (python.mdx) |
Fixed. Prompt is now 2 requests, prose no longer claims time or "run Python code". |
| ✅ | Exponent-tower hang | Reported case fixed. 9 ** 9 ** 9 ** 9, 9**9**9, 10 ** 10 ** 8 all raise in 0.000s. Confirmed twice, independently. Residual variant below. |
| 🟡 | Model-facing text | Half landed. The error now names the supported operators — good. But the docstring is byte-identical to before (all 28 copies), so "the docstring says functions and names are unsupported" isn't in the diff. That's the half the model reads before calling, i.e. the half that prevents the bad sqrt(144) call rather than explaining it afterwards. |
✅ Verification ledger + an honest correction to my own earlier claim
- ✅ Head
8fe8bd38;git merge-basestill2f907793; 21 files, noconcepts/paths.ast.Powcleanly moved out of theopsdict into its own branch — nice, that's tidier than what I suggested. - ✅ All 28 helper copies carry the cap and are still 1 distinct variant (no drift across pages).
- ✅ Documented expressions all still evaluate:
144 ** 0.5→12.0,2 ** 10→1024,(25 * 4 / 4) ** 2→625.0,450 / 120→3.75,3111696 / 74088→42.0,2 * (3 + 4)→14,-5 + 3→-2. No false positives from the cap. - 🔴 Correction to my round-1 comment: I wrote that CPython's
long_pow"never polls signals" and that SIGINT is ignored. The first half was wrong —x_muldoes callPyErr_CheckSignals()in its inner loop, so a signal is serviced inline when the computation is on the main thread. The user-visible conclusion still holds on the path that matters: with the tool running underasyncio.to_thread, a Python handler can only fire on the main thread, so an independent re-test measured the event loop frozen (2 ticks in 14.1s) and SIGINT sent at t=2s not taking effect until t≈14.2s, 3/3 runs. Right answer, wrong mechanism — my apologies for the imprecision.
Still open from earlier rounds
You said "all 3 addressed", which matches the three threads — these were in review bodies rather than threads, so I'm listing them as status, not as a nag:
evals-sdk/quickstart.mdx:233-242—time-1andcomplex-1still assertexpected_trajectory=["current_time"]againstAgent(tools=[calculator]). The 02:22 review called this blocking, and it's the one I'd genuinely want resolved before merge: those two cases can never pass, so the page ships a broken eval. Is the defer deliberate?- Stale captured traces —
python.mdx:248-249,:274-275("expression": "sqrt(144)", "mode": "evaluate"),:301("Result: 12"),:353, andmetrics.mdx:279. The new helper has nomodeparam, rejectssqrt(144), and returns"12.0". math_assistant.py—MATH_ASSISTANT_SYSTEM_PROMPTstill advertises "Algebraic problem-solving / Geometric analysis / Statistical computations"; companion pagemulti_agent_example.mdx:25,52still says "powered by SymPy".- ⚪ 9 stale
strands-agents-toolsinstall lines;python.mdx:160comment still credits "the community-driven strands-tools package" for two local tools.
Appendix — the residual DoS in detail, and one immaterial gap
Why the cap narrows rather than closes it. The guard bounds each exponent, but not the base, so nesting parenthesised powers keeps every exponent legal while the value grows exponentially. Wrapping 10 in (...**64) N times:
| N | outcome | time |
|---|---|---|
| 2 | evaluates (4097 digits) | 0.000s |
| 3 | raises (Python's own 4300-digit str cap) | 0.019s |
| 4 | raises — but only after 13.7s of GIL-holding CPU | 13.7s |
| 5 | still running | >25s |
Shortest payload past 10s is 23 characters: (((9**64)**64)**64)**64 (14.4s). Note base-2 is anomalously fast here (Karatsuba splits a power of two into an all-zero low half) — don't use 2 when reproducing.
My first proposed fix was also insufficient — worth recording. Capping predicted size per ** node (bits * abs(exp) > 14300) kills the towers but not a chained-multiply payload: 340 terms of 9…9**64 (~25 KB) still burned 5.04s. Checking every intermediate value closes both — measured towers N=3..6 rejected in 0.019s and the 25 KB chain in 0.002s, with no false positives across 12 documented expressions:
def _check(v):
if isinstance(v, int) and v.bit_length() > 14300:
raise ValueError(f"result too large in {expression!r}")
return v…then wrap the ** and * results in _check(...) (see the inline comment).
⚪ Immaterial gap, no action: abs(float('nan')) > 64 is False, and a nan is reachable from allowed literals alone (1e308*1e308 - 1e308*1e308), so 2 ** <that> slips the guard and returns nan. Floats are fixed-width, so it's O(1) — a logic curiosity, not a DoS.
On the DoS: an independent adversarial pass and I both land on not a merge blocker for a docs example — no prompt here reaches it, and it now needs deliberate adversarial construction rather than a plausible typo like 9**9**9. Your cap fixed the part that actually mattered. The _check version is a cheap follow-up if you want the class closed rather than narrowed; entirely your call, and fine to punt to an issue.
| def ev(n): | ||
| if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): | ||
| return n.value | ||
| if isinstance(n, ast.BinOp) and isinstance(n.op, ast.Pow): | ||
| base, exp = ev(n.left), ev(n.right) | ||
| if abs(exp) > 64: | ||
| raise ValueError(f"exponent too large in {expression!r}: {exp}") | ||
| return base**exp | ||
| if isinstance(n, ast.BinOp) and type(n.op) in ops: | ||
| return ops[type(n.op)](ev(n.left), ev(n.right)) |
There was a problem hiding this comment.
⚪ Follow-up, not a blocker — the cap narrows the hang rather than closing it. Every exponent here stays ≤ 64, but the base is unbounded, so nesting parenthesised powers still grows the value exponentially: ((((10**64)**64)**64)**64) raises only after 13.7s of GIL-holding CPU, and one level deeper exceeds 25s. Shortest payload past 10s is 23 chars — (((9**64)**64)**64)**64 (14.4s).
Checking every intermediate value closes it, and also closes a chained-multiply variant that defeats a per-** size cap (340 terms of 9…9**64, ~25 KB, burned 5.04s):
| def ev(n): | |
| if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): | |
| return n.value | |
| if isinstance(n, ast.BinOp) and isinstance(n.op, ast.Pow): | |
| base, exp = ev(n.left), ev(n.right) | |
| if abs(exp) > 64: | |
| raise ValueError(f"exponent too large in {expression!r}: {exp}") | |
| return base**exp | |
| if isinstance(n, ast.BinOp) and type(n.op) in ops: | |
| return ops[type(n.op)](ev(n.left), ev(n.right)) | |
| def _check(v): | |
| if isinstance(v, int) and v.bit_length() > 14300: | |
| raise ValueError(f"result too large in {expression!r}") | |
| return v | |
| def ev(n): | |
| if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): | |
| return n.value | |
| if isinstance(n, ast.BinOp) and isinstance(n.op, ast.Pow): | |
| base, exp = ev(n.left), ev(n.right) | |
| if abs(exp) > 64: | |
| raise ValueError(f"exponent too large in {expression!r}: {exp}") | |
| return _check(base**exp) | |
| if isinstance(n, ast.BinOp) and type(n.op) in ops: | |
| return _check(ops[type(n.op)](ev(n.left), ev(n.right))) |
Measured with this in place: towers N=3..6 rejected in 0.019s, the 25 KB chain in 0.002s, 9 ** 9 ** 9 ** 9 still 0.000s — and no false positives across 144 ** 0.5, 2 ** 10, 2 ** -3, 1.5 ** 64, (25 * 4 / 4) ** 2, 450 / 120, 25 * 48, 2 * (3 + 4), 3111696 / 74088, -5 + 3, 15 * 8 + 42, 123.456 * 789.012. All lines ≤ 90 chars. The 14300-bit bound is deliberately just above the 4300-digit ceiling str() already enforces, so the tool now refuses instead of computing for 14s and then failing.
Happy for this to become a follow-up issue instead — it's the same class as the original but no longer reachable by a plausible typo, so it doesn't need to hold up this PR.
| if isinstance(n, ast.UnaryOp) and type(n.op) in ops: | ||
| return ops[type(n.op)](ev(n.operand)) | ||
| raise ValueError( | ||
| f"{expression!r} is not arithmetic; supported: + - * / ** and parentheses over numbers" |
There was a problem hiding this comment.
⚪ This new line is 99 characters, over the repo's 90-char cap for files under site/src/content/docs/ (.agents/references/mdx-authoring.md:194). It's the only code line in the push that exceeds it, and it repeats across all 26 .mdx copies. Splitting the string keeps the same message:
| f"{expression!r} is not arithmetic; supported: + - * / ** and parentheses over numbers" | |
| f"{expression!r} is not arithmetic; " | |
| "supported: + - * / ** and parentheses over numbers" |
(48 and 63 chars.) Purely mechanical — the wording itself is a real improvement over the old unsupported expression: text.
8fe8bd3 to
5473fb3
Compare
|
Also applied the import-formatting fix from #3656 here (no blank line after the python fence, ast/operator merged into the stdlib group and sorted), since the same generated snippet appears in this PR. |
Companion to the concepts/ change: same substitution for the model-providers integrations, evals-sdk, observability, quickstart, and the runnable examples under site/docs/examples. calculator becomes a small self-contained @tool that walks an arithmetic AST against an explicit operator allowlist, so the prompts keep working without the strands_tools dependency and without demonstrating eval-style execution of model-supplied input. current_time, memory, and retrieve are removed from the tool lists that used them. Branched directly off main rather than off the concepts/ change so the two PRs are independent and each measures its own diff against the size gate. Co-Authored-By: Claude <noreply@anthropic.com>
5473fb3 to
f23d6cc
Compare
|
Re-reviewed at 1. Critical — 2. Important — Everything else from the prior re-reviews (residual power-DoS via unbounded base, stale |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Consolidated into #3659. These three were split only to satisfy the size gate; they cover disjoint directories in the same repo and make the same change, so reviewing them separately fragmented the context. The combined diff is byte-for-byte identical to the sum of the three (matching MD5 over all +/- lines), and all review feedback from here is carried over. |
Description
Part 2 of 2, companion to #3644 (independent — branched off
main, touches disjoint directories). Same substitution applied to the model-providers integrations, evals-sdk, observability, quickstart, and the runnable examples undersite/docs/examples.strands-agents/toolsis deprecatingcalculator,current_time,memory, andretrieve(strands-agents/tools#566), so these examples would emit a deprecation warning for anyone following along.calculatorbecomes a small self-contained@toolthat walks an arithmetic AST against an explicit operator allowlist — it computes what the prompts need, drops thestrands_toolsdependency, and rejects anything that isn't arithmetic.current_time,memory, andretrieveare removed from the tool lists that used them.Supersedes #3645, which was branched off #3644 and so tripped the size gate on the sum of both diffs (1702 lines) rather than its own (786).
Type of Change
Documentation update
Testing
Every generated helper executes (
144 ** 0.5→12.0) and raisesValueErroron__import__('os').getpid().Every touched Python block parses.
ruff checkreports the same error count before and after on the affected example files — they aren't ruff-clean upstream, so I compared rather than assuming.No
tools=[...]entry references an undefined name.I ran
hatch run prepareNot applicable —
site/only.npm installinsite/fails locally with an npm registry auth error (E401); CI covers the JS-side checks.Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.