Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion ms_agent/agent_hub/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions ms_agent/agent_hub/_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
87 changes: 87 additions & 0 deletions ms_agent/agent_hub/frameworks/openhuman.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
# ------------------------------------------------------------------
Expand Down
105 changes: 104 additions & 1 deletion tests/agent_hub/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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.

Expand Down
Loading