Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1bac93c
fix bakend logic
alcholiclg Jul 10, 2026
1c5e4e7
feat: resolve settings.json tools + honor enabled flag
alcholiclg Jul 13, 2026
02601de
feat: record turn/tool errors in session log for replay
alcholiclg Jul 14, 2026
b90e72f
feat: persist reasoning content, mem0 vector memory backend, per-work…
alcholiclg Jul 20, 2026
37ecd8f
feat: persist permission decisions and tool/reasoning timing in sessi…
alcholiclg Jul 20, 2026
9ed401e
feat(llm): mid-stream interrupt + Anthropic-protocol thinking/tools s…
alcholiclg Jul 21, 2026
c28adc4
fix: make MCP tool indexing idempotent so reindex with memory doesn't…
alcholiclg Jul 22, 2026
2541066
feat: faithful interrupted-round persistence, public skill expansion,…
alcholiclg Jul 23, 2026
d7d39df
feat: live-tree skill discovery with anchored sources and mid-session…
alcholiclg Jul 24, 2026
8886087
feat: skill update-notice mode — byte-stable system prompt, changes a…
alcholiclg Jul 24, 2026
ccf5b6e
feat: expand ${VAR} env placeholders in managed MCP config at runtime
alcholiclg Jul 24, 2026
44dd430
update requirements
alcholiclg Jul 25, 2026
4990fe7
feat: thread tool_call id to permission ask so decisions correlate to…
alcholiclg Jul 27, 2026
fc20c10
feat: SessionLog.record_loop_end marker for replaying per-loop duration
alcholiclg Jul 27, 2026
67d8938
feat: bare /skill submits the skill body to the agent instead of retu…
alcholiclg Jul 27, 2026
ddc93d6
fix: todo lock dir defaults to the canonical .ms_agent/locks instead …
alcholiclg Jul 29, 2026
30676d3
Merge branch 'main' of https://github.com/modelscope/ms-agent into fe…
alcholiclg Jul 30, 2026
7c7d08a
Merge branch 'main' of https://github.com/modelscope/ms-agent into fe…
alcholiclg Jul 31, 2026
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
357 changes: 299 additions & 58 deletions ms_agent/agent/llm_agent.py

Large diffs are not rendered by default.

116 changes: 86 additions & 30 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 @@ -19,6 +31,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 @@ -30,38 +114,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 @@ -411,6 +411,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,20 +73,61 @@ 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 @@ -87,19 +173,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
11 changes: 8 additions & 3 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 @@ -80,7 +81,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 @@ -93,6 +95,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(hook_runtime, tool_name, args,
Expand All @@ -103,10 +106,12 @@ async def resolve_hook_permission_decision(
return await permission_enforcer.check(
tool_name,
args,
force_decision=PermissionDecision(action='ask', reason=pr.reason),
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 @@ -84,9 +84,17 @@ def __init__(
api_key = api_key or getattr(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
Loading