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
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
307 changes: 275 additions & 32 deletions ms_agent/agent/llm_agent.py

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion ms_agent/callbacks/input_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,19 @@ def __init__(
self,
config: DictConfig,
command_router: Optional['CommandRouter'] = None,
input_source: object = None,
event_sink: 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 input_source is not None else 'cli',
input_source=input_source,
event_sink=event_sink)

@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
96 changes: 96 additions & 0 deletions ms_agent/cli/tui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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.add_argument(
'--mcp-server-file',
'--mcp_server_file',
dest='mcp_server_file',
type=str,
default=None,
help='Path to an MCP servers JSON file ({"mcpServers": {...}}) — '
'stdio / sse / streamable_http servers to connect for this session.')
parser.add_argument(
'--emit-events',
'--emit_events',
dest='emit_events',
type=str,
default=None,
help='Also append every structured AgentEvent as JSON lines to '
'this file — the exact wire payloads a WebUI backend would '
'forward. Use it to inspect/validate the event contract.')
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,
emit_events=self.args.emit_events,
mcp_server_file=self.args.mcp_server_file,
)
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
44 changes: 33 additions & 11 deletions ms_agent/command/builtin/context_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,28 +104,50 @@ async def cmd_tools(ctx: CommandContext) -> CommandResult:


async def cmd_compact(ctx: CommandContext) -> CommandResult:
"""Prune old tool outputs from the live context to free tokens.

Keeps the most recent tool results in full and elides older large ones in
place (the same idea as ContextAssembler's ToolOutputPruner, applied on
demand). Automatic per-round compaction still runs when session_log +
compaction are configured; this is the manual trigger.
"""
messages = ctx.extra.get('messages')
if not messages:
return CommandResult(
type=CommandResultType.MESSAGE, content='No messages available.'
)

try:
from ms_agent.session.context_assembler import ContextAssembler # noqa: F401
except ImportError:
keep_recent = 3
prune_threshold = 200
tool_idxs = [
i for i, m in enumerate(messages)
if getattr(m, 'role', None) == 'tool'
]
to_prune = tool_idxs[:-keep_recent] if len(tool_idxs) > keep_recent else []

pruned = 0
freed = 0
for i in to_prune:
m = messages[i]
content = m.content if isinstance(m.content, str) else str(
m.content or '')
if len(content) > prune_threshold and not content.startswith(
'[compacted'):
placeholder = f'[compacted tool output: {len(content)} chars elided]'
freed += len(content) - len(placeholder)
messages[i].content = placeholder
pruned += 1

if pruned == 0:
return CommandResult(
type=CommandResultType.MESSAGE,
content=(
'Context compaction not available yet.'
),
content=(f'Nothing to compact ({len(messages)} messages; no large '
f'old tool outputs beyond the last {keep_recent}).'),
)

return CommandResult(
type=CommandResultType.MESSAGE,
content=(
f'Compaction requested. Current message count: {len(messages)}.\n'
f'(Full implementation available after PR#912 merge)'
),
content=(f'Compacted {pruned} old tool output(s), ~{freed} chars freed. '
f'The {keep_recent} most recent are kept in full.'),
)


Expand Down
34 changes: 30 additions & 4 deletions ms_agent/command/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,18 @@ class InteractiveTurn:
class InteractiveSession:
"""Drives a single ``>>>`` prompt turn, handling slash commands in a loop."""

def __init__(self, router: CommandRouter, source: str = 'cli') -> None:
def __init__(self, router: CommandRouter, source: str = 'cli',
input_source: Any = None, event_sink: Any = None) -> None:
self._router = router
self._source = source
# Async InputSource (ms_agent.ui). Its awaitable read_prompt lets the
# native loop drive a TUI / WebUI without blocking the event loop. When
# None, bare input() is used so CLI behavior is unchanged.
self._input_source = input_source
# AgentEventSink for command output (MESSAGE results): routed as a
# Notice so a TUI renders it on the same channel as everything else.
# When None, plain print() is used (CLI).
self._event_sink = event_sink

async def run_turn(
self,
Expand All @@ -56,12 +65,22 @@ async def run_turn(
"""
while True:
try:
query = input('>>> ').strip()
if self._input_source is not None:
query = (await
self._input_source.read_prompt('>>> ')).strip()
else:
query = input('>>> ').strip()
except (EOFError, KeyboardInterrupt):
return InteractiveTurn(action='quit')
if not query:
continue
if not self._router.is_command(query):
# Echo the user turn on the event channel (fills the ACP
# user_message_chunk gap). A TUI ignores it — the terminal
# already shows the typed line — but a WebUI renders it.
if self._event_sink is not None:
from ms_agent.ui.events import UserMessage
self._event_sink.emit(UserMessage(text=query))
return InteractiveTurn(action='submit', text=query)

cmd_name, args = self._router.parse_input(query)
Expand All @@ -82,10 +101,17 @@ async def run_turn(
return InteractiveTurn(action='submit', text=query)
if result.type == CommandResultType.QUIT:
if result.content:
print(result.content)
self._emit(result.content)
return InteractiveTurn(action='quit')
if result.type == CommandResultType.SUBMIT_PROMPT:
return InteractiveTurn(action='submit', text=result.content)
# MESSAGE / MUTATE_STATE: show output and prompt again.
if result.content:
print(result.content)
self._emit(result.content)

def _emit(self, text: str) -> None:
if self._event_sink is not None:
from ms_agent.ui.events import Notice
self._event_sink.emit(Notice(level='info', text=text))
else:
print(text)
Loading
Loading