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
5 changes: 5 additions & 0 deletions ms_agent/agent/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ tools:
- shell_executor
- python_executor
- notebook_executor
# Builtin (not an MCP server): list/cancel backgrounded shell tasks.
# `mcp: false` is required — every tools.<name> without it is treated as MCP
# and connect fails with "'url' or 'command' parameter is required".
task_control:
mcp: false

help: |
A commonly use config, try whatever you want!
94 changes: 75 additions & 19 deletions ms_agent/agent/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ def __init__(
# Optional injected PermissionHandler (TUI/WebUI/Server). When None,
# _select_permission_handler() picks by mode + interactivity.
self._permission_handler = kwargs.get('permission_handler', None)
self._permission_decision_provider = kwargs.get(
'permission_decision_provider', None)

# Structured event sink — the UI-agnostic output seam (ms_agent.ui).
# When set, the agent emits semantic AgentEvents (content / reasoning /
Expand Down Expand Up @@ -886,27 +888,33 @@ def _select_permission_handler(self, mode: str):
``permission_handler`` kwarg) always wins — this is how the TUI /
WebUI / Server supply their own confirmation UI.
- ``interactive`` (alias ``restricted``) in an interactive terminal
session -> ``CLIPermissionHandler`` (the ``[y/s/a/e/n]`` prompt),
session -> ``CLIPermissionHandler`` (one-layer Yes / persist / No),
so non-whitelisted tools actually ask the user.
- Everything else (``auto`` / ``strict`` / non-interactive) ->
``AutoPermissionHandler`` (SafetyGuard still enforces the floor).
"""
if self._permission_handler is not None:
return self._permission_handler
explicit_handler = getattr(self, '_permission_handler', None)
if explicit_handler is not None:
return explicit_handler
from ms_agent.permission import (AutoPermissionHandler,
CLIPermissionHandler)

# ``PermissionConfig.from_dict`` already normalizes ``restricted`` ->
# ``interactive``; accept both so a direct caller passing the raw alias
# still gets the interactive prompt (not a silent AutoPermissionHandler).
if mode in ('interactive', 'restricted') and self._interactive:
# ``interactive``. Interactive always uses the CLI prompt so a piped
# e2e session can answer; EOF denies. Delegate keeps the CLI prompt
# when this is an interactive session so uncertain can escalate.
if mode in ('interactive', 'restricted') and getattr(
self, '_interactive', False):
return CLIPermissionHandler()
if mode == 'delegate' and getattr(self, '_interactive', False):
return CLIPermissionHandler()
return AutoPermissionHandler()

def _build_permission_objects(self):
"""Create SafetyGuard and PermissionEnforcer from config if configured."""
from ms_agent.permission import (PermissionConfig, PermissionEnforcer,
PermissionMemory, SafetyGuard)
from ms_agent.permission import (LlmDecisionProvider, PermissionConfig,
PermissionEnforcer, PermissionMemory,
SafetyGuard)

raw = {}
if hasattr(self.config, 'permission'):
Expand All @@ -918,6 +926,10 @@ def _build_permission_objects(self):
workspace_root = str(resolve_workspace_root(self.config))
perm_config = PermissionConfig.from_dict(
raw, project_root=workspace_root)
if getattr(self, '_interactive', False):
from dataclasses import replace
perm_config = replace(
perm_config, human_approval_available=True)

allowed_dirs = list(
perm_config.safety.effective_allowed_directories(workspace_root))
Expand All @@ -931,14 +943,45 @@ def _build_permission_objects(self):

handler = self._select_permission_handler(perm_config.mode)
memory = PermissionMemory(project_path=workspace_root)
provider = self._permission_decision_provider
if (
provider is None
and perm_config.decision_provider == 'llm'
and self.llm is not None
):
provider = LlmDecisionProvider(self.llm)
if (
perm_config.mode == 'delegate'
and perm_config.decision_provider == 'agent'
and provider is None
):
raise ValueError(
'delegate mode with decision_provider=agent requires an '
'independent permission_decision_provider')
enforcer = PermissionEnforcer(
config=perm_config, handler=handler, memory=memory)
config=perm_config,
handler=handler,
memory=memory,
provider=provider,
)

return safety_guard, enforcer, perm_config

def set_permission_handler(self, handler) -> None:
"""Inject a custom PermissionHandler (TUI/WebUI/Server) before run."""
self._permission_handler = handler
tm = getattr(self, 'tool_manager', None)
enforcer = getattr(tm, '_permission_enforcer', None)
if enforcer is not None:
enforcer._handler = handler

def set_permission_decision_provider(self, provider) -> None:
"""Inject an LLM/agent provider used by ``delegate`` mode."""
self._permission_decision_provider = provider
tm = getattr(self, 'tool_manager', None)
enforcer = getattr(tm, '_permission_enforcer', None)
if enforcer is not None:
enforcer._provider = provider

def set_permission_mode(self, mode: str) -> str:
"""Change the permission mode at runtime; returns the normalized mode.
Expand All @@ -949,10 +992,15 @@ def set_permission_mode(self, mode: str) -> str:
``interactive`` (the canonical asking mode).
"""
from dataclasses import replace
mode = {'restricted': 'interactive'}.get(mode, mode)
if mode not in ('auto', 'strict', 'interactive'):
mode = {
'restricted': 'interactive',
'delegated': 'delegate',
}.get(mode, mode)
if mode not in (
'auto', 'strict', 'interactive', 'delegate', 'full_access'):
raise ValueError(f"Unknown permission mode '{mode}' "
'(auto | restricted | strict | interactive)')
'(auto | restricted | strict | interactive | '
'delegate | full_access)')
tm = self.tool_manager
if tm is not None:
tm._permission_mode = mode
Expand All @@ -961,7 +1009,20 @@ def set_permission_mode(self, mode: str) -> str:
tm._permission_config, mode=mode)
enf = getattr(tm, '_permission_enforcer', None)
if enf is not None and getattr(enf, '_config', None) is not None:
enf._config = replace(enf._config, mode=mode)
extra = {'mode': mode}
if mode == 'interactive':
extra['human_approval_available'] = True
if mode == 'delegate' and not enf._config.decision_provider:
extra['decision_provider'] = 'llm'
enf._config = replace(enf._config, **extra)
enf._handler = self._select_permission_handler(mode)
if mode == 'delegate' and getattr(enf, '_provider', None) is None:
llm = getattr(self, 'llm', None)
if llm is not None:
from ms_agent.permission import LlmDecisionProvider
provider = LlmDecisionProvider(llm)
self._permission_decision_provider = provider
enf._provider = provider
return mode

async def prepare_tools(self):
Expand Down Expand Up @@ -1356,7 +1417,7 @@ def _get_command_router(self):
from ms_agent.command import (CommandRouter,
register_builtin_commands)

router = CommandRouter()
router = CommandRouter(owner=self)
register_builtin_commands(router)
self._command_router = router
self._register_plugin_commands()
Expand Down Expand Up @@ -2613,11 +2674,6 @@ async def run_loop(self, messages: Union[List[Message], str],
self._init_session_log()
self.runtime.tag = self.tag

self.task_manager = TaskManager()
for tool in self.tool_manager.extra_tools:
if hasattr(tool, 'set_task_manager'):
tool.set_task_manager(self.task_manager)

if messages is None:
configured = getattr(
getattr(self.config, 'prompt', DictConfig({})), 'query',
Expand Down
23 changes: 23 additions & 0 deletions ms_agent/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ def define_args(parsers: argparse.ArgumentParser):
type=str,
default=None,
help='Comma-separated list of paths for knowledge search.')
parser.add_argument(
'--permission_mode',
required=False,
type=str,
default=None,
choices=[
'auto',
'strict',
'restricted',
'interactive',
'delegate',
'delegated',
'full_access',
],
help='Permission mode for tool calls. When set, overrides agent.yaml.')
parser.set_defaults(func=subparser_func)

@staticmethod
Expand All @@ -168,6 +183,14 @@ def _apply_cli_overrides(config, args):
if output_dir and isinstance(config, DictConfig):
with open_dict(config):
config.output_dir = output_dir
permission_mode = getattr(args, 'permission_mode', None)
if permission_mode and isinstance(config, DictConfig):
with open_dict(config):
if not hasattr(config, 'permission') or config.permission is None:
config.permission = {}
config.permission.mode = permission_mode
if permission_mode in ('interactive', 'restricted'):
config.interactive = True
return config

def execute(self):
Expand Down
10 changes: 9 additions & 1 deletion ms_agent/cli/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,15 @@ def define_args(parsers: argparse.ArgumentParser):
'--permission_mode',
type=str,
default='restricted',
choices=['auto', 'strict', 'restricted', 'interactive'],
choices=[
'auto',
'strict',
'restricted',
'interactive',
'delegate',
'delegated',
'full_access',
],
help='Permission mode for tool calls. Default `restricted` so '
'non-whitelisted tools ask for confirmation.')
parser.add_argument(
Expand Down
3 changes: 3 additions & 0 deletions ms_agent/command/builtin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from ms_agent.command.builtin.config_cmds import register_config_commands
from ms_agent.command.builtin.context_cmds import register_context_commands
from ms_agent.command.builtin.info_cmds import register_info_commands
from ms_agent.command.builtin.permission_cmds import (
register_permission_commands)
from ms_agent.command.builtin.session_cmds import register_session_commands
from ms_agent.command.router import CommandRouter

Expand All @@ -9,4 +11,5 @@ def register_builtin_commands(router: CommandRouter) -> None:
register_session_commands(router)
register_info_commands(router)
register_config_commands(router)
register_permission_commands(router)
register_context_commands(router)
Loading
Loading