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
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ wheels/
/package
/temp
**/tmp/
.env*
.claude-trace/
/apps/agentfabric/tmp/
MANIFEST

Expand Down Expand Up @@ -124,6 +122,8 @@ venv.bak/
.idea
.cursor
.firecrawl
.claude/
.claude-trace/

# custom
*.pkl
Expand Down Expand Up @@ -152,7 +152,6 @@ apps/agentfabric/config/local_user/*
*.pt
.run/
.run/*
.claude*

# ast template
ast_index_file.py
Expand All @@ -168,3 +167,5 @@ ms_agent/app/temp_workspace/

# webui
webui/work_dir/

.ms_agent_snapshots/
4 changes: 4 additions & 0 deletions ms_agent/callbacks/input_callback.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from typing import TYPE_CHECKING, List, Optional

from omegaconf import DictConfig

from ms_agent.agent.runtime import Runtime
Expand All @@ -8,6 +9,9 @@
from ms_agent.llm.utils import Message
from ms_agent.utils import get_logger

if TYPE_CHECKING:
from ms_agent.command.router import CommandRouter

if TYPE_CHECKING:
from ms_agent.command.router import CommandRouter

Expand Down
19 changes: 18 additions & 1 deletion ms_agent/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import asyncio
import os
from importlib import resources as importlib_resources
from omegaconf import OmegaConf
from omegaconf import DictConfig, OmegaConf, open_dict

from ms_agent.config import Config
from ms_agent.config.env import Env
Expand Down Expand Up @@ -91,6 +91,13 @@ def define_args(parsers: argparse.ArgumentParser):
type=str,
default=None,
help='The directory or the repo id of the config file')
parser.add_argument(
'--output_dir',
required=False,
type=str,
default=None,
help='Directory for agent outputs, histories, and artifacts. '
'Overrides output_dir in the config file.')
parser.add_argument(
'--project',
required=False,
Expand Down Expand Up @@ -154,6 +161,15 @@ def define_args(parsers: argparse.ArgumentParser):
help='Comma-separated list of paths for knowledge search.')
parser.set_defaults(func=subparser_func)

@staticmethod
def _apply_cli_overrides(config, args):
"""Apply run-command options that should win over config files."""
output_dir = getattr(args, 'output_dir', None)
if output_dir and isinstance(config, DictConfig):
with open_dict(config):
config.output_dir = output_dir
return config

def execute(self):
if getattr(self.args, 'project', None):
if self.args.config:
Expand Down Expand Up @@ -229,6 +245,7 @@ def _execute_with_config(self):
print(blue_color_prefix + line_end + blue_color_suffix, flush=True)

config = Config.from_task(self.args.config)
config = self._apply_cli_overrides(config, self.args)

# If knowledge_search_paths is provided, configure tools.localsearch
if getattr(self.args, 'knowledge_search_paths', None):
Expand Down
71 changes: 55 additions & 16 deletions ms_agent/command/builtin/config_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
)


def _persist_model_to_config(config, new_model: str):
"""Persist the model change to the project-level config patch.

Writes ``llm.model`` 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.
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.
"""
from omegaconf import OmegaConf

Expand All @@ -43,6 +44,8 @@ def _persist_model_to_config(config, new_model: str):
patch = (OmegaConf.load(patch_path)
if os.path.isfile(patch_path) else OmegaConf.create({}))
OmegaConf.update(patch, 'llm.model', new_model, merge=True)
if service:
OmegaConf.update(patch, 'llm.service', service, merge=True)
OmegaConf.save(patch, patch_path)
return patch_path
except OSError:
Expand All @@ -60,18 +63,54 @@ async def cmd_model(ctx: CommandContext) -> CommandResult:
service = getattr(ctx.runtime.llm.config.llm, 'service', 'unknown')
return CommandResult(
type=CommandResultType.MESSAGE,
content=f'Model: {model}\nService: {service}',
content=(f'Model: {model}\nService: {service}\n'
'Switch with: /model <model> or /model <provider>/<model>'),
)

new_model = ctx.args.strip()
# Accept both "/model <model>" and "/model <provider>/<model>".
arg = ctx.args.strip()
service_override = None
new_model = arg
if '/' in arg:
service_override, new_model = arg.split('/', 1)
service_override = service_override.strip()
new_model = new_model.strip()

from omegaconf import OmegaConf

OmegaConf.update(
ctx.runtime.llm.config, 'llm.model', new_model, merge=True
)
ctx.runtime.llm.model = new_model
saved_path = _persist_model_to_config(ctx.runtime.llm.config, new_model)
content = f'Switched to: {new_model}'
target = ctx.runtime.llm
config = target.config
OmegaConf.update(config, 'llm.model', new_model, merge=True)
if service_override:
OmegaConf.update(config, 'llm.service', service_override, merge=True)

# Always apply the cheap in-place update: legacy LLM classes read
# ``self.model`` at generate time, so this alone switches the model for
# them (and keeps behavior unchanged when nothing else is possible).
target.model = new_model

# Best-effort: rebuild the LLM so the switch also reaches a provider-router
# transport, which caches model/base_url/api_key at build time (setting
# ``.model`` would not reach it). Adopt the rebuilt state into the existing
# object so every holder of the reference sees it (agent.llm and
# runtime.llm are the same object). If the rebuild can't complete (missing
# credentials, test doubles, ...), keep the in-place update above.
from ms_agent.llm import LLM

try:
rebuilt = LLM.from_config(config)
except Exception: # noqa: BLE001 - best-effort; in-place update stands
rebuilt = None
if rebuilt is not None and type(rebuilt) is type(target):
target.__dict__ = rebuilt.__dict__
elif rebuilt is not None:
target.__class__ = rebuilt.__class__
target.__dict__ = rebuilt.__dict__

saved_path = _persist_model_to_config(config, new_model, service_override)
switched = (f'{service_override}/{new_model}'
if service_override else new_model)
content = f'Switched to: {switched}'
if saved_path:
content += f'\nSaved to: {saved_path}'
else:
Expand Down
4 changes: 4 additions & 0 deletions ms_agent/command/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ def list_commands(
@staticmethod
def parse_input(text: str) -> tuple[str, str]:
stripped = text.strip()
# Empty / whitespace-only input has no command; split() would yield an
# empty list and IndexError on parts[0].
if not stripped:
return '', ''
parts = stripped.split(maxsplit=1)
cmd = parts[0].lstrip('/').lower()
args = parts[1] if len(parts) > 1 else ''
Expand Down
4 changes: 3 additions & 1 deletion ms_agent/config/skills_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,6 @@ def _write(path: Path, data: Dict[str, Any]) -> None:
tmp = path.with_suffix('.tmp')
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
tmp.rename(path)
# replace() is atomic and cross-platform; rename() raises on Windows
# when the destination already exists.
tmp.replace(path)
28 changes: 28 additions & 0 deletions ms_agent/llm/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from .llm import LLM
from .utils import Message, collect_response

# Data-driven provider layer (opt-in via config.llm.use_provider_router).
from .adapter import ResponseAdapter
from .credentials import CredentialResolver
from .router import LLMProvider, ProviderRouter
from .spec import ProviderRegistry, ProviderSpec, get_registry
from .types import (LLMResponse, ProviderCapabilities, ProviderCapability,
TextBlock, ThinkingBlock, ToolUseBlock, UsageInfo)

__all__ = [
'LLM',
'Message',
'collect_response',
'ProviderRouter',
'LLMProvider',
'ProviderRegistry',
'ProviderSpec',
'get_registry',
'CredentialResolver',
'ResponseAdapter',
'LLMResponse',
'UsageInfo',
'TextBlock',
'ToolUseBlock',
'ThinkingBlock',
'ProviderCapability',
'ProviderCapabilities',
]
92 changes: 92 additions & 0 deletions ms_agent/llm/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Conversion between the legacy ``Message`` and the typed ``LLMResponse``.

Transports return ``Message`` (the proven hot-path contract). New consumers
(e.g. the WebUI) can use ``ResponseAdapter.to_response`` to get typed content
blocks. ``to_message`` is the inverse, for code that produces ``LLMResponse``
and needs to feed the legacy agent loop.
"""
from __future__ import annotations

import json
from typing import List, Optional

from .types import (LLMResponse, TextBlock, ThinkingBlock, ToolUseBlock,
UsageInfo)
from .utils import Message, ToolCall


def _parse_arguments(raw) -> dict:
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
if not raw.strip():
return {}
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError):
return {}
return {}


class ResponseAdapter:

@staticmethod
def to_response(message: Message) -> LLMResponse:
"""Legacy ``Message`` -> typed ``LLMResponse`` (for new consumers)."""
blocks: List = []
if message.reasoning_content:
blocks.append(ThinkingBlock(thinking=message.reasoning_content))
if message.content and isinstance(message.content, str):
blocks.append(TextBlock(text=message.content))
for tc in (message.tool_calls or []):
blocks.append(
ToolUseBlock(
id=tc.get('id', ''),
name=tc.get('tool_name', ''),
arguments=_parse_arguments(tc.get('arguments', {})),
))
usage = UsageInfo(
prompt_tokens=message.prompt_tokens,
completion_tokens=message.completion_tokens,
cached_tokens=message.cached_tokens,
cache_creation_tokens=message.cache_creation_input_tokens,
reasoning_tokens=message.reasoning_tokens,
)
return LLMResponse(
content_blocks=blocks, usage=usage, id=message.id)

@staticmethod
def to_message(response: LLMResponse) -> Message:
"""Typed ``LLMResponse`` -> legacy ``Message``.

``tool_calls`` arguments are serialized to a JSON string to match the
OpenAI-path contract consumed by the agent loop.
"""
tool_calls: Optional[List[ToolCall]] = None
if response.tool_calls:
tool_calls = []
for idx, tc in enumerate(response.tool_calls):
args = tc.arguments
if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False)
tool_calls.append(
ToolCall(
id=tc.id,
index=idx,
type='function',
tool_name=tc.name,
arguments=args,
))
return Message(
role='assistant',
content=response.text,
reasoning_content=response.thinking,
tool_calls=tool_calls or [],
id=response.id,
prompt_tokens=response.usage.prompt_tokens,
completion_tokens=response.usage.completion_tokens,
cached_tokens=response.usage.cached_tokens,
cache_creation_input_tokens=response.usage.cache_creation_tokens,
reasoning_tokens=response.usage.reasoning_tokens,
)
63 changes: 63 additions & 0 deletions ms_agent/llm/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Unified credential and endpoint resolution.
Resolution order (first hit wins):
1. Provider-specific config field (config.llm.<name>_api_key / _base_url)
2. Generic config field (config.llm.api_key / base_url)
3. Environment variable chain (spec.api_key_env / spec.base_url_env)
4. Spec default (base_url only)
This replaces per-provider ``__init__`` credential handling.
"""
from __future__ import annotations

import os
from typing import Optional

from omegaconf import DictConfig

from .spec import ProviderSpec


def _cfg_get(config: DictConfig, field: str) -> Optional[str]:
llm = getattr(config, 'llm', None)
if llm is None:
return None
try:
value = llm.get(field) if hasattr(llm, 'get') else getattr(
llm, field, None)
except Exception:
value = None
return value or None


class CredentialResolver:

@staticmethod
def resolve_api_key(spec: ProviderSpec,
config: DictConfig) -> Optional[str]:
value = _cfg_get(config, f'{spec.name}_api_key')
if value:
return value
value = _cfg_get(config, 'api_key')
if value:
return value
for env_var in spec.api_key_env:
value = os.environ.get(env_var)
if value:
return value
return None

@staticmethod
def resolve_base_url(spec: ProviderSpec, config: DictConfig) -> str:
value = _cfg_get(config, f'{spec.name}_base_url')
if value:
return value
value = _cfg_get(config, 'base_url')
if value:
return value
for env_var in spec.base_url_env:
value = os.environ.get(env_var)
if value:
return value
return spec.default_base_url
Loading
Loading