Skip to content
Merged
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
72 changes: 45 additions & 27 deletions ms_agent/agent_hub/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ def _print_openhuman_next_steps(root) -> None:
'system_prompt.inline(粘贴 SOUL.md 内容)/ model.exact="inherit";id 仅限字母、数字、"_"、"-"。\n'
' 2) UI「设置 → 智能体 → 新建智能体」,id / model / 工具白名单与 TOML 保持一致。\n'
' 3) UI「设置 → 智能体配置 → 新建配置」,base agent id 填同一个 id。\n'
' 4) 新建一个对话即可使用。'
)
' 4) 新建一个对话即可使用。')
logger.info(
'Conversion/download finished, but the agent is NOT usable yet '
'(if you have not registered it). OpenHuman discovers agents only via the '
Expand All @@ -68,8 +67,7 @@ def _print_openhuman_next_steps(root) -> None:
'id may contain only letters, digits, "_", "-".\n'
' 2) UI "Settings → Agents → New Agent": keep id / model / tool allowlist consistent with the TOML.\n'
' 3) UI "Settings → Agent Config → New Config": set base agent id to the same id.\n'
' 4) Start a new conversation.'
)
' 4) Start a new conversation.')


def _fail(message: str) -> int:
Expand Down Expand Up @@ -289,10 +287,10 @@ def convert_resources(
source_defaults=get_defaults(source_fw),
target_defaults=get_defaults(target_fw),
fill_missing_defaults=fill_missing_defaults,
overflow_target=(_file_per_agent_identity_path(dst_spec)
if dst_spec is not None else None),
identity_source=(_file_per_agent_identity_path(src_spec)
if src_spec is not None else None),
overflow_target=(_file_per_agent_identity_path(
dst_spec, for_target=True) if dst_spec is not None else None),
identity_source=(_file_per_agent_identity_path(
src_spec, for_target=False) if src_spec is not None else None),
existing_skills=(_existing_skill_names(existing_files)
if existing_files else None),
)
Expand Down Expand Up @@ -832,8 +830,8 @@ def cmd_download(
from ._sync import backup_local
if spec.collect():
backup_label = (
target_fw if local_name == ALL_AGENT_NAME else
f'{target_fw}_{local_name}')
target_fw
if local_name == ALL_AGENT_NAME else f'{target_fw}_{local_name}')
display.meta('backup', backup_local(spec, backup_label))

