Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions .github/workflows/citest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ jobs:
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements/code.txt

- name: Install Retrieval Dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements/retrieval.txt

- name: Install Cinema Dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements/cinema.txt

- name: Run tests
shell: bash
run: bash .dev_scripts/dockerci.sh
352 changes: 297 additions & 55 deletions ms_agent/agent/llm_agent.py

Large diffs are not rendered by default.

119 changes: 86 additions & 33 deletions ms_agent/command/skill_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
Disabled skills can still be triggered via / (per meeting decision).

Match logic: skill_id (directory name) first, then frontmatter name.

Besides the interactive interceptor path, two public helpers expose the same
expansion semantics to non-interactive surfaces (WebUI backend, future TUI):

- ``expand_skill(catalog, name_or_id, args)`` — structured invocation: the
caller already knows which skill the user picked (e.g. a composer dropdown
sent the skill id alongside the message).
- ``expand_slash_text(catalog, text)`` — free-text invocation: find the first
whitespace-delimited ``/token`` anywhere in the text (Claude-Code-style
boundary rule, not just line start), gate it against the catalog, and expand
with the rest of the text as the arguments. Unknown ``/x`` tokens fall
through (return None) so ordinary prose is never hijacked.
"""
from __future__ import annotations

Expand All @@ -18,6 +30,78 @@
from ms_agent.skill.schema import SkillSchema

_FRONTMATTER_RE = re.compile(r'^---\s*\n.*?\n---\s*\n', re.DOTALL)
# A slash token: "/" + a word (letters/digits/_/-/.), delimited by whitespace
# or string boundaries on both sides. Mid-word slashes (a/b, http://…) never
# match; whether a matching token IS a command is decided by the catalog gate.
_SLASH_TOKEN_RE = re.compile(r'(?:(?<=\s)|^)/([A-Za-z0-9][\w.-]*)(?=\s|$)')


def find_skill(catalog: 'SkillCatalog', name: str) -> 'SkillSchema | None':
"""Resolve a skill by skill_id (exact) then frontmatter name (case-insensitive)."""
skill = catalog.get_skill(name)
if skill:
return skill
for skill in catalog._skills.values():
if skill.name.lower() == name.lower():
return skill
return None


def expand_skill(catalog: 'SkillCatalog', name_or_id: str,
args: str) -> CommandResult | None:
"""Expand one known-skill invocation into a CommandResult.

Returns None when the skill doesn't exist; else a SUBMIT_PROMPT whose
content is the skill body (frontmatter stripped, ``$ARGUMENTS``
substituted) wrapped with the standard preamble. A bare invocation (empty
``args``) submits too — the model reads the skill and carries out its
instructions — with the tail line noting no extra input was given.
Surface-agnostic: no router/context state is involved.
"""
skill = find_skill(catalog, name_or_id)
if skill is None:
return None

body = _strip_frontmatter(skill.content)
body = body.replace('$ARGUMENTS', args)

tail = (
f"User's request: {args}" if args else
'The user invoked this skill without additional arguments; follow '
'its instructions and proceed (ask for the missing input only if the '
'skill requires one).'
)
enriched = (
f'Use the [{skill.name}] skill located at `{skill.skill_path}`.\n\n'
f'{body}\n\n'
f'{tail}'
)
return CommandResult(
type=CommandResultType.SUBMIT_PROMPT,
content=enriched,
)


def expand_slash_text(catalog: 'SkillCatalog',
text: str) -> CommandResult | None:
"""Expand the first catalog-known ``/token`` found anywhere in ``text``.

