Skip to content

acli: sync 0.6.6 agent-loop hardening and fix the retry budget - #197

Merged
lzsweb merged 15 commits into
mainfrom
release/agentic-cli-5
Sep 17, 2026
Merged

lzsweb merged 15 commits into
mainfrom
release/agentic-cli-5

Conversation

@lzsweb

@lzsweb lzsweb commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Sync the vendored dashscope/acli agent loop with agenticCLI 0.6.6: evidence-anchored reflection (A7), futility refusal with OpenHands-calibrated repeat detection (A8), and an opt-in acceptance gate (A9).
  • Stop the turn-level retry from re-paying a provider retry budget that was already spent. HardenedProvider now raises RetriesExhausted when it exhausts its own attempts, and Agent skips its turn-level retry for that type. Against a stalled endpoint this measured 12 HTTP attempts and ~819s of silent wall clock from a 60s configured timeout; it is now 4 attempts and ~268s. The wrapped message is preserved verbatim because ProviderChain decides profile fallback via is_retryable_error(e) — rewriting it would turn "this profile is spent" into "abort the whole chain".
  • Make the oneshot usage report survive a wedged stream: a daemon thread flushes on a timer rather than only between chunks (a stall means no chunks, which is exactly when the instrumentation was lost), writing via tmp + os.replace so the harness never reads a half-written file.
  • Fix the acceptance gate rejecting answers whose check never actually evaluated, and drop a delegate model override it could not honour.

Test plan

  • pre-commit run --all-files green — black, flake8, pylint, mypy and the rest, no modifications left
  • pytest tests/unit/test_acli_{provider_retries,acceptance,reflection,usage_report}.py — 194 passed
  • Vendored tree verified byte-identical to a fresh scripts/sync_acli.sh run from agenticCLI HEAD
  • terminal-bench hard batch on wheel md5 13a68c94 (acli 0.6.6)

zhansheng.lzs and others added 15 commits September 14, 2026 17:27
Vendors release/agentic-cli-5 (851c3a7..8ba15e5) plus the 0.6.5 version bump.

A1 read_file now windows to 200 lines by default under a 100k-char cap, takes
  a 1-based offset matching the line numbers it prints, and ends with the
  omitted count and the offset that continues from there.
A2 Tool success is a structured ToolOutcome (signal kind + exit code) instead
  of a text prefix the agent had to sniff back out.  Non-zero exits count as
  failures except for commands whose exit status is the answer (grep, diff,
  test) — Reflexion's data favours false negatives over false positives.
A3 Reflection/stagnation/convergence hints moved out of the system message
  into a trailing user message, keeping the prompt prefix byte-stable so
  provider-side caching actually hits.
A4 Truncated tool results from read-only tools record the original call in the
  omission marker, so the model can re-read instead of reasoning over a hole.
  Idempotency now matches the marker by shape: read_file's own windowing
  trailer also says "[omitted", which was silently exempting every windowed
  read from shrinking.

dashscope unit: 702 passed, 6 skipped.  pre-commit --all-files clean.
Mirrors agenticCLI 556255d. The three-failure reflection hint was a
template string about a count; it now quotes the failing tool's own
output plus the structured verdict (signal_kind / exit_code), tail-biased
so tracebacks and FAILED lines survive truncation, and asks which
assumption that output falsifies. Diagnosis and fix are demanded in the
same response, so anchoring adds no model call. Only the latest failure
is kept — accumulating failure history is what AlphaCodium measured as
"no improvement".

The experience lesson moves from hint-injection time to after the
stream, storing the model's own diagnosis with an evidence fallback.

test_hint_deduplicates_tool_names asserted dedup by counting a raw
substring across the whole hint. The verdict line now names the last
failing tool a second time by design, so the count is scoped to the
summary line that renders the deduplicated set.
Vendored from agenticCLI a59e505 + 56afb36.

The stagnation detector only counted read-only streaks and answered with
advice the model could ignore. It now also keys repeats on call identity
(tool plus canonicalised arguments) and a fingerprint of the result, at
OpenHands' production thresholds (repeat action-observation 4, repeat
action-error 3). _execute_tool consults the tracker before execution, so a
call that already failed identically with nothing changed in between is
refused rather than run — a structural interruption in the spirit of ADaPT
forcing decomposition on failure instead of offering it. The refusal is
audited as a denial and still answers its tool_call id.

Only a successful mutating call counts as a state change, so re-running the
test suite after an edit is never mistaken for a loop; and only failures are
refused, so a repeated read still runs and merely escalates the prompt to a
replan demand.

tests/unit gains the repeat-detection coverage. The agent-level tests stay in
agenticCLI, which is where the agent harness lives.
4 vendored files (acceptance.py new) plus the ported unit tests; the agent
loop now withholds a oneshot final answer until the project's test command
passes, against a baseline captured before the first mutating call.

Upstream: bda8fda (fence helper moved to utils.text) + 104a0c9 (the gate).
dashscope/acli/{acceptance,agent}.py, utils/text.py and memory/reflection.py
are byte-identical to the agenticCLI sources modulo the dashscope. import
prefix.

