# -*- coding: utf-8 -*- """Agent registration and workspace initialization for CloudPaw plugin.""" import json import logging import shutil from pathlib import Path from typing import Any from .constants import ( BUILTIN_EXECUTOR_AGENT_ID, BUILTIN_ORCHESTRATION_AGENT_ID, BUILTIN_VERIFIER_AGENT_ID, PLUGIN_DIR, _AGENT_SPECS, ) logger = logging.getLogger("qwenpaw").getChild( __name__.replace("plugin_cloudpaw.", ""), ) def register_extra_tools(agent_id: str, extra_tools: dict[str, dict]) -> None: """Register plugin tools into agent's builtin_tools config.""" if not extra_tools: return try: from pineagents.config.config import ( BuiltinToolConfig, ToolsConfig, load_agent_config, save_agent_config, ) except ImportError: return try: agent_cfg = load_agent_config(agent_id) except Exception: return if not agent_cfg.tools: agent_cfg.tools = ToolsConfig() changed = False _SYNC_FIELDS = ( "enabled", "async_execution", "description", "icon", "display_to_user", ) for tool_name, tool_spec in extra_tools.items(): if tool_name not in agent_cfg.tools.builtin_tools: agent_cfg.tools.builtin_tools[tool_name] = BuiltinToolConfig( **tool_spec, ) changed = True logger.info( "Registered tool '%s' for agent %s", tool_name, agent_id, ) continue # Tool already exists in config (e.g. as a default builtin) — sync # spec fields onto the existing entry so plugin-driven overrides # (enabled / async_execution / icon / etc.) take effect on subsequent # startups too. existing = agent_cfg.tools.builtin_tools[tool_name] for field in _SYNC_FIELDS: if field not in tool_spec: continue new_value = tool_spec[field] if getattr(existing, field, None) != new_value: setattr(existing, field, new_value) changed = True logger.info( "Updated tool '%s' field %s=%r for agent %s", tool_name, field, new_value, agent_id, ) if changed: try: save_agent_config(agent_id, agent_cfg) except Exception as exc: logger.warning( "Failed to save tools for agent %s: %s", agent_id, exc, ) def _build_acp_config(spec: dict[str, Any]) -> Any: """Build an ACPConfig from the spec's acp_agent definition. ACP child processes inherit the parent's full os.environ automatically (see ACPService._open_conversation: env={**os.environ, **agent_config.env}), so Alibaba Cloud credentials (AK/SK) are passed implicitly. We only need to inject LLM provider config that iac-code reads from IAC_CODE_* vars. """ try: from pineagents.config.config import ACPAgentConfig, ACPConfig except ImportError: logger.warning("Cannot import ACPConfig; ACP configuration skipped") return None acp_spec = spec["acp_agent"] env = {**acp_spec.get("env", {})} _inject_llm_env(env) agent_config = ACPAgentConfig( enabled=True, command=acp_spec["command"], args=acp_spec.get("args", []), env=env, trusted=acp_spec.get("trusted", True), tool_parse_mode=acp_spec.get("tool_parse_mode", "call_detail"), ) return ACPConfig(agents={acp_spec["name"]: agent_config}) def _inject_llm_env(env: dict[str, str]) -> None: """Inject LLM config for iac-code. For iac-code >= 0.1.2: write llm_source: qwenpaw to settings.yml so iac-code reads config directly from QwenPaw. For older versions: inject IAC_CODE_* environment variables. """ import os if os.environ.get("IAC_CODE_PROVIDER") or env.get("IAC_CODE_PROVIDER"): return _write_qwenpaw_mode_to_settings() return def _write_qwenpaw_mode_to_settings() -> None: """Write llm_source: qwenpaw to ~/.iac-code/settings.yml.""" import yaml settings_path = Path.home() / ".iac-code" / "settings.yml" settings_path.parent.mkdir(parents=True, exist_ok=True) # Load existing settings if settings_path.exists(): try: with open(settings_path, "r", encoding="utf-8") as f: settings = yaml.safe_load(f) or {} except Exception: settings = {} else: settings = {} # Write llm_source: qwenpaw if settings.get("llm_source") != "qwenpaw": settings["llm_source"] = "qwenpaw" try: with open(settings_path, "w", encoding="utf-8") as f: yaml.dump( settings, f, default_flow_style=False, allow_unicode=True, ) except Exception as exc: logger.warning("Failed to write iac-code settings.yml: %s", exc) def ensure_builtin_agents() -> None: """Register built-in Aliyun agents.""" try: from pineagents.config.config import ( AgentProfileConfig, AgentProfileRef, AgentsRunningConfig, ChannelConfig, HeartbeatConfig, MCPConfig, save_agent_config, ) from pineagents.config.utils import load_config, save_config from pineagents.constant import WORKING_DIR except ImportError: logger.error( "Cannot import config modules; agent registration skipped", ) return config = load_config() if config.agents.active_agent in ("default", ""): config.agents.active_agent = BUILTIN_ORCHESTRATION_AGENT_ID save_config(config) logger.info( "Set active_agent to orchestration agent: %s", BUILTIN_ORCHESTRATION_AGENT_ID, ) for spec in _AGENT_SPECS: agent_id = spec["agent_id"] expected_ws = ( (Path(WORKING_DIR) / "workspaces" / agent_id) .expanduser() .resolve() ) if agent_id in config.agents.profiles: ref = config.agents.profiles[agent_id] actual = Path(ref.workspace_dir).expanduser().resolve() if actual != expected_ws: logger.warning( "Agent %s workspace mismatch (%s vs %s); skipping", agent_id, actual, expected_ws, ) continue else: expected_ws.mkdir(parents=True, exist_ok=True) config.agents.profiles[agent_id] = AgentProfileRef( id=agent_id, workspace_dir=str(expected_ws), ) save_config(config) logger.info("Registered agent %s at %s", agent_id, expected_ws) running_overrides = spec.get("running_overrides", {}) running_cfg = ( AgentsRunningConfig(**running_overrides) if running_overrides else AgentsRunningConfig() ) acp_config = _build_acp_config(spec) if "acp_agent" in spec else None agent_cfg = AgentProfileConfig( id=agent_id, name=spec["name"], description=spec["description"], workspace_dir=str(expected_ws), language=config.agents.language or "zh", channels=ChannelConfig(), mcp=MCPConfig(), heartbeat=HeartbeatConfig(), running=running_cfg, acp=acp_config, approval_level=spec.get("approval_level", "AUTO"), ) _initialize_agent_workspace( expected_ws, skill_names=spec["skill_names"], persona_pack=spec["persona_pack"], language=config.agents.language or "zh", ) try: save_agent_config(agent_id, agent_cfg) except ValueError: logger.exception("Failed to save agent.json for %s", agent_id) register_extra_tools(agent_id, spec.get("extra_tools", {})) def _initialize_agent_workspace( workspace_dir: Path, skill_names: list[str], persona_pack: str, language: str = "zh", ) -> None: """Initialize agent workspace with persona md files and skills.""" from pineagents.agents.skill_system import get_workspace_skills_dir (workspace_dir / "sessions").mkdir(exist_ok=True) (workspace_dir / "memory").mkdir(exist_ok=True) skills_dir = get_workspace_skills_dir(workspace_dir) skills_dir.mkdir(exist_ok=True) _seed_persona_md_files(workspace_dir, language, persona_pack) _install_workspace_skills(workspace_dir, skill_names) for fname, default in [ ("chats.json", {"version": 1, "chats": []}), ("jobs.json", {"version": 1, "jobs": []}), ]: fpath = workspace_dir / fname if not fpath.exists(): fpath.write_text( json.dumps(default, ensure_ascii=False, indent=2), encoding="utf-8", ) def _seed_persona_md_files( workspace_dir: Path, language: str, persona_pack: str, ) -> None: """Copy persona markdown files from the plugin's agents directory.""" agents_dir = PLUGIN_DIR / "agents" role_md_dir = agents_dir / persona_pack / language if not role_md_dir.exists(): role_md_dir = agents_dir / persona_pack / "en" if not role_md_dir.exists(): logger.warning("Persona pack dir not found: %s", persona_pack) return for md_file in role_md_dir.glob("*.md"): target = workspace_dir / md_file.name if not target.exists(): try: shutil.copy2(md_file, target) except Exception as e: logger.warning("Failed to copy %s: %s", md_file.name, e) try: from pineagents.agents.prompt import _get_agent_md_dir generic_md = _get_agent_md_dir(language) if generic_md and generic_md.exists(): for name in ("MEMORY.md",): src = generic_md / name dst = workspace_dir / name if src.exists() and not dst.exists(): shutil.copy2(src, dst) except (ImportError, Exception) as e: logger.debug("Generic MD copy skipped: %s", e) def uninstall_agents() -> None: """Uninstall all CloudPaw agents and related resources. Removes agent profiles, workspaces, plugin skills from the pool. Environment variables are intentionally preserved. """ _uninstall_agent_profiles() _uninstall_plugin_skills() def _uninstall_agent_profiles() -> None: """Remove CloudPaw agent profiles and their workspaces.""" agent_ids = [ BUILTIN_ORCHESTRATION_AGENT_ID, BUILTIN_EXECUTOR_AGENT_ID, BUILTIN_VERIFIER_AGENT_ID, ] try: from pineagents.config.utils import load_config, save_config except ImportError: logger.warning("Cannot import config modules; agent uninstall skipped") return config = load_config() changed = False for agent_id in agent_ids: if agent_id in config.agents.profiles: ref = config.agents.profiles[agent_id] ws_dir = Path(ref.workspace_dir).expanduser().resolve() del config.agents.profiles[agent_id] changed = True logger.info("Removed agent profile: %s", agent_id) if ws_dir.exists(): try: shutil.rmtree(ws_dir) logger.info("Deleted workspace: %s", ws_dir) except Exception as exc: logger.warning( "Failed to delete workspace %s: %s", ws_dir, exc, ) else: logger.debug("Agent %s not in profiles, skipping", agent_id) if config.agents.active_agent in agent_ids: config.agents.active_agent = "default" changed = True logger.info("Reset active_agent to 'default'") if changed: try: save_config(config) logger.info("Saved updated agent config") except Exception as exc: logger.warning("Failed to save config after uninstall: %s", exc) def _uninstall_plugin_skills() -> None: """Remove CloudPaw skills from the shared skill pool.""" from .constants import _PLUGIN_SKILLS try: from pineagents.agents.skill_system import ( get_skill_pool_dir, ensure_skill_pool_initialized, ) except ImportError: logger.warning("Cannot import skill_system; skill uninstall skipped") return try: ensure_skill_pool_initialized() except Exception as exc: logger.warning("Skill pool init failed: %s", exc) pool_dir = get_skill_pool_dir() manifest_path = pool_dir / "skill.json" for skill_name in _PLUGIN_SKILLS: skill_dir = pool_dir / skill_name if skill_dir.exists(): try: shutil.rmtree(skill_dir) logger.info("Deleted skill from pool: %s", skill_name) except Exception as exc: logger.warning( "Failed to delete skill %s: %s", skill_name, exc, ) if manifest_path.exists(): try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) skills = manifest.get("skills", {}) changed = False for skill_name in _PLUGIN_SKILLS: if skill_name in skills: if skills[skill_name].get("source") == "plugin:cloudpaw": del skills[skill_name] changed = True if changed: manifest_path.write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) logger.info("Updated skill pool manifest") except Exception as exc: logger.warning("Failed to update skill pool manifest: %s", exc) def _install_workspace_skills( workspace_dir: Path, skill_names: list[str], ) -> None: """Install skills from pool into agent workspace and enable them.""" try: from pineagents.agents.skill_system import ( get_skill_pool_dir, get_workspace_skills_dir, reconcile_workspace_manifest, ) from pineagents.agents.skill_system.store import ( get_workspace_skill_manifest_path, ) except ImportError: return pool_dir = get_skill_pool_dir() ws_skills = get_workspace_skills_dir(workspace_dir) for skill_name in skill_names: src = pool_dir / skill_name dst = ws_skills / skill_name if not src.exists(): logger.warning( "Skill %s not in pool, cannot install to workspace", skill_name, ) continue if dst.exists(): continue try: shutil.copytree(src, dst) except Exception as e: logger.warning( "Failed to install skill %s to workspace: %s", skill_name, e, ) reconcile_workspace_manifest(workspace_dir) manifest_path = get_workspace_skill_manifest_path(workspace_dir) try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) skills = manifest.get("skills", {}) for skill_name in skill_names: if skill_name in skills: skills[skill_name]["enabled"] = True skills[skill_name].setdefault("channels", ["all"]) skills[skill_name].setdefault("source", "plugin:cloudpaw") manifest_path.write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) except Exception as exc: logger.warning( "Failed to enable skills in workspace manifest: %s", exc, )