diff --git a/README.md b/README.md index 0629145..5e7976e 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,17 @@ skillclaw skills pull If your team uses a mounted local shared directory instead of OSS/S3, use `sharing.backend local` plus `sharing.local_root /path/to/shared/root` instead of the remote storage keys. +To store skill assets in Nacos while keeping session and validation artifacts in the existing shared storage, set a +skill backend override: + +```bash +skillclaw config sharing.backend oss +skillclaw config sharing.skill_backend nacos +skillclaw config sharing.nacos_server http://nacos.example.com +``` + +When `sharing.skill_backend` is empty, SkillClaw keeps the legacy behavior and uses `sharing.backend` for skill assets. + When you join a shared group: - you still run only the local client proxy on your machine diff --git a/evolve_server/__main__.py b/evolve_server/__main__.py index 0bbd88c..b59d21d 100644 --- a/evolve_server/__main__.py +++ b/evolve_server/__main__.py @@ -49,7 +49,10 @@ def _build_config_from_args(args: argparse.Namespace) -> EvolveServerConfig: if not config.storage_backend: if args.oss_endpoint or args.oss_bucket: config.storage_backend = "oss" - elif config.storage_bucket or config.storage_endpoint: + elif config.storage_bucket or ( + config.storage_endpoint + and str(getattr(config, "skill_storage_backend", "") or "").strip().lower() != "nacos" + ): config.storage_backend = "s3" if args.group_id: config.group_id = args.group_id @@ -240,6 +243,18 @@ def main() -> None: "or use --use-skillclaw-config." ) raise SystemExit(1) + elif not backend: + if str(getattr(config, "skill_storage_backend", "") or "").strip().lower() == "nacos": + logger.error( + "sharing.skill_backend=nacos stores skill assets only. Configure session storage with " + "sharing.backend, sharing.session_backend, sharing.local_root, EVOLVE_STORAGE_*, or use --mock." + ) + raise SystemExit(1) + logger.error( + "Storage backend is not configured. Set EVOLVE_STORAGE_BACKEND, use --use-skillclaw-config, " + "use --local-root for local mode, or use --mock." + ) + raise SystemExit(1) else: if not config.storage_bucket: logger.error( diff --git a/evolve_server/core/config.py b/evolve_server/core/config.py index 2bc760f..a9f14c4 100644 --- a/evolve_server/core/config.py +++ b/evolve_server/core/config.py @@ -256,10 +256,12 @@ def from_skillclaw_config(cls, config) -> "EvolveServerConfig": """Build from an existing ``SkillClawConfig`` (reuse sharing + LLM settings).""" engine = _first_env("EVOLVE_ENGINE", default="workflow").strip().lower() or "workflow" sharing_backend = str(getattr(config, "sharing_backend", "") or "").strip().lower() + skill_backend = str(getattr(config, "sharing_skill_backend", "") or "").strip().lower() or sharing_backend session_backend = str(getattr(config, "sharing_session_backend", "") or "").strip().lower() - storage_endpoint = str( + sharing_endpoint = str( getattr(config, "sharing_endpoint", "") or getattr(config, "sharing_oss_endpoint", "") or "" ) + storage_endpoint = "" if sharing_backend == "nacos" and not session_backend else sharing_endpoint storage_bucket = str(getattr(config, "sharing_bucket", "") or getattr(config, "sharing_oss_bucket", "") or "") storage_access_key_id = str( getattr(config, "sharing_access_key_id", "") or getattr(config, "sharing_oss_access_key_id", "") or "" @@ -297,25 +299,24 @@ def from_skillclaw_config(cls, config) -> "EvolveServerConfig": default="openai-completions", ) + storage_backend = _first_env("EVOLVE_STORAGE_BACKEND", default="") + if not storage_backend: + if session_backend: + storage_backend = session_backend + elif local_root: + storage_backend = "local" + elif sharing_backend and sharing_backend != "nacos": + storage_backend = sharing_backend + elif (storage_bucket or storage_endpoint) and sharing_backend != "nacos": + storage_backend = "oss" if "aliyuncs.com" in storage_endpoint else "s3" + + nacos_server = str(getattr(config, "sharing_nacos_server", "") or "") + if not nacos_server and sharing_backend == "nacos" and skill_backend == "nacos": + nacos_server = sharing_endpoint + return cls( engine=engine, - storage_backend=_first_env("EVOLVE_STORAGE_BACKEND", default="") - or ( - session_backend - if session_backend - else "local" - if local_root - else "s3" - if (storage_bucket or storage_endpoint) and sharing_backend != "nacos" - else "" - ) - or ( - "local" - if local_root - else "oss" - if sharing_backend != "nacos" - else "" - ), + storage_backend=storage_backend, storage_endpoint=storage_endpoint, storage_bucket=storage_bucket, storage_access_key_id=storage_access_key_id, @@ -344,8 +345,8 @@ def from_skillclaw_config(cls, config) -> "EvolveServerConfig": validation_required_approvals=int(os.environ.get("EVOLVE_VALIDATION_REQUIRED_APPROVALS", "1")), validation_min_mean_score=float(os.environ.get("EVOLVE_VALIDATION_MIN_MEAN_SCORE", "0.75")), validation_max_rejections=int(os.environ.get("EVOLVE_VALIDATION_MAX_REJECTIONS", "1")), - skill_storage_backend="nacos" if sharing_backend == "nacos" else "", - nacos_server=str(getattr(config, "sharing_nacos_server", "") or storage_endpoint), + skill_storage_backend="nacos" if skill_backend == "nacos" else "", + nacos_server=nacos_server, nacos_namespace_id=str(getattr(config, "sharing_nacos_namespace_id", "") or "public"), nacos_access_token=str(getattr(config, "sharing_nacos_access_token", "") or ""), nacos_username=str(getattr(config, "sharing_nacos_username", "") or ""), diff --git a/evolve_server/engines/common.py b/evolve_server/engines/common.py index 81e2343..ef68c17 100644 --- a/evolve_server/engines/common.py +++ b/evolve_server/engines/common.py @@ -37,6 +37,14 @@ def _build_bucket( """Create the object-store adapter for an engine.""" if mock: return LocalBucket(root=mock_root) + if ( + str(getattr(config, "skill_storage_backend", "") or "").strip().lower() == "nacos" + and not str(getattr(config, "storage_backend", "") or "").strip() + ): + raise ValueError( + "sharing.skill_backend=nacos stores skill assets only. Configure session storage with " + "sharing.backend, sharing.session_backend, sharing.local_root, EVOLVE_STORAGE_*, or use --mock." + ) return build_object_store( backend=config.storage_backend, endpoint=config.storage_endpoint, diff --git a/evolve_server/engines/workflow.py b/evolve_server/engines/workflow.py index 7727319..2a77af8 100644 --- a/evolve_server/engines/workflow.py +++ b/evolve_server/engines/workflow.py @@ -139,16 +139,36 @@ def _overlay_manifest_metadata( def _fetch_skill(self, name: str) -> Optional[str]: if self._nacos_skill_client is not None: try: - from skillclaw.nacos_skill_hub import _nacos_zip_to_bundle + from skillclaw.nacos_skill_hub import ( + _nacos_published_version, + _nacos_working_version, + _nacos_zip_to_bundle, + ) record = self._load_remote_skill_record(name) or {} - labels = record.get("labels") if isinstance(record.get("labels"), dict) else {} - version = labels.get(str(getattr(self.config, "nacos_label", "") or "latest")) - zip_bytes = self._nacos_skill_client.download_skill_zip( - name, - version=version, - label=str(getattr(self.config, "nacos_label", "") or "latest"), - ) + try: + detail = self._nacos_skill_client.get_skill(name) if record else {} + except Exception: + detail = {} + working = _nacos_working_version(record, detail) + if working: + _status, version = working + zip_bytes = self._nacos_skill_client.download_skill_zip(name, version=version, admin=True) + else: + label = str(getattr(self.config, "nacos_label", "") or "latest") + version = _nacos_published_version(record, detail, label=label) + if not version: + logger.info( + "[EvolveServer] Nacos skill %s has no published %s version", + name, + label, + ) + return None + zip_bytes = self._nacos_skill_client.download_skill_zip( + name, + version=version, + label=label, + ) bundle = _nacos_zip_to_bundle(zip_bytes) data = bundle.get("SKILL.md") return data.decode("utf-8") if data is not None else None @@ -157,23 +177,60 @@ def _fetch_skill(self, name: str) -> Optional[str]: return None return fetch_skill_content(self._bucket, self._prefix, name) - def _upload_skill(self, skill: dict, action: str) -> None: + def _upload_skill(self, skill: dict, action: str) -> str: name = skill.get("name", "") if not name: - return + return "skipped_missing_name" if self._nacos_skill_client is not None: - from skillclaw.nacos_skill_hub import _bundle_to_nacos_zip, _next_version + from skillclaw.nacos_skill_hub import ( + _bundle_matches_remote, + _bundle_to_nacos_zip, + _nacos_working_version, + _nacos_zip_to_bundle, + _next_version, + ) md_content = build_skill_md(skill) md_bytes = md_content.encode("utf-8") + bundle_files = {"SKILL.md": md_bytes} record = self._load_remote_skill_record(name) or {} try: detail = self._nacos_skill_client.get_skill(name) if record else {} except Exception: detail = {} + working = _nacos_working_version(record, detail) + if working: + status, version = working + try: + zip_bytes = self._nacos_skill_client.download_skill_zip(name, version=version, admin=True) + remote_bundle = _nacos_zip_to_bundle(zip_bytes) + except Exception as exc: + logger.warning( + "[EvolveServer] skipping Nacos skill %s: failed to inspect %s version %s: %s", + name, + status, + version, + exc, + ) + return f"skipped_existing_{status}" + if _bundle_matches_remote(bundle_files, remote_bundle): + logger.info( + "[EvolveServer] skipped Nacos skill %s: %s version %s already matches", + name, + status, + version, + ) + return f"skipped_existing_{status}" + logger.info( + "[EvolveServer] skipped Nacos skill %s: %s version %s already exists", + name, + status, + version, + ) + return f"skipped_existing_{status}" target_version = _next_version(record, detail) - zip_bytes = _bundle_to_nacos_zip(name, {"SKILL.md": md_bytes}) + zip_bytes = _bundle_to_nacos_zip(name, bundle_files) self._nacos_skill_client.upload_skill_zip( zip_bytes=zip_bytes, filename=f"{name}-{target_version}.zip", @@ -187,7 +244,7 @@ def _upload_skill(self, skill: dict, action: str) -> None: target_version, action, ) - return + return "uploaded" skill_id = self._id_registry.get_or_create(name) md_content = build_skill_md(skill) @@ -234,6 +291,7 @@ def _upload_skill(self, skill: dict, action: str) -> None: version, object_key, ) + return "uploaded" def _detect_conflict(self, name: str, incoming_skill: dict) -> bool: if self._nacos_skill_client is not None: @@ -251,18 +309,18 @@ def _detect_conflict(self, name: str, incoming_skill: dict) -> bool: incoming_sha = hashlib.sha256(incoming_md.encode("utf-8")).hexdigest() return existing_sha != incoming_sha - async def _resolve_and_upload(self, skill: dict, action_type: str) -> str: + async def _resolve_and_upload(self, skill: dict, action_type: str) -> tuple[str, bool]: name = skill.get("name", "") has_conflict = await self._call_storage(self._detect_conflict, name, skill) if not has_conflict: - await self._call_storage(self._upload_skill, skill, action_type) - return action_type + upload_status = await self._call_storage(self._upload_skill, skill, action_type) + return (action_type, True) if upload_status == "uploaded" else (upload_status, False) logger.info("[EvolveServer] conflict detected for '%s' - merging", name) existing_md = await self._call_storage(self._fetch_skill, name) if not existing_md: - await self._call_storage(self._upload_skill, skill, action_type) - return action_type + upload_status = await self._call_storage(self._upload_skill, skill, action_type) + return (action_type, True) if upload_status == "uploaded" else (upload_status, False) existing_skill = parse_skill_content(name, existing_md) existing_skill = self._overlay_manifest_metadata( @@ -273,12 +331,12 @@ async def _resolve_and_upload(self, skill: dict, action_type: str) -> str: merged = await execute_merge(self._llm, existing_skill, skill) if merged and merged.get("name"): merged["name"] = name - await self._call_storage(self._upload_skill, merged, "merge") - return "merge" + upload_status = await self._call_storage(self._upload_skill, merged, "merge") + return ("merge", True) if upload_status == "uploaded" else (upload_status, False) logger.warning("[EvolveServer] merge failed for '%s' - keeping incoming version", name) - await self._call_storage(self._upload_skill, skill, action_type) - return action_type + upload_status = await self._call_storage(self._upload_skill, skill, action_type) + return (action_type, True) if upload_status == "uploaded" else (upload_status, False) def _empty_judge_summary(self) -> dict[str, Any]: return { @@ -354,6 +412,7 @@ def _empty_validation_publish_summary(self) -> dict[str, Any]: "pending": 0, "published": 0, "rejected": 0, + "skipped": 0, } def _build_validation_evidence(self, sessions: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -518,11 +577,11 @@ async def _finalize_validation_jobs(self) -> tuple[list[dict[str, Any]], dict[st summary["rejected"] += 1 continue action_type = str(job.get("proposed_action", DecisionAction.CREATE) or DecisionAction.CREATE) - actual_action = await self._resolve_and_upload(candidate_skill, action_type) + actual_action, uploaded = await self._resolve_and_upload(candidate_skill, action_type) self._validation_store.save_decision( job_id, { - "status": "published", + "status": "published" if uploaded else "skipped", "published_action": actual_action, "result_count": len(results), "accepted_count": accepted, @@ -530,10 +589,13 @@ async def _finalize_validation_jobs(self) -> tuple[list[dict[str, Any]], dict[st "mean_score": mean_score, }, ) - summary["published"] += 1 + if uploaded: + summary["published"] += 1 + else: + summary["skipped"] += 1 records.append( { - "action": "published_after_validation", + "action": "published_after_validation" if uploaded else actual_action, "published_action": actual_action, "skill_name": str(candidate_skill.get("name", "")), "skill_id": self._id_registry.get_or_create(str(candidate_skill.get("name", ""))), @@ -541,7 +603,7 @@ async def _finalize_validation_jobs(self) -> tuple[list[dict[str, Any]], dict[st "session_ids": list(job.get("session_ids") or []), "rationale": str(job.get("rationale", "") or ""), "source": "validation_publish", - "uploaded": True, + "uploaded": uploaded, "validation_job_id": job_id, "validation_results": { "result_count": len(results), @@ -688,7 +750,7 @@ async def _materialize_skill( record["verification"] = verification return record - actual_action = await self._resolve_and_upload(evolved_skill, action_type) + actual_action, uploaded = await self._resolve_and_upload(evolved_skill, action_type) logger.info( "[EvolveServer] %s skill '%s' (id=%s, v%d)", actual_action, @@ -705,7 +767,7 @@ async def _materialize_skill( "rationale": rationale, "source": source, "edit_summary": evolved_skill.get("edit_summary"), - "uploaded": True, + "uploaded": uploaded, "verification": verification, } @@ -896,9 +958,7 @@ async def trigger_evolve(): @app.get("/status") async def status(): entries = ( - self._load_remote_skills() - if self._uses_nacos_skill_registry() - else self._id_registry.all_entries() + self._load_remote_skills() if self._uses_nacos_skill_registry() else self._id_registry.all_entries() ) pending_keys = await self._call_storage(list_session_keys, self._bucket, self._prefix) return JSONResponse( diff --git a/scripts/demo_nacos_skill_lifecycle.py b/scripts/demo_nacos_skill_lifecycle.py index e52660c..9fb71e8 100644 --- a/scripts/demo_nacos_skill_lifecycle.py +++ b/scripts/demo_nacos_skill_lifecycle.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# ruff: noqa: I001 """Demo SkillClaw's Nacos-backed skill lifecycle without a real Nacos server. The demo uses httpx.MockTransport to show the exact lifecycle SkillClaw now @@ -13,8 +14,8 @@ from __future__ import annotations import tempfile -from pathlib import Path import sys +from pathlib import Path import httpx diff --git a/skillclaw/api_server.py b/skillclaw/api_server.py index bcbce8e..0fcc4b7 100644 --- a/skillclaw/api_server.py +++ b/skillclaw/api_server.py @@ -92,7 +92,6 @@ def _normalize_assistant_content_parts(content: list[dict]) -> tuple[str, list[d return (" ".join(text_parts).strip(), tool_calls) - _THINK_RE = re.compile(r".*?", re.DOTALL) _TOOL_HANDLE_RE = re.compile(r"^call_(?:kimi|xml)_\d+$") _KIMI_TOOL_CALL_RE = re.compile( @@ -253,21 +252,12 @@ def _looks_like_path(value: str) -> bool: text = str(value or "").strip() if not text or text in {".", ".."}: return False - return ( - "/" in text - or "\\" in text - or text.startswith("~") - or text.endswith("SKILL.md") - ) + return "/" in text or "\\" in text or text.startswith("~") or text.endswith("SKILL.md") def _extract_skill_paths_from_patch(raw_text: str) -> list[str]: return _deduplicate_paths( - [ - match.group(1).strip() - for match in _PATCH_PATH_RE.finditer(str(raw_text or "")) - if match.group(1).strip() - ] + [match.group(1).strip() for match in _PATCH_PATH_RE.finditer(str(raw_text or "")) if match.group(1).strip()] ) @@ -1127,7 +1117,7 @@ def _image_dimensions_from_bytes(data: bytes) -> tuple[int, int] | None: continue if index + 2 > len(data): return None - segment_length = struct.unpack(">H", data[index:index + 2])[0] + segment_length = struct.unpack(">H", data[index : index + 2])[0] if segment_length < 2 or index + segment_length > len(data): return None if marker in { @@ -1146,7 +1136,7 @@ def _image_dimensions_from_bytes(data: bytes) -> tuple[int, int] | None: 0xCF, }: if segment_length >= 7: - height, width = struct.unpack(">HH", data[index + 3:index + 7]) + height, width = struct.unpack(">HH", data[index + 3 : index + 7]) return (width, height) if width > 0 and height > 0 else None return None index += segment_length @@ -3005,7 +2995,6 @@ async def _stream_anthropic_response( async for chunk in anthropic_protocol.stream_from_openai_result(result, model, tool_names): yield chunk - # ------------------------------------------------------------------ # # Lifecycle # # ------------------------------------------------------------------ # diff --git a/skillclaw/claw_adapter.py b/skillclaw/claw_adapter.py index c9da57e..b0636a1 100644 --- a/skillclaw/claw_adapter.py +++ b/skillclaw/claw_adapter.py @@ -748,7 +748,7 @@ def inspect_codex_config(cfg: "SkillClawConfig") -> dict[str, object]: next_steps.append("Start SkillClaw once with `claw_type=codex` so it can register ~/.codex/config.toml.") if configured_provider == "skillclaw": issues.append("Codex global model_provider still points at SkillClaw; normal Codex runs may be intercepted.") - next_steps.append("Remove top-level `model_provider = \"skillclaw\"` or run `skillclaw restore codex`.") + next_steps.append('Remove top-level `model_provider = "skillclaw"` or run `skillclaw restore codex`.') if not expected_skills_dir.is_dir(): issues.append(f"Codex skills directory is missing: {expected_skills_dir}") next_steps.append(f"Create or prepare the Codex skills directory: {expected_skills_dir}") @@ -942,9 +942,7 @@ def _latest_opencode_backup_path() -> Path | None: def _prepare_opencode_skills_dir(cfg: "SkillClawConfig") -> None: - target_dir = Path( - str(getattr(cfg, "skills_dir", "") or _OPENCODE_SKILLS_DIR) - ).expanduser() + target_dir = Path(str(getattr(cfg, "skills_dir", "") or _OPENCODE_SKILLS_DIR)).expanduser() _prepare_external_skills_dir(target_dir, "OpenCode") @@ -995,10 +993,7 @@ def inspect_opencode_config(cfg: "SkillClawConfig") -> dict[str, object]: config_path = _OPENCODE_CONFIG_PATH expected_model = cfg.served_model_name or cfg.llm_model_id or "skillclaw-model" expected_base_url = f"http://127.0.0.1:{cfg.proxy_port}/v1" - expected_api_key = cfg.proxy_api_key or "skillclaw" - expected_skills_dir = Path( - str(getattr(cfg, "skills_dir", "") or _OPENCODE_SKILLS_DIR) - ).expanduser() + expected_skills_dir = Path(str(getattr(cfg, "skills_dir", "") or _OPENCODE_SKILLS_DIR)).expanduser() data = _load_json_mapping(config_path, "OpenCode") provider_block = data.get("provider") if isinstance(data, dict) else {} @@ -1027,8 +1022,7 @@ def inspect_opencode_config(cfg: "SkillClawConfig") -> dict[str, object]: uses_default_skills_dir = expected_skills_dir == _OPENCODE_SKILLS_DIR issues: list[str] = [] notes: list[str] = [ - "OpenCode uses SkillClaw through a custom provider block in" - " ~/.config/opencode/opencode.json.", + "OpenCode uses SkillClaw through a custom provider block in ~/.config/opencode/opencode.json.", "OpenCode session capture falls back to proxy-side heuristics" " because OpenCode does not send explicit SkillClaw session headers.", ] diff --git a/skillclaw/cli.py b/skillclaw/cli.py index d8d95b2..89c99a0 100644 --- a/skillclaw/cli.py +++ b/skillclaw/cli.py @@ -748,7 +748,10 @@ def skills(): def _sharing_backend(cfg) -> str: - backend = str(getattr(cfg, "sharing_backend", "") or "").strip().lower() + backend = ( + str(getattr(cfg, "sharing_skill_backend", "") or "").strip().lower() + or str(getattr(cfg, "sharing_backend", "") or "").strip().lower() + ) if backend: return backend if getattr(cfg, "sharing_local_root", ""): @@ -762,7 +765,11 @@ def _sharing_target(cfg) -> str: if backend == "local": return f"local storage ({cfg.sharing_local_root}/{group})" if backend == "nacos": - server = getattr(cfg, "sharing_nacos_server", "") or getattr(cfg, "sharing_endpoint", "") + server = getattr(cfg, "sharing_nacos_server", "") or ( + getattr(cfg, "sharing_endpoint", "") + if str(getattr(cfg, "sharing_backend", "") or "").strip().lower() == "nacos" + else "" + ) namespace_id = getattr(cfg, "sharing_nacos_namespace_id", "public") label = getattr(cfg, "sharing_nacos_label", "latest") return f"nacos ({namespace_id}, label={label} @ {server})" @@ -803,9 +810,15 @@ def _require_sharing(cs: ConfigStore): "OSS credentials are not configured. Set sharing.access_key_id and sharing.secret_access_key." ) elif backend == "nacos": - if not (getattr(cfg, "sharing_nacos_server", "") or getattr(cfg, "sharing_endpoint", "")): + legacy_endpoint = ( + getattr(cfg, "sharing_endpoint", "") + if str(getattr(cfg, "sharing_backend", "") or "").strip().lower() == "nacos" + else "" + ) + if not (getattr(cfg, "sharing_nacos_server", "") or legacy_endpoint): raise click.ClickException( - "Nacos sharing backend is not configured. Set sharing.nacos_server or sharing.endpoint first." + "Nacos skill backend is not configured. Set sharing.nacos_server first " + "(legacy sharing.backend=nacos may use sharing.endpoint)." ) else: raise click.ClickException( @@ -863,11 +876,10 @@ def skills_publish(name, version, no_update_latest): cs = ConfigStore() cfg, hub = _require_sharing(cs) if _sharing_backend(cfg) != "nacos" or not hasattr(hub, "publish_skill"): - raise click.ClickException("skills publish is only available when sharing.backend is nacos.") + raise click.ClickException("skills publish is only available when sharing.skill_backend is nacos.") result = hub.publish_skill(name, version, update_latest_label=not no_update_latest) click.echo( - f"Published {result['skill_name']} {result['version']} " - f"(updated latest: {result['updated_latest_label']})." + f"Published {result['skill_name']} {result['version']} (updated latest: {result['updated_latest_label']})." ) diff --git a/skillclaw/config.py b/skillclaw/config.py index de70d0f..bbd5a6c 100644 --- a/skillclaw/config.py +++ b/skillclaw/config.py @@ -87,8 +87,11 @@ class SkillClawConfig: sharing_region: str = "" sharing_session_token: str = "" sharing_local_root: str = "" - # Optional object-storage backend for non-skill artifacts when - # sharing_backend is reserved for the Skill registry. + # Optional override for skill assets. When empty, sharing_backend keeps its + # legacy behavior and is used for both skills and session artifacts. + sharing_skill_backend: str = "" + # Optional object-storage backend for non-skill artifacts when the skill + # backend is reserved for the Skill registry. sharing_session_backend: str = "" sharing_nacos_server: str = "" sharing_nacos_namespace_id: str = "public" diff --git a/skillclaw/config_store.py b/skillclaw/config_store.py index 81c7567..53669e9 100644 --- a/skillclaw/config_store.py +++ b/skillclaw/config_store.py @@ -69,6 +69,7 @@ "region": "", "session_token": "", "local_root": "", + "skill_backend": "", "session_backend": "", "nacos_server": "", "nacos_namespace_id": "public", @@ -293,7 +294,12 @@ def to_skillclaw_config(self) -> SkillClawConfig: sharing_region = _first_non_empty(sharing, "region") sharing_session_token = _first_non_empty(sharing, "session_token") sharing_local_root = _first_non_empty(sharing, "local_root") + sharing_skill_backend = _first_non_empty(sharing, "skill_backend") sharing_session_backend = _first_non_empty(sharing, "session_backend") + effective_skill_backend = sharing_skill_backend or sharing_backend + nacos_server = str(sharing.get("nacos_server", "") or "") + if not nacos_server and sharing_backend == "nacos" and effective_skill_backend == "nacos": + nacos_server = sharing_endpoint prm_provider = prm.get("provider", "openai") prm_url = str(prm.get("url", "") or llm_api_base) @@ -354,17 +360,14 @@ def to_skillclaw_config(self) -> SkillClawConfig: sharing_region=sharing_region, sharing_session_token=sharing_session_token, sharing_local_root=sharing_local_root, + sharing_skill_backend=sharing_skill_backend, sharing_session_backend=sharing_session_backend, - sharing_nacos_server=str(sharing.get("nacos_server", "") or sharing_endpoint), + sharing_nacos_server=nacos_server, sharing_nacos_namespace_id=str( - sharing.get("nacos_namespace_id", "") - or sharing.get("namespace_id", "") - or "public" + sharing.get("nacos_namespace_id", "") or sharing.get("namespace_id", "") or "public" ), sharing_nacos_access_token=str( - sharing.get("nacos_access_token", "") - or sharing.get("access_token", "") - or "" + sharing.get("nacos_access_token", "") or sharing.get("access_token", "") or "" ), sharing_nacos_username=str(sharing.get("nacos_username", "") or sharing.get("username", "") or ""), sharing_nacos_password=str(sharing.get("nacos_password", "") or sharing.get("password", "") or ""), @@ -431,17 +434,27 @@ def describe(self) -> str: validation = data.get("validation", {}) if sharing.get("enabled"): backend = _infer_sharing_backend(sharing) or "unknown" + skill_backend = str(sharing.get("skill_backend", "") or "").strip().lower() + effective_skill_backend = skill_backend or backend lines += [ "sharing.enabled: True", f"sharing.backend: {backend}", ] + if skill_backend: + lines.append(f"sharing.skill_backend: {skill_backend}") if backend == "local": lines += [ f"sharing.local_root: {sharing.get('local_root', '?')}", ] - elif backend == "nacos": + elif backend in {"s3", "oss"}: lines += [ - f"sharing.nacos_server: {sharing.get('nacos_server') or sharing.get('endpoint', '?')}", + f"sharing.bucket: {_first_non_empty(sharing, 'bucket', default='?')}", + f"sharing.endpoint: {_first_non_empty(sharing, 'endpoint', default='(default)')}", + ] + if effective_skill_backend == "nacos": + nacos_server = sharing.get("nacos_server") or (sharing.get("endpoint") if backend == "nacos" else "?") + lines += [ + f"sharing.nacos_server: {nacos_server}", "sharing.nacos_namespace: " f"{sharing.get('nacos_namespace_id') or sharing.get('namespace_id', 'public')}", f"sharing.nacos_label: {sharing.get('nacos_label') or sharing.get('label', 'latest')}", @@ -449,11 +462,6 @@ def describe(self) -> str: "sharing.session_backend: " f"{sharing.get('session_backend') or ('local' if sharing.get('local_root') else 'not configured')}", ] - else: - lines += [ - f"sharing.bucket: {_first_non_empty(sharing, 'bucket', default='?')}", - f"sharing.endpoint: {_first_non_empty(sharing, 'endpoint', default='(default)')}", - ] lines += [ f"sharing.group: {sharing.get('group_id', 'default')}", f"sharing.alias: {sharing.get('user_alias', '?')}", diff --git a/skillclaw/dashboard_ingest.py b/skillclaw/dashboard_ingest.py index 3831f12..3db7ce3 100644 --- a/skillclaw/dashboard_ingest.py +++ b/skillclaw/dashboard_ingest.py @@ -1360,6 +1360,7 @@ def build_dashboard_snapshot(config: SkillClawConfig) -> dict[str, Any]: "sharing_enabled": bool(config.sharing_enabled), "dashboard_include_shared": bool(config.dashboard_include_shared), "sharing_backend": str(config.sharing_backend or ""), + "sharing_skill_backend": str(config.sharing_skill_backend or ""), "sharing_group_id": str(config.sharing_group_id or "default"), "sharing_local_root": str(config.sharing_local_root or ""), "sharing_user_alias": str(config.sharing_user_alias or ""), diff --git a/skillclaw/dashboard_server.py b/skillclaw/dashboard_server.py index ab405cd..75aacb4 100644 --- a/skillclaw/dashboard_server.py +++ b/skillclaw/dashboard_server.py @@ -51,7 +51,8 @@ def _build_skill_filter(config: SkillClawConfig, *, no_filter: bool = False) -> def _sharing_backend(config: SkillClawConfig) -> str: - backend = str(config.sharing_backend or "").strip().lower() + backend = str(config.sharing_skill_backend or "").strip().lower() + backend = backend or str(config.sharing_backend or "").strip().lower() if backend: return backend if config.sharing_local_root: @@ -65,6 +66,11 @@ def _sharing_target(config: SkillClawConfig) -> str: backend = _sharing_backend(config) if backend == "local": return f"local:{config.sharing_local_root}/{config.sharing_group_id}" + if backend == "nacos": + server = config.sharing_nacos_server or ( + config.sharing_endpoint if str(config.sharing_backend or "").strip().lower() == "nacos" else "" + ) + return f"nacos:{config.sharing_nacos_namespace_id}/{config.sharing_nacos_label}@{server}" if config.sharing_bucket: return f"{backend}:{config.sharing_bucket}/{config.sharing_group_id}" return f"{backend}:{config.sharing_group_id}" @@ -80,6 +86,11 @@ def _require_sharing_hub(config: SkillClawConfig) -> SkillHub: raise ValueError("s3 sharing backend requires sharing_bucket") if backend == "oss" and (not config.sharing_bucket or not config.sharing_endpoint): raise ValueError("oss sharing backend requires sharing_bucket and sharing_endpoint") + if backend == "nacos" and not ( + config.sharing_nacos_server + or (config.sharing_endpoint if str(config.sharing_backend or "").strip().lower() == "nacos" else "") + ): + raise ValueError("nacos skill backend requires sharing_nacos_server") if not backend: raise ValueError("sharing backend is not configured") return SkillHub.from_config(config) @@ -362,9 +373,7 @@ def activate_skill_version(self, skill_id: str, *, target: str) -> dict[str, Any else: document = str(version_payload.get("skill_md") or version_payload.get("content") or "").strip() if self._requires_full_bundle(current_bundle_record): - raise ValueError( - "selected version only has a SKILL.md snapshot; full bundle replay is unavailable" - ) + raise ValueError("selected version only has a SKILL.md snapshot; full bundle replay is unavailable") self._write_document_version(skill_root, document) label = f"共享 v{version_num}" else: diff --git a/skillclaw/nacos_skill_hub.py b/skillclaw/nacos_skill_hub.py index 58c817f..01299ed 100644 --- a/skillclaw/nacos_skill_hub.py +++ b/skillclaw/nacos_skill_hub.py @@ -20,6 +20,7 @@ import httpx +from .nacos_versions import _next_version, _parse_semver, _parse_v_version from .skill_bundle import ( bundle_entrypoint_text, bundle_file_records, @@ -30,6 +31,8 @@ from .skill_hub import _is_hermes_skill_root, _skill_dir_for_root logger = logging.getLogger(__name__) +_PUBLISHED_VERSION_STATUSES = {"published", "online", "released"} +_REVIEW_VERSION_STATUSES = {"reviewing", "reviewed"} class NacosSkillClient: @@ -48,7 +51,10 @@ def __init__( ) -> None: self.server = str(server or "").rstrip("/") if not self.server: - raise ValueError("Nacos sharing requires sharing.nacos_server or sharing.endpoint.") + raise ValueError( + "Nacos skill backend requires sharing.nacos_server " + "(legacy sharing.backend=nacos may use sharing.endpoint)." + ) self.namespace_id = str(namespace_id or "public") self.access_token = access_token self.username = username @@ -230,29 +236,73 @@ def _nacos_zip_to_bundle(zip_bytes: bytes) -> dict[str, bytes]: return bundle -def _parse_version_number(version: str | None) -> int: - raw = str(version or "").strip() - if raw.startswith("v"): - raw = raw[1:] - try: - return int(raw) - except ValueError: - return 0 - - -def _next_version(summary: dict[str, Any], detail: dict[str, Any] | None = None) -> str: - versions: list[str] = [] - labels = summary.get("labels") - if isinstance(labels, dict): - versions.extend(str(v) for v in labels.values() if v) - for field in ("editingVersion", "reviewingVersion", "version"): - if summary.get(field): - versions.append(str(summary[field])) +def _largest_nacos_version(versions: list[str]) -> str | None: + semver_versions = [(parsed, version) for version in versions if (parsed := _parse_semver(version)) is not None] + if semver_versions: + return max(semver_versions, key=lambda item: item[0])[1] + v_versions = [(parsed, version) for version in versions if (parsed := _parse_v_version(version)) is not None] + if v_versions: + return max(v_versions, key=lambda item: item[0])[1] + return max(versions) if versions else None + + +def _nacos_working_version( + summary: dict[str, Any], + detail: dict[str, Any] | None = None, +) -> tuple[str, str] | None: + for source in (summary, detail or {}): + reviewing = str(source.get("reviewingVersion") or "").strip() + if reviewing: + return "reviewing", reviewing + review_versions: list[str] = [] + for item in (detail or {}).get("versions") or []: + if not isinstance(item, dict): + continue + status = str(item.get("status") or "").strip().lower() + version = str(item.get("version") or "").strip() + if version and status in _REVIEW_VERSION_STATUSES: + review_versions.append(version) + review_version = _largest_nacos_version(review_versions) + if review_version: + return "reviewing", review_version + for source in (summary, detail or {}): + editing = str(source.get("editingVersion") or "").strip() + if editing: + return "editing", editing + return None + + +def _nacos_published_version( + summary: dict[str, Any], + detail: dict[str, Any] | None = None, + *, + label: str = "latest", +) -> str | None: + target_label = str(label or "latest") + for source in (summary, detail or {}): + labels = source.get("labels") + if isinstance(labels, dict): + version = str(labels.get(target_label) or "").strip() + if version: + return version + published_versions: list[str] = [] for item in (detail or {}).get("versions") or []: - if isinstance(item, dict) and item.get("version"): - versions.append(str(item["version"])) - next_num = max([_parse_version_number(v) for v in versions] or [0]) + 1 - return f"v{next_num}" + if not isinstance(item, dict): + continue + status = str(item.get("status") or "").strip().lower() + version = str(item.get("version") or "").strip() + if version and status in _PUBLISHED_VERSION_STATUSES: + published_versions.append(version) + if not published_versions: + return None + return _largest_nacos_version(published_versions) + return None + + +def _bundle_matches_remote(local_bundle: dict[str, bytes], remote_bundle: dict[str, bytes]) -> bool: + if not local_bundle or not remote_bundle: + return False + return bundle_tree_sha256(local_bundle) == bundle_tree_sha256(remote_bundle) class NacosSkillHub: @@ -271,8 +321,12 @@ def __init__( @classmethod def from_config(cls, config) -> "NacosSkillHub": + sharing_backend = str(getattr(config, "sharing_backend", "") or "").strip().lower() + server = str(getattr(config, "sharing_nacos_server", "") or "") + if not server and sharing_backend == "nacos": + server = str(getattr(config, "sharing_endpoint", "") or "") client = NacosSkillClient( - server=str(getattr(config, "sharing_nacos_server", "") or getattr(config, "sharing_endpoint", "") or ""), + server=server, namespace_id=str(getattr(config, "sharing_nacos_namespace_id", "") or "public"), access_token=str(getattr(config, "sharing_nacos_access_token", "") or ""), username=str(getattr(config, "sharing_nacos_username", "") or ""), @@ -292,23 +346,40 @@ def _local_bundle_matches_remote(skill_dir: str, remote_bundle: dict[str, bytes] local_bundle, _records, local_sha = read_skill_bundle_with_meta(skill_dir) return bool(local_bundle) and local_sha == bundle_tree_sha256(remote_bundle) - def _download_skill_bundle(self, name: str, rec: dict[str, Any]) -> dict[str, bytes]: - labels = rec.get("labels") if isinstance(rec.get("labels"), dict) else {} - version = labels.get(self._label) + def _download_skill_bundle( + self, + name: str, + rec: dict[str, Any], + detail: dict[str, Any] | None = None, + ) -> dict[str, bytes]: + if detail is None: + try: + detail = self._client.get_skill(name) + except Exception: + detail = {} + version = _nacos_published_version(rec, detail, label=self._label) + if not version: + raise FileNotFoundError(f"Nacos skill {name} has no published {self._label} version") zip_bytes = self._client.download_skill_zip(name, version=version, label=self._label) return _nacos_zip_to_bundle(zip_bytes) + def _download_skill_bundle_version(self, name: str, version: str) -> dict[str, bytes]: + zip_bytes = self._client.download_skill_zip(name, version=version, admin=True) + return _nacos_zip_to_bundle(zip_bytes) + def list_remote(self) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for item in self._client.list_skills(): labels = item.get("labels") if isinstance(item.get("labels"), dict) else {} - out.append({ - **item, - "version": labels.get(self._label) or item.get("version") or item.get("reviewingVersion") or "", - "uploaded_by": item.get("owner") or item.get("from") or "nacos", - "uploaded_at": item.get("updatedAt") or item.get("updateTime") or "", - "category": item.get("category") or "general", - }) + out.append( + { + **item, + "version": labels.get(self._label) or item.get("version") or item.get("reviewingVersion") or "", + "uploaded_by": item.get("owner") or item.get("from") or "nacos", + "uploaded_at": item.get("updatedAt") or item.get("updateTime") or "", + "category": item.get("category") or "general", + } + ) return out def push_skills( @@ -343,17 +414,46 @@ def push_skills( bundle_files, _bundle_records, _tree_sha = read_skill_bundle_with_meta(skill_dir) remote_rec = remote.get(skill_name) - if remote_rec: + try: + detail = self._client.get_skill(skill_name) if remote_rec else {} + except Exception: + detail = {} + working = _nacos_working_version(remote_rec or {}, detail) + if working: + status, version = working + try: + remote_bundle = self._download_skill_bundle_version(skill_name, version) + except Exception as exc: + raise RuntimeError( + f"failed to inspect Nacos {status} version {version} for {skill_name}: {exc}" + ) from exc + if _bundle_matches_remote(bundle_files, remote_bundle): + skipped += 1 + logger.info( + "[NacosSkillHub] skipped %s: %s version %s already matches", + skill_name, + status, + version, + ) + continue + if status == "reviewing": + raise RuntimeError( + f"Nacos skill {skill_name} already has reviewing version {version}; " + "finish or reject it before pushing new content." + ) + target_version = version + elif remote_rec: try: remote_bundle = self._download_skill_bundle(skill_name, remote_rec) - if self._local_bundle_matches_remote(skill_dir, remote_bundle): + if _bundle_matches_remote(bundle_files, remote_bundle): skipped += 1 continue except Exception as exc: logger.info("[NacosSkillHub] remote comparison skipped for %s: %s", skill_name, exc) + target_version = _next_version(remote_rec or {}, detail) + else: + target_version = _next_version({}, {}) - detail = self._client.get_skill(skill_name) if remote_rec else {} - target_version = _next_version(remote_rec or {}, detail) zip_bytes = _bundle_to_nacos_zip(skill_name, bundle_files) self._client.upload_skill_zip( zip_bytes=zip_bytes, @@ -423,7 +523,19 @@ def pull_skills( skipped += 1 continue try: - bundle = self._download_skill_bundle(name, rec) + detail = self._client.get_skill(name) + except Exception: + detail = {} + if not _nacos_published_version(rec, detail, label=self._label): + skipped += 1 + logger.info( + "[NacosSkillHub] skipped %s: no published %s version", + name, + self._label, + ) + continue + try: + bundle = self._download_skill_bundle(name, rec, detail) except Exception as exc: failed += 1 failed_names.append(name) diff --git a/skillclaw/nacos_versions.py b/skillclaw/nacos_versions.py new file mode 100644 index 0000000..462bdf4 --- /dev/null +++ b/skillclaw/nacos_versions.py @@ -0,0 +1,61 @@ +"""Version selection helpers for Nacos skill lifecycle operations.""" + +from __future__ import annotations + +import re +from typing import Any + +_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +_V_VERSION_RE = re.compile(r"^v(\d+)$") + + +def _collect_versions(summary: dict[str, Any], detail: dict[str, Any] | None = None) -> list[str]: + versions: list[str] = [] + labels = summary.get("labels") + if isinstance(labels, dict): + latest = labels.get("latest") + if latest: + versions.append(str(latest)) + versions.extend(str(v) for k, v in labels.items() if k != "latest" and v) + for field in ("editingVersion", "reviewingVersion", "version"): + if summary.get(field): + versions.append(str(summary[field])) + for item in (detail or {}).get("versions") or []: + if isinstance(item, dict) and item.get("version"): + versions.append(str(item["version"])) + return versions + + +def _parse_v_version(version: str | None) -> int | None: + raw = str(version or "").strip() + match = _V_VERSION_RE.fullmatch(raw) + return int(match.group(1)) if match else None + + +def _parse_semver(version: str | None) -> tuple[int, int, int] | None: + match = _SEMVER_RE.fullmatch(str(version or "").strip()) + if not match: + return None + return tuple(int(part) for part in match.groups()) + + +def _next_version(summary: dict[str, Any], detail: dict[str, Any] | None = None) -> str: + versions = _collect_versions(summary, detail) + selected_format = "" + for version in versions: + if _parse_semver(version) is not None: + selected_format = "semver" + break + if _parse_v_version(version) is not None: + selected_format = "v" + break + + if selected_format == "v": + next_num = max(_parse_v_version(version) or 0 for version in versions) + 1 + return f"v{next_num}" + + semver_versions = [parsed for version in versions if (parsed := _parse_semver(version)) is not None] + if not semver_versions: + return "0.0.1" + major, minor, patch = max(semver_versions) + return f"{major}.{minor}.{patch + 1}" diff --git a/skillclaw/runtime_state.py b/skillclaw/runtime_state.py index 3a9c555..c30b379 100644 --- a/skillclaw/runtime_state.py +++ b/skillclaw/runtime_state.py @@ -35,6 +35,7 @@ def _process_alive_unix(pid: int) -> bool: def _process_alive_windows(pid: int) -> bool: import ctypes + kernel32 = ctypes.windll.kernel32 PROCESS_QUERY_INFORMATION = 0x0400 handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, False, pid) diff --git a/skillclaw/setup_wizard.py b/skillclaw/setup_wizard.py index 99131bb..03618fe 100644 --- a/skillclaw/setup_wizard.py +++ b/skillclaw/setup_wizard.py @@ -286,6 +286,18 @@ def run(self): "user_alias": user_alias, "auto_pull_on_start": auto_pull, } + for key in ( + "skill_backend", + "session_backend", + "nacos_server", + "nacos_namespace_id", + "nacos_access_token", + "nacos_username", + "nacos_password", + "nacos_label", + ): + if current_sharing.get(key): + sharing_config[key] = current_sharing[key] if sharing_backend == "local": local_root = _prompt( "Local shared storage root", diff --git a/skillclaw/skill_bundle.py b/skillclaw/skill_bundle.py index 5905c73..0f50263 100644 --- a/skillclaw/skill_bundle.py +++ b/skillclaw/skill_bundle.py @@ -90,11 +90,13 @@ def bundle_file_records(bundle_files: Mapping[str, bytes | bytearray | str]) -> records: list[dict[str, int | str]] = [] for rel_path, raw_data in sorted(coerce_skill_bundle(bundle_files).items()): data = _coerce_bytes(raw_data) - records.append({ - "path": rel_path, - "sha256": hashlib.sha256(data).hexdigest(), - "size": len(data), - }) + records.append( + { + "path": rel_path, + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + } + ) return records diff --git a/skillclaw/skill_hub.py b/skillclaw/skill_hub.py index e81c750..632dd11 100644 --- a/skillclaw/skill_hub.py +++ b/skillclaw/skill_hub.py @@ -91,7 +91,8 @@ def __init__( @classmethod def from_config(cls, config) -> "SkillHub": - backend = str(getattr(config, "sharing_backend", "") or "").strip().lower() + sharing_backend = str(getattr(config, "sharing_backend", "") or "").strip().lower() + backend = str(getattr(config, "sharing_skill_backend", "") or "").strip().lower() or sharing_backend if backend == "nacos": from .nacos_skill_hub import NacosSkillHub @@ -118,9 +119,10 @@ def from_config(cls, config) -> "SkillHub": def object_storage_from_config(cls, config) -> Optional["SkillHub"]: """Build the legacy object-store hub for non-skill artifacts. - ``sharing.backend=nacos`` selects only the Skill registry. Sessions, - validation jobs, and other non-skill artifacts must continue to use - local/OSS/S3 object storage when that storage is explicitly configured. + ``sharing.skill_backend=nacos`` selects only the Skill registry. + Sessions, validation jobs, and other non-skill artifacts must continue + to use local/OSS/S3 object storage when that storage is explicitly + configured. """ sharing_backend = str(getattr(config, "sharing_backend", "") or "").strip().lower() backend = str(getattr(config, "sharing_session_backend", "") or "").strip().lower() @@ -182,11 +184,7 @@ def _iter_remote_keys(self, prefix: str): return self._bucket.iter_objects(prefix=prefix) def _delete_remote_bundle_extras(self, skill_name: str, keep_paths: Collection[str]) -> None: - keep_keys = { - self._skill_bundle_key(skill_name, rel_path) - for rel_path in keep_paths - if rel_path != "SKILL.md" - } + keep_keys = {self._skill_bundle_key(skill_name, rel_path) for rel_path in keep_paths if rel_path != "SKILL.md"} for obj in self._iter_remote_keys(self._skill_files_prefix(skill_name)): key = str(getattr(obj, "key", "") or "") if key and key not in keep_keys: diff --git a/tests/test_anthropic_messages.py b/tests/test_anthropic_messages.py index c1974f3..d68a3cb 100644 --- a/tests/test_anthropic_messages.py +++ b/tests/test_anthropic_messages.py @@ -7,15 +7,11 @@ def test_anthropic_tool_result_blocks_convert_to_openai_tool_messages(): "messages": [ { "role": "assistant", - "content": [ - {"type": "tool_use", "id": "toolu_1", "name": "Skill", "input": {"name": "debug"}} - ], + "content": [{"type": "tool_use", "id": "toolu_1", "name": "Skill", "input": {"name": "debug"}}], }, { "role": "user", - "content": [ - {"type": "tool_result", "tool_use_id": "toolu_1", "content": "Skill instructions"} - ], + "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "Skill instructions"}], }, ], } @@ -130,9 +126,7 @@ def test_openai_tool_calls_convert_to_anthropic_tool_use_blocks(): converted = anthropic_messages.from_openai_response(openai_resp, "claude-code-test") assert converted["stop_reason"] == "tool_use" - assert converted["content"] == [ - {"type": "tool_use", "id": "call_1", "name": "Skill", "input": {"name": "debug"}} - ] + assert converted["content"] == [{"type": "tool_use", "id": "call_1", "name": "Skill", "input": {"name": "debug"}}] def test_openai_read_tool_call_normalizes_to_claude_code_schema(): @@ -405,6 +399,7 @@ async def _collect_stream_events(result, model): def test_streaming_openai_tool_calls_emit_anthropic_tool_use_events(): import asyncio import json + result = { "response": { "id": "chatcmpl_1", @@ -487,10 +482,10 @@ def test_streaming_read_tool_call_emits_sanitized_claude_code_arguments(): ) - def test_streaming_openai_tool_calls_use_tool_use_stop_reason_even_if_finish_reason_is_stop(): import asyncio import json + result = { "response": { "id": "chatcmpl_1", @@ -518,11 +513,11 @@ def test_streaming_openai_tool_calls_use_tool_use_stop_reason_even_if_finish_rea parsed = [(name, json.loads(data)) for name, data in events] assert any( - name == "message_delta" - and payload["delta"] == {"stop_reason": "tool_use", "stop_sequence": None} + name == "message_delta" and payload["delta"] == {"stop_reason": "tool_use", "stop_sequence": None} for name, payload in parsed ) + def test_anthropic_system_blocks_preserve_text_and_cache_control(): body = { "model": "claude-code-test", @@ -604,7 +599,6 @@ def test_openai_response_with_tool_calls_uses_tool_use_stop_reason_even_if_finis assert converted["stop_reason"] == "tool_use" - def test_anthropic_server_web_search_tool_is_not_converted_to_function_tool(): body = { "model": "claude-code-test", @@ -628,6 +622,7 @@ def test_anthropic_server_web_search_tool_is_not_converted_to_function_tool(): } ] + def test_anthropic_multimodal_image_input_converts_to_openai_chat_content_parts(): body = { "model": "claude-code-test", diff --git a/tests/test_nacos_skill_hub.py b/tests/test_nacos_skill_hub.py index 3507b51..9287693 100644 --- a/tests/test_nacos_skill_hub.py +++ b/tests/test_nacos_skill_hub.py @@ -6,7 +6,6 @@ from skillclaw.nacos_skill_hub import NacosSkillClient, NacosSkillHub, _bundle_to_nacos_zip - SKILL_MD = """--- name: demo-skill description: Demo skill @@ -38,11 +37,11 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/v3/admin/ai/skills/upload": assert request.url.params.get("targetVersion") is None assert b'name="targetVersion"' in request.content - assert b"v1" in request.content + assert b"0.0.1" in request.content return _json("demo-skill") if request.method == "POST" and request.url.path == "/v3/admin/ai/skills/submit": - assert request.content == b"namespaceId=public&skillName=demo-skill&version=v1" - return _json("v1") + assert request.content == b"namespaceId=public&skillName=demo-skill&version=0.0.1" + return _json("0.0.1") raise AssertionError(f"unexpected request: {request.method} {request.url}") client = NacosSkillClient( @@ -128,7 +127,56 @@ def handler(request: httpx.Request) -> httpx.Response: assert (restored / "demo-skill" / "references" / "guide.md").read_bytes() == b"hello\n" -def test_nacos_pull_skips_failed_download_and_continues(tmp_path: Path) -> None: +def test_nacos_pull_downloads_largest_published_version_without_latest_label(tmp_path: Path) -> None: + zip_bytes = _bundle_to_nacos_zip("demo-skill", {"SKILL.md": SKILL_MD.encode("utf-8")}) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v3/admin/ai/skills/list": + return _json( + { + "totalCount": 1, + "pageItems": [ + { + "name": "demo-skill", + "description": "Demo skill", + "labels": {}, + } + ], + } + ) + if request.url.path == "/v3/admin/ai/skills": + return _json( + { + "name": "demo-skill", + "labels": {}, + "versions": [ + {"version": "0.0.2", "status": "published"}, + {"version": "0.0.4", "status": "published"}, + {"version": "0.0.9", "status": "reviewed"}, + ], + } + ) + if request.url.path == "/v3/client/ai/skills": + assert request.url.params["name"] == "demo-skill" + assert request.url.params["version"] == "0.0.4" + return httpx.Response(200, content=zip_bytes) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + client = NacosSkillClient( + server="http://nacos.test", + namespace_id="public", + transport=httpx.MockTransport(handler), + ) + hub = NacosSkillHub(client=client) + restored = tmp_path / "restored" + + result = hub.pull_skills(str(restored)) + + assert result["downloaded"] == 1 + assert (restored / "demo-skill" / "SKILL.md").read_text(encoding="utf-8") == SKILL_MD + + +def test_nacos_pull_skips_unpublished_skill_and_continues(tmp_path: Path) -> None: zip_bytes = _bundle_to_nacos_zip("demo-skill", {"SKILL.md": SKILL_MD.encode("utf-8")}) def handler(request: httpx.Request) -> httpx.Response: @@ -151,8 +199,6 @@ def handler(request: httpx.Request) -> httpx.Response: } ) if request.url.path == "/v3/client/ai/skills": - if request.url.params["name"] == "broken-skill": - return httpx.Response(404) assert request.url.params["name"] == "demo-skill" return httpx.Response(200, content=zip_bytes) raise AssertionError(f"unexpected request: {request.method} {request.url}") @@ -168,7 +214,8 @@ def handler(request: httpx.Request) -> httpx.Response: result = hub.pull_skills(str(restored)) assert result["downloaded"] == 1 - assert result["failed"] == 1 - assert result["failed_names"] == ["broken-skill"] + assert result["skipped"] == 1 + assert result["failed"] == 0 + assert result["failed_names"] == [] assert (restored / "demo-skill" / "SKILL.md").read_text(encoding="utf-8") == SKILL_MD assert not (restored / "broken-skill").exists() diff --git a/tests/test_nacos_versioning.py b/tests/test_nacos_versioning.py new file mode 100644 index 0000000..0ab854a --- /dev/null +++ b/tests/test_nacos_versioning.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from skillclaw.nacos_versions import _next_version + + +def test_next_version_defaults_to_semver_for_new_skill() -> None: + assert _next_version({}) == "0.0.1" + + +def test_next_version_increments_existing_semver_patch() -> None: + summary = {"labels": {"latest": "0.0.3"}} + detail = {"versions": [{"version": "0.0.2"}, {"version": "0.0.4"}]} + + assert _next_version(summary, detail) == "0.0.5" + + +def test_next_version_keeps_existing_v_format() -> None: + summary = {"reviewingVersion": "v1"} + + assert _next_version(summary) == "v2" + + +def test_next_version_uses_current_version_format_when_formats_are_mixed() -> None: + summary = {"labels": {"latest": "0.0.3"}, "reviewingVersion": "v9"} + + assert _next_version(summary) == "0.0.4" diff --git a/tests/test_nacos_working_version_push.py b/tests/test_nacos_working_version_push.py new file mode 100644 index 0000000..0970e79 --- /dev/null +++ b/tests/test_nacos_working_version_push.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import pytest + +try: + import httpx # noqa: F401 +except ModuleNotFoundError: + httpx_stub = types.ModuleType("httpx") + httpx_stub.BaseTransport = object + httpx_stub.Client = object + httpx_stub.Response = object + sys.modules["httpx"] = httpx_stub + +from evolve_server.core.config import EvolveServerConfig # noqa: E402 +from evolve_server.engines.workflow import EvolveServer # noqa: E402 +from skillclaw.nacos_skill_hub import NacosSkillHub, _bundle_to_nacos_zip # noqa: E402 + +SKILL_MD = """--- +name: demo-skill +description: Demo skill +--- + +# Demo Skill +""" + +UPDATED_SKILL_MD = """--- +name: demo-skill +description: Demo skill +--- + +# Demo Skill + +Updated content. +""" + + +class FakeNacosClient: + def __init__(self, items: list[dict], downloads: dict[tuple[str, str], bytes]) -> None: + self.items = items + self.downloads = downloads + self.uploads: list[dict] = [] + self.submits: list[tuple[str, str]] = [] + self.download_calls: list[dict] = [] + + def list_skills(self) -> list[dict]: + return self.items + + def get_skill(self, name: str) -> dict: + for item in self.items: + if item.get("name") == name: + return item + return {} + + def download_skill_zip(self, name: str, *, version: str | None = None, label: str = "latest", admin: bool = False): + self.download_calls.append({"name": name, "version": version, "label": label, "admin": admin}) + return self.downloads[(name, str(version or ""))] + + def upload_skill_zip(self, *, zip_bytes: bytes, filename: str, overwrite: bool, target_version: str | None) -> str: + self.uploads.append( + { + "zip_bytes": zip_bytes, + "filename": filename, + "overwrite": overwrite, + "target_version": target_version, + } + ) + return "demo-skill" + + def submit(self, name: str, version: str) -> str: + self.submits.append((name, version)) + return version + + +def _write_skill(root: Path, body: str = SKILL_MD) -> None: + skill_dir = root / "demo-skill" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(body, encoding="utf-8") + + +def _zip(body: str) -> bytes: + return _bundle_to_nacos_zip("demo-skill", {"SKILL.md": body.encode("utf-8")}) + + +def test_push_skips_when_reviewing_version_matches_local_bundle(tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + _write_skill(skills_dir) + client = FakeNacosClient( + [{"name": "demo-skill", "reviewingVersion": "0.0.1", "labels": {}}], + {("demo-skill", "0.0.1"): _zip(SKILL_MD)}, + ) + + result = NacosSkillHub(client=client).push_skills(str(skills_dir)) + + assert result["skipped"] == 1 + assert result["uploaded"] == 0 + assert client.uploads == [] + assert client.download_calls == [{"name": "demo-skill", "version": "0.0.1", "label": "latest", "admin": True}] + + +def test_push_fails_when_reviewing_version_has_different_content(tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + _write_skill(skills_dir, UPDATED_SKILL_MD) + client = FakeNacosClient( + [{"name": "demo-skill", "reviewingVersion": "0.0.1", "labels": {}}], + {("demo-skill", "0.0.1"): _zip(SKILL_MD)}, + ) + + with pytest.raises(RuntimeError, match="already has reviewing version 0.0.1"): + NacosSkillHub(client=client).push_skills(str(skills_dir)) + + assert client.uploads == [] + assert client.submits == [] + + +def test_push_fails_when_reviewed_version_has_different_content(tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + _write_skill(skills_dir, UPDATED_SKILL_MD) + client = FakeNacosClient( + [ + { + "name": "demo-skill", + "labels": {}, + "versions": [{"version": "0.0.3", "status": "reviewed"}], + } + ], + {("demo-skill", "0.0.3"): _zip(SKILL_MD)}, + ) + + with pytest.raises(RuntimeError, match="already has reviewing version 0.0.3"): + NacosSkillHub(client=client).push_skills(str(skills_dir)) + + assert client.download_calls == [{"name": "demo-skill", "version": "0.0.3", "label": "latest", "admin": True}] + assert client.uploads == [] + assert client.submits == [] + + +def test_push_overwrites_existing_editing_version(tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + _write_skill(skills_dir, UPDATED_SKILL_MD) + client = FakeNacosClient( + [{"name": "demo-skill", "editingVersion": "0.0.1", "labels": {}}], + {("demo-skill", "0.0.1"): _zip(SKILL_MD)}, + ) + + result = NacosSkillHub(client=client).push_skills(str(skills_dir)) + + assert result["uploaded"] == 1 + assert result["submitted"] == 1 + assert client.uploads[0]["target_version"] == "0.0.1" + assert client.uploads[0]["overwrite"] is True + assert client.submits == [("demo-skill", "0.0.1")] + + +def test_evolve_upload_skips_existing_editing_version_with_different_content() -> None: + client = FakeNacosClient( + [{"name": "demo-skill", "editingVersion": "0.0.1", "labels": {}}], + {("demo-skill", "0.0.1"): _zip(SKILL_MD)}, + ) + server = EvolveServer.__new__(EvolveServer) + server.config = EvolveServerConfig(skill_storage_backend="nacos") + server._nacos_skill_client = client + server._load_remote_skill_record = lambda name: client.get_skill(name) + + status = server._upload_skill( + {"name": "demo-skill", "description": "Demo skill", "content": "Updated content."}, + "improve", + ) + + assert status == "skipped_existing_editing" + assert client.uploads == [] + assert client.submits == [] + + +def test_evolve_upload_skips_existing_reviewed_version_with_different_content() -> None: + client = FakeNacosClient( + [ + { + "name": "demo-skill", + "labels": {}, + "versions": [{"version": "0.0.3", "status": "reviewed"}], + } + ], + {("demo-skill", "0.0.3"): _zip(SKILL_MD)}, + ) + server = EvolveServer.__new__(EvolveServer) + server.config = EvolveServerConfig(skill_storage_backend="nacos") + server._nacos_skill_client = client + server._load_remote_skill_record = lambda name: client.get_skill(name) + + status = server._upload_skill( + {"name": "demo-skill", "description": "Demo skill", "content": "Updated content."}, + "improve", + ) + + assert status == "skipped_existing_reviewing" + assert client.download_calls == [{"name": "demo-skill", "version": "0.0.3", "label": "latest", "admin": True}] + assert client.uploads == [] + assert client.submits == [] + + +def test_evolve_upload_creates_version_when_nacos_skill_has_no_versions() -> None: + client = FakeNacosClient( + [{"name": "demo-skill", "labels": {}, "versions": [], "editingVersion": None, "reviewingVersion": None}], + {}, + ) + server = EvolveServer.__new__(EvolveServer) + server.config = EvolveServerConfig(skill_storage_backend="nacos") + server._nacos_skill_client = client + server._load_remote_skill_record = lambda name: client.get_skill(name) + + status = server._upload_skill( + {"name": "demo-skill", "description": "Demo skill", "content": "New content."}, + "create_skill", + ) + + assert status == "uploaded" + assert client.uploads[0]["target_version"] == "0.0.1" + assert client.submits == [("demo-skill", "0.0.1")] + + +def test_evolve_fetch_returns_none_when_nacos_skill_has_no_published_label() -> None: + client = FakeNacosClient( + [{"name": "demo-skill", "labels": {}, "editingVersion": None, "reviewingVersion": None}], + {}, + ) + server = EvolveServer.__new__(EvolveServer) + server.config = EvolveServerConfig(skill_storage_backend="nacos") + server._nacos_skill_client = client + server._load_remote_skill_record = lambda name: client.get_skill(name) + + content = server._fetch_skill("demo-skill") + + assert content is None + assert client.download_calls == [] + + +def test_evolve_fetch_downloads_nacos_published_label_version() -> None: + client = FakeNacosClient( + [{"name": "demo-skill", "labels": {"latest": "0.0.3"}, "editingVersion": None, "reviewingVersion": None}], + {("demo-skill", "0.0.3"): _zip(SKILL_MD)}, + ) + server = EvolveServer.__new__(EvolveServer) + server.config = EvolveServerConfig(skill_storage_backend="nacos", nacos_label="latest") + server._nacos_skill_client = client + server._load_remote_skill_record = lambda name: client.get_skill(name) + + content = server._fetch_skill("demo-skill") + + assert content == SKILL_MD + assert client.download_calls == [{"name": "demo-skill", "version": "0.0.3", "label": "latest", "admin": False}] + + +def test_evolve_fetch_downloads_reviewed_version_as_reviewing_working_version() -> None: + client = FakeNacosClient( + [ + { + "name": "demo-skill", + "labels": {}, + "editingVersion": None, + "reviewingVersion": None, + "versions": [ + {"version": "0.0.3", "status": "reviewed"}, + {"version": "0.0.2", "status": "published"}, + ], + } + ], + {("demo-skill", "0.0.3"): _zip(SKILL_MD)}, + ) + server = EvolveServer.__new__(EvolveServer) + server.config = EvolveServerConfig(skill_storage_backend="nacos", nacos_label="latest") + server._nacos_skill_client = client + server._load_remote_skill_record = lambda name: client.get_skill(name) + + content = server._fetch_skill("demo-skill") + + assert content == SKILL_MD + assert client.download_calls == [{"name": "demo-skill", "version": "0.0.3", "label": "latest", "admin": True}] + + +def test_evolve_fetch_downloads_largest_published_version_when_latest_label_missing() -> None: + client = FakeNacosClient( + [ + { + "name": "demo-skill", + "labels": {}, + "editingVersion": None, + "reviewingVersion": None, + "versions": [ + {"version": "0.0.2", "status": "published"}, + {"version": "0.0.4", "status": "published"}, + ], + } + ], + {("demo-skill", "0.0.4"): _zip(SKILL_MD)}, + ) + server = EvolveServer.__new__(EvolveServer) + server.config = EvolveServerConfig(skill_storage_backend="nacos", nacos_label="latest") + server._nacos_skill_client = client + server._load_remote_skill_record = lambda name: client.get_skill(name) + + content = server._fetch_skill("demo-skill") + + assert content == SKILL_MD + assert client.download_calls == [{"name": "demo-skill", "version": "0.0.4", "label": "latest", "admin": False}] diff --git a/tests/test_responses_native.py b/tests/test_responses_native.py index 3995eb9..b22140f 100644 --- a/tests/test_responses_native.py +++ b/tests/test_responses_native.py @@ -133,7 +133,7 @@ def raise_for_status(self): async def aiter_raw(self): yield b'data: {"type":"response.created"}\n\n' yield b'data: {"type":"response.completed"}\n\n' - yield b'data: [DONE]\n\n' + yield b"data: [DONE]\n\n" class FakeStreamContext: async def __aenter__(self): @@ -182,7 +182,7 @@ def stream(self, method, url, json, headers): assert chunks == [ b'data: {"type":"response.created"}\n\n', b'data: {"type":"response.completed"}\n\n', - b'data: [DONE]\n\n', + b"data: [DONE]\n\n", ] assert captured["method"] == "POST" assert captured["url"] == "http://upstream.test/v1/responses" @@ -205,7 +205,7 @@ async def test_responses_endpoint_passthroughs_native_stream(): async def fake_stream(body): yield b'data: {"type":"response.created","upstream":true}\n\n' - yield b'data: [DONE]\n\n' + yield b"data: [DONE]\n\n" server._stream_llm_responses = fake_stream client = httpx.AsyncClient(transport=httpx.ASGITransport(app=server.app), base_url="http://test") @@ -241,9 +241,7 @@ async def fake_handle_request(body, session_id, turn_type, session_done): "response": { "id": f"chatcmpl_{idx}", "created": 0, - "choices": [ - {"message": {"role": "assistant", "content": f"ok {idx}"}, "finish_reason": "stop"} - ], + "choices": [{"message": {"role": "assistant", "content": f"ok {idx}"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, } } @@ -297,9 +295,7 @@ async def fake_handle_request(body, session_id, turn_type, session_done): "response": { "id": f"chatcmpl_{idx}", "created": 0, - "choices": [ - {"message": {"role": "assistant", "content": f"ok {idx}"}, "finish_reason": "stop"} - ], + "choices": [{"message": {"role": "assistant", "content": f"ok {idx}"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, } } @@ -379,9 +375,7 @@ async def fake_handle_request(body, session_id, turn_type, session_done): "response": { "id": f"chatcmpl_{idx}", "created": 0, - "choices": [ - {"message": {"role": "assistant", "content": f"ok {idx}"}, "finish_reason": "stop"} - ], + "choices": [{"message": {"role": "assistant", "content": f"ok {idx}"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, } } diff --git a/tests/test_skill_backend_config.py b/tests/test_skill_backend_config.py new file mode 100644 index 0000000..7042bab --- /dev/null +++ b/tests/test_skill_backend_config.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from evolve_server.core.config import EvolveServerConfig +from skillclaw.config import SkillClawConfig +from skillclaw.config_store import ConfigStore + + +def test_skill_backend_overrides_skill_storage_without_changing_session_storage(monkeypatch) -> None: + monkeypatch.delenv("EVOLVE_STORAGE_BACKEND", raising=False) + cfg = SkillClawConfig( + sharing_backend="oss", + sharing_skill_backend="nacos", + sharing_endpoint="https://oss-cn-hangzhou.aliyuncs.com", + sharing_bucket="skillclaw-sessions", + sharing_access_key_id="ak", + sharing_secret_access_key="sk", + sharing_nacos_server="http://nacos.test", + sharing_group_id="team-a", + ) + + evolve_config = EvolveServerConfig.from_skillclaw_config(cfg) + + assert evolve_config.skill_storage_backend == "nacos" + assert evolve_config.nacos_server == "http://nacos.test" + assert evolve_config.storage_backend == "oss" + assert evolve_config.storage_endpoint == "https://oss-cn-hangzhou.aliyuncs.com" + assert evolve_config.storage_bucket == "skillclaw-sessions" + + +def test_skill_backend_empty_keeps_legacy_nacos_backend_behavior(monkeypatch) -> None: + monkeypatch.delenv("EVOLVE_STORAGE_BACKEND", raising=False) + cfg = SkillClawConfig( + sharing_backend="nacos", + sharing_endpoint="http://legacy-nacos.test", + sharing_group_id="team-a", + ) + + evolve_config = EvolveServerConfig.from_skillclaw_config(cfg) + + assert evolve_config.skill_storage_backend == "nacos" + assert evolve_config.nacos_server == "http://legacy-nacos.test" + assert evolve_config.storage_backend == "" + assert evolve_config.storage_endpoint == "" + + +def test_config_store_reads_skill_backend() -> None: + class InlineConfigStore(ConfigStore): + def load(self) -> dict: + return { + "sharing": { + "enabled": True, + "backend": "oss", + "skill_backend": "nacos", + "endpoint": "https://oss-cn-hangzhou.aliyuncs.com", + "bucket": "skillclaw-sessions", + "nacos_server": "http://nacos.test", + } + } + + cfg = InlineConfigStore().to_skillclaw_config() + + assert cfg.sharing_backend == "oss" + assert cfg.sharing_skill_backend == "nacos" + assert cfg.sharing_nacos_server == "http://nacos.test" diff --git a/tests/test_skill_bundle_support.py b/tests/test_skill_bundle_support.py index 7b1677e..7c5c887 100644 --- a/tests/test_skill_bundle_support.py +++ b/tests/test_skill_bundle_support.py @@ -104,11 +104,13 @@ def test_skill_hub_push_pull_roundtrips_single_file_skill(tmp_path: Path) -> Non rec = manifest["solo-skill"] assert rec["format"] == "bundle_v1" assert rec["entrypoint"] == "SKILL.md" - assert rec["files"] == [{ - "path": "SKILL.md", - "sha256": rec["sha256"], - "size": len(solo_md.encode("utf-8")), - }] + assert rec["files"] == [ + { + "path": "SKILL.md", + "sha256": rec["sha256"], + "size": len(solo_md.encode("utf-8")), + } + ] restored_dir = tmp_path / "restored-skills" pull_result = hub.pull_skills(str(restored_dir)) @@ -225,11 +227,13 @@ def test_skill_hub_roundtrips_extra_unstructured_files_and_attributes_them(tmp_p read_skills = _extract_read_skills_from_tool_calls(read_calls, skill_path_map) - assert read_skills == [{ - "skill_id": manager.get_all_skills()[0]["id"], - "skill_name": "extra-skill", - "path": extra_path, - }] + assert read_skills == [ + { + "skill_id": manager.get_all_skills()[0]["id"], + "skill_name": "extra-skill", + "path": extra_path, + } + ] def test_agent_workspace_detects_nested_bundle_changes(tmp_path: Path) -> None: @@ -248,9 +252,7 @@ def test_agent_workspace_detects_nested_bundle_changes(tmp_path: Path) -> None: ) before = workspace.snapshot_skills() - (workspace.skills_dir / "demo-skill" / "references" / "guide.md").write_text( - "second\n", encoding="utf-8" - ) + (workspace.skills_dir / "demo-skill" / "references" / "guide.md").write_text("second\n", encoding="utf-8") changes = workspace.collect_changes(before) @@ -302,17 +304,21 @@ def test_skill_path_map_and_tool_attribution_include_bundle_files(tmp_path: Path read_skills = _extract_read_skills_from_tool_calls(read_calls, skill_path_map) modified_skills = _extract_modified_skills_from_tool_calls(write_calls, skill_path_map) - assert read_skills == [{ - "skill_id": manager.get_all_skills()[0]["id"], - "skill_name": "demo-skill", - "path": reference_path, - }] - assert modified_skills == [{ - "skill_id": manager.get_all_skills()[0]["id"], - "skill_name": "demo-skill", - "path": script_path, - "action": "edit_file", - }] + assert read_skills == [ + { + "skill_id": manager.get_all_skills()[0]["id"], + "skill_name": "demo-skill", + "path": reference_path, + } + ] + assert modified_skills == [ + { + "skill_id": manager.get_all_skills()[0]["id"], + "skill_name": "demo-skill", + "path": script_path, + "action": "edit_file", + } + ] def test_hermes_skill_tool_attribution_uses_bundle_child_paths(tmp_path: Path) -> None: @@ -364,17 +370,21 @@ def test_hermes_skill_tool_attribution_uses_bundle_child_paths(tmp_path: Path) - read_skills = _extract_read_skills_from_tool_calls(read_calls, skill_path_map) modified_skills = _extract_modified_skills_from_tool_calls(write_calls, skill_path_map) - assert read_skills == [{ - "skill_id": skill_id, - "skill_name": "demo-skill", - "path": reference_path, - }] - assert modified_skills == [{ - "skill_id": skill_id, - "skill_name": "demo-skill", - "path": script_path, - "action": "skill_manage", - }] + assert read_skills == [ + { + "skill_id": skill_id, + "skill_name": "demo-skill", + "path": reference_path, + } + ] + assert modified_skills == [ + { + "skill_id": skill_id, + "skill_name": "demo-skill", + "path": script_path, + "action": "skill_manage", + } + ] def test_claude_code_skill_tool_detected(tmp_path: Path) -> None: @@ -389,9 +399,7 @@ def test_claude_code_skill_tool_detected(tmp_path: Path) -> None: manager = SkillManager(str(skills_dir)) skill_path_map = manager.get_skill_path_map() skill_id = manager.get_all_skills()[0]["id"] - evolve_paths = [ - p for p, info in skill_path_map.items() if info.get("skill_name") == "evolve-demo" - ] + evolve_paths = [p for p, info in skill_path_map.items() if info.get("skill_name") == "evolve-demo"] skill_calls = [ { @@ -404,8 +412,10 @@ def test_claude_code_skill_tool_detected(tmp_path: Path) -> None: read_skills = _extract_read_skills_from_tool_calls(skill_calls, skill_path_map) - assert read_skills == [{ - "skill_id": skill_id, - "skill_name": "evolve-demo", - "path": evolve_paths[0], - }] + assert read_skills == [ + { + "skill_id": skill_id, + "skill_name": "evolve-demo", + "path": evolve_paths[0], + } + ]