From 39879b5a4dc29448719f300d520b1acc3f936d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=A0=83?= Date: Tue, 25 Aug 2026 16:25:51 +0800 Subject: [PATCH] fix(agent_hub): convert openhuman active profile with workspace-level persona fallbacks --- ms_agent/agent_hub/_commands.py | 9 +- ms_agent/agent_hub/_workspace.py | 12 +++ ms_agent/agent_hub/frameworks/openhuman.py | 87 +++++++++++++++++ tests/agent_hub/test_workspace.py | 105 ++++++++++++++++++++- 4 files changed, 211 insertions(+), 2 deletions(-) diff --git a/ms_agent/agent_hub/_commands.py b/ms_agent/agent_hub/_commands.py index ca90da090..89daba58a 100644 --- a/ms_agent/agent_hub/_commands.py +++ b/ms_agent/agent_hub/_commands.py @@ -1116,7 +1116,14 @@ def cmd_convert( if err: return _fail(err) - src_name = from_name or DEFAULT_AGENT_NAME + if from_name: + src_name = from_name + else: + # An omitted --from-name asks the framework which agent it should + # convert: frameworks with an "active" sub-agent notion (openhuman's + # activeProfileId) return it, everything else returns ``default``. + src_name = build_spec(source_fw, DEFAULT_AGENT_NAME, + local_dir).resolve_default_agent_name() dst_name = target_name or src_name src_spec = build_spec(source_fw, src_name, local_dir) dst_spec = build_spec(target_fw, dst_name, out_dir) diff --git a/ms_agent/agent_hub/_workspace.py b/ms_agent/agent_hub/_workspace.py index 1ee1580a9..d1ee571f4 100644 --- a/ms_agent/agent_hub/_workspace.py +++ b/ms_agent/agent_hub/_workspace.py @@ -479,6 +479,18 @@ def list_agents(self) -> list[str]: """ return [DEFAULT_AGENT_NAME] + def resolve_default_agent_name(self) -> str: + """Which agent an omitted ``--name`` should operate on. + + Default: the ``default`` agent. Frameworks that keep a notion of an + *active* sub-agent (e.g. openhuman's ``activeProfileId``) override + this so a name-less convert picks the persona the user is actually + working with instead of the bare default. Must never raise: an + absent / unreadable active-agent marker falls back to + ``DEFAULT_AGENT_NAME``. + """ + return DEFAULT_AGENT_NAME + def _list_agents_from_dir(self, agents_dir: Path) -> list[str]: """List agents from a directory, prepending DEFAULT if not present.""" agents = _list_agent_files(agents_dir) diff --git a/ms_agent/agent_hub/frameworks/openhuman.py b/ms_agent/agent_hub/frameworks/openhuman.py index d7cdfdd15..3ce856a83 100644 --- a/ms_agent/agent_hub/frameworks/openhuman.py +++ b/ms_agent/agent_hub/frameworks/openhuman.py @@ -2,12 +2,17 @@ """OpenHuman workspace specification (single-agent install).""" from __future__ import annotations +import copy +import json import re from pathlib import Path +from ms_agent.utils.logger import get_logger from .._workspace import (DEFAULT_AGENT_NAME, WorkspaceSpec, is_secret_key, register_framework) +logger = get_logger() + class OpenhumanWorkspace(WorkspaceSpec): """Workspace spec for the OpenHuman agent framework (root-per-agent). @@ -52,6 +57,15 @@ class OpenhumanWorkspace(WorkspaceSpec): _USERS_DIRNAME = 'users' _WORKSPACE_DIRNAME = 'workspace' _PROFILES_DIRNAME = 'personalities' + _PROFILES_JSON_FILENAME = 'agent_profiles.json' + + # Persona files that fall back to the workspace-level copy when a Profile + # does not carry its own -- the app's own lookup order (Profile file > + # workspace-level default). Deliberately limited to these four: + # ``config.toml`` is machine-local, and ``wiki/`` / ``skills/`` are large + # trees whose per-profile duplication would bloat upload/sync. + _WORKSPACE_FALLBACK_FILES = frozenset( + ['SOUL.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md']) @property def product_name(self) -> str: @@ -160,6 +174,79 @@ def list_agents(self) -> list[str]: agents = [DEFAULT_AGENT_NAME] + agents return agents + # ------------------------------------------------------------------ + # Active-profile auto-selection + # ------------------------------------------------------------------ + + def resolve_default_agent_name(self) -> str: + """Omitted ``--name`` selects the ACTIVE profile, not bare ``default``. + + The app keeps the currently selected persona in + ``agent_profiles.json`` (``activeProfileId``); converting without an + explicit name should migrate that persona, matching what the user + sees in the app. Strictly best-effort: a missing / malformed marker + or an id whose directory does not exist falls back to ``default`` + (the workspace-level persona) without raising. + """ + active = self._active_profile_id() + if active and active in self.list_agents(): + return active + return DEFAULT_AGENT_NAME + + def _active_profile_id(self) -> str | None: + """Read ``activeProfileId`` from ``agent_profiles.json`` (best effort). + + Returns ``None`` on any failure (file absent, unreadable, not JSON, + unexpected shape) -- callers treat that as "no active profile". + """ + path = self.root / self._PROFILES_JSON_FILENAME + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, UnicodeDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + active = data.get('activeProfileId') + if not isinstance(active, str) or not active.strip(): + return None + return active.strip() + + # ------------------------------------------------------------------ + # Workspace-level persona fallback for Profile agents + # ------------------------------------------------------------------ + + def collect(self) -> dict[str, str]: + return self._with_workspace_fallbacks(super().collect(), text=True) + + def collect_bytes(self) -> dict[str, bytes]: + return self._with_workspace_fallbacks(super().collect_bytes(), + text=False) + + def _with_workspace_fallbacks(self, resources: dict, + *, text: bool) -> dict: + """Fill missing Profile files from the workspace-level copies. + + A Profile that lacks e.g. ``MEMORY.md`` runs with the workspace-level + one at runtime (app lookup order), so a converted agent must get it + too: missing files in :data:`_WORKSPACE_FALLBACK_FILES` are taken + from the workspace root when present there. Files the Profile already + has always win; all-mode is exempt (each Profile mirrors to its own + repo and workspace files would duplicate across every Profile). + """ + if self._is_all() or self.workspace_root == self.root: + return resources + workspace_spec = copy.copy(self) + workspace_spec.agent_name = DEFAULT_AGENT_NAME + for rel, f in workspace_spec._walk_matched(): + if rel not in self._WORKSPACE_FALLBACK_FILES or rel in resources: + continue + try: + resources[rel] = (f.read_text(encoding='utf-8') + if text else f.read_bytes()) + except (OSError, UnicodeDecodeError) as e: + logger.warning('Skip workspace fallback %s: %s', f, e) + return resources + # ------------------------------------------------------------------ # config.toml secret sanitization (inbound + outbound) # ------------------------------------------------------------------ diff --git a/tests/agent_hub/test_workspace.py b/tests/agent_hub/test_workspace.py index ee29dad9a..3789c9f2d 100644 --- a/tests/agent_hub/test_workspace.py +++ b/tests/agent_hub/test_workspace.py @@ -336,7 +336,10 @@ def test_profiles_are_sub_agents(self): alice = build_spec("openhuman", "Alice", str(self.root)) self.assertEqual(alice.workspace_root, self.ws / "personalities" / "Alice") - self.assertEqual(sorted(alice.collect_bytes()), ["SOUL.md"]) + # Alice lacks IDENTITY.md, so the workspace-level copy falls back in + # (app lookup order: Profile file > workspace-level default). + self.assertEqual(sorted(alice.collect_bytes()), + ["IDENTITY.md", "SOUL.md"]) def test_all_mode_prefixes_profile_dirs(self): spec = build_spec("openhuman", "all", str(self.root)) @@ -359,6 +362,106 @@ def test_fresh_install_without_users_dir_is_not_an_error(self): self.assertEqual(spec.collect_bytes(), {}) +class TestOpenhumanActiveProfile(unittest.TestCase): + """an omitted --from-name must convert the ACTIVE profile + (``agent_profiles.json`` ``activeProfileId``), not the workspace-level + fallback persona, and a Profile without its own MEMORY.md must fall back + to the workspace-level one -- otherwise converting an openhuman install + loses the active persona's memory. + """ + + USER_ID = "local-u-x" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / ".openhuman" + self.ws = self.root / "users" / self.USER_ID / "workspace" + librarian = self.ws / "personalities" / "to-ms-librarian" + librarian.mkdir(parents=True) + (self.ws / "personalities" / "idle").mkdir() + # Workspace-level fallback persona + curated memory. + (self.ws / "SOUL.md").write_text("# fallback soul\n") + (self.ws / "IDENTITY.md").write_text("# id\n") + (self.ws / "MEMORY.md").write_text("# workspace memory\n") + # Active profile carries its own SOUL but NOT its own MEMORY. + (librarian / "SOUL.md").write_text("# librarian soul\n") + + def tearDown(self): + self.tmp.cleanup() + + def _write_profiles(self, content): + (self.ws / "agent_profiles.json").write_text(content) + + def test_omitted_name_selects_active_profile(self): + self._write_profiles(json.dumps({"activeProfileId": "to-ms-librarian"})) + spec = build_spec("openhuman", "default", str(self.root)) + self.assertEqual(spec.resolve_default_agent_name(), "to-ms-librarian") + + def test_no_profiles_json_falls_back_to_default(self): + spec = build_spec("openhuman", "default", str(self.root)) + self.assertEqual(spec.resolve_default_agent_name(), "default") + + def test_malformed_profiles_json_falls_back_to_default(self): + self._write_profiles("{not json") + spec = build_spec("openhuman", "default", str(self.root)) + self.assertEqual(spec.resolve_default_agent_name(), "default") + + def test_unknown_profile_id_falls_back_to_default(self): + self._write_profiles(json.dumps({"activeProfileId": "ghost"})) + spec = build_spec("openhuman", "default", str(self.root)) + self.assertEqual(spec.resolve_default_agent_name(), "default") + + def test_empty_profile_id_falls_back_to_default(self): + self._write_profiles(json.dumps({"activeProfileId": " "})) + spec = build_spec("openhuman", "default", str(self.root)) + self.assertEqual(spec.resolve_default_agent_name(), "default") + + def test_profile_memory_falls_back_to_workspace_level(self): + spec = build_spec("openhuman", "to-ms-librarian", str(self.root)) + files = spec.collect() + # Profile's own SOUL wins over the workspace-level one ... + self.assertEqual(files["SOUL.md"], "# librarian soul\n") + # ... while the missing persona files fall back to workspace copies. + self.assertEqual(files["MEMORY.md"], "# workspace memory\n") + self.assertEqual(files["IDENTITY.md"], "# id\n") + self.assertNotIn("config.toml", files) + self.assertNotIn("wiki/note.md", files) + + def test_profile_file_always_wins_over_workspace_fallback(self): + (self.ws / "personalities" / "to-ms-librarian" / + "MEMORY.md").write_text("# profile memory\n") + spec = build_spec("openhuman", "to-ms-librarian", str(self.root)) + self.assertEqual(spec.collect()["MEMORY.md"], "# profile memory\n") + + def test_missing_memory_everywhere_is_silent(self): + (self.ws / "MEMORY.md").unlink() + spec = build_spec("openhuman", "to-ms-librarian", str(self.root)) + self.assertNotIn("MEMORY.md", spec.collect()) + + def test_all_mode_stays_bare_per_profile(self): + spec = build_spec("openhuman", "all", str(self.root)) + # No workspace-level duplication leaks into the per-profile repos. + self.assertEqual(sorted(spec.collect()), + ["to-ms-librarian/SOUL.md"]) + + def test_convert_without_name_uses_active_profile(self): + """cmd_convert auto-selects the active profile end to end.""" + self._write_profiles(json.dumps({"activeProfileId": "to-ms-librarian"})) + out_dir = Path(self.tmp.name) / "out" + rc = cmd_convert( + "openhuman", "nanobot", None, None, str(self.root), str(out_dir)) + self.assertEqual(rc, 0) + # The ACTIVE profile's own SOUL (not the workspace-level fallback + # persona) must be the converted persona ... + self.assertIn("# librarian soul", + (out_dir / "SOUL.md").read_text(encoding="utf-8")) + # ... and the workspace-level MEMORY falls back into the target's + # memory slot instead of being lost (BUG-0825). + memory = (out_dir / "memory" / "MEMORY.md").read_text( + encoding="utf-8") + self.assertIn("# workspace memory", memory) + + class TestQwenpawAgentJsonSecrets(unittest.TestCase): """agent.json sanitize must blank secrets ANYWHERE in the JSON tree.