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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,4 @@ ms_agent/app/temp_workspace/
webui/work_dir/

.ms_agent_snapshots/
.ms_agent/
13 changes: 13 additions & 0 deletions ms_agent/agent/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ def __init__(self,
self.config.output_dir = self.output_dir
except Exception:
pass
# Merge the work-dir project patch (e.g. a persisted /model override) so
# config overrides round-trip from <work_dir>/.ms_agent/config.yaml —
# anchored to the project (the work dir), not the config file's
# directory. This keeps running a shared/template config from picking up
# (or scattering) overrides in that config's folder.
try:
from omegaconf import OmegaConf
from ms_agent.config.resolver import ConfigResolver
patch = ConfigResolver()._load_project_patch(self.output_dir)
if patch is not None:
self.config = OmegaConf.merge(self.config, patch)
except Exception:
pass

@abstractmethod
async def run(
Expand Down
84 changes: 71 additions & 13 deletions ms_agent/agent/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ def __init__(
# Gates both the initial >>> prompt and the mid-turn InputCallback.
self._interactive = False

# Optional injected PermissionHandler (TUI/WebUI/Server). When None,
# _select_permission_handler() picks by mode + interactivity.
self._permission_handler = kwargs.get('permission_handler', None)

# Optional console I/O sink (TUI). When None, streaming tokens go to
# sys.stdout and the interactive prompt uses input() — i.e. current CLI
# behavior. A ConsoleIO with write()/read_prompt() reroutes both so a
# rich TUI can own rendering without changing the agent lifecycle.
self._console_io = kwargs.get('console_io', None)

# Personalization (lazy-loaded in _build_personalization_section)
self._profile_manager = ProfileManager()

Expand Down Expand Up @@ -456,7 +466,8 @@ def register_callback_from_config(self):
if self._interactive:
self.callbacks.append(callbacks_mapping[_callback](
self.config,
command_router=self._get_command_router()))
command_router=self._get_command_router(),
io=self._console_io))
else:
self.callbacks.append(callbacks_mapping[_callback](
self.config))
Expand All @@ -469,7 +480,8 @@ def register_callback_from_config(self):
self.callbacks.append(
input_cls(
self.config,
command_router=self._get_command_router()))
command_router=self._get_command_router(),
io=self._console_io))