written = spec.apply(filtered)
Expand All @@ -850,18 +848,27 @@ def cmd_download(
return 0


def _file_per_agent_identity_path(dst_spec: WorkspaceSpec) -> str | None:
"""Resolve the per-agent identity file for a file-per-agent target.
def _file_per_agent_identity_path(spec: WorkspaceSpec, *,
for_target: bool) -> str | None:
"""Resolve the per-agent identity file of a file-per-agent layout.

File-per-agent frameworks (e.g. qoder) declare a ``{name}`` placeholder
pattern such as ``agents/{name}.md``. Format it with the destination
agent name so converted persona content can be routed into that file.
Returns ``None`` when the layout has no single ``{name}`` file pattern.
pattern such as ``agents/{name}.md``. Format it with the agent name so
persona content can be routed there. Returns ``None`` when the layout
has no single ``{name}`` file pattern.

``for_target=True`` resolves the overflow target (where converted persona
lands); qoder opts out there: imported persona folds into the shared
``AGENTS.md`` instead of spawning ``agents/<name>.md``.
``for_target=False`` resolves the identity source (flag this layout's own
persona file so it folds into the target persona instead of being
dropped); qoder must stay in there -- skipping it on the source side
loses the persona on every qoder->X conversion.
"""
if dst_spec.product_name == 'qoder':
if for_target and spec.product_name == 'qoder':
return None
name = dst_spec.agent_name or DEFAULT_AGENT_NAME
for pattern in dst_spec.patterns:
name = spec.agent_name or DEFAULT_AGENT_NAME
for pattern in spec.patterns:
# Only single-file placeholders (no wildcard) identify the persona file;
# skip glob patterns like ``skills/{name}/*`` if any exist.
if '{name}' in pattern and '*' not in pattern:
Expand Down Expand Up @@ -973,23 +980,27 @@ def convert_workspace(
default_paths = set()
skipped_skills = []
else:
# File-per-agent targets (e.g. qoder ``agents/{name}.md``) keep
# per-agent identity in a dedicated sub-agent file; route overflow
# (persona content with no shared mapping) there instead of the
# shared catch-all so it does not pollute other sub-agents.
# Symmetrically, a file-per-agent SOURCE's persona file is flagged so
# it folds into the target's persona file instead of being dropped.
# File-per-agent targets declare a per-agent identity file
# (``agents/{name}.md``); overflow (persona content with no shared
# mapping) is routed there instead of the shared catch-all so it does
# not pollute other sub-agents. qoder opts out on the target side:
# its imported persona folds into the shared AGENTS.md instead (see
# _file_per_agent_identity_path). Symmetrically, a file-per-agent
# SOURCE's persona file is flagged so it folds into the target's
# persona file instead of being dropped.
overflow_target = None
if any('{name}' in p for p in dst_spec.patterns):
overflow_target = _file_per_agent_identity_path(dst_spec)
overflow_target = _file_per_agent_identity_path(
dst_spec, for_target=True)
result = merge_resources(
incoming=resources,
source_product=source_fw,
target_product=target_fw,
source_defaults=get_defaults(source_fw),
target_defaults=get_defaults(target_fw),
overflow_target=overflow_target,
identity_source=_file_per_agent_identity_path(src_spec),
identity_source=_file_per_agent_identity_path(
src_spec, for_target=False),
fill_missing_defaults=False,
existing_skills=_existing_skill_names(existing_paths),
)
Expand Down Expand Up @@ -1116,7 +1127,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
27 changes: 24 additions & 3 deletions ms_agent/agent_hub/_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,13 +485,21 @@ def merge(
# every file verbatim by design).
PRODUCT_PRIVATE_FILES = {
'hermes': frozenset(['config.yaml', 'hooks/*']),
'ms-agent':
frozenset(['settings.json', 'skills.json',
'mcp.json']),
'ms-agent': frozenset(['settings.json', 'skills.json', 'mcp.json']),
'qwenpaw': frozenset(['agent.json', 'skill.json']),
'openhuman': frozenset(['config.toml']),
}

# openhuman records HOW a skill was installed in a per-skill sidecar:
# ``_meta.json`` (marketplace entry: owner/slug/publishedAt) and
# ``metadata.json`` (github source: repo/path/downloaded_at). Both are
# openhuman-private provenance with no meaning to other frameworks, so a
# cross-framework convert drops them while same-framework sync keeps them
# (BUG-0828).
_SKILL_PROVENANCE_FILES = {
'openhuman': frozenset(['_meta.json', 'metadata.json']),
}


def _is_private_file(product: str, path: str) -> bool:
"""Whether *path* is a framework-private (non-portable) file of *product*.
Expand Down Expand Up @@ -775,6 +783,19 @@ def merge_resources(
if skill_path.startswith('skills/'):
parts = skill_path.split('/')
skill_name = parts[1] if len(parts) > 1 else ''
# Framework-private per-skill provenance sidecars never travel
# across frameworks (BUG-0828).
if (is_cross_product and len(parts) == 3
and parts[2] in _SKILL_PROVENANCE_FILES.get(
source_product, ())):
result.actions.append(
MergeAction(
path=path,
action='skip',
detail=(f'{path} is {source_product}-private skill '
f'provenance, dropped'),
))
continue
if skill_name in existing_skill_set:
result.actions.append(
MergeAction(
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
29 changes: 21 additions & 8 deletions ms_agent/agent_hub/frameworks/_bundled_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ class BundledSkillFilterMixin:
A ``skills/<dir>/`` is treated as framework-provided when its ``SKILL.md``
frontmatter carries any of: a ``name`` listed in the sibling
``.bundled_manifest`` (Hermes's content library); a ``builtin_skill_version``
field; or a ``metadata`` block whose nested product entry carries install
hints (``emoji``/``requires``/``install``). User skills carry none of these
and are the only ones kept. A bare ``license`` field or a bare
``metadata.<product>`` key is deliberately NOT a marker: both appear on
user-authored skills (open-source license, custom per-product parameters)
and treating them as "bundled" silently dropped those skills
(BUG-021/BUG-022). Only the ``skills/`` tree is filtered (Hermes's
``optional-skills/`` is left untouched).
field; a ``metadata`` block whose nested product entry carries install
hints (``emoji``/``requires``/``install``); or a ``metadata.hermes``
catalog block carrying ``tags`` (Hermes app-native skills seeded outside
the manifest, BUG-0828). User skills carry none of these and are the only
ones kept. A bare ``license`` field or a bare ``metadata.<product>`` key is
deliberately NOT a marker: both appear on user-authored skills
(open-source license, custom per-product parameters) and treating them as
"bundled" silently dropped those skills (BUG-021/BUG-022). Only the
``skills/`` tree is filtered (Hermes's ``optional-skills/`` is left
untouched).
"""

def _walk_matched(self):
Expand Down Expand Up @@ -113,6 +115,17 @@ def _is_framework_skill(self, skill_md: Path, bundled: frozenset) -> bool:
for v in md.values():
if isinstance(v, dict) and (v.keys() & _BUNDLED_SKILL_KEYS):
return True
# Hermes app-native skills (desktop plugins, themes, the
# ``apple/`` / ``media/`` / ``mlops/`` category libraries) are
# seeded by the app OUTSIDE the ``.bundled_manifest`` sync, so
# neither the manifest nor the install-hint keys above catch
# them. They carry a ``metadata.hermes`` catalog block; ``tags``
# is the shape marker. A bare ``hermes`` key or a
# ``metadata.hermes.config`` settings block is a legitimate user
# skill and must NOT be marked (BUG-022 precedent, BUG-0828).
hermes_md = md.get('hermes')
if isinstance(hermes_md, dict) and 'tags' in hermes_md:
return True
return False

def _user_skill_dirs(self, skills_rel: str) -> set:
Expand Down
Loading
Loading