Tokens must be whitespace-delimited (or at the string boundaries); the
FIRST token that resolves to a real skill wins and the arguments are the
whole text with that token removed (so a mid-sentence invocation keeps the
surrounding words as the request). Tokens that don't match any skill are
left alone — the text falls through as an ordinary message (None).
"""
for match in _SLASH_TOKEN_RE.finditer(text):
skill = find_skill(catalog, match.group(1))
if skill is None:
continue
# Drop the token and mend the seam (collapse doubled spaces/tabs only —
# newlines and the rest of the text stay untouched).
args = re.sub(r'[ \t]{2,}', ' ',
text[:match.start()] + text[match.end():]).strip()
return expand_skill(catalog, match.group(1), args)
return None


class SkillCommandBridge:
Expand All @@ -29,41 +113,10 @@ def register(self, router: CommandRouter) -> None:
router.register_interceptor(self._intercept)

def _find_skill(self, name: str) -> 'SkillSchema | None':
skill = self._catalog.get_skill(name)
if skill:
return skill
for skill in self._catalog._skills.values():
if skill.name.lower() == name.lower():
return skill
return None
return find_skill(self._catalog, name)

async def _intercept(self, ctx: CommandContext) -> CommandResult | None:
skill = self._find_skill(ctx.command_name)
if skill is None:
return None

if not ctx.args:
return CommandResult(
type=CommandResultType.MESSAGE,
content=(
f'Skill: {skill.name}\n'
f'Description: {skill.description}\n'
f'Usage: /{skill.skill_id} <your instruction>'
),
)

body = _strip_frontmatter(skill.content)
body = body.replace('$ARGUMENTS', ctx.args)

enriched = (
f'Use the [{skill.name}] skill located at `{skill.skill_path}`.\n\n'
f'{body}\n\n'
f"User's request: {ctx.args}"
)
return CommandResult(
type=CommandResultType.SUBMIT_PROMPT,
content=enriched,
)
return expand_skill(self._catalog, ctx.command_name, ctx.args)


def _strip_frontmatter(content: str) -> str:
Expand Down
6 changes: 6 additions & 0 deletions ms_agent/config/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,12 @@ def _settings_to_agent_config(settings: Dict[str, Any]) -> DictConfig:
p_fields['memory_backend'] = p['memory_backend']
if p_fields:
agent_fields['personalization'] = p_fields
# Builtin/repo tool config (e.g. the WebUI settings.json `tools` block)
# takes part in the multi-level resolve like other sections. Presence of
# `tools.<id>` enables a tool; `tools.<id>.enabled: false` disables it
# (honored in ToolManager), which lets a higher layer turn a tool off.
if 'tools' in settings:
agent_fields['tools'] = settings['tools']
return OmegaConf.create(agent_fields)

@staticmethod
Expand Down
115 changes: 106 additions & 9 deletions ms_agent/config/skills_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,66 @@

Storage format (skills.json):
{
"sources": ["/path/to/skills", "modelscope://org/skill-pack"],
"sources": ["/path/to/skills", "./relative/to/scope-root",
"modelscope://org/skill-pack"],
"disabled": ["skill-a", "skill-b"]
}

Read-side semantics (loads and list_sources):
* Relative source entries are anchored at the scope root — the global dir
for the global file, the project root (parent of ``.ms_agent/``) for a
project file — never the process cwd. The file itself keeps the raw
strings (writers round-trip them untouched) so hand-written relative
paths stay portable.
* Each scope has one implicit **live tree** source prepended when the
directory exists: ``<global_dir>/skills`` and
``<project>/.ms_agent/skills``. Dropping a skill directory there
registers it without touching skills.json ("existence = filesystem,
state = disabled list"). Explicit sources come after the implicit tree
so they win on skill_id collisions.
"""
from __future__ import annotations

import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional

from ms_agent.config.resolver import merge_skills_configs

SKILLS_FILE = 'skills.json'
PROJECT_META_DIR = '.ms-agent'
#: Live-tree directory name, shared by both scopes (``<global_dir>/skills``
#: and ``<project>/.ms_agent/skills``).
SKILLS_TREE_DIR = 'skills'

#: Remote-source prefixes that must never be path-anchored.
_REMOTE_PREFIXES = ('modelscope://', 'http://', 'https://', 'git://', '@')
#: ``owner/repo`` hub shorthand (mirrors sources.parse_skill_source).
_OWNER_REPO_RE = re.compile(r'^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')


def resolve_source_entry(entry: Any, base: Path) -> Any:
"""Anchor a relative local-path source entry at *base*.

Remote schemes, hub shorthands and absolute paths pass through
untouched; ``~`` expands to the user home. ``owner/repo`` stays a hub
id unless it actually exists under *base*.
"""
if not isinstance(entry, str):
return entry
raw = entry.strip()
if not raw or raw.startswith(_REMOTE_PREFIXES):
return entry
if raw.startswith('~'):
return str(Path(raw).expanduser())
if os.path.isabs(raw):
return entry
candidate = Path(base) / raw
if (not raw.startswith(('./', '../'))
and _OWNER_REPO_RE.match(raw) and not candidate.exists()):
return entry # hub shorthand, not a local dir
return str(candidate.resolve())


class SkillsConfigManager:
Expand All @@ -28,19 +73,60 @@ class SkillsConfigManager:
def __init__(self, global_dir: str = '~/.ms_agent') -> None:
self._global_dir = Path(os.path.expanduser(global_dir))

# -- load --
# -- load (read-side: anchored paths + implicit live tree) --

def load_global(self) -> Dict[str, Any]:
return self._read(self._global_path())
data = self._read(self._global_path())
return self._resolved(data, base=self._global_dir,
tree=self.global_skills_tree())

def load_project(self, project_path: str) -> Dict[str, Any]:
return self._read(self._project_path(project_path))
data = self._read(self._project_path(project_path))
base = Path(os.path.expanduser(str(project_path))).resolve()
return self._resolved(data, base=base,
tree=self.project_skills_tree(project_path))

def load_merged(self, project_path: Optional[str] = None) -> Dict[str, Any]:
g = self.load_global()
p = self.load_project(project_path) if project_path else {}
return merge_skills_configs(g, p)

