diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index b5ff1e5..9fe8765 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -3,7 +3,7 @@ import uuid -__version__ = "0.7.1" +__version__ = "0.7.2" # 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 6df31bb..1ca1e4f 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -25,6 +25,7 @@ from dashscope.acli.hooks import HookBus, HookContext, create_hook_bus from dashscope.acli.memory.manager import MemoryManager from dashscope.acli.memory.reflection import ( + compression_hint, convergence_hint, is_readonly_tool_call, time_budget_hint, @@ -453,6 +454,11 @@ def __init__( # silence nudge has already been spent this turn. self._silent_tool_calls = 0 self._silent_nudge_injected = False + # Compression observability toward the model: the event is set by + # either compression path and spent once by _compression_section; + # the ratio is refreshed by the mid-turn guard's estimate. + self._compression_event: tuple[int, int] | None = None + self._context_ratio = 0.0 # Explicit /skill invocations to count on the next turn self._pending_skill_names: list[str] = [] @@ -677,6 +683,20 @@ def _time_budget_section(self) -> str: self.time_hard_ratio, ) + def _compression_section(self) -> str: + """Tell the model about compression it would otherwise never see. + + The compression event is spent once — after a compression the ratio + drops below the warn band anyway, so the state-based pre-hint alone + cannot double-fire. The ratio is refreshed by the mid-turn guard, so + the pre-hint can only fire from the second loop iteration on; the + first iteration of a turn is covered by the event path instead + (``_auto_compress`` runs just before the loop). + """ + event = self._compression_event + self._compression_event = None + return compression_hint(event, self._context_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 @@ -938,10 +958,19 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: shrink_old_tool_messages(self.messages) # Mid-turn guard: a long tool loop can overflow the window within # a single user turn. Estimate-only check; compress and rebuild - # the request list only past the hard threshold. + # the request list only past the hard threshold. The estimate is + # kept as the context-fill ratio for the compression hint below. + if _loop_i > 0: + used_tokens = ( + estimate_message_tokens(self.messages) + overhead_tokens + ) + self._context_ratio = ( + used_tokens / self._context_window + if self._context_window > 0 + else 0.0 + ) if _loop_i > 0 and ( - estimate_message_tokens(self.messages) + overhead_tokens - >= int(self._context_window * HARD_THRESHOLD_RATIO) + used_tokens >= int(self._context_window * HARD_THRESHOLD_RATIO) ): compressed = await safety_compress_if_needed( self.messages, @@ -952,6 +981,7 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: if compressed is not None: old_count = len(self.messages) self.messages = compressed + self._compression_event = (old_count, len(self.messages)) messages_with_system = [ {"role": "system", "content": system_prompt}, *self.messages, @@ -983,6 +1013,7 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: "stagnation": self._stagnation_section(), "convergence": self._convergence_section(_loop_i), "time_budget": self._time_budget_section(), + "compression": self._compression_section(), "silence": self._silence_section(), } hint = "".join(hint_parts.values()).strip() @@ -1131,6 +1162,21 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: ) + estimate_tokens(json.dumps(tools_schema, ensure_ascii=False)) if hint_chars: prompt_stats["hint_chars"] = hint_chars + # Size of the cacheable request prefix: every bucket except the + # volatile trailing hint. A drop between consecutive llm_call + # events means the prefix changed byte-for-byte and the + # provider's prompt cache was defeated — this makes drift + # measurable from traces instead of guessed (dsh's tracker has + # the same lesson measured: 42% vs 99.8% cache hits). + prompt_stats["prefix_chars"] = ( + prompt_stats["system_chars"] + + prompt_stats["user_chars"] + + prompt_stats["assistant_chars"] + + prompt_stats["tool_result_chars"] + + prompt_stats["tool_call_args_chars"] + + prompt_stats["tools_schema_chars"] + - len(hint) + ) self.trace_logger.log_llm_call( messages=request_messages, tools=tools_schema, @@ -1746,6 +1792,7 @@ async def _auto_compress(self, extra_tokens: int = 0) -> None: old_count = len(self.messages) self.messages = compressed + self._compression_event = (old_count, len(self.messages)) console.print( f"[dim]auto-compression: {old_count} messages → " f"{len(self.messages)}[/dim]", diff --git a/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml b/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml index 9e53f3b..6b6fd35 100644 --- a/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml +++ b/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml @@ -24,11 +24,11 @@ models = [ "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", - "qwen-turbo", - "qwen-vl-max", - "qwen-vl-plus", + "qwen3.8-flash", + "qwen3-vl-flash", + "qwen3-vl-plus", ] -vision_models = ["qwen-vl-max", "qwen-vl-plus"] +vision_models = ["qwen3-vl-flash", "qwen3-vl-plus"] protocol = "openai" # ============================================================================ @@ -49,8 +49,8 @@ protocol = "openai" # [[providers]] # name = "ollama" # base_url = "http://localhost:11434/v1" -# default_model = "llama3" -# models = ["llama3", "mistral"] +# default_model = "qwen3.8-flash" +# models = ["qwen3.8-flash", "mistral"] # protocol = "openai" # auth = false # local service, no API key needed @@ -68,42 +68,42 @@ protocol = "openai" # - type = "vision" — calls a vision LLM to read images; requires provider + model # ============================================================================ -# ---- Example 1: Tongyi Wanxiang image generation (HTTP tool, reuses DASHSCOPE_API_KEY) ---- +# ---- Example 1: Qwen-Image generation (HTTP tool, reuses DASHSCOPE_API_KEY) ---- # export DASHSCOPE_API_KEY="xxx" # /capability enable dashscope.image # Say in chat: "draw a bicycle parked by a lake" # [[capabilities]] # key = "dashscope.image" -# display = "Tongyi Wanxiang Image Generation" +# display = "Qwen-Image Generation" # api_key_env = "DASHSCOPE_API_KEY" # # [[capabilities.tools]] -# name = "generate_wan_image" -# description = "Generate an image from a text description with the Tongyi Wanxiang Wan2.6 / Wanx models" +# name = "generate_image" +# description = "Generate an image from a text description with the open-source Qwen-Image model" # endpoint = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis" # http_method = "POST" # permission = "confirm" # params = [ # {name = "prompt", type = "string", required = true, description = "Image description"}, -# {name = "model", type = "string", required = false, default = "wanx2.1-t2i-turbo", description = "Image generation model name"}, +# {name = "model", type = "string", required = false, default = "qwen-image-3.0", description = "Image generation model name"}, # {name = "size", type = "string", required = false, default = "1024x1024", description = "Output image size"}, # ] # body_template = '{"model": {{model}}, "input": {"prompt": {{prompt}}}, "parameters": {"size": {{size}}}}' -# ---- Example 2: Tongyi vision understanding (vision tool, calls qwen-vl-max to read images) ---- +# ---- Example 2: Tongyi vision understanding (vision tool, calls qwen3-vl-flash to read images) ---- # Lets the text agent call a vision model on demand to understand images; no need to switch the main model. # Usage: /capability enable tongyi.vision, then say "look at this image @photo.png and describe it" # export DASHSCOPE_API_KEY="xxx" # [[capabilities]] # key = "tongyi.vision" -# display = "Tongyi Vision Understanding (qwen-vl-max)" +# display = "Tongyi Vision Understanding (qwen3-vl-flash)" # api_key_env = "DASHSCOPE_API_KEY" # # [[capabilities.tools]] # name = "understand_image" # type = "vision" # provider = "tongyi" -# model = "qwen-vl-max" +# model = "qwen3-vl-flash" # description = "Understand image content with a vision model and return a text description. No need to switch the main model." # permission = "auto" # params = [ diff --git a/dashscope/acli/examples/basic-chat/README.md b/dashscope/acli/examples/basic-chat/README.md index c96402c..9b53cd5 100644 --- a/dashscope/acli/examples/basic-chat/README.md +++ b/dashscope/acli/examples/basic-chat/README.md @@ -52,8 +52,8 @@ name = "tongyi" base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" api_key_env = "DASHSCOPE_API_KEY" # ← stores only the env var name; the shell provides sk-xxx default_model = "qwen3.8-max" -models = ["qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen-turbo", "qwen-vl-max"] -vision_models = ["qwen-vl-max"] # ← tells acli these models accept image input +models = ["qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-flash", "qwen3-vl-flash"] +vision_models = ["qwen3-vl-flash"] # ← tells acli these models accept image input protocol = "openai" # ← openai / anthropic / dashscope ``` diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml b/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml index c365601..29a6a34 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/custom-extensions.toml @@ -15,9 +15,9 @@ models = [ "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", - "qwen-turbo", - "qwen-vl-max", - "qwen-vl-plus", + "qwen3.8-flash", + "qwen3-vl-flash", + "qwen3-vl-plus", ] -vision_models = ["qwen-vl-max", "qwen-vl-plus"] +vision_models = ["qwen3-vl-flash", "qwen3-vl-plus"] protocol = "openai" diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md index 11e5e6d..3f4e227 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md @@ -55,7 +55,7 @@ else: from dashscope.agentstudio import Client from dashscope.agentstudio.types import user_message client = Client(api_key="sk-xxx", workspace="my-ws") -agent = client.agents.create(name="demo", model="qwen-max") +agent = client.agents.create(name="demo", model="qwen3.8-flash") session = client.sessions.create(agent=agent.id) client.sessions.events.send(session.id, [user_message("Hello!")]) with client.sessions.events.stream(session.id) as stream: diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md index 0493ed6..d10a627 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md @@ -41,21 +41,21 @@ Prerequisite: `export DASHSCOPE_API_KEY=sk-xxx` (or pass the global `-k/--api-ke dashscope auth whoami # verify the active key against the API and show its source dashscope auth login --key sk-xxx # save a key to ~/.dashscope/api_key dashscope auth logout # remove the saved key file -dashscope generation create -m qwen-plus -p "Hello" -s --temperature 0.7 -dashscope ft create -m qwen-turbo -t file-xxx -v file-yyy -e 2 -b 16 -l 1e-5 +dashscope generation create -m qwen3.7-plus -p "Hello" -s --temperature 0.7 +dashscope ft create -m qwen3.8-32b -t file-xxx -v file-yyy -e 2 -b 16 -l 1e-5 dashscope files upload -f train.jsonl -p fine-tune -d "training set" -dashscope deployments create -m qwen-turbo -s demo -c 1 -dashscope oss upload -m qwen-plus -f ./pic.png +dashscope deployments create -m qwen3.8-32b -s demo -c 1 +dashscope oss upload -m qwen3.7-plus -f ./pic.png dashscope rerank create -m gte-rerank -q "query" -d doc1 -d doc2 -n 3 dashscope embeddings create -m text-embedding-v3 -i "text1" -i "text2" -dashscope tokenization create -m qwen-turbo -p "text to count" +dashscope tokenization create -m qwen3.8-flash -p "text to count" dashscope models list -p 1 -s 20 dashscope application create -a APP_ID -p "question" --has-thoughts -dashscope image-synthesis create -m wanx-v1 -p "a cat" -n 2 --size 1024*1024 -dashscope image-generation create -m qwen-image -t "a cat" --image ref.png -dashscope video-synthesis create -m wanx2.1-t2v-turbo -p "ocean waves" +dashscope image-synthesis create -m qwen-image-3.0 -p "a cat" -n 2 --size 1024*1024 +dashscope image-generation create -m qwen-image-3.0 -t "a cat" --image ref.png +dashscope video-synthesis create -m wan2.2-t2v-plus -p "ocean waves" dashscope video-synthesis wait TASK_ID # fetch/cancel/list take task_id the same way -dashscope multimodal-conversation create -m qwen-vl-plus -t "describe the image" --image a.jpg +dashscope multimodal-conversation create -m qwen3-vl-plus -t "describe the image" --image a.jpg dashscope multimodal-embedding create -m multimodal-embedding-v1 --text "cat" --image c.png dashscope transcription create -m paraformer-v2 --file-url https://.../a.wav dashscope speech-synthesis create -m cosyvoice-v1 -t "Hello" --voice longxiaochun diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md index 1f567a1..0495b09 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md @@ -78,7 +78,7 @@ up = Files.upload("train.jsonl", purpose="fine_tune") # validates JSONL locally file_id = up.output["uploaded_files"][0]["file_id"] job = FineTunes.call( - model="qwen-turbo", + model="qwen3.8-32b", training_file_ids=[file_id], hyper_parameters={"n_epochs": 3}, ) diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md index ebd6854..eff7bc4 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md @@ -8,9 +8,9 @@ Locate before answering: understanding tasks use `MultiModalConversation` (sync/ ## Applicable Models and Scenarios -- Understanding (image/video understanding, OCR, function calling): `qwen-vl-max`, `qwen-vl-plus`, qwen-ocr series, etc. (the `Models` class in source only has the constant `qwen_vl_chat_v1`; pass others as strings). -- Text-to-image / image editing (`ImageSynthesis`, task=`text2image`): `wanx-v1`, `wanx-sketch-to-image-v1` (sketch coloring), `wanx2.1-imageedit` (image editing, automatically switches to image2image task). -- Video generation (`VideoSynthesis`, task=`video-generation`): `wanx2.1-t2v-turbo/plus` (text-to-video), `wanx2.1-i2v-turbo/plus` (image-to-video), `wanx2.1-kf2v-plus`/`wanx-kf2v` (first/last frame). `wanx-txt2video-pro` and `wanx-img2video-pro` are deprecated. +- Understanding (image/video understanding, OCR, function calling): `qwen3-vl-flash`, `qwen3-vl-plus`, qwen-ocr series, etc. (the `Models` class in source only has the constant `qwen_vl_chat_v1`; pass others as strings). +- Text-to-image / image editing (`ImageSynthesis`, task=`text2image`): `qwen-image-3.0`, `qwen-image-edit-plus` (image editing, automatically switches to image2image task). +- Video generation (`VideoSynthesis`, task=`video-generation`): `wan2.2-t2v-plus` (text-to-video), `wan2.2-i2v-plus` (image-to-video), `wan2.2-kf2v-flash` (first/last frame). ## SDK Interfaces @@ -53,7 +53,7 @@ Image-text understanding: from http import HTTPStatus from dashscope import MultiModalConversation -rsp = MultiModalConversation.call(model="qwen-vl-max", messages=messages) +rsp = MultiModalConversation.call(model="qwen3-vl-flash", messages=messages) if rsp.status_code == HTTPStatus.OK: print(rsp.output.choices[0].message.content[0]["text"]) ``` @@ -64,7 +64,7 @@ Text-to-image async task: from dashscope import ImageSynthesis rsp = ImageSynthesis.async_call( - model=ImageSynthesis.Models.wanx_v1, + model="qwen-image-3.0", prompt="A cat running under the moonlight", n=2, size="1024*1024", ) rsp = ImageSynthesis.wait(rsp) # poll until SUCCEEDED/FAILED @@ -100,7 +100,7 @@ import com.alibaba.dashscope.aigc.multimodalconversation.*; MultiModalConversation conv = new MultiModalConversation(); MultiModalConversationParam param = MultiModalConversationParam.builder() - .model("qwen-vl-max") + .model("qwen3-vl-flash") .messages(java.util.Arrays.asList(userMessage)) .build(); MultiModalConversationResult result = conv.call(param); diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md index 73f9fc3..1785ea2 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md @@ -10,7 +10,7 @@ Before answering, verify the user's SDK version and signature: `python -c "impor ## Applicable Models and Scenarios - Entry point `dashscope.Generation` (sync) / `dashscope.AioGeneration` (async, both top-level exports in `dashscope/__init__.py`), `task = "text-generation"`. -- Plain text chat/continuation: qwen series. Built-in constants in `Generation.Models`: `qwen_turbo="qwen-turbo"`, `qwen_plus="qwen-plus"`, `qwen_max="qwen-max"`; `qwen_v1` and `qwen_plus_v1` are deprecated (calls only warn). +- Plain text chat/continuation: qwen series (e.g. `qwen3.7-plus`, `qwen3.8-flash`). `Generation.Models` only carries a few legacy constants; pass the current model name as a plain string. - The `model` parameter is just a `str`; the SDK does not validate against a whitelist. For new models like qwen3, pass the model name string directly; refer to the console/docs for exact names. - `enable_search` is only supported when `model` starts with `"qwen"`; when it starts with `"bailian"` you must pass `customized_model_id`, otherwise `InputRequired` is raised. - Do not use this API for multimodal; use `MultiModalConversation`. @@ -49,7 +49,7 @@ Returns: `Generator[GenerationResponse, None, None]` when `stream=True` (AsyncGe | enable_thinking / thinking_budget | bool/int | None | Hybrid thinking models: enable thinking / thinking token budget | | n / logprobs / top_logprobs | int/bool/int | None | Number of generations 1-4 / return log probabilities and candidates per position | | response_format | dict | None | e.g. `{"type": "json_object"}` (JSON mode) | -| output_format | str | None | qwen-deep-research only: "model_detailed_report" (default) / "model_summary_report" | +| output_format | str | None | Deep-research models only: "model_detailed_report" (default) / "model_summary_report" | | workspace / plugins | str/str\|dict | None | Workspace id / plugins (sent via `X-DashScope-Plugin` header) | ## Input/Output @@ -75,7 +75,7 @@ from dashscope import Generation dashscope.api_key = "sk-xxx" # or set env var DASHSCOPE_API_KEY # Non-streaming -resp = Generation.call(model="qwen-plus", +resp = Generation.call(model="qwen3.7-plus", messages=[{"role": "user", "content": "Hello"}], result_format="message") if resp.status_code == HTTPStatus.OK: @@ -84,7 +84,7 @@ else: print(resp.request_id, resp.code, resp.message) # Streaming (incremental output) -for r in Generation.call(model="qwen-plus", +for r in Generation.call(model="qwen3.7-plus", messages=[{"role": "user", "content": "Write a short poem"}], result_format="message", stream=True, incremental_output=True): @@ -96,7 +96,7 @@ for r in Generation.call(model="qwen-plus", # OpenAI-compatible from openai import OpenAI client = OpenAI(api_key=dashscope.api_key, base_url=dashscope.base_compatible_api_url) -print(client.chat.completions.create(model="qwen-plus", +print(client.chat.completions.create(model="qwen3.7-plus", messages=[{"role": "user", "content": "Hello"}]).choices[0].message.content) ``` @@ -132,7 +132,7 @@ import com.alibaba.dashscope.aigc.generation.*; Generation gen = new Generation(); GenerationParam param = GenerationParam.builder() - .model("qwen-plus") + .model("qwen3.7-plus") .messages(java.util.Arrays.asList( Message.builder().role("user").content("Hello").build())) .resultFormat("message") diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md index ead0bb1..0fcc048 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md @@ -63,6 +63,24 @@ When verification is needed, use `run_command` to inspect the installed `dashsco - **Find source**: `python -c 'import dashscope; import os; print(os.path.dirname(dashscope.__file__))'` - Then use `read_file` on the specific source file if needed. +### Cookbook examples (examples/ directory) + +The Python SDK repo ships runnable cookbook scripts under `examples/`, organized by API generation. When the user asks you to implement a task, start from the matching script instead of writing from scratch: + +- `examples/v1/` — 1.x SDK (`dashscope.Generation`, `dashscope.MultiModalConversation`, ...): text generation (`generation/`, `aio_generation/`), multimodal chat (`multimodal_conversation/`, `aio_multimodal_conversation/`), image/video synthesis, application / agentstudio / assistants, speech (`qwen_tts.py`, `qwen_asr.py`), tingwu, rerank, embeddings. +- `examples/v2/` — 2.0 SDK (resource-style `dashscope.Client()`): `chat.py`, `multimodal_chat.py`, `embeddings.py`, `images.py` / `videos.py` (one-shot generate + manual jobs flow), `rerank.py`, `models.py`, `files.py`, `apps.py`, `tokenization.py`, `audio_tts.py` / `audio_asr.py`, `tingwu.py`, `fine_tunes.py`. +- Java cookbook samples are not in this repo; they live in the Java SDK repo (https://github.com/dashscope/dashscope-sdk-java) and are referenced by class name in the skills. + +### Python repo layout: v1.x vs 2.0 (V2) + +In the Python SDK repo the 2.0 API is isolated by directory. Only these paths are V2: + +- `dashscope/v2/` — the 2.0 client and resources +- `tests/v2/` — V2 tests +- `examples/v2/` — V2 cookbook + +Everything else (`dashscope/` top-level modules, the rest of `tests/`, `examples/v1/`) is v1.x code. Match the tree to the user's API generation before reading source or quoting examples: resource-style `dashscope.Client` calls → V2 paths; module-level `dashscope.Generation.call(...)` style → v1.x paths. + ### Workflow 1. Identify the question type (question about you / issue analysis / API question / example). diff --git a/dashscope/acli/memory/reflection.py b/dashscope/acli/memory/reflection.py index be6e7fd..24d2266 100644 --- a/dashscope/acli/memory/reflection.py +++ b/dashscope/acli/memory/reflection.py @@ -789,3 +789,51 @@ def time_budget_hint( "is your best attempt." ) return "" + + +def compression_hint( + event: tuple[int, int] | None, + context_ratio: float, + warn_ratio: float = 0.40, +) -> str: + """Context-window counterpart of the budget hints, persistence-first. + + Auto-compression is silent by design at the UI layer (a dim console + line), but the *model* is never told its history was just rewritten — + it keeps referring to tool outputs and reasoning that no longer exist + in the prompt, and it gets no chance to move key state to disk while + the full text is still around. dsh's tracker shows the un-managed end + of this failure: a full window with no user-facing signal at all. The + window is shared with the soft compression trigger (50%), so the + pre-hint band sits just below it (40%) and the post-compression notice + fires once per event. + + ``event`` is (old_count, new_count) when a compression just ran, else + None. Returns "" when nothing fired; ``warn_ratio >= SOFT`` would make + the pre-hint unreachable (compression fires first), so it is clamped + below the soft trigger by the caller passing a sane value. + """ + if event is not None: + old_count, new_count = event + return ( + "\n\n## 🗜 Context was just compressed\n" + f"History shrank from {old_count} to {new_count} messages; " + "earlier tool outputs and reasoning may be gone from your " + "context now.\n" + "- Re-read any file you still need instead of assuming you " + "remember its contents.\n" + "- If a finding or deliverable existed only in messages, write " + "it to disk now — the next compression will take what this one " + "left.\n" + ) + if 0.0 < warn_ratio <= context_ratio: + pct = int(context_ratio * 100) + return ( + "\n\n## 🗜 Context filling up\n" + f"About {pct}% of the context window is in use; automatic " + "compression is approaching.\n" + "Persist key findings and any deliverable to disk NOW, while " + "the full text is still in front of you. After compression, " + "only what is on disk is guaranteed to survive verbatim.\n" + ) + return "" diff --git a/dashscope/acli/tools/checkpoint.py b/dashscope/acli/tools/checkpoint.py index e6f84af..cfb6553 100644 --- a/dashscope/acli/tools/checkpoint.py +++ b/dashscope/acli/tools/checkpoint.py @@ -22,6 +22,12 @@ # Maximum number of checkpoint entries kept in the index. _MAX_ENTRIES = 50 +# Size ceiling for backing up a directory tree: a node_modules-scale delete +# would otherwise copy gigabytes into the checkpoint store on every call. +# Oversized trees are recorded with no backup, so undo reports the gap +# instead of pretending the delete is reversible. +_MAX_DIR_BACKUP_BYTES = 256 * 1024 * 1024 + _INDEX_NAME = "index.jsonl" # Maps a checkpoint action to the tool name shown in undo messages. @@ -29,9 +35,34 @@ "overwrite": "write_file", "create": "write_file", "delete": "delete_file", + "delete_dir": "delete_directory", } +def _remove_backup(cp_dir: Path, name: str) -> None: + """Drop one backup payload, file (``.bak``) or tree (``.bakdir``).""" + victim = cp_dir / name + try: + if name.endswith(".bakdir"): + shutil.rmtree(victim) + else: + os.remove(victim) + except OSError: + pass + + +def _tree_size(path: str) -> int: + """Total bytes of every file under *path*; unreadable files count 0.""" + total = 0 + for root, _dirs, files in os.walk(path): + for name in files: + try: + total += os.path.getsize(os.path.join(root, name)) + except OSError: + continue + return total + + def _checkpoint_dir() -> Path: """Directory holding backup files and the JSONL index.""" # Lazy import so loading this module never triggers config cycles. @@ -88,9 +119,16 @@ def _snapshot(path: str, action: str) -> None: cp_dir.mkdir(parents=True, exist_ok=True) entry_id = uuid.uuid4().hex backup = None + note = None if os.path.isfile(abs_path): backup = entry_id + ".bak" shutil.copy2(abs_path, cp_dir / backup) + elif os.path.isdir(abs_path): + if _tree_size(abs_path) <= _MAX_DIR_BACKUP_BYTES: + backup = entry_id + ".bakdir" + shutil.copytree(abs_path, cp_dir / backup) + else: + note = "directory too large to back up" entry = { "id": entry_id, "path": abs_path, @@ -98,6 +136,8 @@ def _snapshot(path: str, action: str) -> None: "action": action, "ts": time.time(), } + if note: + entry["note"] = note entries = _read_entries(cp_dir) entries.append(entry) overflow = len(entries) - _MAX_ENTRIES @@ -107,10 +147,7 @@ def _snapshot(path: str, action: str) -> None: name = old.get("backup") if not name: continue - try: - os.remove(cp_dir / name) - except OSError: - pass + _remove_backup(cp_dir, name) def undo() -> str: @@ -124,6 +161,33 @@ def undo() -> str: return f"Error: undo failed - {e}" +def _restore_backup( + cp_dir: Path, + backup: str, + path: str, + tool_name: str, +) -> str: + """Restore one backup payload (file or tree) to *path*.""" + src = cp_dir / backup + if backup.endswith(".bakdir"): + if not src.is_dir(): + return f"Error: backup directory missing for {path}" + shutil.copytree(src, path) + shutil.rmtree(src) + return f"Undid {tool_name}: restored {path}" + if not src.is_file(): + return f"Error: backup file missing for {path}" + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + shutil.copy2(src, path) + try: + os.remove(src) + except OSError: + pass + return f"Undid {tool_name}: restored {path}" + + def _undo() -> str: """Internal undo implementation (may raise).""" cp_dir = _checkpoint_dir() @@ -139,18 +203,7 @@ def _undo() -> str: tool_name = _TOOL_BY_ACTION.get(action, action or "unknown") if backup: - src = cp_dir / backup - if not src.is_file(): - return f"Error: backup file missing for {path}" - parent = os.path.dirname(path) - if parent: - os.makedirs(parent, exist_ok=True) - shutil.copy2(src, path) - try: - os.remove(src) - except OSError: - pass - return f"Undid {tool_name}: restored {path}" + return _restore_backup(cp_dir, backup, path, tool_name) if action == "create": try: diff --git a/dashscope/acli/tools/filesystem.py b/dashscope/acli/tools/filesystem.py index 4612789..eaa5b28 100644 --- a/dashscope/acli/tools/filesystem.py +++ b/dashscope/acli/tools/filesystem.py @@ -278,7 +278,8 @@ def delete_file(path: str) -> str: @tool( name="delete_directory", description=( - "Delete a directory and all its contents. " "This cannot be undone." + "Delete a directory and all its contents. The tree is checkpointed " + "first, so /undo can restore it within the checkpoint size budget." ), permission=PermissionLevel.DANGEROUS, ) @@ -294,6 +295,11 @@ def delete_directory(path: str) -> str: cwd = os.path.abspath(".") if abs_path == cwd: return "Error: cannot delete the current working directory" + # Checkpoint so /undo can restore the tree; oversized trees are recorded + # without a backup and undo will say so. + from dashscope.acli.tools import checkpoint + + checkpoint.snapshot(path, "delete_dir") shutil.rmtree(path) return f"Deleted directory: {path}"