diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index 79c639e..b5ff1e5 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -3,7 +3,7 @@ import uuid -__version__ = "0.6.7" +__version__ = "0.7.1" # 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 128c5f9..6df31bb 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -27,6 +27,7 @@ from dashscope.acli.memory.reflection import ( convergence_hint, is_readonly_tool_call, + time_budget_hint, ) from dashscope.acli.platforms.base import MemoryProvider from dashscope.acli.prompt_pipeline import PromptContext, default_pipeline @@ -46,6 +47,7 @@ text_of, tool_result_for_display, tool_result_for_history, + truncate, ) console = Console() @@ -121,52 +123,62 @@ def _budget_burned_slowly(error: BaseException) -> bool: reuse paths seen in training data (e.g. /Users/xxx/...) 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 + the edit — but you must verify it afterwards (rule 17). 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 +4. 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 -7. If the "user profile" contains info, personalize answers with it (e.g. +5. Tools prefixed [MCP:xxx] come from Bailian MCP services; call them directly +6. If the "user profile" contains info, personalize answers with it (e.g. recommend Python solutions for a Python user) -8. When a request matches an "available Skill template" below, prefer that +7. When a request matches an "available Skill template" below, prefer that flow — if the Skill's MCP is connected, call its tools directly; if not, connect first with mcp_connect, then call -9. When a question may involve personal preferences, tech stack, or work +8. When a question may involve personal preferences, tech stack, or work environment, proactively search the profile with memory_search for context -10. If no suitable tool can complete the task, answer from your knowledge; - never call a nonexistent tool -11. To switch model/Provider → call switch_model or switch_provider directly -12. To use cloud services (time, code execution, doc parsing, etc.) → call +9. If no suitable tool can complete the task, answer from your knowledge; + never call a nonexistent tool +10. To switch model/Provider → call switch_model or switch_provider directly +11. To use cloud services (time, code execution, doc parsing, etc.) → call mcp_connect directly -13. To enable/disable capabilities → call capability_enable / +12. To enable/disable capabilities → call capability_enable / capability_disable directly -14. Never reply "please use the /xxx command" — if you have a tool that can +13. Never reply "please use the /xxx command" — if you have a tool that can do it, just do it -15. **Do not use run_command with python3 -c inline scripts to read/write +14. **Do not use run_command with python3 -c inline scripts to read/write files** — it is inefficient. Read with read_file, write with write_file; shell tools like sed/grep are fine to use -16. **If a question can be answered directly (no files, commands, or external +15. **If a question can be answered directly (no files, commands, or external info needed), answer from your knowledge without calling tools** — e.g. self-introduction, explaining concepts, translating text, small talk -17. **Multiple independent subtasks (e.g. "look at these 2 files", several +16. **Multiple independent subtasks (e.g. "look at these 2 files", several unrelated tasks at once) → call delegate_parallel to fan out subagents**, or send multiple delegate calls in the same turn; a single independent 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 +17. **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. When the output transforms or joins data (ETL, CSV/JSON, migrations), also assert every cross-reference resolves — schema-valid output with dangling IDs still - fails + fails. When the task names a ground-truth tool or command for a check, + run the final verification with that exact tool and its stated + parameters — a calibrated substitute proves nothing about the grade +18. **Snapshot a fragile artifact before inspecting it.** A tool meant to + read can still mutate what it opens — SQLite deletes an invalid WAL + when the connection closes, and destroyed evidence cannot be + re-downloaded. Copy the file to scratch space first (cp to /tmp), or + open it immutably (e.g. sqlite3 'file:...?immutable=1') +19. **Read images with the vision model, not by proxy.** When a task + hinges on perceiving an image — text or digits in a picture, a chart, + a rendered layout — call view_image on the file itself; reading it + back through terminal-resolution ASCII art or hand-rolled pixel + scripts throws away exactly the detail the answer depends on. If the + artifact is data rather than an image (geometry, toolpaths, a + waveform), render it to a PNG first and then look at it with + view_image Reply style: - **Concise**. No filler like "let me see / let me help you / I'll analyze @@ -239,6 +251,14 @@ def __init__( self.turn_retry_max = 2 self.turn_retry_delay = 5.0 self.turn_retry_backoff_cap = 30.0 + # Empty-turn backstop (oneshot only): a turn that ends with no tool + # calls and no content is a provider failure shape, not "done". An + # autonomous run must not exit on it while budget remains — nudge + # and continue, bounded so a persistently empty endpoint still + # terminates. Interactive keeps the historical break: there the + # user sees the notice and simply speaks again. + self._empty_turns = 0 + self._empty_turn_max = 2 # 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 @@ -257,6 +277,34 @@ def __init__( ) except ValueError: self.converge_hard_ratio = 0.85 + # Wall-clock budget nudge (oneshot only): the turn-count convergence + # hint never fires when a run is few-but-huge generations, yet the + # harness kills the run on wall time all the same — and the hard-set + # tasks that died so (dna-assembly, write-compressor) all died with + # their deliverable never written. The bench adapter injects the + # in-container `timeout` value as ACLI_TIME_BUDGET_SEC; unset/<=0 + # disables. Ratios: ACLI_TIME_SOFT / ACLI_TIME_HARD. + try: + self.time_budget_sec = float( + os.environ.get("ACLI_TIME_BUDGET_SEC", "0"), + ) + except ValueError: + self.time_budget_sec = 0.0 + try: + self.time_soft_ratio = float( + os.environ.get("ACLI_TIME_SOFT", "0.5"), + ) + except ValueError: + self.time_soft_ratio = 0.5 + try: + self.time_hard_ratio = float( + os.environ.get("ACLI_TIME_HARD", "0.8"), + ) + except ValueError: + self.time_hard_ratio = 0.8 + # Monotonic anchor for the budget above, set on the first turn: a + # wall clock would credit host-suspend time the process never had. + self._run_started: float | None = None # Silence nudge: one reminder per turn after N tool calls have gone by # without any prose reaching the user. Mode-independent on purpose — # the shape this exists for is an interactive turn of *distinct* @@ -293,6 +341,14 @@ def __init__( if self.oneshot: self.system_prompt = (self.system_prompt or SYSTEM_PROMPT) + ( "\n\nTask execution policy (autonomous mode):\n" + "- Work from a written plan, not from memory: early in the " + "run, write a short plan file (goal, acceptance check, " + "ordered steps); after each step, record in it what is done " + "and what remains; before reporting completion, re-read it " + "and verify each item. Sketch the deliverable itself first, " + "then iterate on it. Never spend a whole turn only " + "thinking — every turn must land something on disk: a file, " + "a measurement, or an updated plan.\n" "- If the request has a measurable acceptance criterion " "(numeric threshold, size/time limit, test suite, " "similarity/accuracy target), iterate until it is met: " @@ -301,14 +357,27 @@ def __init__( "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 " + "or switch to a different approach. A wall-clock hint means " + "the run is killed when time runs out: get the required " + "deliverable written early, then keep improving it.\n" + "- Before reporting completion, turn every stated " + "requirement into its own executable check and run them " + "all — the measurable criterion, the format rules, and " + "any constraint that mentions components the deliverable " + "must contain. Where a check depends on domain facts, " + "state the fact explicitly first (a wrong assumption is " + "worse than a flagged unknown). 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." + "the measurement shows, then re-measure.\n" + "- Do not stake a large share of the remaining budget on " + "one long unverified command: before an install, compile " + "or download that could run for minutes, spend seconds " + "probing that the path can succeed at all (package " + "available, toolchain present); give long commands an " + "explicit timeout so a stuck one fails fast and frees " + "the rest of the budget." ) if self._acceptance_gate.active: # An armed gate implies oneshot — from_env hands back an empty @@ -597,6 +666,79 @@ def _convergence_section(self, loop_index: int) -> str: self.converge_hard_ratio, ) + def _time_budget_section(self) -> str: + """Wall-clock budget nudge; see time_budget_hint for the rationale.""" + if not self.oneshot or self._run_started is None: + return "" + return time_budget_hint( + time.monotonic() - self._run_started, + self.time_budget_sec, + self.time_soft_ratio, + self.time_hard_ratio, + ) + + # Seconds of wall-clock budget a single run_command may never consume: + # the remainder minus a reserve for writing/finalizing the deliverable. + # Run 2026-10-12__reqenum-0612 lost 720s of an 1800s budget to two + # run_command timeouts (a 600s pip compile that could never succeed + # offline plus a 120s follow-up) and died with the deliverable never + # written; the hint alone can only ask, this enforces. + _CMD_TIMEOUT_RESERVE_SEC = 180 + _CMD_TIMEOUT_MIN_SEC = 30 + + def _command_timeout_ceiling(self) -> int | None: + """Max seconds one run_command may take, or None when unbudgeted.""" + if ( + not self.oneshot + or self.time_budget_sec <= 0 + or self._run_started is None + ): + return None + remaining = self.time_budget_sec - ( + time.monotonic() - self._run_started + ) + return max( + self._CMD_TIMEOUT_MIN_SEC, + int(remaining - self._CMD_TIMEOUT_RESERVE_SEC), + ) + + def _clamp_run_command_timeout(self, tool_call) -> str: + """Clamp run_command's timeout to the budget ceiling. + + Mutates tool_call.arguments and returns a note for the tool + result when it clamped, "" otherwise. The note must ride the + result: the assistant's own tool_call entry in history keeps the + model's original arguments, so without it the model cannot see + why its 600s command died at 120s. + """ + if tool_call.name != "run_command": + return "" + ceiling = self._command_timeout_ceiling() + if ceiling is None or self._run_started is None: + return "" + from dashscope.acli.tools.shell import default_timeout + + raw = tool_call.arguments.get("timeout") + try: + current = ( + int(raw) if raw not in (None, "", 0) else default_timeout() + ) + except (TypeError, ValueError): + current = default_timeout() + if current <= ceiling: + return "" + tool_call.arguments["timeout"] = ceiling + remaining = self.time_budget_sec - ( + time.monotonic() - self._run_started + ) + return ( + f"[timeout clamped from {current}s to {ceiling}s: " + f"{int(remaining)}s of wall-clock budget remain, " + f"{self._CMD_TIMEOUT_RESERVE_SEC}s reserved for finalizing. " + "Break the work into commands that fit, or pick a path that " + "does not need the long step at all.]\n" + ) + def _silence_section(self) -> str: """Inject one reminder when tool calls stop producing prose. @@ -659,7 +801,15 @@ async def _store_memory(self, messages: list[dict[str, str]]): console.print(f"\n[dim red](memory error: {e})[/dim red]") async def run_stream(self, user_input) -> AsyncIterator[str]: - self.messages.append({"role": "user", "content": user_input}) + # A failed/interrupted turn can leave its user message as the last + # history entry; resubmitting the same text must not stack a second + # copy on top of it — the orphan is reused as this turn's input. + if not ( + self.messages + and self.messages[-1].get("role") == "user" + and self.messages[-1].get("content") == user_input + ): + self.messages.append({"role": "user", "content": user_input}) from dashscope.acli.audit import get_audit_logger @@ -671,17 +821,42 @@ async def run_stream(self, user_input) -> AsyncIterator[str]: async for chunk in self._run_stream_body(user_input): _partial.append(chunk) yield chunk - except BaseException: + except BaseException as e: # On cancellation / error, save whatever was received so the # user message and partial reply survive the next session load. + # A partial that is pure markdown scaffolding ("**", "```") + # carries no information and is dropped. partial_text = "".join(_partial).strip() - if partial_text: + if partial_text and any(ch.isalnum() for ch in partial_text): self.messages.append( { "role": "assistant", "content": partial_text + " [interrupted]", }, ) + elif not isinstance( + e, + ( + UserAbortedTurn, + KeyboardInterrupt, + SystemExit, + GeneratorExit, + asyncio.CancelledError, + ), + ): + # A provider/turn failure with zero output otherwise leaves + # only the user message behind: the history then shows N + # stacked user prompts with no trace of why each attempt + # failed, and the model cannot learn the cause on resubmit. + self.messages.append( + { + "role": "assistant", + "content": ( + "[Turn failed before any response: " + f"{sanitize_text(str(e))[:500]}]" + ), + }, + ) self.save_session() raise finally: @@ -703,6 +878,8 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # Clear tool tracking for new turn self._current_turn_tools = [] + if self._run_started is None: + self._run_started = time.monotonic() self.turn_tool_calls = 0 self.turn_subagents = 0 self.turn_mcp_calls = 0 @@ -798,15 +975,21 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # second one would replace the real prompt outright, and the Tongyi # converter only reads messages[0] as system. # - # All four sections are rebuilt every iteration because tracker + # All five sections are rebuilt every iteration because tracker # state changes as tools run; each returns "" when its condition # is not met, so an empty hint costs no message at all. - hint = ( - self._reflection_section() - + self._stagnation_section() - + self._convergence_section(_loop_i) - + self._silence_section() - ).strip() + hint_parts = { + "reflection": self._reflection_section(), + "stagnation": self._stagnation_section(), + "convergence": self._convergence_section(_loop_i), + "time_budget": self._time_budget_section(), + "silence": self._silence_section(), + } + hint = "".join(hint_parts.values()).strip() + # Which behaviour hints fired this call, and their sizes. Kept + # out of the prompt and logged with the llm_call event so hint + # efficacy is measurable from traces instead of guessed. + hint_chars = {k: len(v) for k, v in hint_parts.items() if v} request_messages = ( [*messages_with_system, {"role": "user", "content": hint}] if hint @@ -835,6 +1018,7 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: tool_calls: list[ToolCall] = [] seen_tool_calls: set[tuple[str, str]] = set() last_chunk = None + finish_reason = "" llm_start = time.monotonic() # Turn-level retry around the streaming call, for providers that @@ -844,9 +1028,14 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # by turn_retry_max, which against a stalled endpoint measured # 819s of silent wall clock from a 60s configured timeout. A # budget spent failing fast is still re-attempted — see - # _budget_burned_slowly. We only retry when nothing has been - # streamed yet; once content is yielded, a retry would duplicate - # output, so that case degrades to a partial turn instead. + # _budget_burned_slowly. We only retry when nothing user-visible + # was streamed yet; once content is yielded, a retry would + # duplicate output, so that case degrades to a partial turn + # instead. Reasoning chunks do not count as emitted: they are + # never yielded to the user, so retrying a stream that carried + # only thinking duplicates nothing. A stalled stream of pure + # reasoning otherwise slipped past both retry layers and ended + # the run as an empty turn (dna-insert, 2026-10-12__fix-071). turn_attempt = 0 while True: full_content = "" @@ -854,6 +1043,7 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: tool_calls = [] seen_tool_calls = set() last_chunk = None + finish_reason = "" emitted = False try: async for chunk in self.provider.chat_stream( @@ -873,7 +1063,6 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: yield chunk.delta_content if chunk.delta_reasoning_content: - emitted = True full_reasoning += chunk.delta_reasoning_content if chunk.tool_calls: @@ -897,6 +1086,8 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: if chunk.usage: self.executor.record_api_call(chunk.usage) last_chunk = chunk + if chunk.finish_reason: + finish_reason = chunk.finish_reason break except Exception as e: if ( @@ -928,10 +1119,18 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: raise # Log LLM call trace - self.executor.record_prompt_composition( + prompt_stats = self.executor.record_prompt_composition( request_messages, tools_schema, ) + # Char-based estimate logged next to the provider's usage in the + # same event, so the two token accounts can be reconciled from + # traces instead of drifting silently apart. + prompt_stats["est_prompt_tokens"] = estimate_message_tokens( + request_messages, + ) + estimate_tokens(json.dumps(tools_schema, ensure_ascii=False)) + if hint_chars: + prompt_stats["hint_chars"] = hint_chars self.trace_logger.log_llm_call( messages=request_messages, tools=tools_schema, @@ -949,6 +1148,7 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: }, duration_ms=int((time.monotonic() - llm_start) * 1000), model=self.model_name, + prompt_stats=prompt_stats, ) # If this iteration carried a reflection hint, its answer is the @@ -969,6 +1169,8 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: self._silent_tool_calls = 0 else: self._silent_tool_calls += len(tool_calls) + if full_content.strip() or tool_calls: + self._empty_turns = 0 for tc in tool_calls: if tc.name in ("delegate", "subagent_invoke"): self.turn_subagents += 1 @@ -981,6 +1183,38 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: self.turn_mcp_calls += 1 if not tool_calls: + if finish_reason == "length": + # Output cap cut the generation mid-turn: this is not a + # final answer, so skip the gate and do not break. The + # partial goes into history and the loop continues with + # a shorter-chunks demand — which is also what makes + # every between-iteration mechanism (time budget, + # convergence, reflection) reachable again on the + # monolithic-generation failure shape (2 requests per + # 1800s in run 2026-10-12__time-069). + partial = full_content or ( + "[response cut off by the output-length limit " + "during planning, before any visible output]" + ) + cut_msg: dict = {"role": "assistant", "content": partial} + if full_reasoning: + cut_msg["reasoning_content"] = full_reasoning + self.messages.append(cut_msg) + messages_with_system.append(cut_msg) + nudge = ( + "[Output-length limit reached — your response was " + "cut off. Continue working, but keep each response " + "short: put plans and intermediate results into " + "files with write_file in small pieces instead of " + "one long response; if you were composing the " + "deliverable, write it now in several smaller " + "calls.]" + ) + yield f"\n{nudge}\n" + nudge_msg = {"role": "user", "content": nudge} + self.messages.append(nudge_msg) + messages_with_system.append(nudge_msg) + continue # Acceptance gate. This branch is the loop's only voluntary # exit, so it is the one place where "the model is done" can # be checked against something other than the model's own @@ -1037,6 +1271,28 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: self.messages.append(final_msg) last_content = full_content else: + if ( + self.oneshot + and self._empty_turns < self._empty_turn_max + ): + # A stall that slipped both retry layers (reasoning- + # only stream, then a clean-but-empty stop) must not + # pass for "done": with budget left, exiting here is + # how dna-insert died at 562/1800s with the + # deliverable never written (2026-10-12__fix-071). + self._empty_turns += 1 + nudge = ( + "[Empty response: the model returned no content " + "and no tool calls. The task is not finished — " + "keep working: re-read your plan file and take " + "the next step; if a deliverable file is " + "required, make sure it exists on disk.]" + ) + yield f"\n{nudge}\n" + nudge_msg = {"role": "user", "content": nudge} + self.messages.append(nudge_msg) + messages_with_system.append(nudge_msg) + continue yield ( "\n[Model returned no content; the model name " "may not exist or the service is unresponsive]\n" @@ -1215,7 +1471,9 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: # Record experience for learning (only if tools were used) if self._current_turn_tools: - task_summary = text_of(user_input)[:100] # Truncate for storage + # Flatten + ellipsis: raw prompts carry newlines, and a + # mid-word cut breaks the "Past Lessons" list rendering. + task_summary = truncate(" ".join(text_of(user_input).split()), 100) outcome = _classify_outcome( self._turn_tool_successes, self._turn_tool_failures, @@ -1262,6 +1520,10 @@ async def _execute_tool(self, tool_call: ToolCall) -> str: if not tool_def: return f"Error: unknown tool '{tool_call.name}'" + # Budget back-pressure on the one tool that can burn minutes of + # wall clock in a single call; see _clamp_run_command_timeout. + budget_note = self._clamp_run_command_timeout(tool_call) + # Track tool usage for experience recording (deduped, keep # first-use order) if tool_call.name not in self._current_turn_tools: @@ -1349,6 +1611,8 @@ async def _execute_tool(self, tool_call: ToolCall) -> str: ) duration_ms = int((time.time() - start_time) * 1000) result = outcome.text + if budget_note: + result = budget_note + result success = outcome.ok self.trace_logger.log_tool_execution( tool_call.name, diff --git a/dashscope/acli/cli/constants.py b/dashscope/acli/cli/constants.py index fc7bb70..6433f9c 100644 --- a/dashscope/acli/cli/constants.py +++ b/dashscope/acli/cli/constants.py @@ -197,6 +197,7 @@ "/tts", "/camera", "/provider", + "/model", "/setup", "/capability", "/subagents", diff --git a/dashscope/acli/cli/dispatch.py b/dashscope/acli/cli/dispatch.py index 9a1d8dc..cadc35c 100644 --- a/dashscope/acli/cli/dispatch.py +++ b/dashscope/acli/cli/dispatch.py @@ -367,6 +367,13 @@ def _handle_slash_command( handle_provider_command(cmd, agent, config) return True + elif cmd.startswith("/model"): + from dashscope.acli.cli.handlers_provider import ( + handle_model_command, + ) + + handle_model_command(cmd, agent, config) + return True elif cmd.startswith("/dev test provider") or cmd.startswith( "/dev debug call", ): diff --git a/dashscope/acli/cli/handlers_provider.py b/dashscope/acli/cli/handlers_provider.py index f52b23a..ae9f588 100644 --- a/dashscope/acli/cli/handlers_provider.py +++ b/dashscope/acli/cli/handlers_provider.py @@ -301,3 +301,57 @@ def handle_provider_command(cmd: str, agent: Agent, config: Config) -> bool: "[dim]/provider is now a Q&A wizard; args ignored[/dim]", ) return _provider_wizard(agent, config) + + +def handle_model_command(cmd: str, agent: Agent, config: Config) -> bool: + """/model [name] — switch the model inside the current provider. + + Users coming from other CLIs type /model reflexively; making them + rediscover it under the /provider wizard costs a turn. Provider, key + and protocol stay with /provider. + """ + parts = cmd.split(maxsplit=1) + if len(parts) == 1 or not parts[1].strip(): + console.print( + f"[cyan]{config.provider}/{config.model}[/cyan] " + f"protocol={config.protocol}", + ) + console.print( + "[dim]Usage: /model switches the model; " + "/provider covers provider/key/protocol.[/dim]", + ) + return True + + model = parts[1].strip() + normalized = normalize_model_name(model) + if normalized != model: + console.print( + f"[dim]Model name normalized to {normalized} " + f"(API model IDs are case-sensitive)[/dim]", + ) + + # Same rule as the wizard's model step: an unlisted name under a + # built-in provider is registered as a custom model; under an + # extension provider it is used as-is. + models = PROVIDER_MODELS.get(config.provider, []) + if not models: + ext = find_provider(config.provider) + if ext is not None: + models = ext.resolved_models() + if ( + models + and normalized not in models + and config.provider in PROVIDER_MODELS + ): + register_custom_model(config, config.provider, normalized) + console.print( + f"[green]✓ Registered {config.provider}/{normalized}[/green]", + ) + config.model = normalized + config.save_global() + config.save_workspace() + if _rebuild_provider(agent, config): + console.print( + f"[green]✓ Switched: {config.provider}/{config.model}[/green]", + ) + return True diff --git a/dashscope/acli/cli/repl.py b/dashscope/acli/cli/repl.py index eab9e5b..f824dc2 100644 --- a/dashscope/acli/cli/repl.py +++ b/dashscope/acli/cli/repl.py @@ -562,6 +562,15 @@ async def _cli_confirm_callback( continue elif result: continue + else: + # Not a registered command: the text goes to the model (an + # agent can legitimately answer slash-command questions), + # but say so — a typo'd command must not fail silently. + console.print( + f"[dim]Unknown command " + f"{user_input.split()[0]}; sending it to the model " + f"instead (/help lists commands).[/dim]", + ) # Expand @path tokens to file contents before sending to the agent. # Done here (post slash-command handling) so slash commands themselves diff --git a/dashscope/acli/cli/runners.py b/dashscope/acli/cli/runners.py index 11cdab8..23eac00 100644 --- a/dashscope/acli/cli/runners.py +++ b/dashscope/acli/cli/runners.py @@ -148,12 +148,32 @@ async def _run_oneshot(config: Config, prompt: str): auto_approve=config.auto_approve, confirm_mode=config.confirm_mode, ) + + # Memory and the shared hook bus must exist before the Agent does (same + # wiring as cli/repl.py): without them oneshot had no memory provider at + # all, so every memory- or skill-shaped capability scored a constant zero + # in non-interactive runs. + memory = None + if config.memory_enabled: + from dashscope.acli.cli.handlers_profile import set_memory_client + from dashscope.acli.platforms import get_memory_provider + + memory = get_memory_provider(config) + if memory: + set_memory_client(memory) + + from dashscope.acli.hooks import create_hook_bus + + hook_bus = create_hook_bus() + agent = Agent( provider=provider, executor=executor, max_turns=config.max_turns, + memory=memory, provider_name=config.provider, model_name=config.model, + hook_bus=hook_bus, oneshot=True, ) @@ -178,11 +198,53 @@ async def _run_oneshot(config: Config, prompt: str): set_delegate_parent(agent) set_delegate_config(config) - from dashscope.acli.cli.mcp import _connect_mcp + from dashscope.acli.cli.mcp import ( + _connect_mcp, + _disconnect_mcp, + _mcp_clients, + ) from dashscope.acli.tools.platform import register_platform_tools register_platform_tools(config, connect_mcp_fn=_connect_mcp) + # The four registrations below mirror the interactive/TUI paths + # (cli/repl.py, _run_tui_mode). Oneshot used to skip them, which left + # skill packages unloaded and the session/skill/evolution tools + # unregistered in exactly the mode benchmarks run in. + from dashscope.acli.skills import get_skill_manager + + skill_manager = get_skill_manager() + skill_manager._registry_url = getattr(config, "skill_registry", "") + skill_manager._global = False + skill_manager.load(hook_bus=hook_bus) + + from dashscope.acli.tools.session import register_session_tools + + register_session_tools( + config, + get_agent=lambda: agent, + get_provider_fn=get_provider, + connect_mcp_fn=_connect_mcp, + disconnect_mcp_fn=_disconnect_mcp, + list_mcp_services_fn=list_known_services, + get_mcp_clients_fn=lambda: _mcp_clients, + ) + + # Vision too: without it, oneshot (every bench run 071c–071k) offered + # no view_image at all — the prompt named a tool the model could not + # see — and read_file/run_command auto-perceive had no context. + from dashscope.acli.tools.vision import register_vision_tools + + register_vision_tools(config, get_provider_fn=get_provider) + + from dashscope.acli.tools.evolution import register_evolution_tools + + register_evolution_tools(get_agent=lambda: agent) + + from dashscope.acli.tools.skills import register_skill_tools + + register_skill_tools(get_agent=lambda: agent) + expanded, images, audio_clips = _expand_at_references(prompt) if images and not is_vision_model(config.model): console.print( @@ -225,7 +287,12 @@ async def _run_oneshot(config: Config, prompt: str): if usage_path: _write_usage_file(usage_path, executor) if failure is not None: + from dashscope.acli.providers.hardening import account_error_hint + print(f"Error: {failure}", file=sys.stderr) + hint = account_error_hint(failure) + if hint: + print(hint, file=sys.stderr) sys.exit(1) diff --git a/dashscope/acli/cli/streaming.py b/dashscope/acli/cli/streaming.py index e77fa72..28f6a5a 100644 --- a/dashscope/acli/cli/streaming.py +++ b/dashscope/acli/cli/streaming.py @@ -21,6 +21,7 @@ AsyncSpinner, UserAbortedTurn, message_text_for_compress, + truncate, ) console = Console() @@ -206,7 +207,10 @@ async def _do_summarize( lesson = f"Steps: {'; '.join(steps)}" tracker.record_experience( - task_summary=task[:200], + # Flatten newlines and mark truncation: the task is usually + # the raw prompt, and a mid-word cut with embedded newlines + # breaks the numbered "Past Lessons" list in the system prompt. + task_summary=truncate(" ".join(task.split()), 200), tools_used=tools_used, outcome=outcome, lesson=lesson, @@ -313,6 +317,11 @@ def _sigint_handler(): console.print("\n[dim]Turn aborted[/dim]") except Exception as e: console.print(f"\n[red]Error: {e}[/red]") + from dashscope.acli.providers.hardening import account_error_hint + + hint = account_error_hint(e) + if hint: + console.print(f"[yellow]{hint}[/yellow]") finally: # Restore default SIGINT handler try: diff --git a/dashscope/acli/commands.py b/dashscope/acli/commands.py index 5817f3c..cbacd2c 100644 --- a/dashscope/acli/commands.py +++ b/dashscope/acli/commands.py @@ -80,6 +80,11 @@ "/provider", "Configure Provider / Key / model / protocol (Q&A style)", ), + ( + "/model", + "Switch the model within the current provider " + "(/model )", + ), ( "/trust", "Tool trust/deny cache for this session " diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index c390a86..5c79cab 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -32,6 +32,7 @@ "qwen-max", "qwen-plus", "qwen-turbo", + "qwen3-vl-plus", "qwen-vl-max", "qwen-vl-plus", "qwen-omni-turbo", diff --git a/dashscope/acli/executor.py b/dashscope/acli/executor.py index e1b8e16..95079d0 100644 --- a/dashscope/acli/executor.py +++ b/dashscope/acli/executor.py @@ -308,8 +308,21 @@ def record_prompt_composition( self, messages: list[dict], tools_schema: list[dict] | None = None, - ) -> None: - """Accumulate prompt size (chars) by category for one LLM call.""" + ) -> dict[str, int]: + """Accumulate prompt size (chars) by category for one LLM call. + + Also returns the per-call breakdown so the trace can persist what + this specific request was made of; the accumulator feeding /stats + keeps its coarser system/user/assistant/tools buckets. + """ + per_call = { + "system_chars": 0, + "user_chars": 0, + "assistant_chars": 0, + "tool_result_chars": 0, + "tool_call_args_chars": 0, + "tools_schema_chars": 0, + } for msg in messages: role = msg.get("role") content = msg.get("content") @@ -321,16 +334,20 @@ def record_prompt_composition( n = 0 if role in ("system", "user", "assistant"): self._prompt_composition[role] += n + per_call[f"{role}_chars"] += n elif role == "tool": self._prompt_composition["tools"] += n + per_call["tool_result_chars"] += n if role == "assistant": for tc in msg.get("tool_calls") or []: args = (tc.get("function") or {}).get("arguments") or "" self._prompt_composition["tools"] += len(args) + per_call["tool_call_args_chars"] += len(args) if tools_schema: - self._prompt_composition["tools"] += len( - json.dumps(tools_schema, ensure_ascii=False), - ) + n = len(json.dumps(tools_schema, ensure_ascii=False)) + self._prompt_composition["tools"] += n + per_call["tools_schema_chars"] += n + return per_call async def execute(self, tool_def: ToolDefinition, arguments: dict) -> str: """Run one tool call and return its text result. diff --git a/dashscope/acli/memory/directives_learning.py b/dashscope/acli/memory/directives_learning.py index 3595015..cc6cb9c 100644 --- a/dashscope/acli/memory/directives_learning.py +++ b/dashscope/acli/memory/directives_learning.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import threading from collections import Counter from datetime import datetime, timezone from pathlib import Path @@ -36,6 +37,11 @@ def _empty_patterns() -> dict[str, Any]: # The patterns file is read on every prompt assembly; memoize on mtime. _patterns_cache: tuple[Path, int, dict[str, Any]] | None = None +# Held across the whole load→mutate→save of every writer below: without it +# two threads recording a sequence (or accepting a directive) interleave +# their read-modify-write and silently drop one side's update. +_cache_lock = threading.Lock() + def _load_patterns() -> dict[str, Any]: global _patterns_cache @@ -97,19 +103,20 @@ def record_tool_sequence(tools: list[str]) -> None: if len(tools) < 2: return - patterns = _load_patterns() - patterns["sequences"].append( - { - "tools": tools, - "timestamp": datetime.now(timezone.utc).isoformat(), - }, - ) + with _cache_lock: + patterns = _load_patterns() + patterns["sequences"].append( + { + "tools": tools, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + ) - # Keep only last 100 sequences - if len(patterns["sequences"]) > 100: - patterns["sequences"] = patterns["sequences"][-100:] + # Keep only last 100 sequences + if len(patterns["sequences"]) > 100: + patterns["sequences"] = patterns["sequences"][-100:] - _save_patterns(patterns) + _save_patterns(patterns) def analyze_patterns() -> list[dict[str, Any]]: @@ -179,18 +186,19 @@ def _generate_directive(tool1: str, tool2: str, count: int) -> str: def propose_directive(directive: str, rationale: str) -> dict[str, Any]: """Create a directive proposal.""" - patterns = _load_patterns() + with _cache_lock: + patterns = _load_patterns() - proposal = { - "id": f"dir_{int(datetime.now(timezone.utc).timestamp())}", - "directive": directive, - "rationale": rationale, - "status": "pending", - "created_at": datetime.now(timezone.utc).isoformat(), - } + proposal = { + "id": f"dir_{int(datetime.now(timezone.utc).timestamp())}", + "directive": directive, + "rationale": rationale, + "status": "pending", + "created_at": datetime.now(timezone.utc).isoformat(), + } - patterns["proposed_directives"].append(proposal) - _save_patterns(patterns) + patterns["proposed_directives"].append(proposal) + _save_patterns(patterns) return proposal @@ -207,38 +215,40 @@ def list_proposed_directives(status: str = "pending") -> list[dict[str, Any]]: def accept_directive(proposal_id: str, config: Any) -> bool: """Accept a proposed directive and add it to user_directives.""" - patterns = _load_patterns() - proposals = patterns.get("proposed_directives", []) + with _cache_lock: + patterns = _load_patterns() + proposals = patterns.get("proposed_directives", []) - for p in proposals: - if p["id"] == proposal_id and p.get("status") == "pending": - directive_text = p["directive"] + for p in proposals: + if p["id"] == proposal_id and p.get("status") == "pending": + directive_text = p["directive"] - # Add to config.user_directives - if hasattr(config, "user_directives"): - if directive_text not in config.user_directives: - config.user_directives.append(directive_text) - config.save_workspace() + # Add to config.user_directives + if hasattr(config, "user_directives"): + if directive_text not in config.user_directives: + config.user_directives.append(directive_text) + config.save_workspace() - p["status"] = "accepted" - p["accepted_at"] = datetime.now(timezone.utc).isoformat() - _save_patterns(patterns) - return True + p["status"] = "accepted" + p["accepted_at"] = datetime.now(timezone.utc).isoformat() + _save_patterns(patterns) + return True return False def reject_directive(proposal_id: str) -> bool: """Reject a proposed directive.""" - patterns = _load_patterns() - proposals = patterns.get("proposed_directives", []) - - for p in proposals: - if p["id"] == proposal_id and p.get("status") == "pending": - p["status"] = "rejected" - p["rejected_at"] = datetime.now(timezone.utc).isoformat() - _save_patterns(patterns) - return True + with _cache_lock: + patterns = _load_patterns() + proposals = patterns.get("proposed_directives", []) + + for p in proposals: + if p["id"] == proposal_id and p.get("status") == "pending": + p["status"] = "rejected" + p["rejected_at"] = datetime.now(timezone.utc).isoformat() + _save_patterns(patterns) + return True return False diff --git a/dashscope/acli/memory/experience.py b/dashscope/acli/memory/experience.py index 781e42b..20f84bf 100644 --- a/dashscope/acli/memory/experience.py +++ b/dashscope/acli/memory/experience.py @@ -7,8 +7,10 @@ from __future__ import annotations import json +import os from pathlib import Path from typing import Any +from uuid import uuid4 from dashscope.acli.utils.keywords import ( expand_scoring_terms, @@ -60,7 +62,12 @@ def _rotate_if_oversize(self) -> None: encoding="utf-8", ).splitlines() keep = [line for line in lines if line.strip()][-self.KEEP_LINES :] - tmp = self.experience_file.with_suffix(".jsonl.tmp") + # Unique temp name: two trackers rotating at once must not + # clobber each other's temp file before the replace. + tmp = self.experience_file.with_name( + f"{self.experience_file.name}.{os.getpid()}." + f"{uuid4().hex[:8]}.tmp", + ) tmp.write_text("\n".join(keep) + "\n", encoding="utf-8") tmp.replace(self.experience_file) except OSError: diff --git a/dashscope/acli/memory/reflection.py b/dashscope/acli/memory/reflection.py index a89019d..be6e7fd 100644 --- a/dashscope/acli/memory/reflection.py +++ b/dashscope/acli/memory/reflection.py @@ -626,7 +626,7 @@ def _replan_demand(self, hard_cap: int | None = None) -> str: "2. Which approaches are ruled out, and by which output?", ( "3. What is the revised plan? If it is worth writing " - "down, call create_plan." + "down, write it down." ), ( "A structurally different call, or one specific question " @@ -725,3 +725,67 @@ def convergence_hint( "passing result." ) return "" + + +def time_budget_hint( + elapsed_sec: float, + budget_sec: float, + soft_ratio: float = 0.5, + hard_ratio: float = 0.8, +) -> str: + """Wall-clock counterpart of ``convergence_hint``, deliverable-first. + + The turn-count nudge never fires on the failure shape this covers: a run + of very few, very long generations (7/150 turns) can still burn 95% of + the harness's wall-clock cap, and every hard-set task that died that way + (dna-assembly, write-compressor) died with its required output file + never written at all. So the soft band does NOT say "converge": it says + "land a best-effort deliverable now, then keep refining" — securing an + artifact early is what the turn nudge's plateaued loop already has and + these runs lack. + + Returns "" below ``soft_ratio`` or when ``budget_sec`` is unset/<=0; + a secure-the-deliverable nudge in the soft band; a finalize nudge + at/after ``hard_ratio`` that forces a partial write when the disk is + still empty — "best version on disk" means nothing to a run that has + written nothing (solve-sudoku in 2026-10-12__medium12 died that way). + """ + if budget_sec <= 0 or elapsed_sec < 0: + return "" + frac = elapsed_sec / budget_sec + remaining = max(0.0, budget_sec - elapsed_sec) + elapsed_i = int(elapsed_sec) + budget_i = int(budget_sec) + remaining_i = int(remaining) + if frac >= hard_ratio: + return ( + "\n\n## ⏳ Wall-clock budget nearly exhausted\n" + f"{elapsed_i}s of your ~{budget_i}s budget are gone " + f"({remaining_i}s left).\n" + "Do NOT start a new approach — there is no time left to debug " + "it. Right now:\n" + "1. If the required deliverable file is not on disk yet, write " + "your current best partial answer to it immediately — an " + "incomplete or placeholder version can still score; an empty " + "path scores zero. If one is on disk, make sure it is the best " + "version you have produced (overwrite a broken draft with an " + "older working one if needed).\n" + "2. Run ONE final verification against the acceptance " + "criterion.\n" + "3. Finish.\n" + ) + if frac >= soft_ratio: + return ( + "\n\n## ⏳ Wall-clock budget half spent\n" + f"{elapsed_i}s of your ~{budget_i}s budget are gone " + f"({remaining_i}s left); the run is killed when it hits 0.\n" + "If the file/artifact this task requires does not exist yet in " + "complete form, write a best-effort version NOW with your " + "current approach — a rough working deliverable beats a perfect " + "plan that never lands. Then keep refining and overwrite it " + "with better versions.\n" + "If it already exists, keep improving it — but re-save after " + "every improvement, so whatever is on disk when time runs out " + "is your best attempt." + ) + return "" diff --git a/dashscope/acli/memory/skill_evolution.py b/dashscope/acli/memory/skill_evolution.py index b9a3f32..cb4dfc2 100644 --- a/dashscope/acli/memory/skill_evolution.py +++ b/dashscope/acli/memory/skill_evolution.py @@ -125,10 +125,6 @@ def _identify_pattern(tool_sequence: list[str]) -> str | None: return "edit-and-test" return "file-edit" - # Code refactoring workflow - if "read_file" in tool_sequence and "edit_file" in tool_sequence: - return "code-refactor" - # Project setup workflow if "run_command" in tool_sequence and "write_file" in tool_sequence: if any("init" in t or "create" in t for t in tool_sequence): @@ -139,10 +135,6 @@ def _identify_pattern(tool_sequence: list[str]) -> str | None: if "read_file" in tool_sequence: return "search-and-analyze" - # Git workflow - if any("git" in t for t in tool_sequence): - return "git-workflow" - return None @@ -176,39 +168,49 @@ def _extract_parameters(user_request: str) -> list[dict[str, str]]: def generate_skill_markdown(analysis: dict[str, Any]) -> str: - """Generate skill markdown from trajectory analysis.""" + """Generate skill markdown from trajectory analysis. + + Frontmatter stays within what the loader's _parse_simple_yaml can read: + one entry per line, lists in ``[a, b]`` form. The previous nested YAML + ``arguments:`` block parsed as garbage keys, and its inner + ``description:`` lines clobbered the real top-level description. + """ name = analysis["pattern_name"] tools = analysis["tools_sequence"] params = analysis["parameters"] - # Build skill markdown + arg_names = [p["name"] for p in params] lines = [ "---", f"name: {name}", f"description: Automated workflow - {analysis['rationale']}", - "arguments:", + f"arguments: [{', '.join(arg_names)}]", + "---", + "", + f"# {name}", + "", + "Automatically run the following workflow:", + "", ] - - for p in params: - lines.append(f" - name: {p['name']}") - lines.append(f" description: {p['description']}") - lines.append(" required: true") - if "example" in p: - lines.append(f" example: {p['example']}") - - lines.append("---") - lines.append("") - lines.append(f"# {name}") - lines.append("") - lines.append("Automatically run the following workflow:") - lines.append("") for i, tool in enumerate(tools, 1): lines.append(f"{i}. `{tool}`") lines.append("") + + if params: + lines.append("## Arguments") + lines.append("") + for p in params: + entry = f"- `{p['name']}` — {p['description']}" + if "example" in p: + entry += f" (e.g. {p['example']})" + lines.append(entry) + lines.append("") + lines.append("## Usage example") lines.append("") lines.append("```") - lines.append(f"/{name} {params[0]['example'] if params else ''}") + example_args = " ".join(p.get("example", p["name"]) for p in params) + lines.append(f"/{name} {example_args}" if example_args else f"/{name}") lines.append("```") lines.append("") lines.append("## Workflow") @@ -218,6 +220,10 @@ def generate_skill_markdown(analysis: dict[str, Any]) -> str: for i, tool in enumerate(tools, 1): lines.append(f"{i}. Call the `{tool}` tool") lines.append("") + if arg_names: + refs = ", ".join(f"`{{{n}}}`" for n in arg_names) + lines.append(f"Apply the workflow to {refs}.") + lines.append("") lines.append("Report the results when done.") return "\n".join(lines) diff --git a/dashscope/acli/memory/tool_chains.py b/dashscope/acli/memory/tool_chains.py index 5fcfa39..f6b4caf 100644 --- a/dashscope/acli/memory/tool_chains.py +++ b/dashscope/acli/memory/tool_chains.py @@ -33,13 +33,13 @@ "steps": [ "1. search_files to find all usages", "2. read_file to read the relevant code", - "3. create_plan for the refactor plan", + "3. plan the order of edits before touching files", "4. write_file to edit files one by one", "5. run_command to run tests and verify", ], "example": ( "User: 'refactor the auth module, extract shared functions'\n" - "→ search_files 'def auth' → read_file → create_plan " + "→ search_files 'def auth' → read_file " "→ write_file → run_command pytest" ), }, diff --git a/dashscope/acli/memory/trace.py b/dashscope/acli/memory/trace.py index ff41e20..fa6f0a3 100644 --- a/dashscope/acli/memory/trace.py +++ b/dashscope/acli/memory/trace.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import os import time from datetime import datetime from pathlib import Path @@ -19,7 +20,12 @@ class TraceLogger: def __init__(self, workspace_dir: Path): self.traces_dir = workspace_dir / "traces" self.traces_dir.mkdir(parents=True, exist_ok=True) - self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S") + # pid suffix: two acli processes started in the same second (parent + + # subagent, parallel runs) would otherwise share one trace file and + # interleave their JSONL lines. + self.session_id = ( + f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.getpid()}" + ) self.trace_file = self.traces_dir / f"{self.session_id}.jsonl" self._session_start = time.time() @@ -47,11 +53,14 @@ def log_llm_call( response: dict, duration_ms: int = 0, model: str = "", + prompt_stats: dict | None = None, ) -> None: """Log an LLM API call. Lightweight fields (counts, usage, duration) are always recorded; the full prompt payload is included only in debug mode (/debug on). + ``prompt_stats`` carries the per-call section char counts and token + estimate (always on — sizes only, never content). """ data: dict[str, Any] = { "model": model, @@ -62,6 +71,8 @@ def log_llm_call( "tool_calls": response.get("tool_calls", []), "usage": response.get("usage"), } + if prompt_stats: + data["prompt_stats"] = prompt_stats from dashscope.acli import debuglog if debuglog.debug_enabled(): diff --git a/dashscope/acli/prompt_pipeline.py b/dashscope/acli/prompt_pipeline.py index bdba8d2..8673855 100644 --- a/dashscope/acli/prompt_pipeline.py +++ b/dashscope/acli/prompt_pipeline.py @@ -212,7 +212,12 @@ def render(self, ctx: PromptContext) -> str: f"run_command executes via: {shell_label()}", ] if ctx.user_name: - env_info.append(f"Current user: {ctx.user_name}") + # Display name from config.toml, NOT the OS login — the model + # once fabricated /Users//... paths from this line. + env_info.append( + f"Current user: {ctx.user_name} " + "(configured display name, not the OS account)", + ) if ctx.provider_name or ctx.model_name: env_info.append( f"Current model: {ctx.provider_name}/{ctx.model_name} " diff --git a/dashscope/acli/providers/hardening.py b/dashscope/acli/providers/hardening.py index 1d42f5a..4b2141b 100644 --- a/dashscope/acli/providers/hardening.py +++ b/dashscope/acli/providers/hardening.py @@ -74,6 +74,13 @@ "model not found", "insufficient_quota", "billing", + # Gateway account blocks, e.g. "Access denied, please make sure your + # account is in good standing ... #overdue-payment" — permanent until + # the account is fixed, so never retry/fallback on them. + "access denied", + "accessdenied", + "good standing", + "overdue", # Chinese patterns "权限", # permission "认证", # authentication @@ -83,6 +90,7 @@ "配额", # quota "账单", # billing "不存在", # does not exist + "欠费", # overdue payment # English equivalents of the Chinese entries not already covered # by the English block above "permission", @@ -91,6 +99,32 @@ "does not exist", ) +# Subset of non-retryable errors where resubmitting the same request is +# pointless until the user fixes their account (overdue payment, service +# not activated). Surfaced as an actionable hint at the display layer. +_ACCOUNT_ERROR_PATTERNS = ( + "access denied", + "accessdenied", + "good standing", + "overdue", + "insufficient_quota", + "billing", + "欠费", +) + + +def account_error_hint(error: BaseException) -> str: + """Actionable hint for account/billing failures, else an empty string.""" + text = str(error).lower() + if any(p in text for p in _ACCOUNT_ERROR_PATTERNS): + return ( + "This is an account/billing error (overdue payment or service " + "not activated); retrying will not help until the account is " + "in good standing." + ) + return "" + + # Hint appended to a retry when the model produced an empty reply. _EMPTY_RECOVERY_HINT = { "role": "user", diff --git a/dashscope/acli/providers/tongyi.py b/dashscope/acli/providers/tongyi.py index 9b8b1a3..5200e2b 100644 --- a/dashscope/acli/providers/tongyi.py +++ b/dashscope/acli/providers/tongyi.py @@ -2,8 +2,10 @@ # pylint: disable=too-many-branches,too-many-statements from __future__ import annotations +import asyncio import json import os +import time from typing import AsyncIterator, ClassVar import httpx @@ -140,6 +142,12 @@ def _to_native_content(content): def _to_native_messages(messages: list[dict]) -> list[dict]: result = [] for m in messages: + # reasoning_content is kept in local history for display/session + # restore, but it must not go back on the wire: the API does not + # accept it as input, and resending it re-bills the whole thinking + # trace on every turn. + if "reasoning_content" in m: + m = {k: v for k, v in m.items() if k != "reasoning_content"} if isinstance(_safe_get(m, "content"), list): m = {**m, "content": _to_native_content(m["content"])} result.append(m) @@ -263,11 +271,47 @@ def __init__( protocol: str = "openai", base_url: str | None = None, module: str = "app", + max_output_tokens: int | None = None, + thinking_budget: int | None = None, ): self.model = model self.api_key = api_key self.request_timeout = request_timeout self.protocol = protocol + # Per-call output cap (reasoning tokens count against it). The + # failure shape this exists for: a run of 15-minute, ~25K-token + # monolithic generations that never lands a deliverable before the + # wall-clock cap — with no cap the agent loop never regains control + # between request boundaries, so every between-turn mechanism (time + # budget, convergence, reflection) is unreachable. Truncation is + # safe: the stream assembly repairs or empties a cut tool call, and + # the agent treats finish_reason=length as "continue", not "done". + # Env: ACLI_MAX_OUTPUT_TOKENS (0/unset disables). + if max_output_tokens is None: + try: + max_output_tokens = int( + os.environ.get("ACLI_MAX_OUTPUT_TOKENS", "0"), + ) + except ValueError: + max_output_tokens = 0 + self.max_output_tokens = max(0, max_output_tokens) + # Reasoning-token budget. Probing qwen3.8-max on the native + # multimodal-generation path showed max_tokens caps only the + # visible text (100 → 284 output tokens incl. 180 reasoning), so + # the monolithic-thinking shape sails past it untouched; + # thinking_budget bounds the reasoning itself (200 → exactly 200). + # When the budget runs out the server wraps up the turn with + # finish_reason=stop, so the agent loop regains control without + # needing the length-continuation branch. Env: + # ACLI_THINKING_BUDGET (0/unset disables). + if thinking_budget is None: + try: + thinking_budget = int( + os.environ.get("ACLI_THINKING_BUDGET", "0"), + ) + except ValueError: + thinking_budget = 0 + self.thinking_budget = max(0, thinking_budget) base = (base_url or DASHSCOPE_BASE_URL).rstrip("/") for prefix in _LEGACY_PREFIXES: if base.endswith(prefix): @@ -354,6 +398,10 @@ def _build_request_body( if stream: parameters["stream"] = True parameters["incremental_output"] = True + if self.max_output_tokens > 0: + parameters["max_tokens"] = self.max_output_tokens + if self.thinking_budget > 0: + parameters["thinking_budget"] = self.thinking_budget ds_tools = self._convert_tools(tools) if ds_tools: parameters["tools"] = ds_tools @@ -594,11 +642,37 @@ async def _iter_stream(self, response) -> AsyncIterator[LLMChunk]: usage_sent = False _json_buf: str | None = None - async for line in response.aiter_lines(): + # SSE keepalive comment lines (": ...") carry no progress but reset + # httpx's read timeout, so a wedged generation can hold the stream + # open indefinitely — measured 882s on a request that never emitted + # a single data: line, surviving the whole max_retries x + # request_timeout budget until the caller's own cap killed it. + # Count only data: lines as progress and bound their gap at one + # request_timeout; a stalled stream must surface as a retryable + # error, not hang the turn. + lines = response.aiter_lines() + last_progress = time.monotonic() + while True: + remaining = self.request_timeout - ( + time.monotonic() - last_progress + ) + try: + line = await asyncio.wait_for( + lines.__anext__(), + timeout=max(remaining, 0), + ) + except StopAsyncIteration: + break + except asyncio.TimeoutError: + raise RuntimeError( + f"stream stalled: no data received for " + f"{self.request_timeout}s (keepalive timeout)", + ) from None # Native SSE sends "data:{...}" with no space; # compatible-mode sends "data: {...}". if not line.startswith("data:"): continue + last_progress = time.monotonic() payload = line[5:].strip() if payload == "[DONE]": break diff --git a/dashscope/acli/skills/__init__.py b/dashscope/acli/skills/__init__.py index 2fac2f6..58c7c5a 100644 --- a/dashscope/acli/skills/__init__.py +++ b/dashscope/acli/skills/__init__.py @@ -9,6 +9,7 @@ list_skills, load_skill_files, register, + register_skill_file, render_skill, skills_summary_for_llm, ) @@ -28,6 +29,7 @@ "list_skills", "load_skill_files", "register", + "register_skill_file", "render_skill", "skills_summary_for_llm", ] diff --git a/dashscope/acli/skills/base.py b/dashscope/acli/skills/base.py index 2cc9fbc..3aead59 100644 --- a/dashscope/acli/skills/base.py +++ b/dashscope/acli/skills/base.py @@ -3,6 +3,8 @@ import json import re +import shutil +import threading from dataclasses import dataclass from pathlib import Path @@ -18,37 +20,89 @@ class Skill: BUILTIN_SKILLS: dict[str, Skill] = {} +# Guards every mutation of BUILTIN_SKILLS. RLock because load_skill_files() +# re-enters register() while holding it. +_skills_lock = threading.RLock() + +# Names that came from .md files, as opposed to register() calls from +# custom-extensions.toml or dev tooling. Only these are cleared on rescan: +# apply_extensions() registers toml skills BEFORE load_skill_files() runs, +# so clearing everything would wipe them. +_FILE_SKILL_NAMES: set[str] = set() + def register(skill: Skill): - BUILTIN_SKILLS[skill.name] = skill + with _skills_lock: + BUILTIN_SKILLS[skill.name] = skill def load_skill_files(): """Load skills from .md files in ~/.acli/skills/ and .acli/skills/. - Global files load first; workspace files override by name. + Global files load first; workspace files override by name. File-sourced + entries from the previous scan are dropped first, so deleting a skill + file propagates without a restart. """ from dashscope.acli.config import CONFIG_DIR, WORKSPACE_DIR global_dir = CONFIG_DIR / "skills" workspace_dir = WORKSPACE_DIR / "skills" - for skills_dir in (global_dir, workspace_dir): - if not skills_dir.is_dir(): - continue - for path in sorted(skills_dir.glob("*.md")): - try: - skill = _parse_skill_md(path) + with _skills_lock: + for name in _FILE_SKILL_NAMES: + BUILTIN_SKILLS.pop(name, None) + _FILE_SKILL_NAMES.clear() + + for skills_dir in (global_dir, workspace_dir): + if not skills_dir.is_dir(): + continue + for path in sorted(skills_dir.glob("*.md")): + try: + skill = _parse_skill_md(path) + except Exception as e: + import logging + + logging.getLogger(__name__).warning( + "Skipping unparseable skill file %s: %s", + path, + e, + ) + continue if skill: register(skill) - except Exception as e: - import logging - - logging.getLogger(__name__).warning( - "Skipping unparseable skill file %s: %s", - path, - e, - ) + _FILE_SKILL_NAMES.add(skill.name) + + +def register_skill_file(path: Path | str) -> bool: + """Register one skill .md at runtime, without waiting for a restart. + + Copies *path* into the workspace skills dir when it lives elsewhere, + then rescans so the catalog, /skill completion, and the prompt summary + all see the change immediately. Returns True when the file parsed and + its skill is registered after the rescan. + """ + from dashscope.acli.config import WORKSPACE_DIR + + src = Path(path) + try: + skill = _parse_skill_md(src) + except Exception: + return False + if skill is None: + return False + + skills_dir = WORKSPACE_DIR / "skills" + target = skills_dir / src.name + try: + if src.resolve() != target.resolve(): + skills_dir.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, target) + except OSError: + return False + + load_skill_files() + with _skills_lock: + return BUILTIN_SKILLS.get(skill.name) is not None def _parse_skill_md(path: Path) -> Skill | None: diff --git a/dashscope/acli/tools/evolution.py b/dashscope/acli/tools/evolution.py index c50eb80..8cb6c1b 100644 --- a/dashscope/acli/tools/evolution.py +++ b/dashscope/acli/tools/evolution.py @@ -65,11 +65,19 @@ async def evolve_skill(force: bool = False) -> str: "or generation failed" ) + from dashscope.acli.skills import register_skill_file + + registered = register_skill_file(skill_path) + availability = ( + "Registered and available right now" + if registered + else "Saved; will load on next start" + ) return ( f"✓ Generated new skill from this conversation\n" f" Name: {analysis['pattern_name']}\n" f" Path: {skill_path}\n" f" Workflow: {' → '.join(analysis['tools_sequence'][:3])}\n\n" - f"Auto-loaded on next start. " + f"{availability}. " f"Trigger with /{analysis['pattern_name']}." ) diff --git a/dashscope/acli/tools/filesystem.py b/dashscope/acli/tools/filesystem.py index 405407f..4612789 100644 --- a/dashscope/acli/tools/filesystem.py +++ b/dashscope/acli/tools/filesystem.py @@ -28,6 +28,26 @@ # blow up the context on its own. _READ_MAX_CHARS = 100_000 +# Binary image formats read_file redirects to view_image: decoding them as +# text returns replacement-char noise that is never what the caller wants +# (solve-sudoku, 2026-10-13__fix-071e, read the 23KB jpeg this way and +# perceived none of it). The redirect alone proved ignorable — 071i read +# it at t=136s and was back to pixel scripting 3s later — so read_file now +# auto-perceives the image inline when a vision context is registered, and +# only prints the redirect when perception is unavailable. SVG stays +# readable — it is XML markup, not binary. +_IMAGE_BINARY_EXTS = { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".webp", + ".tif", + ".tiff", + ".heic", +} + # --------------------------------------------------------------------------- # Tools @@ -56,6 +76,21 @@ def read_file( if not os.path.isfile(path): return f"Error: file not found - {path}" + if os.path.splitext(path)[1].lower() in _IMAGE_BINARY_EXTS: + from dashscope.acli.tools import vision + + perceived = vision.auto_perceive(path) + if perceived is not None: + return perceived + return ( + f"[{path} is an image; reading it as text returns binary " + "noise, not its content. To perceive what the image shows — " + "text, digits, a chart, geometry — call view_image on this " + "path, or run tesseract OCR when exact text matters. For " + "metadata only (format, dimensions), run_command with " + "file/identify instead.]" + ) + with open(path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() diff --git a/dashscope/acli/tools/registry.py b/dashscope/acli/tools/registry.py index 4cc342f..9b8de94 100644 --- a/dashscope/acli/tools/registry.py +++ b/dashscope/acli/tools/registry.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +import os import sys import types from dataclasses import dataclass, field @@ -97,6 +98,242 @@ def _build_parameters_schema(func: Callable) -> dict: }, ) +# Core filesystem/shell tools are offered on every call. +_CORE_TOOLS = frozenset( + { + "read_file", + "write_file", + "list_directory", + "search_files", + "create_directory", + "delete_file", + "delete_directory", + "move_file", + "run_command", + "web_search", + }, +) + +# Session tools offered on every call, plus use_skill (model-invocable +# skill templates from .acli/skills/*.md). +_SESSION_ALWAYS_ON = frozenset( + { + "switch_model", + "switch_provider", + "capability_enable", + "capability_disable", + "view_image", + "mcp_connect", + "mcp_disconnect", + "use_skill", + }, +) + +# bailian_* CLI wrappers are numerous (~25 schemas each with full +# parameter definitions); offering them only when the request mentions +# the matching capability keeps every unrelated call much smaller. +_B_ALL = [ + "bailian_text_chat", + "bailian_omni", + "bailian_image_generate", + "bailian_image_edit", + "bailian_video_generate", + "bailian_video_edit", + "bailian_video_ref", + "bailian_video_task_get", + "bailian_video_download", + "bailian_vision_describe", + "bailian_app_call", + "bailian_app_list", + "bailian_memory_add", + "bailian_memory_search", + "bailian_memory_list", + "bailian_memory_update", + "bailian_memory_delete", + "bailian_memory_profile_create", + "bailian_memory_profile_get", + "bailian_knowledge_retrieve", + "bailian_search_web", + "bailian_speech_synthesize", + "bailian_speech_recognize", + "bailian_file_upload", + "bailian_model_list", + "bailian_console_call", + "bailian_usage_free", +] +_B_IMAGE = [ + "bailian_image_generate", + "bailian_image_edit", + "bailian_vision_describe", + "bailian_omni", +] +_B_VIDEO = [ + "bailian_video_generate", + "bailian_video_edit", + "bailian_video_ref", + "bailian_video_task_get", + "bailian_video_download", +] +_B_SPEECH = ["bailian_speech_synthesize", "bailian_speech_recognize"] +_B_MEMORY = [ + "bailian_memory_add", + "bailian_memory_search", + "bailian_memory_list", + "bailian_memory_update", + "bailian_memory_delete", + "bailian_memory_profile_create", + "bailian_memory_profile_get", +] + +# Keyword → tool mapping for conditional inclusion. +# Chinese keywords are kept for CJK input; English equivalents +# are appended alongside them so English prompts match too. +_KEYWORD_TOOLS = { + "memory": ["memory_search", "memory_store"] + _B_MEMORY, + "档案": ["memory_search", "memory_store"], + "profile": ["memory_search", "memory_store"], + "偏好": ["memory_search", "memory_store"], + "preference": ["memory_search", "memory_store"], + "记忆": _B_MEMORY, + "数据": [ + "data_upload", + "data_files", + "data_delete", + "data_categories", + ], + "data": [ + "data_upload", + "data_files", + "data_delete", + "data_categories", + ], + "prompt": [ + "prompt_list", + "prompt_get", + "prompt_create", + "prompt_delete", + "prompt_render", + ], + "模板": [ + "prompt_list", + "prompt_get", + "prompt_create", + "prompt_delete", + "prompt_render", + ], + "template": [ + "prompt_list", + "prompt_get", + "prompt_create", + "prompt_delete", + "prompt_render", + ], + "搜索": ["web_search", "context_search"], + "search": ["web_search", "context_search"], + "模型": ["switch_model", "switch_provider"], + "model": ["switch_model", "switch_provider"], + "能力": ["capability_enable", "capability_disable"], + "capability": ["capability_enable", "capability_disable"], + # Browser / web scraping tools + "scrape": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "抓取": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "网页": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "webpage": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "截图": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "screenshot": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "browser": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "爬取": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + "crawl": [ + "scrape_web", + "scrape_web_html", + "scrape_web_screenshot", + ], + # Camera tools + "拍照": ["camera_capture", "camera_record"], + "photo": ["camera_capture", "camera_record"], + "摄像头": ["camera_capture", "camera_record"], + "录屏": ["camera_capture", "camera_record"], + "录制": ["camera_capture", "camera_record"], + "record": ["camera_capture", "camera_record"], + "camera": ["camera_capture", "camera_record"], + # Bailian platform tools (schemas are keyword-gated, see _B_* above) + "图片": _B_IMAGE, + "画图": _B_IMAGE, + "draw": _B_IMAGE, + "image": _B_IMAGE, + "vision": _B_IMAGE, + "多模态": ["bailian_omni"], + "multimodal": ["bailian_omni"], + "omni": ["bailian_omni"], + "视频": _B_VIDEO, + "video": _B_VIDEO, + "语音": _B_SPEECH, + "speech": _B_SPEECH, + "tts": ["bailian_speech_synthesize"], + "朗读": ["bailian_speech_synthesize"], + "read aloud": ["bailian_speech_synthesize"], + "知识库": ["bailian_knowledge_retrieve"], + "knowledge": ["bailian_knowledge_retrieve"], + "rag": ["bailian_knowledge_retrieve"], + "百炼应用": ["bailian_app_call", "bailian_app_list"], + "bailian app": ["bailian_app_call", "bailian_app_list"], + "用量": ["bailian_usage_free"], + "usage": ["bailian_usage_free"], + "quota": ["bailian_usage_free"], + "上传": ["bailian_file_upload"], + "upload": ["bailian_file_upload"], + # Catch-all: mentioning the platform unlocks the full set + "百炼": _B_ALL, + "bailian": _B_ALL, +} + + +def ever_visible_tool_names() -> frozenset[str]: + """Every tool name any to_schema_list call can offer statically. + + Union of the always-on sets and all keyword-gated values. Capability + extension tools ({cap}_{tool}) and mcp_* are dynamic and deliberately + excluded — base prompts never address them by name. Used by the + consistency guard that fails CI when a base prompt instructs the model + to call a tool it can never see. + """ + names = set(_CORE_TOOLS) | _SESSION_ALWAYS_ON | _CAPABILITY_AGENT_TOOLS + for gated in _KEYWORD_TOOLS.values(): + names.update(gated) + return frozenset(names) + Tool = ToolDefinition # alias for backwards-compatibility @@ -154,227 +391,15 @@ def to_schema_list(self, user_input: str = "") -> list[dict]: Specialized tools (platform, MCP) are included if keywords match.""" result = [] - # Core tools always included - core_tools = { - "read_file", - "write_file", - "list_directory", - "search_files", - "create_directory", - "delete_file", - "delete_directory", - "move_file", - "run_command", - "web_search", - } - - # bailian_* CLI wrappers are numerous (~25 schemas each with full - # parameter definitions); offering them only when the request mentions - # the matching capability keeps every unrelated call much smaller. - b_all = [ - "bailian_text_chat", - "bailian_omni", - "bailian_image_generate", - "bailian_image_edit", - "bailian_video_generate", - "bailian_video_edit", - "bailian_video_ref", - "bailian_video_task_get", - "bailian_video_download", - "bailian_vision_describe", - "bailian_app_call", - "bailian_app_list", - "bailian_memory_add", - "bailian_memory_search", - "bailian_memory_list", - "bailian_memory_update", - "bailian_memory_delete", - "bailian_memory_profile_create", - "bailian_memory_profile_get", - "bailian_knowledge_retrieve", - "bailian_search_web", - "bailian_speech_synthesize", - "bailian_speech_recognize", - "bailian_file_upload", - "bailian_model_list", - "bailian_console_call", - "bailian_usage_free", - ] - b_image = [ - "bailian_image_generate", - "bailian_image_edit", - "bailian_vision_describe", - "bailian_omni", - ] - b_video = [ - "bailian_video_generate", - "bailian_video_edit", - "bailian_video_ref", - "bailian_video_task_get", - "bailian_video_download", - ] - b_speech = ["bailian_speech_synthesize", "bailian_speech_recognize"] - b_memory = [ - "bailian_memory_add", - "bailian_memory_search", - "bailian_memory_list", - "bailian_memory_update", - "bailian_memory_delete", - "bailian_memory_profile_create", - "bailian_memory_profile_get", - ] - - # Keyword → tool mapping for conditional inclusion. - # Chinese keywords are kept for CJK input; English equivalents - # are appended alongside them so English prompts match too. - keyword_tools = { - "memory": ["memory_search", "memory_store"] + b_memory, - "档案": ["memory_search", "memory_store"], - "profile": ["memory_search", "memory_store"], - "偏好": ["memory_search", "memory_store"], - "preference": ["memory_search", "memory_store"], - "记忆": b_memory, - "数据": [ - "data_upload", - "data_files", - "data_delete", - "data_categories", - ], - "data": [ - "data_upload", - "data_files", - "data_delete", - "data_categories", - ], - "prompt": [ - "prompt_list", - "prompt_get", - "prompt_create", - "prompt_delete", - "prompt_render", - ], - "模板": [ - "prompt_list", - "prompt_get", - "prompt_create", - "prompt_delete", - "prompt_render", - ], - "template": [ - "prompt_list", - "prompt_get", - "prompt_create", - "prompt_delete", - "prompt_render", - ], - "搜索": ["web_search", "context_search"], - "search": ["web_search", "context_search"], - "模型": ["switch_model", "switch_provider"], - "model": ["switch_model", "switch_provider"], - "能力": ["capability_enable", "capability_disable"], - "capability": ["capability_enable", "capability_disable"], - # Browser / web scraping tools - "scrape": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "抓取": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "网页": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "webpage": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "截图": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "screenshot": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "browser": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "爬取": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - "crawl": [ - "scrape_web", - "scrape_web_html", - "scrape_web_screenshot", - ], - # Camera tools - "拍照": ["camera_capture", "camera_record"], - "photo": ["camera_capture", "camera_record"], - "摄像头": ["camera_capture", "camera_record"], - "录屏": ["camera_capture", "camera_record"], - "录制": ["camera_capture", "camera_record"], - "record": ["camera_capture", "camera_record"], - "camera": ["camera_capture", "camera_record"], - # Bailian platform tools (schemas are keyword-gated, see b_* above) - "图片": b_image, - "画图": b_image, - "draw": b_image, - "image": b_image, - "vision": b_image, - "多模态": ["bailian_omni"], - "multimodal": ["bailian_omni"], - "omni": ["bailian_omni"], - "视频": b_video, - "video": b_video, - "语音": b_speech, - "speech": b_speech, - "tts": ["bailian_speech_synthesize"], - "朗读": ["bailian_speech_synthesize"], - "read aloud": ["bailian_speech_synthesize"], - "知识库": ["bailian_knowledge_retrieve"], - "knowledge": ["bailian_knowledge_retrieve"], - "rag": ["bailian_knowledge_retrieve"], - "百炼应用": ["bailian_app_call", "bailian_app_list"], - "bailian app": ["bailian_app_call", "bailian_app_list"], - "用量": ["bailian_usage_free"], - "usage": ["bailian_usage_free"], - "quota": ["bailian_usage_free"], - "上传": ["bailian_file_upload"], - "upload": ["bailian_file_upload"], - # Catch-all: mentioning the platform unlocks the full set - "百炼": b_all, - "bailian": b_all, - } - # Determine which tools to include - include_tools = set(core_tools) + include_tools = set(_CORE_TOOLS) - # Add session tools (always available) - include_tools.update( - [ - "switch_model", - "switch_provider", - "capability_enable", - "capability_disable", - "view_image", - ], - ) - include_tools.update(["mcp_connect", "mcp_disconnect"]) + include_tools.update(_SESSION_ALWAYS_ON) - # Model-invocable skill templates (.acli/skills/*.md) - include_tools.add("use_skill") + # evolve_skill stays env-gated until caps and utility pruning land — + # auto-generated skills without pruning are library noise. + if os.environ.get("ACLI_EVOLVE_SKILL", "0") == "1": + include_tools.add("evolve_skill") # Add capability-gated agent tools (delegate/subagent_invoke) whenever # they are registered — registration itself is the capability gate. @@ -406,7 +431,7 @@ def to_schema_list(self, user_input: str = "") -> list[dict]: ).lower() else: input_lower = user_input.lower() - for keyword, tools in keyword_tools.items(): + for keyword, tools in _KEYWORD_TOOLS.items(): if keyword.lower() in input_lower: include_tools.update(tools) diff --git a/dashscope/acli/tools/shell.py b/dashscope/acli/tools/shell.py index 7514047..6219703 100644 --- a/dashscope/acli/tools/shell.py +++ b/dashscope/acli/tools/shell.py @@ -28,6 +28,157 @@ # POSIX, hence getattr. _NEW_GROUP_FLAGS = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) +# Moment-of-relevance tool discovery: when a command touches an image +# file, or its output is a terminal approximation of something visual +# (ASCII-art render), a tip reminds the model that perception belongs to +# view_image. A static system-prompt rule is invisible once the model +# has framed the task as hand-rolled CV — solve-sudoku and gcode-to-text +# burned five runs between them on terminal ASCII cell/glyph extraction +# with view_image in the schema the whole time (zero mentions of it in +# the transcripts). +_IMAGE_PATH_RE = re.compile( + r"\.(?:png|jpe?g|gif|bmp|webp|tiff?|svg|heic)\b", + re.IGNORECASE, +) +_VISION_TIP_MAX = 3 +_vision_tip_count = 0 + +# Escalating wording by sighting ordinal: a soft pointer first, then a +# directive with the evidence, then blunt. solve-sudoku (071e) saw the +# soft tip at t=6s — before any route commitment — and still spent the +# run on read_file binary dumps and pixel scripts, so repetition alone +# is not the lever; the tone has to change. +# Escalating wording by sighting ordinal — but the first sighting is +# already directive. solve-sudoku (071e/071g) ignored the soft opener at +# t=6s twice and committed to hand-rolled extraction; the directive tone +# that later tips carried moved tooling within minutes (tesseract +# installed), just too late to rebuild the route around it. The verdict +# has to arrive before the route does. +_VISION_TIPS = ( + "this looks like image-perception work, and terminal/pixel " + "approximations lose exactly the detail the answer depends on — " + "use a purpose-built tool from the start: call view_image on the " + "image (for data you rendered, render a PNG first and call it on " + "that); for exact digit-level text, run a local OCR engine such " + "as tesseract on the image file", + "the proxy route has already started, and hand-rolled pixel " + "extraction has produced wrong answers in practice — stop and " + "switch: view_image on the image (or a PNG render), or tesseract " + "OCR for exact text", + "if you keep your own extraction, cross-check it with an " + "independent method (view_image, or tesseract for exact text) — " + "one misread value quietly invalidates everything built on it", +) + + +def _looks_like_ascii_art(output: str) -> bool: + """Grid of low-alphabet lines: the signature of rendering to text. + + A binary numpy matrix print qualifies too — and should, since + eyeballing a bitmap in text form is the same failure shape. Hex + dumps (rich per-line alphabet) and markdown tables (short lines or + rich alphabet) stay out. + + Judged on the grid lines alone: the truncation notice and [stderr] + marker this tool appends are prose, and counting them in the alphabet + silenced the detector on every large render (gcode-to-text, + 2026-10-13__fix-071d). Renderers also rstrip rows, so sparse art + comes in under 40 chars a line — the floor is 30, plus an absolute + grid-line count so progress-bar spam and `seq` output stay out. + """ + lines = [ln for ln in output.split("\n") if ln.strip()] + if len(lines) < 10: + return False + grid = [ln for ln in lines if len(ln) >= 30 and len(set(ln)) <= 8] + if len(grid) < 10 or len(grid) < 0.5 * len(lines): + return False + return len(set("".join(grid))) <= 12 + + +def _vision_inject(command: str) -> str: + """Perception delivered into the command result itself. + + read_file's auto-perceive only fires when the model opens the image + with read_file — but the bench evidence (071j) is that the model's + perception channel is run_command: `file sudoku.jpeg` at t=0s, then + heredoc pixel scripts for the rest of the budget, never a read_file. + When a command references an image file, resolve it (absolute, + process cwd, or a `cd` target earlier in the command) and inject the + auto-perception inline. SVG is excluded: it is XML markup the model + reads fine as text. Falls back to the tip when no image resolves or + the hook is unavailable. + """ + if os.environ.get("ACLI_AUTO_VISION", "1") == "0": + return "" + if not _IMAGE_PATH_RE.search(command): + return "" + from dashscope.acli.tools import vision + + if not vision.perception_available(): + return "" + + tokens = re.split(r"[\s\"'()=<>]+", command) + candidates = [] + for tok in tokens: + tok = tok.strip(";,:") + if ( + _IMAGE_PATH_RE.search(tok) + and not tok.lower().endswith(".svg") + and tok not in candidates + ): + candidates.append(tok) + if not candidates: + return "" + + bases = [os.getcwd()] + bases += re.findall(r"(?:^|&&|\|\||;)\s*cd\s+([^\s;&|]+)", command) + for cand in candidates: + if os.path.isabs(cand): + trials = [cand] + else: + trials = [os.path.join(b, cand) for b in bases] + for p in trials: + if os.path.isfile(p): + perceived = vision.auto_perceive(p, via="command") + if perceived is not None: + return "\n" + perceived + break + return "" + + +def _vision_tip(command: str, output: str) -> str: + """Pointer at view_image, fired at the moment perception is on the + table. Covers reading an existing image, writing one (perceive your + own render), and the ASCII-art fallback shape. ACLI_VISION_TIP=0 + disables. Once perception has actually happened — a view_image call, + or a read_file auto-perception — the tool-choice battle is over: + tips stop instead of nagging a model that already has the image + content (and may be rendering PNGs for it).""" + global _vision_tip_count + if _vision_tip_count >= _VISION_TIP_MAX: + return "" + if os.environ.get("ACLI_VISION_TIP", "1") == "0": + return "" + from dashscope.acli.tools import vision + + if vision.view_image_call_count() > 0 or vision.auto_perceive_count() > 0: + return "" + if _IMAGE_PATH_RE.search(command): + reason = "this command references an image file" + elif _looks_like_ascii_art(output): + reason = "this output is a terminal approximation of something visual" + else: + return "" + text = _VISION_TIPS[min(_vision_tip_count, len(_VISION_TIPS) - 1)] + _vision_tip_count += 1 + return f"\n[tip: {reason} — {text}]" + + +def _reset_vision_tip() -> None: + """Test hook: the tip budget is per-process, suites need a clean slate.""" + global _vision_tip_count + _vision_tip_count = 0 + def default_timeout() -> int: """Seconds a run_command call gets before it is killed. @@ -792,4 +943,8 @@ async def run_command(command: str, timeout: int | None = None) -> str: ) exit_info = f"\n[exit code: {proc.returncode}]" - return output.strip() + exit_info + return ( + output.strip() + + exit_info + + (_vision_inject(command) or _vision_tip(command, output)) + ) diff --git a/dashscope/acli/tools/vision.py b/dashscope/acli/tools/vision.py index 8392e9a..d45e30c 100644 --- a/dashscope/acli/tools/vision.py +++ b/dashscope/acli/tools/vision.py @@ -13,6 +13,14 @@ ``view_image`` auto-slices them with Pillow (when installed) and runs one vision call per slice. Without Pillow it falls back to sending the whole image as-is. + +``auto_perceive`` is the read_file hook: nine bench runs (071c–071i) +showed the model reads an image as text exactly once, receives the +"call view_image / tesseract" redirect, and goes back to pixel +scripting within seconds — advice is ignorable, delivered perception +is not. When read_file hits a binary image it calls ``auto_perceive``, +which runs the same vision pipeline inline and injects the result, +falling back to the text redirect whenever perception is unavailable. """ from __future__ import annotations @@ -21,6 +29,8 @@ import base64 import io import math +import os +import threading from typing import Callable from dashscope.acli.config import Config, is_vision_model @@ -31,7 +41,12 @@ ) _FALLBACK_VISION_MODELS = { - "tongyi": "qwen-vl-max", + # qwen-vl-max is retired on the public DashScope endpoint (404 "Model + # not exist"; qwen-vl-max-latest exists but is 403-gated on accounts + # without access). qwen3-vl-plus is the current generally-available VL + # model — verified against the live endpoint 2026-10-13 after the + # stale fallback silently broke every view_image call. + "tongyi": "qwen3-vl-plus", "openai": "gpt-4o", "anthropic": "claude-sonnet-4-20250514", } @@ -45,6 +60,72 @@ _SLICE_MAX_COUNT = 32 _SLICE_JPEG_QUALITY = 88 +# Region crops (the ``region`` parameter): small crops are enlarged +# before the vision call so dense content stays legible. +_REGION_MIN_SHORT_SIDE = 768 +_REGION_MAX_UPSCALE = 4.0 + +# Independent samples per region read. Region reads exist for +# exact-character verification, where one sample is a coin flip at the +# recognizer's noise floor — gcode-to-text 071r got i/ï/1 for the same +# glyph across identical crops and 071s committed after single reads of +# each region, never seeing the disagreements. Sampling surfaces the +# noise in the result text so it can be adjudicated instead of trusted. +# Runs concurrently: wall-clock stays ~one read. Env-overridable. +_REGION_SAMPLES = int(os.environ.get("ACLI_REGION_SAMPLES", "3")) + +# Adopted-once signal for the run_command vision tip: after the first +# view_image call the tool-choice nudges stop (see shell._vision_tip). +_view_image_calls = 0 + +# Hard cap on one vision call. gcode-to-text (071n) ended with a +# view_image await that produced no output for 700+s — the spinner +# froze, the turn never returned, and the harness watchdog reaped the +# container before the in-container cap could. A vision read that +# takes minutes is indistinguishable from a hang; fail it and let the +# model retry or narrow the ask. Env-overridable for slow endpoints. +_VISION_CALL_TIMEOUT = int(os.environ.get("ACLI_VISION_TIMEOUT", "300")) + + +def view_image_call_count() -> int: + return _view_image_calls + + +def _reset_view_image_calls() -> None: + """Test hook: the counter is per-process, suites need a clean slate.""" + global _view_image_calls + _view_image_calls = 0 + + +# Auto-perception state. _PERCEPTION_CTX is stored by +# register_vision_tools so the read_file hook can reach the active +# provider/config without threading them through the filesystem layer. +_PERCEPTION_CTX = None +_auto_perceive_attempts = 0 +_auto_perceived_paths: set = set() +_AUTO_PERCEIVE_MAX = 5 +# A sliced oversized image runs its calls concurrently; 240s bounds the +# tail without cutting realistic reads. +_AUTO_PERCEIVE_TIMEOUT = 240 + + +def auto_perceive_count() -> int: + return _auto_perceive_attempts + + +def _reset_auto_perceive() -> None: + """Test hook: attempts are per-process, suites need a clean slate.""" + global _auto_perceive_attempts + _auto_perceive_attempts = 0 + _auto_perceived_paths.clear() + + +def _auto_perceive_budget() -> int: + try: + return int(os.environ.get("ACLI_AUTO_VISION_MAX", _AUTO_PERCEIVE_MAX)) + except ValueError: + return _AUTO_PERCEIVE_MAX + def pick_vision_model(config: Config) -> str: """Choose a vision-capable model on the active provider. @@ -61,7 +142,7 @@ def pick_vision_model(config: Config) -> str: ext = find_provider(config.provider) if ext and ext.vision_models: return ext.vision_models[0] - return _FALLBACK_VISION_MODELS.get(config.provider, "qwen-vl-max") + return _FALLBACK_VISION_MODELS.get(config.provider, "qwen3-vl-plus") def plan_slices(width: int, height: int): @@ -108,16 +189,65 @@ def plan_slices(width: int, height: int): def _slice_data_urls(image, boxes) -> list: """Crop ``boxes`` out of a PIL image, JPEG-encode as data URLs.""" - urls = [] - for box in boxes: - crop = image.crop(box) - if crop.mode not in ("RGB", "L"): - crop = crop.convert("RGB") - buf = io.BytesIO() - crop.save(buf, format="JPEG", quality=_SLICE_JPEG_QUALITY) - b64 = base64.b64encode(buf.getvalue()).decode() - urls.append(f"data:image/jpeg;base64,{b64}") - return urls + return [_encode_jpeg(image.crop(box)) for box in boxes] + + +def _encode_jpeg(crop) -> str: + if crop.mode not in ("RGB", "L"): + crop = crop.convert("RGB") + buf = io.BytesIO() + crop.save(buf, format="JPEG", quality=_SLICE_JPEG_QUALITY) + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/jpeg;base64,{b64}" + + +def parse_region(region: str, width: int, height: int) -> tuple: + """Parse ``x0,y0,x1,y1`` fractions into a pixel crop box. + + Fractions keep the parameter resolution-independent: the model can + ask for "the top-left ninth" as 0,0,0.333,0.333 without knowing the + image size. Pixels would invite off-by-one hallucinations. + """ + parts = [p.strip() for p in region.split(",")] + if len(parts) != 4: + raise ValueError( + f'expected "x0,y0,x1,y1" (4 comma-separated fractions), ' + f"got {region!r}", + ) + try: + x0, y0, x1, y1 = (float(p) for p in parts) + except ValueError: + raise ValueError( + f"region values must be numbers, got {region!r}", + ) from None + if not (0 <= x0 < x1 <= 1 and 0 <= y0 < y1 <= 1): + raise ValueError( + f"region must satisfy 0<=x0= _REGION_MIN_SHORT_SIDE: + return crop + factor = min(_REGION_MAX_UPSCALE, _REGION_MIN_SHORT_SIDE / short) + from PIL import Image + + return crop.resize( + (max(1, round(w * factor)), max(1, round(h * factor))), + Image.LANCZOS, + ) async def _ask_vision(provider, data_url: str, text: str) -> str: @@ -130,12 +260,287 @@ async def _ask_vision(provider, data_url: str, text: str) -> str: ], }, ] - response = await provider.chat(messages) + try: + response = await asyncio.wait_for( + provider.chat(messages), + timeout=_VISION_CALL_TIMEOUT, + ) + except (TimeoutError, asyncio.TimeoutError): + # asyncio.TimeoutError is distinct from TimeoutError on 3.10. + return ( + f"Error: vision call timed out after {_VISION_CALL_TIMEOUT}s; " + "retry, or narrow the region/question." + ) return response.content or "(vision model returned no content)" +def _aggregate_region_answers(answers: list[str]) -> str: + """Merge the independent samples of one region read. + + A unanimous vote returns the single answer, indistinguishable from a + one-shot read. Any split shows every sample with its vote count so + the disagreement is visible in the result — hiding it would be the + single-read coin flip all over again (gcode-to-text 071s). + """ + if not answers: + return "(vision model returned no content)" + if len(set(answers)) == 1: + return answers[0] + samples = "\n\n".join( + f"[sample {i + 1}/{len(answers)}]\n{a}" for i, a in enumerate(answers) + ) + return ( + samples + + "\n\n[(these independent reads of the same crop disagree — do " + "not just pick one: re-read a tighter crop of only the contested " + "glyph with a feature question (dot, serif, enclosed counters " + "present or absent), and prefer the candidate that makes the " + "assembled string read as coherent text)]" + ) + + +async def _perceive_region( + config: Config, + get_provider_fn: Callable, + provider, + safe_path: str, + question: str, + slice_mode: str, + region: str, +) -> str: + """Region read: real pixel crop, upscaled, sampled _REGION_SAMPLES×.""" + # A real pixel crop beats asking about a region of the full + # image: solve-sudoku 071l did everything procedurally right + # (whole read, band re-reads, box re-reads) but the crops were + # attention-simulated by the VL and two cells came back wrong — + # the model then "resolved" the inconsistency by guessing and + # solved a different puzzle. + try: + from PIL import Image + except ImportError: + # Pillow is optional and the offline bench bundle is + # pure-python (gcode-to-text 071o: three region calls + # errored in a row). Degrade to the whole-image read with + # working alternatives instead of failing the verification + # loop outright. + whole = await _perceive_image( + config, + get_provider_fn, + safe_path, + question, + slice_mode, + ) + return ( + # Warning FIRST: appended after the read, the note let + # gcode-to-text 071u mistake two whole-image reads for + # two independent region verifications of its answer. + "[REGION READ FAILED — Pillow is not installed in this " + "environment. What follows is a WHOLE-IMAGE read, NOT " + "the requested crop: it is not independent verification " + "of the region, and it must not be treated as " + "confirmation of an earlier whole-image read. " + "`pip install pillow` enables real region crops; or " + "zoom the region into its own image file and read " + "that.]\n\n" + whole + ) + try: + with Image.open(safe_path) as im: + im.load() + box = parse_region(region, *im.size) + crop = _upscale_for_vision(im.crop(box)) + data_url = _encode_jpeg(crop) + except ValueError as e: + return f"Error: invalid region: {e}" + except Exception as e: # pylint: disable=broad-except + return f"Error: failed to crop region: {e}" + try: + answers = await asyncio.gather( + *( + _ask_vision(provider, data_url, question) + for _ in range(max(_REGION_SAMPLES, 1)) + ), + ) + except Exception as e: # pylint: disable=broad-except + return f"Error: vision call failed: {e}" + return _aggregate_region_answers(list(answers)) + + +async def _perceive_image( + config: Config, + get_provider_fn: Callable, + safe_path: str, + question: str, + slice_mode: str, + region: str = "", +) -> str: + """Run one vision read of ``safe_path`` (pre-validated). + + Shared by the view_image tool and the read_file auto-perceive hook; + errors are returned as text, matching the tool contract. + """ + model = pick_vision_model(config) + provider = get_provider_fn( + config.provider, + model, + config.api_key, + base_url=config.base_url or None, + ) + + if region: + return await _perceive_region( + config, + get_provider_fn, + provider, + safe_path, + question, + slice_mode, + region, + ) + + boxes = None + image = None + if slice_mode != "never": + try: + from PIL import Image + + image = Image.open(safe_path) + image.load() + boxes = plan_slices(*image.size) + except Exception: # pylint: disable=broad-except + # No Pillow, or not a decodable image: fall back to the + # whole-image path and let it report the real error. + boxes = None + image = None + + if boxes: + axis = ( + "top-to-bottom" + if image.height >= image.width + else ("left-to-right") + ) + urls = _slice_data_urls(image, boxes) + image.close() + total = len(urls) + tasks = [ + _ask_vision( + provider, + url, + ( + f"Slice {i + 1}/{total} ({axis}) of one large " + "image. " + question + ), + ) + for i, url in enumerate(urls) + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + parts = [ + f"(Image auto-split into {total} slices {axis}; " + "results per slice below.)", + ] + for i, r in enumerate(results, start=1): + if isinstance(r, BaseException): + parts.append(f"--- slice {i}/{total} ---\nError: {r}") + else: + parts.append(f"--- slice {i}/{total} ---\n{r}") + return "\n\n".join(parts) + + try: + from dashscope.acli.utils.images import image_to_data_url + + data_url = image_to_data_url(safe_path) + except (ValueError, OSError) as e: + return f"Error: failed to read image: {e}" + try: + return await _ask_vision(provider, data_url, question) + except Exception as e: # pylint: disable=broad-except + return f"Error: vision call failed: {e}" + + +def perception_available() -> bool: + """Whether an agent has registered a perception context.""" + return _PERCEPTION_CTX is not None + + +def auto_perceive(safe_path: str, *, via: str = "read_file") -> str | None: + """Perceive an image inline and return the read, or None. + + Called by read_file (an image opened as text) and by run_command (a + command referencing an image). ``safe_path`` must already exist. + Returns None — caller then falls back to its text hint — when the + hook is disabled (ACLI_AUTO_VISION=0), unconfigured (no agent + registered a perception context), this same image was already + perceived once (the content is in context; repeats just burn + latency), out of budget (ACLI_AUTO_VISION_MAX successful+failed + attempts per process), or the pipeline itself raises. + + The agent loop runs sync tools inline on the event-loop thread, so + the async pipeline runs on a helper thread with its own loop — the + same blocking shape run_command already has. + """ + global _auto_perceive_attempts + if os.environ.get("ACLI_AUTO_VISION", "1") == "0": + return None + if _PERCEPTION_CTX is None: + return None + real = os.path.realpath(safe_path) + if real in _auto_perceived_paths: + return None + if _auto_perceive_attempts >= _auto_perceive_budget(): + return None + _auto_perceive_attempts += 1 + config, get_provider_fn = _PERCEPTION_CTX + question = ( + "Describe this image in detail. If it contains text, digits or " + "tables, transcribe them verbatim." + ) + + box: list = [] + + def _runner(): + try: + box.append( + asyncio.run( + _perceive_image( + config, + get_provider_fn, + real, + question, + "auto", + ), + ), + ) + except BaseException: # pylint: disable=broad-except + box.append(None) + + worker = threading.Thread(target=_runner, daemon=True) + worker.start() + worker.join(timeout=_AUTO_PERCEIVE_TIMEOUT) + if not box or box[0] is None: + return None + _auto_perceived_paths.add(real) + if via == "command": + prefix = ( + "[this command references an image; its content was " + "perceived with a vision model (auto-perception):]" + ) + else: + prefix = ( + "[read_file cannot decode images as text; this image was " + "perceived with a vision model (auto-perception):]" + ) + return ( + f"{prefix}\n" + f"{box[0]}\n" + "[for exact digit-level text cross-check with tesseract OCR on " + "the file, or call view_image with a focused question or a " + "cropped region.]" + ) + + def register_vision_tools(config: Config, *, get_provider_fn: Callable): """Register ``view_image``; called once after agent construction.""" + global _PERCEPTION_CTX + _PERCEPTION_CTX = (config, get_provider_fn) async def view_image( image_path: str, @@ -144,7 +549,11 @@ async def view_image( "tables, transcribe them verbatim." ), slice_mode: str = "auto", + region: str = "", ) -> str: + global _view_image_calls + _view_image_calls += 1 + from dashscope.acli.utils.paths import validate_path try: @@ -152,81 +561,62 @@ async def view_image( except (ValueError, OSError) as e: return f"Error: failed to read image: {e}" - model = pick_vision_model(config) - provider = get_provider_fn( - config.provider, - model, - config.api_key, - base_url=config.base_url or None, + return await _perceive_image( + config, + get_provider_fn, + safe_path, + question, + slice_mode, + region, ) - boxes = None - image = None - if slice_mode != "never": - try: - from PIL import Image - - image = Image.open(safe_path) - image.load() - boxes = plan_slices(*image.size) - except Exception: # pylint: disable=broad-except - # No Pillow, or not a decodable image: fall back to the - # whole-image path and let it report the real error. - boxes = None - image = None - - if boxes: - axis = ( - "top-to-bottom" - if image.height >= image.width - else ("left-to-right") - ) - urls = _slice_data_urls(image, boxes) - image.close() - total = len(urls) - tasks = [ - _ask_vision( - provider, - url, - ( - f"Slice {i + 1}/{total} ({axis}) of one large " - "image. " + question - ), - ) - for i, url in enumerate(urls) - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - parts = [ - f"(Image auto-split into {total} slices {axis}; " - "results per slice below.)", - ] - for i, r in enumerate(results, start=1): - if isinstance(r, BaseException): - parts.append(f"--- slice {i}/{total} ---\nError: {r}") - else: - parts.append(f"--- slice {i}/{total} ---\n{r}") - return "\n\n".join(parts) - - try: - from dashscope.acli.utils.images import image_to_data_url - - data_url = image_to_data_url(safe_path) - except (ValueError, OSError) as e: - return f"Error: failed to read image: {e}" - try: - return await _ask_vision(provider, data_url, question) - except Exception as e: # pylint: disable=broad-except - return f"Error: vision call failed: {e}" - registry.register( ToolDefinition( name="view_image", description=( - "Analyze a local image file (screenshot, photo, chart) " - "with a vision model: OCR/transcribe text, describe " - "layout or content. Very tall/wide images are split " - "automatically. Use after scrape_web_screenshot or " - "camera_capture, or whenever you need to read an image." + "Analyze a local image file (screenshot, photo, chart, " + "scan, render) with a vision model: OCR/transcribe text " + "or digits, describe layout or content. Very tall/wide " + "images are split automatically. Use whenever a task " + "requires reading an image's content — including images " + "you rendered yourself from data (plots, geometry, " + "toolpaths) — instead of approximating the perception " + "with terminal output or pixel scripting. For grid-like " + "content (tables, digit grids), read the whole image " + "first, then re-read it block by block with the region " + "parameter: region is a real pixel crop (upscaled " + "automatically), far more accurate than asking about a " + "region of the full image. When every character matters " + "(codes, serial numbers, mixed-case text), never trust " + "one whole-image read of small or stylized text: " + "verify it with region reads of a few characters each " + "before committing to an answer, and overlap adjacent " + "crops so no character sits on a crop boundary. When " + "re-reads of one glyph keep disagreeing, more identical " + "re-reads will not converge — the glyph is at the " + "recognizer's noise floor: adjudicate with a feature " + "question (a dot, serif, or enclosed counter present or " + "absent) on a crop of only that glyph, and prefer the " + "candidate that makes the assembled string read as " + "coherent text over the one that does not. Overlapping " + "crops also cross-check each other: characters shared by " + "two crops must read the same in both, and if they do " + "not, re-read the contested span before committing. And " + "when you rendered the image yourself, ambiguous reads " + "are a rendering problem, not a perception limit: " + "re-render larger or higher-contrast and read the new " + "render instead of re-reading the same pixels. Glyphs " + "from confusable sets (0/O/o, 1/l/i/I, 5/S, 8/B, and " + "same-shape letter case like cC, kK, pP) need " + "feature-level confirmation — dot, serif, counter, " + "relative height — not just repeated transcription: " + "stylized fonts make wrong glyphs look unambiguous. " + "Context is what corrupts stylized reads: word-shaped " + "crops get pattern-completed into expected words, while " + "a crop of one isolated glyph can only be read as " + "pixels — settle contested characters glyph by glyph, " + "and never let an expected word override what isolated " + "glyphs clearly show." ), permission=PermissionLevel.AUTO, func=view_image, @@ -256,6 +646,27 @@ async def view_image( "image in one call" ), }, + "region": { + "type": "string", + "description": ( + 'Crop before reading: "x0,y0,x1,y1" as ' + "fractions of width/height (0-1, origin " + "top-left), e.g. the middle horizontal " + 'third is "0,0.333,1,0.667". Overrides ' + "slicing. For dense grids/tables transcribe " + "a few cells per call; for exact-character " + "work (codes, labels, stylized or small " + "text) crop to a few characters per call. " + "When tiling a long string with several " + "crops, overlap adjacent crops: a character " + "at a crop boundary is exactly the one most " + "likely to be dropped or duplicated, so " + "every character must appear fully inside " + "at least one crop. Each region read samples " + "the vision model several times and shows " + "every answer when they disagree." + ), + }, }, "required": ["image_path"], }, diff --git a/dashscope/acli/utils/crypto.py b/dashscope/acli/utils/crypto.py index e0e8fc5..c4dc813 100644 --- a/dashscope/acli/utils/crypto.py +++ b/dashscope/acli/utils/crypto.py @@ -8,8 +8,10 @@ from __future__ import annotations import base64 +import binascii import hashlib import platform +import sys import uuid _ENC_PREFIX = "ENC:" @@ -36,11 +38,30 @@ def encrypt_value(plaintext: str) -> str: def decrypt_value(stored: str) -> str: - """Reverse :func:`encrypt_value`.""" + """Reverse :func:`encrypt_value`; undecryptable values decode to "". + + The machine key derives from hostname + MAC, so a config.toml copied + from another machine — or a renamed host — holds ENC: values this + machine cannot read. Raising here used to kill agent startup with a + bare UnicodeDecodeError; treating the value as unset routes the user + back through key entry instead. + """ if not stored: return "" if not stored.startswith(_ENC_PREFIX): return stored key = _machine_key() - encrypted = base64.b64decode(stored[len(_ENC_PREFIX) :]) - return _xor_cipher(encrypted, key).decode() + try: + encrypted = base64.b64decode( + stored[len(_ENC_PREFIX) :], + validate=True, + ) + return _xor_cipher(encrypted, key).decode() + except (binascii.Error, UnicodeDecodeError): + print( + "acli: a stored ENC: value could not be decrypted on this " + "machine (hostname or hardware changed?); treating it as " + "unset — re-enter it via /provider", + file=sys.stderr, + ) + return "" diff --git a/dashscope/acli/utils/paths.py b/dashscope/acli/utils/paths.py index d529987..454d79f 100644 --- a/dashscope/acli/utils/paths.py +++ b/dashscope/acli/utils/paths.py @@ -5,6 +5,7 @@ import os from pathlib import Path +from uuid import uuid4 # Directory / file names considered sensitive. Any path containing these # as a component is blocked for read, write, and delete operations. @@ -123,10 +124,13 @@ def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None: Writes to a temp file in the SAME directory, flush+fsync, then os.replace — a crash mid-write leaves the old file intact instead of - truncating it. + truncating it. The temp name carries pid+uuid: two writers sharing one + fixed temp path would clobber each other's content before the replace. """ path = Path(path) - tmp = path.with_suffix(path.suffix + ".tmp") + tmp = path.with_name( + f"{path.name}.{os.getpid()}.{uuid4().hex[:8]}.tmp", + ) try: with tmp.open("w", encoding=encoding) as f: f.write(text) diff --git a/tests/unit/test_acli_provider_retries.py b/tests/unit/test_acli_provider_retries.py index ca67650..6b5eda5 100644 --- a/tests/unit/test_acli_provider_retries.py +++ b/tests/unit/test_acli_provider_retries.py @@ -32,6 +32,7 @@ from dashscope.acli.providers.hardening import ( HardenedProvider, RetriesExhausted, + account_error_hint, is_retryable_error, ) from dashscope.acli.providers.profile import ProviderChain, ProviderProfile @@ -207,3 +208,33 @@ async def test_a_failure_after_the_first_chunk_stays_plain(): assert [c.delta_content for c in seen] == ["hi"] assert not isinstance(exc.value, RetriesExhausted) assert inner.stream_calls == 1 + + +# The literal 403 the DashScope gateway raises on an overdue account. +# Permanent until the user fixes billing, so neither the retry layer nor +# the fallback chain may spend a budget on it. +OVERDUE = ( + "DashScope API error: 403 - Access denied, please make sure your " + "account is in good standing. For details, see: " + "https://help.aliyun.com/zh/model-studio/error-code#overdue-payment" +) + + +async def test_a_gateway_account_block_is_never_retried(): + inner = _Scripted(chat=[RuntimeError(OVERDUE)] * 4) + hardened = HardenedProvider(inner, max_retries=3, retry_delay=0.01) + + with pytest.raises(RuntimeError) as exc: + await hardened.chat(MESSAGES) + assert not isinstance(exc.value, RetriesExhausted) + assert inner.chat_calls == 1 + assert is_retryable_error(exc.value) is False + + +def test_account_error_hint_flags_billing_errors_only(): + assert "account/billing" in account_error_hint(RuntimeError(OVERDUE)) + assert account_error_hint(RuntimeError("账户已欠费,请充值")) + # Non-retryable is not the same as an account error: a bad key gets no + # billing hint, and neither does a transient timeout. + assert account_error_hint(RuntimeError("invalid api key")) == "" + assert account_error_hint(RuntimeError("timeout")) == "" diff --git a/tests/unit/test_acli_reflection.py b/tests/unit/test_acli_reflection.py index baa214f..a99e464 100644 --- a/tests/unit/test_acli_reflection.py +++ b/tests/unit/test_acli_reflection.py @@ -10,6 +10,7 @@ StagnationTracker, convergence_hint, is_readonly_tool_call, + time_budget_hint, ) @@ -717,3 +718,58 @@ def test_soft_hint_contains_switch_advice(self): def test_hard_hint_says_no_new_approach(self): hint = convergence_hint(90, 100) assert "Do NOT start a new approach" in hint + + +# ── time_budget_hint ───────────────────────────────────────────────────────── + + +class TestTimeBudgetHint: + """Wall-clock counterpart of convergence_hint, deliverable-first. + + Fires on elapsed/budget, covering the few-but-huge-generations shape + where the turn-count nudge never triggers (7/150 turns can still burn + 95% of the harness's wall-clock cap). + """ + + def test_below_soft_returns_empty(self): + assert time_budget_hint(100, 900) == "" + # 449/900 = 0.499 < 0.5 + assert time_budget_hint(449, 900) == "" + + def test_at_soft_boundary_is_deliverable_first(self): + # 450/900 = 0.50 → fires soft + hint = time_budget_hint(450, 900) + assert "half spent" in hint + assert "450s of your ~900s budget" in hint + assert "(450s left)" in hint + assert "best-effort version NOW" in hint + # Explicitly not the turn nudge's "converge" wording: the failure + # shape here is a missing artifact, not a plateaued loop. + assert "converge" not in hint + assert "nearly exhausted" not in hint + + def test_soft_band_existing_deliverable_clause(self): + hint = time_budget_hint(600, 900) + assert "re-save after every improvement" in hint + + def test_at_hard_boundary(self): + # 720/900 = 0.80 → fires hard + hint = time_budget_hint(720, 900) + assert "nearly exhausted" in hint + assert "best version" in hint + assert "ONE final verification" in hint + assert "Do NOT start a new approach" in hint + assert "half spent" not in hint + + def test_remaining_clamps_at_zero(self): + hint = time_budget_hint(1000, 900) + assert "(0s left)" in hint + + def test_custom_ratios(self): + assert "half spent" in time_budget_hint(200, 900, soft_ratio=0.2) + hint = time_budget_hint(230, 900, soft_ratio=0.2, hard_ratio=0.25) + assert "nearly exhausted" in hint + + def test_disabled_without_budget(self): + assert time_budget_hint(100, 0) == "" + assert time_budget_hint(100, -5) == "" diff --git a/tests/unit/test_acli_vision_tool.py b/tests/unit/test_acli_vision_tool.py index d0d0f2e..92e2f2f 100644 --- a/tests/unit/test_acli_vision_tool.py +++ b/tests/unit/test_acli_vision_tool.py @@ -213,7 +213,9 @@ def test_fallback_per_provider(self, monkeypatch): "dashscope.acli.extensions.find_provider", lambda name: None, ) - assert vision.pick_vision_model(_config()) == "qwen-vl-max" + # qwen-vl-max is retired on the public DashScope endpoint (404); + # the tongyi fallback must be a currently-served VL model. + assert vision.pick_vision_model(_config()) == "qwen3-vl-plus" cfg = _config() cfg.provider = "openai" assert vision.pick_vision_model(cfg) == "gpt-4o" diff --git a/tests/unit/test_tongyi_native.py b/tests/unit/test_tongyi_native.py index 36ff2cc..b25d1d5 100644 --- a/tests/unit/test_tongyi_native.py +++ b/tests/unit/test_tongyi_native.py @@ -11,7 +11,9 @@ # pylint: disable=redefined-outer-name,protected-access +import asyncio import json +import time import pytest @@ -344,6 +346,88 @@ def test_openai_content_parts_converted_to_native(): assert converted[1]["content"] == "just text" +def test_reasoning_content_is_stripped_from_the_wire(): + """reasoning_content stays in local history for display/session restore, + but it must not go back on the wire: the API does not accept it as input + and resending it re-bills the whole thinking trace every turn.""" + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "hello", + "reasoning_content": "thinking trace", + }, + {"role": "user", "content": "again"}, + ] + body = _provider()._build_request_body( # pylint: disable=protected-access + messages, + None, + ) + converted = body["input"]["messages"] + assert converted[1] == {"role": "assistant", "content": "hello"} + # The caller's history dict keeps the trace — only the wire copy is + # rebuilt without it. + assert messages[1]["reasoning_content"] == "thinking trace" + + +def test_max_output_tokens_sent_when_configured(): + body = _provider( + max_output_tokens=4096, + )._build_request_body( # pylint: disable=protected-access + MESSAGES, + None, + ) + assert body["parameters"]["max_tokens"] == 4096 + + +def test_max_output_tokens_absent_by_default(monkeypatch): + monkeypatch.delenv("ACLI_MAX_OUTPUT_TOKENS", raising=False) + body = _provider()._build_request_body( # pylint: disable=protected-access + MESSAGES, + None, + ) + assert "max_tokens" not in body["parameters"] + + +def test_max_output_tokens_env_override(monkeypatch): + monkeypatch.setenv("ACLI_MAX_OUTPUT_TOKENS", "2048") + assert _provider().max_output_tokens == 2048 + # An explicit constructor argument wins over the env. + assert _provider(max_output_tokens=512).max_output_tokens == 512 + # An unparseable value falls back to disabled, not a crash. + monkeypatch.setenv("ACLI_MAX_OUTPUT_TOKENS", "junk") + assert _provider().max_output_tokens == 0 + + +def test_thinking_budget_sent_when_configured(): + body = _provider( + thinking_budget=4096, + )._build_request_body( # pylint: disable=protected-access + MESSAGES, + None, + ) + assert body["parameters"]["thinking_budget"] == 4096 + + +def test_thinking_budget_absent_by_default(monkeypatch): + monkeypatch.delenv("ACLI_THINKING_BUDGET", raising=False) + body = _provider()._build_request_body( # pylint: disable=protected-access + MESSAGES, + None, + ) + assert "thinking_budget" not in body["parameters"] + + +def test_thinking_budget_env_override(monkeypatch): + monkeypatch.setenv("ACLI_THINKING_BUDGET", "2048") + assert _provider().thinking_budget == 2048 + # An explicit constructor argument wins over the env. + assert _provider(thinking_budget=512).thinking_budget == 512 + # An unparseable value falls back to disabled, not a crash. + monkeypatch.setenv("ACLI_THINKING_BUDGET", "junk") + assert _provider().thinking_budget == 0 + + # --------------------------------------------------------------------------- # base_url normalization # --------------------------------------------------------------------------- @@ -705,3 +789,48 @@ def test_request_body_is_json_serializable(): assert parsed["parameters"]["response_format"] == { "type": "json_object", } + + +class _KeepaliveOnlyResponse(_FakeStreamResponse): + """The wedged-generation signature: keepalive comments flow forever + (resetting httpx's read timeout) while no data: line ever arrives — + the 067b bench failures sat in this state until the harness cap.""" + + async def aiter_lines(self): + while True: + await asyncio.sleep(0.01) + yield ": keep-alive" + + +class _StallAfterContentResponse(_FakeStreamResponse): + """One real chunk, then keepalive silence forever.""" + + async def aiter_lines(self): + yield NATIVE_SSE_LINES[3] + while True: + await asyncio.sleep(0.01) + yield ": keep-alive" + + +async def test_stream_stall_on_keepalives_raises_retryable(fake_http): + fake_http._stream_queue.append(_KeepaliveOnlyResponse([])) + provider = _provider(request_timeout=1) + started = time.monotonic() + with pytest.raises(RuntimeError, match="stream stalled") as excinfo: + async for _ in provider.chat_stream(MESSAGES): + pass + # bounded by the idle timeout (~1s), not by the caller's cap + assert time.monotonic() - started < 10 + from dashscope.acli.providers.hardening import is_retryable_error + + assert is_retryable_error(excinfo.value) + + +async def test_stream_stall_after_content_raises(fake_http): + fake_http._stream_queue.append(_StallAfterContentResponse([])) + provider = _provider(request_timeout=1) + chunks = [] + with pytest.raises(RuntimeError, match="stream stalled"): + async for chunk in provider.chat_stream(MESSAGES): + chunks.append(chunk) + assert any(c.delta_content for c in chunks)