async def on_task_begin(self, messages: List[Message]):
self.log_output(f'Agent {self.tag} task beginning.')
Expand Down Expand Up @@ -573,16 +585,39 @@ async def parallel_tool_call(self,
self.log_output(_new_message.content)
return messages

def _select_permission_handler(self, mode: str):
"""Pick the PermissionHandler by mode + runtime environment.

- An explicitly injected handler (``set_permission_handler`` / the
``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),
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
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:
return CLIPermissionHandler()
return AutoPermissionHandler()

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

raw = {}
if hasattr(self.config, 'permission'):
Expand All @@ -605,12 +640,16 @@ def _build_permission_objects(self):
workspace_root=workspace_root,
)

handler = AutoPermissionHandler()
handler = self._select_permission_handler(perm_config.mode)
memory = PermissionMemory(project_path=workspace_root)
enforcer = PermissionEnforcer(config=perm_config, handler=handler, memory=memory)

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

async def prepare_tools(self):
"""Initialize and connect the tool manager."""
import uuid
Expand Down Expand Up @@ -1053,10 +1092,20 @@ def _init_session_log(self) -> None:
session_cfg, 'dir', None
) if session_cfg else None
if session_dir is None:
session_dir = os.path.join(
getattr(self.config, 'output_dir', 'output'),
'sessions',
)
# Sessions live globally, keyed by the work dir (Claude Code style),
# decoupled from output_dir. An explicit session_log.dir (set by the
# WebUI/Server per project) still wins. Legacy <output_dir>/sessions
# is migrated forward if present.
from ms_agent.project.paths import global_sessions_dir
output_dir = getattr(self.config, 'output_dir', 'output')
session_dir = str(global_sessions_dir(output_dir))
legacy_dir = os.path.join(output_dir, 'sessions')
if os.path.isdir(legacy_dir) and not os.path.isdir(session_dir):
try:
import shutil
shutil.copytree(legacy_dir, session_dir)
except Exception:
pass

session_key = getattr(session_cfg, 'session_key', None) if session_cfg else None
self.session_log = SessionLog(session_dir, session_key=session_key)
Expand Down Expand Up @@ -1309,8 +1358,11 @@ async def step(
if _printed_reasoning_header and not _printed_reasoning_footer:
self._write_thinking_footer()
_printed_reasoning_footer = True
sys.stdout.write(new_content)
sys.stdout.flush()
if self._console_io is not None:
self._console_io.write(new_content)
else:
sys.stdout.write(new_content)
sys.stdout.flush()
_content = _response_message.content
messages[-1] = _response_message
yield messages
Expand All @@ -1327,7 +1379,10 @@ async def step(
self._write_reasoning(final_reasoning, dim=True)
self._write_thinking_footer()

sys.stdout.write('\n')
if self._console_io is not None:
self._console_io.end_stream()
else:
sys.stdout.write('\n')
else:
_response_message = self.llm.generate(messages, tools=tools)
if self.show_reasoning:
Expand Down Expand Up @@ -1603,7 +1658,10 @@ async def run_loop(self, messages: Union[List[Message], str],
messages = configured
elif self._interactive:
from ms_agent.command.interactive import InteractiveSession
session = InteractiveSession(self._get_command_router())
session = InteractiveSession(
self._get_command_router(),
source='tui' if self._console_io is not None else 'cli',
io=self._console_io)
turn = await session.run_turn(
messages=None, runtime=self.runtime)
if turn.action == 'quit':
Expand Down
6 changes: 5 additions & 1 deletion ms_agent/callbacks/input_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@ def __init__(
self,
config: DictConfig,
command_router: Optional['CommandRouter'] = None,
io: object = None,
):
super().__init__(config)
if command_router is None:
# Fallback for standalone / test instantiation. In normal CLI use
# the agent injects its own router so there is a single instance.
command_router = self._build_default_router()
self._session = InteractiveSession(command_router)
self._session = InteractiveSession(
command_router,
source='tui' if io is not None else 'cli',
io=io)

@staticmethod
def _build_default_router() -> 'CommandRouter':
Expand Down
2 changes: 2 additions & 0 deletions ms_agent/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ms_agent.cli.cron import CronCMD
from ms_agent.cli.plugin import PluginCMD
from ms_agent.cli.run import RunCMD
from ms_agent.cli.tui import TuiCMD
from ms_agent.cli.ui import UICMD


Expand All @@ -28,6 +29,7 @@ def run_cmd():
ACPProxyCmd.define_args(subparsers)
ACPRegistryCmd.define_args(subparsers)
RunCMD.define_args(subparsers)
TuiCMD.define_args(subparsers)
AppCMD.define_args(subparsers)
UICMD.define_args(subparsers)
CronCMD.define_args(subparsers)
Expand Down
1 change: 0 additions & 1 deletion ms_agent/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ def _execute_with_config(self):
if tl is None or not OmegaConf.is_config(tl):
localsearch_config = {
'paths': paths,
'work_path': './.sirchmunk',
'mode': 'FAST',
}
config.tools['localsearch'] = OmegaConf.create(
Expand Down
77 changes: 77 additions & 0 deletions ms_agent/cli/tui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
import argparse

from .base import CLICommand


def subparser_func(args):
"""Factory for the `tui` sub-command."""
return TuiCMD(args)


class TuiCMD(CLICommand):
"""Terminal UI: a lightweight REPL over the LLMAgent lifecycle.

Note: `ui` is taken by the (gradio) webui, so this command is `tui`.
"""

name = 'tui'

def __init__(self, args):
self.args = args

@staticmethod
def define_args(parsers: argparse.ArgumentParser):
parser: argparse.ArgumentParser = parsers.add_parser(TuiCMD.name)
parser.add_argument(
'--config',
type=str,
default=None,
help='Path to an agent.yaml (or a task dir / modelscope id). '
'Defaults to the built-in agent.yaml.')
parser.add_argument(
'--work-dir',
'--work_dir',
dest='work_dir',
type=str,
default=None,
help='Working/project directory (== output_dir). Defaults to the '
'current directory. Framework internals go to <work_dir>/.ms_agent/; '
'sessions are global under ~/.ms_agent/projects/.')
parser.add_argument(
'--env',
type=str,
default=None,
help='Path to a .env file (defaults to ./.env).')
parser.add_argument(
'--permission_mode',
type=str,
default='restricted',
choices=['auto', 'strict', 'restricted', 'interactive'],
help='Permission mode for tool calls. Default `restricted` so '
'non-whitelisted tools ask for confirmation.')
parser.add_argument(
'--trust_remote_code',
action='store_true',
help='Allow loading external code referenced by the config.')
parser.set_defaults(func=subparser_func)

def execute(self):
import importlib.resources as importlib_resources

config = self.args.config
if not config:
# Fall back to the packaged default agent.yaml.
default_config = importlib_resources.files('ms_agent').joinpath(
'agent', 'agent.yaml')
with importlib_resources.as_file(default_config) as p:
config = str(p)

from ms_agent.tui.app import main
main(
config,
env_file=self.args.env,
permission_mode=self.args.permission_mode,
trust_remote_code=self.args.trust_remote_code,
work_dir=self.args.work_dir,
)
27 changes: 17 additions & 10 deletions ms_agent/command/builtin/config_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,30 @@ def _persist_model_to_config(config, new_model: str, service=None):
"""Persist the model (and optional service) change to the project patch.

Writes ``llm.model`` (and ``llm.service`` when a provider switch happened)
into ``<local_dir>/.ms-agent/config.yaml`` rather than mutating the
version-controlled source YAML. ``Config.from_task`` merges this patch back
(patch wins) on the next run, so the override survives without ever touching
the committed config. Returns the patch path on success, or None if no
source directory is known or the write fails.
into ``<work_dir>/.ms_agent/config.yaml`` rather than mutating the
version-controlled source YAML. Anchored to the **work dir** (``output_dir``)
— the project — not the config file's directory, so running a shared or
packaged template config (e.g. ``demos/``) never scatters a ``.ms_agent/``
next to it. The work-dir patch is merged back (patch wins) on the next run.
Returns the patch path on success, or None if no dir is known / write fails.
"""
from omegaconf import OmegaConf

local_dir = getattr(config, 'local_dir', None)
if not local_dir:
# Prefer the work dir; fall back to the config's own dir only if unset.
base_dir = (getattr(config, 'output_dir', None)
or getattr(config, 'local_dir', None))
if not base_dir:
return None
patch_dir = os.path.join(str(local_dir), '.ms-agent')
# Write to the new .ms_agent/ dir; migrate an existing legacy
# .ms-agent/config.yaml so a single patch file remains authoritative.
patch_dir = os.path.join(str(base_dir), '.ms_agent')
patch_path = os.path.join(patch_dir, 'config.yaml')
legacy_path = os.path.join(str(base_dir), '.ms-agent', 'config.yaml')
try:
os.makedirs(patch_dir, exist_ok=True)
patch = (OmegaConf.load(patch_path)
if os.path.isfile(patch_path) else OmegaConf.create({}))
source = patch_path if os.path.isfile(patch_path) else legacy_path
patch = (OmegaConf.load(source)
if os.path.isfile(source) else OmegaConf.create({}))
OmegaConf.update(patch, 'llm.model', new_model, merge=True)
if service:
OmegaConf.update(patch, 'llm.service', service, merge=True)
Expand Down
Loading
Loading