Skip to content

Commit 6545692

Browse files
author
zhansheng.lzs
committed
acli: sync 0.6.7 — skills, search_files, platform line, Windows decoding
Vendors agenticCLI 4876f43 plus the 0.6.7 bump. All seven files are byte-identical to the upstream sources modulo the dashscope. import prefix. render_skill no longer runs str.format() over a skill body. A body is Markdown full of literal braces from the JSON and Python it documents, so text-generation.md — which shows messages=[{"role": "user", ...}] and declares no arguments at all — raised KeyError('"role"'), and the old `except KeyError` reported that as "missing args" for a skill that takes none. 6 of the 13 bundled skills were unusable through use_skill while /skill and the catalog kept advertising them. Substitution is now a plain replace of the declared arguments only; use_skill and the scheduler branch on `is None` rather than falsiness, so the missing-args hint can no longer render empty. search_files matches the pattern against paths relative to the search root as well as the basename, and skips dot-directories only unless the pattern names one, so .acli/skills/*.md is reachable without walking every .git in the tree. Its description now says that * crosses separators, which it does under fnmatch and did not under the old basename-only match, and the no-match message states what was actually searched instead of implying the file is absent. EnvironmentSection states the platform and how run_command executes, through a new shell_label() that lives next to _spawn so the prompt cannot drift from what the tool really runs. Nothing used to say which shell this is: on Windows the model wrote `2>/dev/null || find /` and spent a turn on a PowerShell ParserError. Child-process output decoding tries UTF-8 strictly first, then the Windows OEM code page from GetOEMCP() and the stdio/locale encodings. A piped child writes in its console's code page, which need not equal sys.stdout.encoding, and cp936 accepts nearly any byte sequence so it would silently mangle UTF-8 rather than fail. A UnicodeDecodeError in the last four bytes is the byte-budget truncation splitting a character, not evidence of another encoding, so it is repaired instead of re-decoding the whole buffer under a DBCS page. Adds tests/unit/test_acli_skill_render.py: the 13 bundled skill files ship only in this wheel, so they are the real corpus that broke and the one piece of coverage with dashscope-only value. Verified by restoring the pre-fix render_skill — exactly those 6 skills fail, 19 tests — then restoring the fix. The upstream coverage for the other three fixes stays in agenticCLI, which is where those test modules live. dashscope unit: 1011 passed, 7 skipped. pre-commit --all-files green, mypy included. dashscope/acli/examples/ is untouched by the sync.
1 parent 7d28991 commit 6545692

9 files changed

Lines changed: 288 additions & 36 deletions

File tree

‎dashscope/acli/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import uuid
55

6-
__version__ = "0.6.6"
6+
__version__ = "0.6.7"
77

88
# Per-process identifier sent as x-dashscope-sdk-session-id so the
99
# backend can group multi-turn requests from one CLI run.

‎dashscope/acli/prompt_pipeline.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,17 @@ class EnvironmentSection:
200200
name = "environment"
201201

202202
def render(self, ctx: PromptContext) -> str:
203-
env_info: list[str] = []
203+
import platform
204+
205+
from dashscope.acli.tools.shell import shell_label
206+
207+
# The platform is stated because run_command's syntax depends on it and
208+
# a wrong guess costs a whole turn: on Windows the model wrote
209+
# `2>/dev/null || find /` and got back a PowerShell ParserError.
210+
env_info: list[str] = [
211+
f"Current OS: {platform.system()} {platform.machine()}",
212+
f"run_command executes via: {shell_label()}",
213+
]
204214
if ctx.user_name:
205215
env_info.append(f"Current user: {ctx.user_name}")
206216
if ctx.provider_name or ctx.model_name:
@@ -210,8 +220,6 @@ def render(self, ctx: PromptContext) -> str:
210220
"mentioned in history or summaries may be outdated — "
211221
"always defer to this line for model questions)",
212222
)
213-
if not env_info:
214-
return ""
215223
env_info.append(
216224
"Rule: when mentioning this CLI's commands, only cite real "
217225
"commands that definitely exist; when unsure, point the user "

‎dashscope/acli/scheduler.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,9 @@ async def _execute_job(self, job: CronJob) -> None:
591591
continue
592592

593593
rendered = render_skill(skill, inv.args)
594-
if not rendered:
594+
# `is None`, not falsy: None is the missing-arguments signal, so
595+
# arg_hint is never empty here.
596+
if rendered is None:
595597
arg_hint = " ".join(f"<{a}>" for a in skill.arguments)
596598
results.append(
597599
f"missing args: /skill {inv.name} {arg_hint}",

‎dashscope/acli/skills/base.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,18 @@ def render_skill(skill: Skill, args: list[str]) -> str | None:
200200
don't match."""
201201
if len(args) < len(skill.arguments):
202202
return None
203-
kwargs = {}
203+
template = skill.prompt_template
204+
# A skill body is Markdown, not a format string: it is full of literal
205+
# braces from the JSON and Python it documents. Running str.format() over
206+
# the whole body raised KeyError on `{"role": ...}`, which the old
207+
# `except KeyError` turned into a bogus "missing args" report for every
208+
# no-argument skill that showed a payload, and let the IndexError from
209+
# `{}` escape entirely. Substitute only the declared arguments and leave
210+
# every other brace exactly as the author wrote it.
204211
for i, arg_name in enumerate(skill.arguments):
205212
if i == len(skill.arguments) - 1:
206-
kwargs[arg_name] = " ".join(args[i:])
213+
value = " ".join(args[i:])
207214
else:
208-
kwargs[arg_name] = args[i]
209-
try:
210-
return skill.prompt_template.format(**kwargs)
211-
except KeyError:
212-
return None
215+
value = args[i]
216+
template = template.replace("{" + arg_name + "}", value)
217+
return template

‎dashscope/acli/tools/filesystem.py‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -262,11 +262,22 @@ def delete_directory(path: str) -> str:
262262
return f"Deleted directory: {path}"
263263

264264

265+
def _pattern_names_hidden_dir(pattern: str) -> bool:
266+
"""True when the caller explicitly asked about a dot-directory."""
267+
return any(
268+
seg.startswith(".")
269+
for seg in pattern.replace(os.sep, "/").split("/")
270+
if seg
271+
)
272+
273+
265274
@tool(
266275
name="search_files",
267276
description=(
268-
"Search files by name pattern. "
269-
"Supports wildcards like *.py, test_*."
277+
"Search files by name or path pattern, relative to `path` "
278+
"(default: the working directory). Supports wildcards like *.py, "
279+
"test_*, src/*.ts and .github/workflows/*.yml. `*` crosses directory "
280+
"separators, so *.md finds every Markdown file at any depth."
270281
),
271282
permission=PermissionLevel.AUTO,
272283
)
@@ -279,31 +290,43 @@ def search_files(pattern: str, path: str | None = None) -> str:
279290
if not os.path.isdir(search_root):
280291
return f"Error: search path not found - {search_root}"
281292

293+
needle = pattern.replace(os.sep, "/")
294+
want_hidden = _pattern_names_hidden_dir(pattern)
282295
matches = []
283296
root_depth = search_root.rstrip(os.sep).count(os.sep)
284297
for root, dirs, files in os.walk(search_root):
285298
# Enforce depth limit
286299
current_depth = root.rstrip(os.sep).count(os.sep) - root_depth
287300
if current_depth >= _MAX_SEARCH_DEPTH:
288301
dirs[:] = []
289-
# Skip hidden and sensitive directories
302+
# Sensitive directories are always skipped. Other dot-directories are
303+
# skipped unless the pattern asks for one, so `.acli/skills/*.md` is
304+
# searchable without walking every `.git` in the tree.
290305
dirs[:] = [
291306
d
292307
for d in dirs
293-
if not d.startswith(".") and d not in SENSITIVE_NAMES
308+
if d not in SENSITIVE_NAMES
309+
and (want_hidden or not d.startswith("."))
294310
]
295311
for name in files:
296-
if fnmatch.fnmatch(name, pattern):
297-
if name in SENSITIVE_NAMES:
298-
continue
312+
if name in SENSITIVE_NAMES:
313+
continue
314+
rel = os.path.relpath(os.path.join(root, name), search_root)
315+
if fnmatch.fnmatch(rel.replace(os.sep, "/"), needle) or (
316+
fnmatch.fnmatch(name, needle)
317+
):
299318
matches.append(os.path.join(root, name))
300319
if len(matches) >= 100:
301320
break
302321
if len(matches) >= 100:
303322
break
304323

305324
if not matches:
306-
return f"No files match '{pattern}'"
325+
return (
326+
f"No files match '{pattern}' under {search_root}. The pattern is "
327+
"matched against paths relative to that root, and dot-directories "
328+
"are skipped unless the pattern names one."
329+
)
307330
result = f"Found {len(matches)} matching files:\n"
308331
result += "\n".join(f" {m}" for m in matches)
309332
return result

‎dashscope/acli/tools/shell.py‎

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,21 +42,58 @@ def default_timeout() -> int:
4242
return value if value > 0 else DEFAULT_TIMEOUT
4343

4444

45-
def _resolve_output_encoding() -> str:
46-
"""Encoding for decoding subprocess output.
45+
def _resolve_output_encodings() -> tuple[str, ...]:
46+
"""Encodings to try when decoding subprocess output, best guess first.
4747
4848
Resolved at import, not per call: the TUI replaces ``sys.stdout`` with
4949
textual's capture object, which has no ``.encoding``, so reading it later
5050
raises AttributeError and fails every command.
51+
52+
A captured child writes in its own console's code page, which on Windows is
53+
the OEM page and need not match Python's stdout encoding. Decoding a zh-CN
54+
PowerShell error as UTF-8 handed the model mojibake instead of the one
55+
message that explained its failure. UTF-8 stays first and is tried
56+
strictly: it fails loudly on OEM bytes, whereas a DBCS page like cp936
57+
accepts almost anything and would silently mangle UTF-8 output.
5158
"""
52-
if not IS_WINDOWS:
53-
return "utf-8"
54-
return (
55-
getattr(sys.stdout, "encoding", None)
56-
or getattr(sys.__stdout__, "encoding", None)
57-
or locale.getpreferredencoding(False)
58-
or "utf-8"
59-
)
59+
candidates = ["utf-8"]
60+
if IS_WINDOWS:
61+
import ctypes
62+
63+
kernel32 = getattr(ctypes, "windll", None)
64+
oem = kernel32.kernel32.GetOEMCP() if kernel32 is not None else 0
65+
if oem:
66+
candidates.append(f"cp{oem}")
67+
for extra in (
68+
getattr(sys.stdout, "encoding", None),
69+
getattr(sys.__stdout__, "encoding", None),
70+
locale.getpreferredencoding(False),
71+
):
72+
if extra:
73+
candidates.append(extra)
74+
unique: list[str] = []
75+
for name in candidates:
76+
if name.lower() not in {u.lower() for u in unique}:
77+
unique.append(name)
78+
return tuple(unique)
79+
80+
81+
def _decode_output(data: bytes) -> str:
82+
"""Decode child output, falling back across console code pages."""
83+
for encoding in (OUTPUT_ENCODING,) + OUTPUT_ENCODINGS:
84+
try:
85+
return data.decode(encoding)
86+
except LookupError:
87+
continue
88+
except UnicodeDecodeError as exc:
89+
# Truncation is byte-based, so it can split a trailing multi-byte
90+
# character. That is not evidence the whole buffer is in another
91+
# encoding — repair the tail instead of re-decoding everything.
92+
if exc.start >= len(data) - 4:
93+
return data.decode(encoding, errors="replace")
94+
continue
95+
# Last resort only reachable if every candidate name is unusable.
96+
return data.decode("utf-8", errors="replace")
6097

6198

6299
def _resolve_win_shell() -> tuple[str, ...]:
@@ -69,9 +106,41 @@ def _resolve_win_shell() -> tuple[str, ...]:
69106
return (exe, "-NoProfile", "-NonInteractive", "-Command")
70107

71108

72-
OUTPUT_ENCODING = _resolve_output_encoding()
109+
OUTPUT_ENCODINGS = _resolve_output_encodings()
110+
# The primary candidate keeps its own name because callers and tests pin a
111+
# single encoding; _decode_output honours it before trying the fallbacks.
112+
OUTPUT_ENCODING = OUTPUT_ENCODINGS[0]
73113
WIN_SHELL = _resolve_win_shell()
74114

115+
116+
def shell_label() -> str:
117+
"""How ``run_command`` executes a command, for the system prompt.
118+
119+
Lives next to ``_spawn`` so the prompt cannot drift from what the tool
120+
really runs. Nothing used to say which shell this is: a model that assumes
121+
POSIX spends a turn on ``ls -la ... 2>/dev/null || find /`` before a
122+
PowerShell ParserError tells it otherwise.
123+
"""
124+
if not IS_WINDOWS:
125+
return (
126+
"POSIX sh via `/bin/sh -c` — not the user's interactive shell, "
127+
"so no aliases, rc files or zsh/bash-only syntax"
128+
)
129+
exe = os.path.basename(WIN_SHELL[0])
130+
if exe.lower().startswith("pwsh"):
131+
chaining = "`&&`/`||` work (PowerShell 7+)"
132+
else:
133+
chaining = (
134+
"`&&`/`||` are not statement separators (Windows PowerShell "
135+
"5.1) — use `;` or separate calls"
136+
)
137+
return (
138+
f"PowerShell on Windows ({exe}). Write PowerShell, not POSIX: no "
139+
f"`2>/dev/null`, no `ls -la`/`find`/`grep`; {chaining}. Use "
140+
"`$env:USERPROFILE`, `Get-ChildItem`, `Select-String`."
141+
)
142+
143+
75144
BLOCKED_PATTERNS = [
76145
# POSIX
77146
"mkfs",
@@ -712,10 +781,9 @@ async def run_command(command: str, timeout: int | None = None) -> str:
712781

713782
output = ""
714783
if stdout:
715-
output += stdout.decode(OUTPUT_ENCODING, errors="replace")
784+
output += _decode_output(stdout)
716785
if stderr:
717-
err = stderr.decode(OUTPUT_ENCODING, errors="replace")
718-
output += "\n[stderr]\n" + err
786+
output += "\n[stderr]\n" + _decode_output(stderr)
719787

720788
if len(output) > MAX_OUTPUT_LENGTH:
721789
output = (

‎dashscope/acli/tools/skills.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ async def use_skill(name: str, args: list[str] | None = None) -> str:
6767
f"{skill.mcp_service}; ask the user to connect first."
6868
)
6969
rendered = render_skill(skill, [str(a) for a in (args or [])])
70-
if not rendered:
70+
# `is None`, not falsy: render_skill only returns None when arguments
71+
# are missing, so the hint below always lists at least one name. An
72+
# empty body is a valid (if useless) skill, not a missing argument.
73+
if rendered is None:
7174
hint = " ".join(f"<{a}>" for a in skill.arguments)
7275
return f"Error: missing args. '{name}' requires: {hint}"
7376
agent = get_agent()

‎dashscope/version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# -*- coding: utf-8 -*-
22
# Copyright (c) Alibaba, Inc. and its affiliates.
33

4-
__version__ = "1.27.6"
4+
__version__ = "1.27.7"

0 commit comments

Comments
 (0)