Verification: tests/unit 855 passed / 6 skipped (net +50, all from the new
test_acli_acceptance.py); pre-commit run --all-files green, mypy included.
The agent-loop half of the upstream tests is not ported — dashscope has no
counterpart for tests/ut/test_agent_core_fixes.py, and no dashscope test
constructs an acli Agent.
ACLI_USAGE_FILE names a path a oneshot run flushes its token totals to, so an
external harness can report real usage instead of zeros. Flushed every 15s
rather than once at the end: the runs that most need measuring are the ones a
timeout kills, and those never reach a final write. Unset, nothing is written.

Also carries the 0.6.5 -> 0.6.6 version bump. The Terminal-Bench adapter that
reads the file back lives in agenticCLI's bench/, which is not vendored here.
… evaluated

Synced from agenticCLI 8a2c0f6.

A check that cannot execute was being read as a failure of the change. The
gate only excused a byte-identical result, on the theory that a suite which
genuinely fails prints timings. That holds for a real failure but not for the
two commonest ways a check dies: the shell cannot find the command (127), and
pytest cannot import the project at collection time (exit 2), whose output ends
in `1 error in 0.05s` -- a timing, so the two runs never match.

The discovered command names sys.executable, which is the right interpreter
only when acli is installed into the project environment. Under pipx, `uv tool`,
or a benchmark image that installs the project during its grading step instead,
every run fails collection with differing timings and the gate withholds a
correct answer after burning a full model round-trip.

Read the exit status instead: 127 and pytest's 2/3/4/5 mean the check never
reached the code. pytest's 1 stays a genuine verdict. Requiring the baseline to
be inert too is what keeps this from excusing real damage -- a green baseline
proves the check does run here, so a collection error the agent introduced still
reads as a REGRESSION. The pytest statuses are matched only when the command
names pytest, because cargo reuses 2 for a build failure.
…onour

From agenticCLI d90b1bd. The advertised per-call `model` only ever retargeted
`Agent.model_name`, which picks vision/audio normalization and the truncation
window; the child shares the parent's provider, whose model is fixed at
construction, so it prepared messages for a model that never saw them.

The kwarg stays in `_delegate`'s signature so an already-learned call gets no
TypeError. /subagents now labels `model` and `temperature` as stored but not
applied — they have no reader anywhere.
The flush used to hang off the oneshot output loop, so a run wedged inside
run_stream -- exactly the run a benchmark most needs to measure -- produced
no usage file at all and the harness read back zeros.
The flusher is the part that decides whether a bench run yields numbers at
all, and it had no coverage here. Each new test was checked against a
deliberately broken flusher: narrowing the tick's exception guard, dropping
the join in stop(), and writing the destination in place instead of renaming
each fail exactly one test.
Syncs agenticCLI a2c515f into the vendored tree. HardenedProvider now
marks an error whose retries it already spent with RetriesExhausted,
carrying the original message verbatim so is_retryable_error still
answers True and ProviderChain keeps falling back to the next profile.
Agent skips its turn-level retry for that type only.

Without the mark, every production provider (all of them arrive wrapped
in HardenedProvider) had its budget re-run by the agent: measured 12
HTTP attempts and ~819s of silent wall clock in one turn from a 60s
request_timeout, against an endpoint that accepts and never replies.
Now 4 attempts.

Adds tests/unit/test_acli_provider_retries.py covering the vendored
copy, including the fallback invariant that a reworded marker would
silently break.
Syncs agenticCLI ef05859 into the vendored tree. oneshot now reports an
unabsorbed provider error on stderr and exits 1 rather than letting it escape
asyncio.run as a traceback; RetriesExhausted carries the wall clock its budget
cost, and Agent skips its turn-level retry only when that shows the attempts
were stalling rather than failing fast; a retryable error after the first
chunk keeps the partial turn and continues the loop, since no layer can retry
it without duplicating output.
The module docstring promised unconditionally that Agent never re-pays an
exhausted budget, which stopped being the contract: the exception type alone
cannot tell a stalled endpoint (268s) from a refused connection (~28s), and
telling them apart is what elapsed_sec was added for. Covers a real fast
exhaustion reporting the backoff it spent, and pins the threshold on both
sides plus the unmeasured default, which must not claim the expensive case --
giving up on a number nobody collected would recreate the type-only skip.

Each was checked against a deliberately broken implementation and each break
fails exactly one. The first attempt at breaking elapsed_sec was a false
negative worth recording: the chat() and chat_stream() raise sites are
textually identical, so a single replace patched chat() while the test
exercises chat_stream(), and all 8 passed. Confirming the anchor count is
part of the check, or "did not hit" reads as "test too weak".
Vendored copy of the agenticCLI fix. Answering [a]lways at a run_command
confirmation cached the tool name, so approving one harmless command silently
approved every later shell command in the turn: `curl ... | sh` and
`rm -rf ~/work` both came back "trusted this turn" without a prompt.

Grants now key on the exact stripped command in their own set, both permission
paths consult them, the prompt label states the scope actually granted, and the
echoed command is escaped. The /trust handler goes through the new
trust_snapshot/trust_tool/deny_tool API instead of the executor's privates.
Twelve tests over the vendored executor: a second command still prompts, grant
matching is exact rather than prefix/wildcard/case-folded, a grant with no
usable command string trusts nothing, other tools still cache by name, the turn
reset and snapshot copies hold, and the sync prompt path echoes the command
escaped.

The read-only allowlist and the global permission policy are both neutralised
per-test: each is consulted before the trust cache, so either could answer the
check without the grant ever being exercised.
@lzsweb
lzsweb merged commit e3f1aa7 into main Sep 17, 2026
5 checks passed
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