def global_skills_tree(self) -> Path:
"""The global live tree: ``<global_dir>/skills``."""
return self._global_dir / SKILLS_TREE_DIR

@staticmethod
def project_skills_tree(project_path: str) -> Path:
"""The project live tree: ``<project>/.ms_agent/skills`` (legacy
``.ms-agent/skills`` honored when only it exists)."""
from ms_agent.project.paths import (INTERNAL_DIR_NAME,
LEGACY_INTERNAL_DIR_NAME)
root = Path(os.path.expanduser(str(project_path))).resolve()
new = root / INTERNAL_DIR_NAME / SKILLS_TREE_DIR
if new.exists():
return new
legacy = root / LEGACY_INTERNAL_DIR_NAME / SKILLS_TREE_DIR
return legacy if legacy.exists() else new

@staticmethod
def _resolved(data: Dict[str, Any], base: Path,
tree: Path) -> Dict[str, Any]:
"""Anchor relative sources at *base*; prepend the live *tree* when it
exists. Preserves the empty-dict shape for missing/empty files."""
out = dict(data)
sources = [
resolve_source_entry(s, base)
for s in (data.get('sources') or [])
]
implicit: List[str] = []
if tree.is_dir():
tree_str = str(tree.resolve())
if tree_str not in sources:
implicit = [tree_str]
if implicit or sources or 'sources' in data:
out['sources'] = implicit + sources
return out

# -- enable/disable --

def set_skill_enabled(
Expand Down Expand Up @@ -86,19 +172,30 @@ def remove_source(
project_path: Optional[str] = None,
) -> None:
path = self._resolve_path(scope, project_path)
if scope == 'project':
base = Path(os.path.expanduser(str(project_path))).resolve()
else:
base = self._global_dir
data = self._read(path)
sources: List[str] = data.get('sources', [])
data['sources'] = [s for s in sources if s != source]
# Match the raw string or its anchored form, so callers may pass
# either what the file stores or what list_sources returned.
data['sources'] = [
s for s in sources
if s != source and resolve_source_entry(s, base) != source
]
self._write(path, data)

def list_sources(
self,
scope: str = 'global',
project_path: Optional[str] = None,
) -> List[str]:
path = self._resolve_path(scope, project_path)
data = self._read(path)
return data.get('sources', [])
if scope == 'project':
if not project_path:
raise ValueError('project_path required for project scope')
return list(self.load_project(project_path).get('sources', []))
return list(self.load_global().get('sources', []))

# -- internal --

Expand Down
8 changes: 6 additions & 2 deletions ms_agent/hooks/permission_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ async def resolve_hook_permission_decision(
permission_config: PermissionConfig | None,
hook_runtime: HookRuntime | None = None,
force_decision: PermissionDecision | None = None,
call_id: str = '',
) -> PermissionDecision | str:
# ``force_decision`` carries a SafetyGuard ``ask`` (interactive): it must
# reach the handler even when whitelist/memory would otherwise allow, so a
Expand All @@ -82,7 +83,8 @@ async def resolve_hook_permission_decision(
if rule and rule.action == 'ask':
if permission_enforcer:
return await permission_enforcer.check(
tool_name, args, force_decision=rule)
tool_name, args, force_decision=rule,
call_id=call_id)
return PermissionDecision(
action='allow',
reason=hook_result.reason or 'Allowed by PreToolUse hook',
Expand All @@ -95,6 +97,7 @@ async def resolve_hook_permission_decision(
args,
force_decision=PermissionDecision(
action='ask', reason=hook_result.reason),
call_id=call_id,
)

pr = await _run_permission_request_hook(
Expand All @@ -107,9 +110,10 @@ async def resolve_hook_permission_decision(
args,
force_decision=PermissionDecision(
action='ask', reason=pr.reason),
call_id=call_id,
)

if permission_enforcer:
return await permission_enforcer.check(
tool_name, args, force_decision=force_decision)
tool_name, args, force_decision=force_decision, call_id=call_id)
return PermissionDecision(action='allow', reason='No permission enforcer')
8 changes: 8 additions & 0 deletions ms_agent/llm/openai_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,17 @@ def __init__(
config.llm, 'openai_api_key', None) or os.environ.get(
'OPENAI_API_KEY')

# Bound the network call so a dead/misconfigured endpoint fails fast
# instead of hanging the caller indefinitely (openai's default is a
# 600s read timeout with no connect bound). Overridable via
# config.llm.timeout / config.llm.connect_timeout.
_read_timeout = getattr(config.llm, 'timeout', None) or 300.0
_connect_timeout = getattr(config.llm, 'connect_timeout', None) or 20.0
self.client = openai.OpenAI(
api_key=api_key,
base_url=base_url,
timeout=httpx.Timeout(
float(_read_timeout), connect=float(_connect_timeout)),
)
self.base_url = base_url or ''
self.args: Dict = OmegaConf.to_container(
Expand Down
Loading