From 9ec8b3e9a7f6bb818fac747b5feeacacec85cf22 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 1 Sep 2026 16:28:25 +0800 Subject: [PATCH 01/10] =?UTF-8?q?release:=20v1.27.4=20=E2=80=94=20sync=20a?= =?UTF-8?q?cli=20v0.6.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync agenticCLI v0.6.3 into dashscope/acli: - stagnation tracker with read-only hard cap for oneshot runs - auto_approve config + ACLI_AUTO_APPROVE env override - oneshot mode flag wired into the CLI runner --- dashscope/acli/__init__.py | 2 +- dashscope/acli/agent.py | 43 +++++- dashscope/acli/cli/runners.py | 3 +- dashscope/acli/config.py | 10 ++ dashscope/acli/memory/manager.py | 7 +- dashscope/acli/memory/reflection.py | 212 ++++++++++++++++++++++++++++ dashscope/version.py | 2 +- 7 files changed, 274 insertions(+), 5 deletions(-) diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index 4a0debf..2a12f2a 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -3,7 +3,7 @@ import uuid -__version__ = "0.6.2" +__version__ = "0.6.3" # Per-process identifier sent as x-dashscope-sdk-session-id so the # backend can group multi-turn requests from one CLI run. diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index b76364a..a48f2de 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -4,6 +4,7 @@ import asyncio import json +import os import time from pathlib import Path from typing import AsyncIterator @@ -21,6 +22,7 @@ from dashscope.acli.executor import EXEC_ERROR_PREFIX, Executor from dashscope.acli.hooks import HookBus, HookContext, create_hook_bus from dashscope.acli.memory.manager import MemoryManager +from dashscope.acli.memory.reflection import is_readonly_tool_call from dashscope.acli.platforms.base import MemoryProvider from dashscope.acli.prompt_pipeline import PromptContext, default_pipeline from dashscope.acli.providers.base import LLMProvider, LLMResponse, ToolCall @@ -146,6 +148,7 @@ def __init__( hook_bus: HookBus | None = None, memory_manager: MemoryManager | None = None, json_mode: bool = False, + oneshot: bool = False, ): self.provider = provider self.executor = executor @@ -155,6 +158,15 @@ def __init__( self.allowed_tools = allowed_tools self.hook_bus = hook_bus or create_hook_bus() self.json_mode = json_mode + self.oneshot = oneshot + # Hard stop for read-only stalls in oneshot (benchmark/script) mode. + # Env override: ACLI_READONLY_HARD_CAP (0 disables). + try: + self.readonly_hard_cap = int( + os.environ.get("ACLI_READONLY_HARD_CAP", "40"), + ) + except ValueError: + self.readonly_hard_cap = 40 # Load custom system prompt: workspace .acli/system-prompt.md first, # then global ~/.acli/system-prompt.md, then built-in default. # runners.py pre-populates system_prompt via _load_system_prompt(); @@ -390,6 +402,14 @@ def _reflection_section(self) -> str: return tracker.get_reflection_hint() return "" + def _stagnation_section(self) -> str: + """Inject a convergence nudge on long read-only streaks.""" + tracker = self.memory_manager.session.stagnation + if not tracker.needs_nudge(): + return "" + cap = self.readonly_hard_cap if self.oneshot else None + return tracker.get_stagnation_hint(hard_cap=cap) + async def _recall_memory(self, user_input) -> str: """Search for relevant profile info and format as context. @@ -486,6 +506,7 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # Reset reflection tracker for new turn self.memory_manager.session.reflection.reset() + self.memory_manager.session.stagnation.reset() # Recall relevant memories memory_context = await self._recall_memory(user_input_text) @@ -548,9 +569,26 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # The hint is appended, keeping the cache-friendly prefix stable. reflection_hint = self._reflection_section() messages_with_system[0]["content"] = ( - system_prompt + reflection_hint + system_prompt + reflection_hint + self._stagnation_section() ) + # Hard stop for read-only stalls in oneshot mode: finish with + # whatever is on hand instead of inspecting forever. + if ( + self.oneshot + and self.readonly_hard_cap + and self.memory_manager.session.stagnation.readonly_streak + >= self.readonly_hard_cap + ): + stop_msg = ( + f"\n[Stopped: {self.readonly_hard_cap} consecutive " + "read-only tool calls with no progress. Ending the run " + "to avoid an infinite verification loop.]\n" + ) + yield stop_msg + last_content = stop_msg + break + full_content = "" full_reasoning = "" tool_calls: list[ToolCall] = [] @@ -1018,6 +1056,9 @@ async def _execute_tool(self, tool_call: ToolCall) -> str: # is neutral and must not feed the consecutive-failure counter. if not result.startswith(_TOOL_CANCEL_PREFIXES): self.memory_manager.record_tool_execution(tool_call.name, success) + self.memory_manager.session.stagnation.record( + is_readonly_tool_call(tool_call.name, tool_call.arguments), + ) if success: self._turn_tool_successes += 1 else: diff --git a/dashscope/acli/cli/runners.py b/dashscope/acli/cli/runners.py index c34fdfd..6d1ff04 100644 --- a/dashscope/acli/cli/runners.py +++ b/dashscope/acli/cli/runners.py @@ -67,13 +67,14 @@ async def _run_oneshot(config: Config, prompt: str): sys.exit(1) provider = get_provider_chain(config) - executor = Executor() + executor = Executor(auto_approve=config.auto_approve) agent = Agent( provider=provider, executor=executor, max_turns=config.max_turns, provider_name=config.provider, model_name=config.model, + oneshot=True, ) # Pin parent agent ref for local.subagent / local.delegate BEFORE platform diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index 3ff97c1..82d1fed 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -542,6 +542,16 @@ def _load_workspace_from(self, path: Path): self.max_turns = int(data["max_turns"]) except (ValueError, TypeError): pass + if "auto_approve" in data: + val = str(data["auto_approve"]).lower() + self.auto_approve = val in ("true", "1", "yes") + # Env override for headless/benchmark use + if os.environ.get("ACLI_AUTO_APPROVE", "").lower() in ( + "1", + "true", + "yes", + ): + self.auto_approve = True if "session_persist" in data: val = str(data["session_persist"]).lower() self.session_persist = val not in ("false", "0", "no") diff --git a/dashscope/acli/memory/manager.py b/dashscope/acli/memory/manager.py index 5f5d5ad..dfcfa74 100644 --- a/dashscope/acli/memory/manager.py +++ b/dashscope/acli/memory/manager.py @@ -20,7 +20,10 @@ from dashscope.acli.memory.experience import ExperienceTracker from dashscope.acli.memory.plan import PlanTracker -from dashscope.acli.memory.reflection import ReflectionTracker +from dashscope.acli.memory.reflection import ( + ReflectionTracker, + StagnationTracker, +) from dashscope.acli.memory.tool_chains import ToolChainLibrary from dashscope.acli.memory.trace import TraceLogger @@ -38,12 +41,14 @@ def __init__(self, workspace_dir: Path): self.plan = PlanTracker() self.reflection = ReflectionTracker() + self.stagnation = StagnationTracker() self.tool_chains = ToolChainLibrary(self.session_dir) def reset(self) -> None: """Reset session memory (called at session start).""" self.plan.clear_plan() self.reflection.reset() + self.stagnation.reset() class PersistentMemory: diff --git a/dashscope/acli/memory/reflection.py b/dashscope/acli/memory/reflection.py index 416caf1..0be6735 100644 --- a/dashscope/acli/memory/reflection.py +++ b/dashscope/acli/memory/reflection.py @@ -6,6 +6,163 @@ from __future__ import annotations +import re +import shlex +from typing import Any + +# Tools that never change local state. +_READONLY_TOOLS = frozenset( + { + "read_file", + "search_files", + "list_directory", + "memory_search", + "web_search", + "image_search", + }, +) + +# Shell verbs whose output is purely informational. +_READONLY_VERBS = frozenset( + { + "cat", + "head", + "tail", + "grep", + "egrep", + "fgrep", + "rg", + "find", + "ls", + "wc", + "file", + "stat", + "du", + "df", + "which", + "command", + "echo", + "printf", + "pwd", + "date", + "uname", + "env", + "printenv", + "diff", + "md5sum", + "sha1sum", + "sha256sum", + "basename", + "dirname", + "id", + "hostname", + "sw_vers", + "ps", + "pgrep", + "jq", + "sort", + "uniq", + "tr", + "cut", + "column", + "true", + "test", + "free", + "uptime", + "nproc", + "lsblk", + "dig", + "nslookup", + "host", + "whoami", + "realpath", + "type", + }, +) + +# Anything containing these marks a mutating command. Deliberately +# conservative: a false positive only suppresses the stagnation nudge. +# Container CLIs (docker/podman/colima) are classified by subcommand below. +_WRITE_MARKERS = re.compile( + r"(?:^|[\s;|&(])" + r"(?:rm|mv|cp|mkdir|rmdir|touch|chmod|chown|ln|dd|truncate|" + r"pip|pip3|uv|npm|npx|pnpm|brew|apt|apt-get|yum|dnf|pacman|" + r"curl|wget|git|kill|killall|" + r"make|cargo|go|gradle|mvn|python|python3|node|setsid|nohup|tmux|" + r"tar|zip|unzip|gzip|sed|awk|patch|tee)\b", +) + +# Read-only subcommands for container runtimes. +_CONTAINER_READONLY_SUBCMDS = { + "docker": {"ps", "images", "logs", "inspect", "stats", "version", "info"}, + "podman": {"ps", "images", "logs", "inspect", "stats", "version", "info"}, + "colima": {"status", "list", "version"}, +} + +# Diagnostic redirections that never change local state. +_DEVNULL_REDIRECT = re.compile(r"(\d?>>?|&>)\s*/dev/null|2>&1") + +_REDIRECT = re.compile(r"[^>&]\s*>[^&]|^\s*>|>>") + + +def _readonly_shell_segment(segment: str) -> bool: + """True if every stage of one ';'/&&-free segment is a read verb.""" + stages = segment.split("|") + for stage in stages: + stage = stage.strip() + if not stage: + continue + try: + tokens = shlex.split(stage) + except ValueError: + return False + tokens = [t for t in tokens if not t.startswith("-")] + if not tokens: + return False + verb = tokens[0] + if verb == "env": + tokens = [t for t in tokens[1:] if "=" not in t] + if not tokens: + continue + verb = tokens[0] + if verb in _READONLY_VERBS: + continue + if verb in _CONTAINER_READONLY_SUBCMDS: + subcmds = _CONTAINER_READONLY_SUBCMDS[verb] + if len(tokens) > 1 and tokens[1] in subcmds: + continue + return False + return False + return True + + +def is_readonly_tool_call( # pylint: disable=too-many-return-statements + tool_name: str, + arguments: dict[str, Any] | None, +) -> bool: + """Classify a tool call as read-only (no local state change). + + Conservative: anything ambiguous is treated as mutating so the + stagnation nudge never fires on genuinely productive work. + """ + if tool_name in _READONLY_TOOLS: + return True + if tool_name.startswith("mcp_"): + return False + if tool_name != "run_command": + return False + command = (arguments or {}).get("command", "") + if not command or not isinstance(command, str): + return False + # Strip benign diagnostic redirections before scanning for writes. + command = _DEVNULL_REDIRECT.sub(" ", command) + if _REDIRECT.search(command) or "tee " in command: + return False + if _WRITE_MARKERS.search(command): + return False + segments = re.split(r";|&&|\|\|", command) + return all(_readonly_shell_segment(seg) for seg in segments if seg.strip()) + class ReflectionTracker: """Tracks consecutive failures and provides reflection hints.""" @@ -67,3 +224,58 @@ def reset(self) -> None: """Reset the tracker for a new turn.""" self.consecutive_failures = 0 self.last_failed_tools = [] + + +class StagnationTracker: + """Detects read-only stalls: long runs of inspection calls with no + action that changes state. + + ReflectionTracker only fires on *failures*; a loop of successful + grep/cat/check calls resets it every time. This tracker closes that + blind spot by counting consecutive read-only calls. + """ + + def __init__(self, threshold: int = 8): + self.threshold = threshold + self.readonly_streak = 0 + + def record(self, readonly: bool) -> None: + if readonly: + self.readonly_streak += 1 + else: + self.readonly_streak = 0 + + def needs_nudge(self) -> bool: + return self.readonly_streak >= self.threshold + + def get_stagnation_hint(self, hard_cap: int | None = None) -> str: + """Imperative convergence nudge; escalates as the streak grows.""" + if not self.needs_nudge(): + return "" + n = self.readonly_streak + lines = [ + "\n\n## 🛑 Stagnation warning", + ( + f"{n} consecutive read-only tool calls with no change " + "executed (no writes, no builds, no commands with side " + "effects). You are verifying, not progressing." + ), + "Required, in order:", + "1. STOP gathering information — you already have enough.", + "2. Execute the FIRST concrete action NOW " + "(write/edit/run the actual change).", + ( + "3. If you are genuinely blocked, ask the user one " + "specific question instead of checking again." + ), + "Do NOT announce an action and then inspect more instead.", + ] + if hard_cap and hard_cap > n: + lines.append( + f"Hard stop in {hard_cap - n} more read-only calls: " + "produce your best-effort result and finish.", + ) + return "\n".join(lines) + + def reset(self) -> None: + self.readonly_streak = 0 diff --git a/dashscope/version.py b/dashscope/version.py index 7ad7e41..dbdebdc 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "1.27.3" +__version__ = "1.27.4" From 651c239a9ee35c0bc3d1f386cc840ba635123a2d Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 2 Sep 2026 15:53:08 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(acli):=20sync=20agenticCLI=20?= =?UTF-8?q?=E2=80=94=20budget-aware=20nudge=20+=20retry=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - convergence nudge becomes budget-aware for autonomous runs - HardenedProvider: capped exponential backoff (3 retries, 4s base, 16s cap) to survive transient model-API failures in long runs --- dashscope/acli/agent.py | 181 +++++++++++++++++++++----- dashscope/acli/memory/reflection.py | 56 ++++++++ dashscope/acli/providers/hardening.py | 23 +++- dashscope/acli/providers/profile.py | 2 +- 4 files changed, 220 insertions(+), 42 deletions(-) diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index a48f2de..59eaf49 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -22,10 +22,14 @@ from dashscope.acli.executor import EXEC_ERROR_PREFIX, Executor from dashscope.acli.hooks import HookBus, HookContext, create_hook_bus from dashscope.acli.memory.manager import MemoryManager -from dashscope.acli.memory.reflection import is_readonly_tool_call +from dashscope.acli.memory.reflection import ( + convergence_hint, + is_readonly_tool_call, +) from dashscope.acli.platforms.base import MemoryProvider from dashscope.acli.prompt_pipeline import PromptContext, default_pipeline from dashscope.acli.providers.base import LLMProvider, LLMResponse, ToolCall +from dashscope.acli.providers.hardening import is_retryable_error from dashscope.acli.skills import get_skill_manager, skills_summary_for_llm from dashscope.acli.tools.registry import PermissionLevel, registry from dashscope.acli.utils import ( @@ -167,6 +171,37 @@ def __init__( ) except ValueError: self.readonly_hard_cap = 40 + # Turn-level retry: a transient model-API failure that exhausts the + # provider's own retries must not kill a long run. Re-run the current + # turn (only when nothing was streamed yet, to avoid duplicate output) + # up to turn_retry_max times with capped exponential backoff. + # Env override: ACLI_TURN_RETRY_MAX (0 disables). + try: + self.turn_retry_max = int( + os.environ.get("ACLI_TURN_RETRY_MAX", "2"), + ) + except ValueError: + self.turn_retry_max = 2 + self.turn_retry_delay = 5.0 + self.turn_retry_backoff_cap = 30.0 + # Budget-aware convergence nudge (oneshot only): as the turn budget is + # consumed, tell the agent to switch approach if plateaued, lock in an + # already-passing result, then finalize. Covers productive-but-stuck + # iteration that ReflectionTracker (failures) and StagnationTracker + # (read-only stalls) both miss. Env: ACLI_CONVERGE_SOFT/_HARD (ratios; + # soft >= 1.0 disables). + try: + self.converge_soft_ratio = float( + os.environ.get("ACLI_CONVERGE_SOFT", "0.6"), + ) + except ValueError: + self.converge_soft_ratio = 0.6 + try: + self.converge_hard_ratio = float( + os.environ.get("ACLI_CONVERGE_HARD", "0.85"), + ) + except ValueError: + self.converge_hard_ratio = 0.85 # Load custom system prompt: workspace .acli/system-prompt.md first, # then global ~/.acli/system-prompt.md, then built-in default. # runners.py pre-populates system_prompt via _load_system_prompt(); @@ -179,6 +214,26 @@ def __init__( ) self.system_prompt = _compose_system_prompt(_load_system_prompt()) + if self.oneshot: + self.system_prompt = (self.system_prompt or SYSTEM_PROMPT) + ( + "\n\nTask execution policy (autonomous mode):\n" + "- If the request has a measurable acceptance criterion " + "(numeric threshold, size/time limit, test suite, " + "similarity/accuracy target), iterate until it is met: " + "implement -> measure against the criterion -> refine. Do " + "not stop at the first version that merely runs; optimize " + "toward the target — but once the criterion is met, lock it " + "in and finish rather than risk regressing a passing result. " + "Heed any budget-check hint: it tells you when to converge " + "or switch to a different approach.\n" + "- Before reporting completion, self-verify with the same " + "check the requester will apply (run the tests, recompute " + "the metric, validate the format). Fix what fails instead " + "of handing back a near miss.\n" + "- When a check fails close to the target, treat it as " + "actionable feedback: change the approach based on what " + "the measurement shows, then re-measure." + ) # Discover project instructions from CWD (rules.jsonl, # .cursorrules, etc.) from dashscope.acli.prompt import discover_project_instructions @@ -410,6 +465,21 @@ def _stagnation_section(self) -> str: cap = self.readonly_hard_cap if self.oneshot else None return tracker.get_stagnation_hint(hard_cap=cap) + def _convergence_section(self, loop_index: int) -> str: + """Inject a budget-aware converge/switch nudge in oneshot runs. + + Fires on the fraction of the turn budget consumed, covering the + productive-but-plateaued loop that failure/read-only trackers miss. + """ + if not self.oneshot: + return "" + return convergence_hint( + loop_index, + self.max_turns, + self.converge_soft_ratio, + self.converge_hard_ratio, + ) + async def _recall_memory(self, user_input) -> str: """Search for relevant profile info and format as context. @@ -569,7 +639,10 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # The hint is appended, keeping the cache-friendly prefix stable. reflection_hint = self._reflection_section() messages_with_system[0]["content"] = ( - system_prompt + reflection_hint + self._stagnation_section() + system_prompt + + reflection_hint + + self._stagnation_section() + + self._convergence_section(_loop_i) ) # Hard stop for read-only stalls in oneshot mode: finish with @@ -596,41 +669,79 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: last_chunk = None llm_start = time.monotonic() - async for chunk in self.provider.chat_stream( - normalize_for_model(messages_with_system, self.model_name), - tools_schema, - response_format=( - {"type": "json_object"} if self.json_mode else None - ), - ): - if chunk.delta_content: - full_content += chunk.delta_content - - yield chunk.delta_content - - if chunk.delta_reasoning_content: - full_reasoning += chunk.delta_reasoning_content - - if chunk.tool_calls: - for tc in chunk.tool_calls: - key = ( - tc.name, - json.dumps( - tc.arguments, - sort_keys=True, - ensure_ascii=False, + # Turn-level retry around the streaming call. A transient + # model-API failure that survives the provider's own retries is + # re-attempted here so a single network blip cannot abort a long + # run. We only retry when nothing has been streamed yet; once + # content is yielded, a retry would duplicate output, so we let + # the error propagate. + turn_attempt = 0 + while True: + full_content = "" + full_reasoning = "" + tool_calls = [] + seen_tool_calls = set() + last_chunk = None + emitted = False + try: + async for chunk in self.provider.chat_stream( + normalize_for_model( + messages_with_system, + self.model_name, + ), + tools_schema, + response_format=( + {"type": "json_object"} if self.json_mode else None + ), + ): + if chunk.delta_content: + emitted = True + full_content += chunk.delta_content + + yield chunk.delta_content + + if chunk.delta_reasoning_content: + emitted = True + full_reasoning += chunk.delta_reasoning_content + + if chunk.tool_calls: + emitted = True + for tc in chunk.tool_calls: + key = ( + tc.name, + json.dumps( + tc.arguments, + sort_keys=True, + ensure_ascii=False, + ), + ) + if key in seen_tool_calls: + continue + seen_tool_calls.add(key) + tool_calls.append(tc) + + # Record token usage from the last chunk + + if chunk.usage: + self.executor.record_api_call(chunk.usage) + last_chunk = chunk + break + except Exception as e: + if ( + not emitted + and is_retryable_error(e) + and turn_attempt < self.turn_retry_max + ): + turn_attempt += 1 + await asyncio.sleep( + min( + self.turn_retry_delay + * (2 ** (turn_attempt - 1)), + self.turn_retry_backoff_cap, ), ) - if key in seen_tool_calls: - continue - seen_tool_calls.add(key) - tool_calls.append(tc) - - # Record token usage from the last chunk - - if chunk.usage: - self.executor.record_api_call(chunk.usage) - last_chunk = chunk + continue + raise # Log LLM call trace self.executor.record_prompt_composition( diff --git a/dashscope/acli/memory/reflection.py b/dashscope/acli/memory/reflection.py index 0be6735..cc95209 100644 --- a/dashscope/acli/memory/reflection.py +++ b/dashscope/acli/memory/reflection.py @@ -279,3 +279,59 @@ def get_stagnation_hint(self, hard_cap: int | None = None) -> str: def reset(self) -> None: self.readonly_streak = 0 + + +def convergence_hint( + loop_index: int, + max_turns: int, + soft_ratio: float = 0.6, + hard_ratio: float = 0.85, +) -> str: + """Budget-aware converge/switch nudge for autonomous (oneshot) runs. + + Third non-convergence detector. ReflectionTracker fires on *failures*, + StagnationTracker on *read-only stalls*; this covers the case where the + agent does productive, successful, mutating work that nonetheless + plateaus and burns the whole turn budget without reaching the goal + (e.g. a renderer stuck just under a similarity threshold). Keyed on the + fraction of the turn budget consumed, so it needs no task metric. + + Returns "" below ``soft_ratio``; a switch-approach-or-lock-in nudge in + the soft band; a finalize-now nudge at/after ``hard_ratio``. A + ``soft_ratio >= 1.0`` disables it; ``max_turns <= 0`` is a safe no-op. + """ + if max_turns <= 0 or soft_ratio >= 1.0: + return "" + used = loop_index + 1 + frac = used / max_turns + remaining = max(0, max_turns - used) + if frac >= hard_ratio: + return ( + "\n\n## ⏳ Budget nearly exhausted — converge now\n" + f"You have used {used}/{max_turns} iterations " + f"({remaining} left).\n" + "Stop refining. With your remaining turns:\n" + "1. Keep the best version you have produced so far.\n" + "2. Run ONE final verification against the acceptance " + "criterion.\n" + "3. Write the final result and finish.\n" + "Do NOT start a new approach now — there is no budget left to " + "debug it." + ) + if frac >= soft_ratio: + return ( + "\n\n## ⏳ Budget check — converge or switch approach\n" + f"You have used {used}/{max_turns} iterations " + f"({remaining} left).\n" + "Assess progress honestly: is your result measurably closer to " + "the goal than it was several iterations ago?\n" + "- If NO (plateaued): this approach is not working. Switch to a " + "structurally different strategy now instead of tweaking the " + "same one.\n" + "- If YES (improving): continue, but reserve the last ~15% of " + "your budget to finalize and self-verify.\n" + "- If the criterion is ALREADY met: stop optimizing, do one " + "final self-verify, and finish — do not risk regressing a " + "passing result." + ) + return "" diff --git a/dashscope/acli/providers/hardening.py b/dashscope/acli/providers/hardening.py index 70e8caa..8a57def 100644 --- a/dashscope/acli/providers/hardening.py +++ b/dashscope/acli/providers/hardening.py @@ -123,12 +123,23 @@ class HardenedProvider: def __init__( self, provider: LLMProvider, - max_retries: int = 2, - retry_delay: float = 0.5, + max_retries: int = 3, + retry_delay: float = 4.0, + max_backoff: float = 16.0, ): self.provider = provider self.max_retries = max_retries self.retry_delay = retry_delay + self.max_backoff = max_backoff + + def _backoff(self, attempt: int) -> float: + """Capped exponential backoff: retry_delay * 2**attempt, ≤ max_backoff. + + With defaults (retry_delay=4, max_backoff=16, max_retries=3) the three + retry sleeps total ~28s — enough to ride out a transient model-API + blip without blocking long on a real outage. + """ + return min(self.retry_delay * (2**attempt), self.max_backoff) async def chat( self, @@ -149,7 +160,7 @@ async def chat( except Exception as e: last_error = e if attempt < self.max_retries and is_retryable_error(e): - await asyncio.sleep(self.retry_delay * (attempt + 1)) + await asyncio.sleep(self._backoff(attempt)) continue raise @@ -157,7 +168,7 @@ async def chat( if attempt < self.max_retries: # Retry once with a recovery hint appended. attempt_messages = list(messages) + [_EMPTY_RECOVERY_HINT] - await asyncio.sleep(self.retry_delay * (attempt + 1)) + await asyncio.sleep(self._backoff(attempt)) continue return resp @@ -185,7 +196,7 @@ async def chat_stream( if not emitted_anything and attempt < self.max_retries: # No chunks at all; treat like empty response. attempt_messages = list(messages) + [_EMPTY_RECOVERY_HINT] - await asyncio.sleep(self.retry_delay * (attempt + 1)) + await asyncio.sleep(self._backoff(attempt)) continue return except Exception as e: @@ -198,6 +209,6 @@ async def chat_stream( ): # Retry with same messages for transient failures. attempt_messages = messages - await asyncio.sleep(self.retry_delay * (attempt + 1)) + await asyncio.sleep(self._backoff(attempt)) continue raise diff --git a/dashscope/acli/providers/profile.py b/dashscope/acli/providers/profile.py index 43296c6..83a4e21 100644 --- a/dashscope/acli/providers/profile.py +++ b/dashscope/acli/providers/profile.py @@ -56,7 +56,7 @@ class ProviderProfile: base_url: str | None = None timeout: float = 120.0 protocol: str = "openai" - max_retries: int = 2 + max_retries: int = 3 def _host_of(url: str | None) -> str: From f30431266e2f33d3a66420c01dc0f9f2594598db Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 2 Sep 2026 18:11:41 +0800 Subject: [PATCH 03/10] fix(acli): render markdown tables in TUI + restore input draft around confirm prompts --- dashscope/acli/ui/tui.py | 67 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index c8b0ad4..4ab806b 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -41,6 +41,14 @@ # guards against over-frequent flushes on bursty bulk output) _STREAM_FLUSH_INTERVAL = 0.8 if _IS_JEDITERM else 0.3 _STREAM_FLUSH_LINES = 400 if _IS_JEDITERM else 20 + +# Markdown table detection for streaming output. Raw table source from the +# model is not visually aligned (spec doesn't require it) and mixes CJK / +# wide emoji, so pipe text shown as-is looks broken; complete table blocks +# are instead rendered via rich.markdown.Markdown, which lays columns out by +# display width (cell_len) and handles CJK correctly. +_TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$") +_TABLE_SEP_RE = re.compile(r"^\s*\|?[\s:\-|]+\|?\s*$") # Wheel batching window: full repaints are costly on JediTerm; trade # frame rate for stability _WHEEL_FLUSH_INTERVAL = 0.12 if _IS_JEDITERM else 0.03 @@ -1668,6 +1676,9 @@ def __init__( # running loop) so a second confirm cannot clobber _confirm_future. self._confirm_lock: asyncio.Lock | None = None self._supplement_future: asyncio.Future | None = None + # Draft text parked out of the input box while a confirmation + # prompt owns the box; restored when the prompt resolves. + self._confirm_saved_draft: str | None = None # Inline input state — for input() calls without modal popups self._inline_input_lock = threading.Lock() self._inline_input_future: threading.Event | None = None @@ -1873,7 +1884,6 @@ def on_command_input_submitted( # Check if we're waiting for confirmation response if self._confirm_future and not self._confirm_future.done(): - input_widget.text = "" choice = command.lower() or "y" # Empty = default to yes # Dangerous ops accept only y/n, matching the sync path (no # always-trust granted) @@ -1883,9 +1893,13 @@ def on_command_input_submitted( else ("y", "n", "u", "a", "s") ) if choice in valid_choices: + input_widget.text = "" self._confirm_future.set_result(choice) self._confirm_future = None else: + # Remove the invalid answer only; the user's draft was + # already parked into _confirm_saved_draft at prompt time. + input_widget.text = "" output = self.query_one("#output", RichLog) if self._confirm_is_dangerous: output.write( @@ -2192,6 +2206,11 @@ async def _prompt_confirm( # Focus input and wait input_widget = self.query_one("#command-input", CommandInput) + # Park whatever the user was drafting out of the box so the + # confirmation owns a clean input; it is restored in finally. + self._confirm_saved_draft = input_widget.text or None + if self._confirm_saved_draft: + input_widget.text = "" input_widget.focus() self._confirm_is_dangerous = is_dangerous self._confirm_future = asyncio.get_running_loop().create_future() @@ -2222,6 +2241,12 @@ async def _prompt_confirm( return "n" finally: self._confirm_future = None + # Give the user's draft back (skip if the box already holds + # newer content, e.g. typing resumed on another path). + saved_draft = self._confirm_saved_draft + self._confirm_saved_draft = None + if saved_draft and not input_widget.text: + input_widget.text = saved_draft # Restore spinner text spinner.text = old_spinner_text if not was_active: @@ -2854,6 +2879,9 @@ async def run_agent(self, command: str) -> None: buffer = "" full_output = "" pending_lines: list[str] = [] + # Consecutive markdown table rows are held back here and rendered + # as a real table once the block ends (see _flush_table_block). + table_block: list[str] = [] loop = asyncio.get_event_loop() last_flush = loop.time() @@ -2866,6 +2894,26 @@ def _flush_lines() -> None: ) pending_lines.clear() + def _flush_table_block() -> None: + # Render a buffered markdown table block as a real table. + # Only a well-formed GFM table (>=2 rows, 2nd row is the + # |---| separator) is rendered; anything else falls back to + # plain cyan text so we never mangle non-table pipe lines. + if not table_block: + return + lines = table_block[:] + table_block.clear() + sep = lines[1].strip() if len(lines) >= 2 else "" + if len(lines) >= 2 and "-" in sep and _TABLE_SEP_RE.match(sep): + try: + from rich.markdown import Markdown + + self._write_output(Markdown("\n".join(lines))) + return + except Exception: + pass # fall back to raw text below + pending_lines.extend(lines) + async for chunk in self.agent.run_stream(command): if not chunk: continue @@ -2874,7 +2922,9 @@ def _flush_lines() -> None: # ... --- diff ---) stripped = chunk.strip() if stripped.startswith("[") and "] →" in stripped: - # Flush any pending text buffer first + # Flush any pending text buffer first (a still-open + # table block must land before the tool trail) + _flush_table_block() if buffer: pending_lines.append(buffer) full_output += buffer @@ -2890,8 +2940,15 @@ def _flush_lines() -> None: # corresponds to a real line of output, not a fixed chunk size. while "\n" in buffer: line, _, rest = buffer.partition("\n") - pending_lines.append(line) buffer = rest + if _TABLE_ROW_RE.match(line): + # Hold table rows back until the block ends. + table_block.append(line) + continue + if table_block: + # First non-table line terminates the block. + _flush_table_block() + pending_lines.append(line) now = loop.time() # Every flush scrolls and repaints the whole visible area; # too small a window (e.g. 0.1s) still causes several @@ -2913,6 +2970,10 @@ def _flush_lines() -> None: getattr(self.agent, "turn_skills", 0), ) + if table_block: + # Table block still open at end of stream (no trailing + # newline after the last row is common). + _flush_table_block() _flush_lines() # Write remaining partial line if buffer: From a11a4c8444c278e160f772e0a00c02a6b49583f2 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 2 Sep 2026 18:11:45 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(acli):=20confirm=5Fmode=3Ddangerous?= =?UTF-8?q?=20=E2=80=94=20only=20risky=20(DANGEROUS)=20ops=20prompt=20for?= =?UTF-8?q?=20confirmation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New config option confirm_mode: "dangerous" (default) | "all" - CONFIRM-level tools (run_command, write_file, ...) auto-pass in dangerous mode - Read-only commands (grep/ls/cat/...) already skip prompts via is_safe_readonly - Policy deny rules still take precedence over auto-pass - DANGEROUS tools (delete_file, delete_directory) always prompt --- dashscope/acli/cli/repl.py | 5 ++++- dashscope/acli/cli/runners.py | 10 ++++++++-- dashscope/acli/config.py | 8 ++++++++ dashscope/acli/executor.py | 26 +++++++++++++++++++++++++- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/dashscope/acli/cli/repl.py b/dashscope/acli/cli/repl.py index a2109a7..fa5cbf2 100644 --- a/dashscope/acli/cli/repl.py +++ b/dashscope/acli/cli/repl.py @@ -102,7 +102,10 @@ async def _run_loop(config: Config): sync_extensions_into_catalog(_ext) # _load_plugins() — deprecated; plugins load via the hooks mechanism provider = get_provider_chain(config) - executor = Executor(auto_approve=config.auto_approve) + executor = Executor( + auto_approve=config.auto_approve, + confirm_mode=config.confirm_mode, + ) # Initialize memory # Memory client managed by handlers_profile diff --git a/dashscope/acli/cli/runners.py b/dashscope/acli/cli/runners.py index 6d1ff04..e4eed70 100644 --- a/dashscope/acli/cli/runners.py +++ b/dashscope/acli/cli/runners.py @@ -67,7 +67,10 @@ async def _run_oneshot(config: Config, prompt: str): sys.exit(1) provider = get_provider_chain(config) - executor = Executor(auto_approve=config.auto_approve) + executor = Executor( + auto_approve=config.auto_approve, + confirm_mode=config.confirm_mode, + ) agent = Agent( provider=provider, executor=executor, @@ -279,7 +282,10 @@ def _run_tui_mode(config: Config): # CLI mode uses the full provider chain so temporary failures can fall back # to configured alternatives instead of dying immediately. provider = get_provider_chain(config) - executor = Executor(auto_approve=config.auto_approve) + executor = Executor( + auto_approve=config.auto_approve, + confirm_mode=config.confirm_mode, + ) from dashscope.acli.agents.delegate import ( set_config as set_delegate_config, diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index 82d1fed..edefd29 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -241,6 +241,10 @@ class Config: openai_api_key: str = "" base_url: str = "" auto_approve: bool = False + # Which tool executions prompt for confirmation: + # "all" - CONFIRM and DANGEROUS both prompt (classic) + # "dangerous" - only DANGEROUS prompts; CONFIRM auto-passes + confirm_mode: str = "dangerous" max_turns: int = 50 timeout: int = 30 mcp_servers: list[MCPServerConfig] = field(default_factory=list) @@ -545,6 +549,10 @@ def _load_workspace_from(self, path: Path): if "auto_approve" in data: val = str(data["auto_approve"]).lower() self.auto_approve = val in ("true", "1", "yes") + if "confirm_mode" in data: + val = str(data["confirm_mode"]).lower() + if val in ("all", "dangerous"): + self.confirm_mode = val # Env override for headless/benchmark use if os.environ.get("ACLI_AUTO_APPROVE", "").lower() in ( "1", diff --git a/dashscope/acli/executor.py b/dashscope/acli/executor.py index d552e97..d818257 100644 --- a/dashscope/acli/executor.py +++ b/dashscope/acli/executor.py @@ -35,8 +35,15 @@ class Executor: - def __init__(self, auto_approve: bool = False): + def __init__( + self, + auto_approve: bool = False, + confirm_mode: str = "dangerous", + ): self.auto_approve = auto_approve + # "all": CONFIRM and DANGEROUS both prompt; "dangerous": only + # DANGEROUS prompts (CONFIRM-level tools auto-pass). + self.confirm_mode = confirm_mode # Trust cache scoped to ONE conversation turn (a single user prompt # plus the agent loop that answers it). Agent.run / run_stream clear # these in a finally block on completion or abort. DANGEROUS tools @@ -214,6 +221,14 @@ async def _async_check_permission( and policy.check_command(cmd) == "deny" ): return False + # confirm_mode="dangerous": only risky (DANGEROUS) operations + # prompt; CONFIRM-level tools auto-pass. Policy deny rules + # above have already been honored. + if ( + self.confirm_mode == "dangerous" + and tool_def.permission == PermissionLevel.CONFIRM + ): + return True # Auto-pass read-only shell commands if tool_def.name == "run_command": cmd = arguments.get("command", "") @@ -284,6 +299,15 @@ def _check_permission( console.print("[dim red]✗ command denied by policy[/dim red]") return False + # confirm_mode="dangerous": only risky (DANGEROUS) operations + # prompt; CONFIRM-level tools auto-pass. Policy deny rules + # above have already been honored. + if ( + self.confirm_mode == "dangerous" + and tool_def.permission == PermissionLevel.CONFIRM + ): + return True + # Auto-pass read-only shell commands (grep, ls, find, git status, …). # The classifier lives in shell.py since it owns the shell semantics; # see is_safe_readonly for the allow/deny rules. Anything not From 2ad72160a301ebe07af7b46be7bd4ec6a5f1084d Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 3 Sep 2026 16:51:15 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat(acli):=20sync=20agenticCLI=20?= =?UTF-8?q?=E2=80=94=20post-change=20verification=20rule=20+=20plan=20wiri?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors upstream 4562c7d and picks up the 0.6.4 version bump (SDK version stays 1.27.4, which is still unreleased on this branch). Coding tasks were ending without a single test run because the prompt asked for it: rule 3 said to edit code "without stating a plan first", the Concise bullet banned "test this / verify" as filler, and another bullet forbade reporting what had just been done. - rule 18: run the tests covering the change, or add a focused test when nothing covers it, and report command + pass/fail - rule 4: multi-step work uses create_plan/complete_step, already registered and echoed as "## Current plan" but never mentioned before Tree verified byte-identical to upstream src/acli after the import rewrite; black passed and every dashscope.acli module imports. --- dashscope/acli/__init__.py | 2 +- dashscope/acli/agent.py | 29 ++++++++++++++++++++--------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index 2a12f2a..d8c0b17 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -3,7 +3,7 @@ import uuid -__version__ = "0.6.3" +__version__ = "0.6.4" # Per-process identifier sent as x-dashscope-sdk-session-id so the # backend can group multi-turn requests from one CLI run. diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index 59eaf49..454f469 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -80,11 +80,16 @@ def _classify_outcome(successes: int, failures: int) -> str: 2. Only use file paths inside the current working directory, paths explicitly given by the user, or paths relative to the CWD. Never invent, guess, or reuse paths seen in training data (e.g. /Users/xxx/...) -3. For clear-intent requests (git commit, read file, search, edit code), call - tools directly without stating a plan first. Only for irreversible - operations (delete, overwrite, force push) or complex multi-step tasks, - briefly explain and wait for user confirmation -4. If a task needs multiple steps, execute them one by one and report progress +3. For clear-intent requests (git commit, read file, search), call tools + directly without stating a plan first. For code changes, go straight to + the edit — but you must verify it afterwards (rule 18). Only for + irreversible operations (delete, overwrite, force push) or complex + multi-step tasks, briefly explain and wait for user confirmation +4. If a task needs several steps (refactor, new feature, multi-file change), + call create_plan with the goal and its steps first, then execute them one + by one, marking each with complete_step — the plan is echoed back to you + as "## Current plan" on every later turn. For a 2-3 step task, skip the + plan and just do it 5. When a tool call fails, do not retry the same call; report the error to the user with a suggestion 6. Tools prefixed [MCP:xxx] come from Bailian MCP services; call them directly @@ -116,10 +121,15 @@ def _classify_outcome(successes: int, failures: int) -> str: but lengthy subtask (whole-file review, multi-file scan) → call subagent_invoke for isolated execution and take back only the conclusion. Do not grind through them serially yourself +18. **Verify code changes before reporting done.** Run the tests that cover + what you touched; if nothing covers it, add a focused test for the new + behaviour and run that. Report the command and its pass/fail result — + never claim a change works without having executed it Reply style: -- **Concise**. No filler like "let me see / test this / verify / let me help - you / I'll analyze it"; just act or give the answer +- **Concise**. No filler like "let me see / let me help you / I'll analyze + it"; just act or give the answer. Announcing that you are about to verify + is filler; running the check and reporting its result is not - **One shot**. Read files with read_file (use offset/limit for large spans); never use python3 -c inline scripts for file I/O - **Batch in parallel**. Issue multiple independent tool calls for the same @@ -127,8 +137,9 @@ def _classify_outcome(successes: int, failures: int) -> str: - **No re-confirmation**. Do not re-read facts already fetched; if a tool fails once, report the error to the user instead of retrying a rephrased version of the same action -- Do not summarize what you just did unless asked — the user can see the - diff / output +- Do not recap the diff or restate what you just did unless asked — but do + report verification results (command + pass/fail), which the user cannot + see for themselves - User input may come from voice transcription (/v command); just understand the intent and do not comment on the voice/recording feature itself""" From c4050f73343fcb35d0beeab6f9c201de9eccdecb Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 4 Sep 2026 10:04:25 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat(acli):=20sync=20agenticCLI=20?= =?UTF-8?q?=E2=80=94=20oneshot=20flag=20overrides=20+=20combined=20sdk-cli?= =?UTF-8?q?ent=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors agenticCLI 999db69 and 72ecc69: - `-c` returned from main() before the --protocol/--max-turns overrides were applied, so oneshot always ran with the config default of 50 turns. Every terminal-bench run that asked for 150 was really capped at 50. - TongyiProvider now sends acli/[/] in a single x-dashscope-sdk-client header instead of splitting the version into x-dashscope-sdk-version. --- dashscope/acli/cli/__init__.py | 58 ++++++++++++++++++------------ dashscope/acli/providers/tongyi.py | 8 +++-- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/dashscope/acli/cli/__init__.py b/dashscope/acli/cli/__init__.py index d2ace54..766a631 100644 --- a/dashscope/acli/cli/__init__.py +++ b/dashscope/acli/cli/__init__.py @@ -86,6 +86,33 @@ _scheduler = None +def _apply_cli_overrides( + config: Config, + protocol_override: str | None, + max_turns_override: str | None, +) -> None: + """Apply --protocol/--max-turns to a loaded config; exit on bad input.""" + if protocol_override: + if protocol_override.lower() in ("openai", "anthropic"): + config.protocol = protocol_override.lower() + else: + print( + f"Error: unknown protocol '{protocol_override}' " + f"(choices: openai, anthropic)", + ) + sys.exit(1) + + if max_turns_override is not None: + try: + config.max_turns = int(max_turns_override) + except ValueError: + print( + f"Error: --max-turns must be an integer, got " + f"'{max_turns_override}'", + ) + sys.exit(1) + + def main(): # Parse --cli / --tui / --dry-run flags (can appear anywhere in argv) use_cli = "--cli" in sys.argv @@ -146,7 +173,7 @@ def main(): ) print( " --max-turns Override max conversation " - "turns (default 1000)", + "turns (default from config: 50)", ) print( " --dry-run Preview current config (loaded " @@ -175,6 +202,13 @@ def main(): print('Usage: acli -c "your request"') sys.exit(1) config = Config.load() + # This branch returns before main()'s override tail, so the + # flags must be applied here or oneshot silently ignores them. + _apply_cli_overrides( + config, + protocol_override, + max_turns_override, + ) asyncio.run(_run_oneshot(config, prompt)) return elif arg in ("example", "examples"): @@ -186,27 +220,7 @@ def main(): _mcp_server_main() return config = Config.load() - # Apply --protocol override - if protocol_override: - if protocol_override.lower() in ("openai", "anthropic"): - config.protocol = protocol_override.lower() - else: - print( - f"Error: unknown protocol '{protocol_override}' " - f"(choices: openai, anthropic)", - ) - sys.exit(1) - - # Apply --max-turns override - if max_turns_override is not None: - try: - config.max_turns = int(max_turns_override) - except ValueError: - print( - f"Error: --max-turns must be an integer, got " - f"'{max_turns_override}'", - ) - sys.exit(1) + _apply_cli_overrides(config, protocol_override, max_turns_override) # Handle --dry-run: preview configuration without starting the agent if use_dry_run: diff --git a/dashscope/acli/providers/tongyi.py b/dashscope/acli/providers/tongyi.py index bebd19c..da45cd4 100644 --- a/dashscope/acli/providers/tongyi.py +++ b/dashscope/acli/providers/tongyi.py @@ -48,12 +48,14 @@ def __init__( request_timeout: int = 60, protocol: str = "openai", base_url: str | None = None, + module: str = "app", ): self.model = model self.api_key = api_key self.request_timeout = request_timeout self.protocol = protocol self.base_url = (base_url or DASHSCOPE_BASE_URL).rstrip("/") + self.module = module def _convert_tools(self, tools: list[dict] | None) -> list[dict] | None: if not tools: @@ -126,8 +128,10 @@ def _get_headers(self) -> dict: if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" if not os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): - headers["x-dashscope-sdk-client"] = "acli" - headers["x-dashscope-sdk-version"] = __version__ + parts = ["acli", __version__] + if self.module: + parts.append(self.module) + headers["x-dashscope-sdk-client"] = "/".join(parts) headers["x-dashscope-sdk-session-id"] = SDK_SESSION_ID return headers From ed7e9900dab2a6148d01182657c50692db385a61 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 4 Sep 2026 10:56:58 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat(acli):=20sync=20agenticCLI=20?= =?UTF-8?q?=E2=80=94=20default=20model=20qwen3.8-max?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors bc7caee: qwen3.8-max becomes the factory default in Config, TongyiProvider, run_interactive/embedded.run and the Alibaba/Bailian setup preset. Also re-vendors examples from agenticCLI-examples (67f2249) so basic-chat and dashscope-sdk-expert stop overriding that default with qwen3.7-max / qwen3.7-plus. --- dashscope/acli/cli/handlers_setup.py | 4 ++-- dashscope/acli/cli/repl.py | 2 +- dashscope/acli/config.py | 5 +++-- dashscope/acli/examples/basic-chat/.acli/config.toml | 2 +- .../acli/examples/basic-chat/.acli/custom-extensions.toml | 3 ++- dashscope/acli/examples/basic-chat/README.md | 6 +++--- .../dashscope-sdk-expert/.acli/custom-extensions.toml | 2 +- dashscope/acli/examples/dashscope-sdk-expert/README.md | 2 +- dashscope/acli/providers/tongyi.py | 2 +- dashscope/acli/sdk.py | 2 +- dashscope/acli/ui/embedded.py | 4 ++-- 11 files changed, 18 insertions(+), 16 deletions(-) diff --git a/dashscope/acli/cli/handlers_setup.py b/dashscope/acli/cli/handlers_setup.py index 8721a2f..fbf49fa 100644 --- a/dashscope/acli/cli/handlers_setup.py +++ b/dashscope/acli/cli/handlers_setup.py @@ -192,7 +192,7 @@ def _setup_finalize(config: Config, agent) -> None: def _apply_preset_bailian(config: Config) -> None: config.provider = "tongyi" - config.model = "qwen3.7-plus" + config.model = "qwen3.8-max" config.enabled_capabilities = [ "bailian.mcp", "bailian.cli", @@ -284,7 +284,7 @@ async def _handle_setup(config: Config, agent) -> None: console.print("\nSelect a configuration mode:") console.print( " [cyan][1][/cyan] [bold]Alibaba/Bailian[/bold] (default) — " - "tongyi/qwen3.7-plus + bailian.mcp/cli", + "tongyi/qwen3.8-max + bailian.mcp/cli", ) console.print( " [cyan][2][/cyan] China general — " diff --git a/dashscope/acli/cli/repl.py b/dashscope/acli/cli/repl.py index fa5cbf2..eab9e5b 100644 --- a/dashscope/acli/cli/repl.py +++ b/dashscope/acli/cli/repl.py @@ -251,7 +251,7 @@ async def _run_loop(config: Config): if not WORKSPACE_CONFIG_FILE.exists(): has_api_key = bool(config.api_key) using_defaults = ( - config.provider == "tongyi" and config.model == "qwen3.7-plus" + config.provider == "tongyi" and config.model == "qwen3.8-max" ) if not has_api_key or using_defaults: await _handle_setup(config, agent) diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index edefd29..c444caa 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -23,6 +23,7 @@ PROVIDER_MODELS = { "tongyi": [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.5-plus", @@ -226,7 +227,7 @@ def from_dict(cls, d: dict) -> DelegationConfig: @dataclass class Config: provider: str = "tongyi" - model: str = "qwen3.7-plus" + model: str = "qwen3.8-max" # Dual-LLM: when thinking_model is set, Plan/Thinking loop phases route to # the thinking model; Execute uses the execution model (provider/model # above). @@ -318,7 +319,7 @@ def api_key(self, value: str) -> None: def load( cls, default_provider: str = "tongyi", - default_model: str = "qwen3.7-plus", + default_model: str = "qwen3.8-max", ) -> Config: config = cls() # Caller's defaults become the initial values; config files and env diff --git a/dashscope/acli/examples/basic-chat/.acli/config.toml b/dashscope/acli/examples/basic-chat/.acli/config.toml index c103eab..c87ae39 100644 --- a/dashscope/acli/examples/basic-chat/.acli/config.toml +++ b/dashscope/acli/examples/basic-chat/.acli/config.toml @@ -1,4 +1,4 @@ user_name = "dashscope" provider = "tongyi" -model = "qwen3.7-max" +model = "qwen3.8-max" memory_user_id = "acli-basic" diff --git a/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml b/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml index 416080e..9592d28 100644 --- a/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml +++ b/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml @@ -19,8 +19,9 @@ name = "tongyi" base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" api_key_env = "DASHSCOPE_API_KEY" -default_model = "qwen3.7-max" +default_model = "qwen3.8-max" models = [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen-turbo", diff --git a/dashscope/acli/examples/basic-chat/README.md b/dashscope/acli/examples/basic-chat/README.md index 851f3fa..c04445a 100644 --- a/dashscope/acli/examples/basic-chat/README.md +++ b/dashscope/acli/examples/basic-chat/README.md @@ -51,8 +51,8 @@ Declares which LLM providers acli can use. A minimal config needs just one `[[pr name = "tongyi" base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" api_key_env = "DASHSCOPE_API_KEY" # ← stores only the env var name; the shell provides sk-xxx -default_model = "qwen3.7-max" -models = ["qwen3.7-max", "qwen3.7-plus", "qwen-turbo", "qwen-vl-max"] +default_model = "qwen3.8-max" +models = ["qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen-turbo", "qwen-vl-max"] vision_models = ["qwen-vl-max"] # ← tells acli these models accept image input protocol = "openai" # ← openai / anthropic / dashscope ``` @@ -95,7 +95,7 @@ How to invoke: ```toml user_name = "dashscope" provider = "tongyi" -model = "qwen3.7-max" +model = "qwen3.8-max" memory_user_id = "acli-basic" ``` diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml b/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml index 1b57b41..c365601 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml @@ -10,7 +10,7 @@ name = "tongyi" base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" api_key_env = "DASHSCOPE_API_KEY" -default_model = "qwen3.7-plus" +default_model = "qwen3.8-max" models = [ "qwen3.8-max", "qwen3.7-max", diff --git a/dashscope/acli/examples/dashscope-sdk-expert/README.md b/dashscope/acli/examples/dashscope-sdk-expert/README.md index 69d018e..f8c1e89 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/README.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/README.md @@ -86,7 +86,7 @@ Before generating code, first verify the user's installed SDK version and API si ```toml user_name = "dashscope" provider = "tongyi" -model = "qwen3.7-plus" +model = "qwen3.8-max" memory_user_id = "acli-dashscope" ``` diff --git a/dashscope/acli/providers/tongyi.py b/dashscope/acli/providers/tongyi.py index da45cd4..f1806f6 100644 --- a/dashscope/acli/providers/tongyi.py +++ b/dashscope/acli/providers/tongyi.py @@ -43,7 +43,7 @@ def _extract_usage(response) -> dict | None: class TongyiProvider: def __init__( self, - model: str = "qwen3.7-plus", + model: str = "qwen3.8-max", api_key: str | None = None, request_timeout: int = 60, protocol: str = "openai", diff --git a/dashscope/acli/sdk.py b/dashscope/acli/sdk.py index 396dfa8..dffff67 100644 --- a/dashscope/acli/sdk.py +++ b/dashscope/acli/sdk.py @@ -149,7 +149,7 @@ def run_once_sync( def run_interactive( system_prompt: Optional[str] = None, app_name: str = "Agent", - default_model: str = "qwen3.7-plus", + default_model: str = "qwen3.8-max", default_provider: str = "tongyi", api_key: Optional[str] = None, base_url: Optional[str] = None, diff --git a/dashscope/acli/ui/embedded.py b/dashscope/acli/ui/embedded.py index 0f96c03..faec249 100644 --- a/dashscope/acli/ui/embedded.py +++ b/dashscope/acli/ui/embedded.py @@ -7,7 +7,7 @@ run( system_prompt="You are ...", app_name="My App", - default_model="qwen3.7-plus", + default_model="qwen3.8-max", default_provider="tongyi", api_key="sk-...", ) @@ -26,7 +26,7 @@ def run( system_prompt: Optional[str] = None, app_name: str = "Agent", - default_model: str = "qwen3.7-plus", + default_model: str = "qwen3.8-max", default_provider: str = "tongyi", api_key: Optional[str] = None, base_url: Optional[str] = None, From e9c47360aafadf8cb7d1e9a539a83e9e7b22a168 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 4 Sep 2026 16:02:20 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(acli):=20sync=20agenticCLI=20?= =?UTF-8?q?=E2=80=94=20run=5Fcommand=20on=20Windows=20inside=20the=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sys.stdout.encoding was read while decoding subprocess output, but the TUI replaces sys.stdout with textual's capture object, which has no .encoding, so every command failed with AttributeError. The encoding is now resolved at import. Also passes the command to PowerShell as its own argv element (no cmd.exe re-quoting), prefers pwsh, and adds -NoProfile. --- dashscope/acli/tools/shell.py | 55 ++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/dashscope/acli/tools/shell.py b/dashscope/acli/tools/shell.py index f376b05..d273c7b 100644 --- a/dashscope/acli/tools/shell.py +++ b/dashscope/acli/tools/shell.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio +import locale import os import re import shlex +import shutil import sys from dashscope.acli.tools.registry import PermissionLevel, tool @@ -15,6 +17,37 @@ DEFAULT_TIMEOUT = 30 IS_WINDOWS = os.name == "nt" + +def _resolve_output_encoding() -> str: + """Encoding for decoding subprocess output. + + Resolved at import, not per call: the TUI replaces ``sys.stdout`` with + textual's capture object, which has no ``.encoding``, so reading it later + raises AttributeError and fails every command. + """ + if not IS_WINDOWS: + return "utf-8" + return ( + getattr(sys.stdout, "encoding", None) + or getattr(sys.__stdout__, "encoding", None) + or locale.getpreferredencoding(False) + or "utf-8" + ) + + +def _resolve_win_shell() -> tuple[str, ...]: + """argv prefix for running a command on Windows. + + ``-NoProfile`` matters for latency: user profile scripts run on every + spawn, and each run_command pays that cost. + """ + exe = shutil.which("pwsh") or shutil.which("powershell") or "powershell" + return (exe, "-NoProfile", "-NonInteractive", "-Command") + + +OUTPUT_ENCODING = _resolve_output_encoding() +WIN_SHELL = _resolve_win_shell() + BLOCKED_PATTERNS = [ # POSIX "mkfs", @@ -530,9 +563,12 @@ async def run_command(command: str, timeout: int | None = None) -> str: try: if IS_WINDOWS: - # Use PowerShell on Windows for better shell syntax support - proc = await asyncio.create_subprocess_shell( - f'powershell -Command "{command}"', + # argv form, never a shell string: interpolating into + # 'powershell -Command "..."' routes it through cmd.exe first and + # any embedded double quote corrupts the command. + proc = await asyncio.create_subprocess_exec( + *WIN_SHELL, + command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=os.getcwd(), @@ -586,17 +622,10 @@ async def run_command(command: str, timeout: int | None = None) -> str: output = "" if stdout: - # On Windows, subprocess output may be in system codepage - # (cp936/cp1252) - encoding = ( - "utf-8" if not IS_WINDOWS else (sys.stdout.encoding or "utf-8") - ) - output += stdout.decode(encoding, errors="replace") + output += stdout.decode(OUTPUT_ENCODING, errors="replace") if stderr: - encoding = ( - "utf-8" if not IS_WINDOWS else (sys.stdout.encoding or "utf-8") - ) - output += "\n[stderr]\n" + stderr.decode(encoding, errors="replace") + err = stderr.decode(OUTPUT_ENCODING, errors="replace") + output += "\n[stderr]\n" + err if len(output) > MAX_OUTPUT_LENGTH: output = ( From def4f5be2ece0a7c4ea7dc149e07dbddd716a781 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 4 Sep 2026 16:35:15 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(acli):=20sync=20agenticCLI=20?= =?UTF-8?q?=E2=80=94=20capture=20the=20mouse=20in=20the=20TUI=20on=20Windo?= =?UTF-8?q?ws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With capture off the terminal never learns the app wants mouse events, so Windows Terminal and conhost translate the wheel into arrow keys or scroll their own buffer: the input box moved through its history, or the whole screen dragged, while the output area never scrolled. --- dashscope/acli/config.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index c444caa..0e5e56c 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -282,9 +282,11 @@ class Config: protocol: str = "openai" # "openai" | "anthropic" tui: bool = True # False = let the terminal handle the mouse (native selection). - # Default off on Windows: Textual's mouse-capture path is crash-prone - # in PowerShell/conhost; native terminal mouse still allows selection. - tui_mouse: bool = field(default_factory=lambda: os.name != "nt") + # Capture is on everywhere, including Windows: without it the wheel goes + # to the console host, which scrolls the whole alternate screen — the + # input box moves with the output and repaints slowly. Set false only if + # your terminal mishandles Textual's mouse reporting. + tui_mouse: bool = True privacy_mode: bool = ( False # When True, all data stays local, no cloud capabilities ) From e0e6069fa5615b198e7e5943d1f80bfbbf20484c Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Fri, 4 Sep 2026 17:27:31 +0800 Subject: [PATCH 10/10] test(acli): add unit tests for reflection.py Cover the four core components: - is_readonly_tool_call: 67 cases (known tools, shell commands, pipes, redirects, container subcmds, env prefix, edge cases) - ReflectionTracker: 11 cases (threshold, reset, hints, lessons) - StagnationTracker: 10 cases (streak, hard cap, mixed sequences) - convergence_hint: 12 cases (soft/hard boundaries, custom ratios, remaining calculation, disable conditions) --- tests/unit/test_acli_reflection.py | 489 +++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 tests/unit/test_acli_reflection.py diff --git a/tests/unit/test_acli_reflection.py b/tests/unit/test_acli_reflection.py new file mode 100644 index 0000000..16bb083 --- /dev/null +++ b/tests/unit/test_acli_reflection.py @@ -0,0 +1,489 @@ +# -*- coding: utf-8 -*- +"""Unit tests for dashscope.acli.memory.reflection.""" + +import pytest + +from dashscope.acli.memory.reflection import ( + ReflectionTracker, + StagnationTracker, + convergence_hint, + is_readonly_tool_call, +) + + +# ── is_readonly_tool_call ──────────────────────────────────────────────────── + + +class TestIsReadonlyToolCall: # pylint: disable=too-many-public-methods + """Tests for the is_readonly_tool_call classifier.""" + + # -- Known read-only tools -- + + @pytest.mark.parametrize( + "tool_name", + ["read_file", "search_files", "list_directory", "memory_search"], + ) + def test_known_readonly_tools(self, tool_name): + assert is_readonly_tool_call(tool_name, {}) is True + + def test_write_file_is_not_readonly(self): + assert is_readonly_tool_call("write_file", {"path": "x.txt"}) is False + + def test_delete_file_is_not_readonly(self): + assert is_readonly_tool_call("delete_file", {"path": "x.txt"}) is False + + def test_mcp_tool_is_not_readonly(self): + assert is_readonly_tool_call("mcp_something", {}) is False + + def test_unknown_tool_is_not_readonly(self): + assert is_readonly_tool_call("some_unknown_tool", {}) is False + + # -- run_command: read-only shell commands -- + + @pytest.mark.parametrize( + "command", + [ + "ls -la", + "cat /etc/hosts", + "head -n 10 file.txt", + "tail -f log.txt", + "grep -r 'pattern' src/", + "find . -name '*.py'", + "wc -l main.py", + "ps aux", + "df -h", + "du -sh .", + "whoami", + "hostname", + "uname -a", + "pwd", + "date", + "nproc", + "uptime", + ], + ) + def test_readonly_commands(self, command): + assert ( + is_readonly_tool_call("run_command", {"command": command}) is True + ) + + # -- run_command: mutating commands -- + # Note: `git` is in _WRITE_MARKERS (conservative: all git treated as mutating) + + @pytest.mark.parametrize( + "command", + [ + "rm -rf /tmp/test", + "mv src.py dst.py", + "cp src.py dst.py", + "mkdir new_dir", + "touch new_file.txt", + "chmod +x script.sh", + "pip install requests", + "npm install", + "git push", + "git commit -m 'fix'", + "git status", + "git log --oneline -5", + "git diff HEAD~1", + "git branch -a", + "python script.py", + "python3 -c 'print(1)'", + "node server.js", + "make build", + "cargo build", + "go run main.go", + "sed -i 's/a/b/' file.txt", + "tee output.txt", + "curl https://example.com", + "wget https://example.com", + ], + ) + def test_mutating_commands(self, command): + assert ( + is_readonly_tool_call("run_command", {"command": command}) is False + ) + + # -- Pipe chains -- + + def test_readonly_pipe_chain(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "cat file.txt | grep foo | wc -l"}, + ) + is True + ) + + def test_mutating_in_pipe(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "cat file.txt | tee backup.txt | wc -l"}, + ) + is False + ) + + # -- Semicolon / && separated commands -- + + def test_all_readonly_segments(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "ls -la; pwd; date"}, + ) + is True + ) + + def test_mutating_segment_among_readonly(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "ls -la; rm -f tmp.txt; pwd"}, + ) + is False + ) + + def test_and_chain_with_mutating(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "ls && rm -f tmp.txt"}, + ) + is False + ) + + def test_or_chain_all_readonly(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "ls || pwd"}, + ) + is True + ) + + # -- Redirections -- + + def test_output_redirect_is_mutating(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "echo hello > output.txt"}, + ) + is False + ) + + def test_append_redirect_is_mutating(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "echo hello >> log.txt"}, + ) + is False + ) + + def test_devnull_redirect_is_benign(self): + """Redirecting stderr to /dev/null should not make it mutating.""" + assert ( + is_readonly_tool_call( + "run_command", + {"command": "ls 2>/dev/null"}, + ) + is True + ) + + def test_2_to_1_redirect_is_benign(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "grep foo file 2>&1"}, + ) + is True + ) + + # -- Container subcommands -- + + @pytest.mark.parametrize( + "command", + [ + "docker ps", + "docker images", + "docker logs mycontainer", + "docker inspect abc123", + "podman ps", + "colima status", + ], + ) + def test_container_readonly_subcmds(self, command): + assert ( + is_readonly_tool_call("run_command", {"command": command}) is True + ) + + def test_container_mutating_subcmd(self): + assert ( + is_readonly_tool_call( + "run_command", + {"command": "docker run nginx"}, + ) + is False + ) + + # -- Edge cases -- + + def test_empty_command(self): + assert is_readonly_tool_call("run_command", {"command": ""}) is False + + def test_no_arguments(self): + assert is_readonly_tool_call("run_command", None) is False + + def test_non_string_command(self): + assert is_readonly_tool_call("run_command", {"command": 123}) is False + + def test_command_with_flags_only(self): + """A segment with only flags and no verb is not readonly.""" + assert ( + is_readonly_tool_call("run_command", {"command": "-la"}) is False + ) + + def test_env_prefix_stripped(self): + """`env VAR=val cmd` should classify by `cmd`.""" + assert ( + is_readonly_tool_call( + "run_command", + {"command": "env FOO=bar ls -la"}, + ) + is True + ) + + def test_env_with_assignment_only_is_benign(self): + """`env VAR=val` with no command after stripping → empty stage → readonly.""" + assert ( + is_readonly_tool_call( + "run_command", + {"command": "env FOO=bar"}, + ) + is True + ) + + +# ── ReflectionTracker ──────────────────────────────────────────────────────── + + +class TestReflectionTracker: + def test_starts_below_threshold(self): + tracker = ReflectionTracker(threshold=3) + assert tracker.needs_reflection() is False + + def test_below_threshold_no_hint(self): + tracker = ReflectionTracker(threshold=3) + tracker.record_failure("read_file") + tracker.record_failure("read_file") + assert tracker.needs_reflection() is False + assert tracker.get_reflection_hint() == "" + + def test_reaches_threshold(self): + tracker = ReflectionTracker(threshold=3) + tracker.record_failure("write_file") + tracker.record_failure("write_file") + tracker.record_failure("run_command") + assert tracker.needs_reflection() is True + + def test_success_resets(self): + tracker = ReflectionTracker(threshold=3) + tracker.record_failure("write_file") + tracker.record_failure("write_file") + tracker.record_success() + assert tracker.needs_reflection() is False + assert tracker.consecutive_failures == 0 + + def test_hint_contains_tool_names(self): + tracker = ReflectionTracker(threshold=2) + tracker.record_failure("read_file") + tracker.record_failure("run_command") + hint = tracker.get_reflection_hint() + assert "read_file" in hint + assert "run_command" in hint + assert "2 consecutive" in hint + + def test_hint_deduplicates_tool_names(self): + tracker = ReflectionTracker(threshold=2) + tracker.record_failure("read_file") + tracker.record_failure("read_file") + hint = tracker.get_reflection_hint() + # "read_file" should appear once in the joined set + assert hint.count("read_file") == 1 + + def test_record_tool_execution_routes_success(self): + tracker = ReflectionTracker(threshold=3) + tracker.record_failure("x") + tracker.record_tool_execution("y", success=True) + assert tracker.consecutive_failures == 0 + + def test_record_tool_execution_routes_failure(self): + tracker = ReflectionTracker(threshold=3) + tracker.record_tool_execution("x", success=False) + assert tracker.consecutive_failures == 1 + + def test_reset(self): + tracker = ReflectionTracker(threshold=3) + tracker.record_failure("x") + tracker.record_failure("y") + tracker.reset() + assert tracker.consecutive_failures == 0 + assert not tracker.last_failed_tools + assert tracker.needs_reflection() is False + + def test_failure_lesson_below_threshold(self): + tracker = ReflectionTracker(threshold=3) + tracker.record_failure("x") + assert tracker.get_failure_lesson() == "" + + def test_failure_lesson_at_threshold(self): + tracker = ReflectionTracker(threshold=2) + tracker.record_failure("read_file") + tracker.record_failure("read_file") + lesson = tracker.get_failure_lesson() + assert "2 consecutive failures" in lesson + assert "read_file" in lesson + + +# ── StagnationTracker ──────────────────────────────────────────────────────── + + +class TestStagnationTracker: + def test_starts_clean(self): + tracker = StagnationTracker(threshold=8) + assert tracker.needs_nudge() is False + assert tracker.get_stagnation_hint() == "" + + def test_below_threshold(self): + tracker = StagnationTracker(threshold=8) + for _ in range(7): + tracker.record(readonly=True) + assert tracker.needs_nudge() is False + + def test_reaches_threshold(self): + tracker = StagnationTracker(threshold=8) + for _ in range(8): + tracker.record(readonly=True) + assert tracker.needs_nudge() is True + + def test_mutating_resets_streak(self): + tracker = StagnationTracker(threshold=8) + for _ in range(7): + tracker.record(readonly=True) + tracker.record(readonly=False) + assert tracker.readonly_streak == 0 + assert tracker.needs_nudge() is False + + def test_hint_contains_streak_count(self): + tracker = StagnationTracker(threshold=3) + for _ in range(5): + tracker.record(readonly=True) + hint = tracker.get_stagnation_hint() + assert "5 consecutive" in hint + + def test_hint_with_hard_cap(self): + tracker = StagnationTracker(threshold=3) + for _ in range(5): + tracker.record(readonly=True) + hint = tracker.get_stagnation_hint(hard_cap=10) + assert "Hard stop in 5 more" in hint + + def test_hint_without_hard_cap(self): + tracker = StagnationTracker(threshold=3) + for _ in range(5): + tracker.record(readonly=True) + hint = tracker.get_stagnation_hint(hard_cap=None) + assert "Hard stop" not in hint + + def test_hint_hard_cap_at_streak(self): + """When streak == hard_cap, remaining is 0 → no hard stop line.""" + tracker = StagnationTracker(threshold=3) + for _ in range(5): + tracker.record(readonly=True) + hint = tracker.get_stagnation_hint(hard_cap=5) + # hard_cap > n is False (5 > 5 is False), so no hard stop line + assert "Hard stop" not in hint + + def test_reset(self): + tracker = StagnationTracker(threshold=3) + for _ in range(5): + tracker.record(readonly=True) + tracker.reset() + assert tracker.readonly_streak == 0 + assert tracker.needs_nudge() is False + + def test_mixed_sequence(self): + """Interleaved reads and writes should reset properly.""" + tracker = StagnationTracker(threshold=3) + tracker.record(True) + tracker.record(True) + tracker.record(False) # reset + tracker.record(True) + tracker.record(True) + assert tracker.readonly_streak == 2 + assert tracker.needs_nudge() is False + + +# ── convergence_hint ────────────────────────────────────────────────────────── + + +class TestConvergenceHint: + """Note: used = loop_index + 1, remaining = max(0, max_turns - used).""" + + def test_below_soft_returns_empty(self): + # loop=0, max=100 → used=1, frac=0.01 < 0.6 + assert convergence_hint(0, 100) == "" + + def test_at_soft_boundary(self): + # loop=59, max=100 → used=60, frac=0.60 → fires soft + hint = convergence_hint(59, 100) + assert "Budget check" in hint + + def test_between_soft_and_hard(self): + # loop=70, max=100 → used=71, frac=0.71 → soft band, remaining=29 + hint = convergence_hint(70, 100, soft_ratio=0.6, hard_ratio=0.85) + assert "Budget check" in hint + assert "29 left" in hint + + def test_at_hard_boundary(self): + # loop=84, max=100 → used=85, frac=0.85 → fires hard + hint = convergence_hint(84, 100, soft_ratio=0.6, hard_ratio=0.85) + assert "converge now" in hint + + def test_past_hard(self): + # loop=95, max=100 → used=96, frac=0.96 → hard, remaining=4 + hint = convergence_hint(95, 100, soft_ratio=0.6, hard_ratio=0.85) + assert "converge now" in hint + assert "4 left" in hint + + def test_max_turns_zero_returns_empty(self): + assert convergence_hint(10, 0) == "" + + def test_soft_ratio_ge_1_disables(self): + assert convergence_hint(99, 100, soft_ratio=1.0) == "" + assert convergence_hint(99, 100, soft_ratio=2.0) == "" + + def test_custom_ratios(self): + # loop=4, max=10 → used=5, frac=0.5; soft=0.4 → fires soft + hint = convergence_hint(4, 10, soft_ratio=0.4, hard_ratio=0.8) + assert "Budget check" in hint + + def test_custom_ratios_hard(self): + # loop=8, max=10 → used=9, frac=0.9; hard=0.8 → fires hard + hint = convergence_hint(8, 10, soft_ratio=0.4, hard_ratio=0.8) + assert "converge now" in hint + + def test_remaining_is_non_negative(self): + # loop=100, max=100 → used=101, remaining=max(0,-1)=0 + hint = convergence_hint(100, 100, soft_ratio=0.6, hard_ratio=0.85) + assert "0 left" in hint + + def test_soft_hint_contains_switch_advice(self): + hint = convergence_hint(70, 100) + assert "Switch to a structurally different strategy" in hint + + def test_hard_hint_says_no_new_approach(self): + hint = convergence_hint(90, 100) + assert "Do NOT start a new approach" in hint