From 69c4649f2d4047b83c8e9e7797bbbeaf5fbbf890 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 3 Mar 2026 13:57:35 -0500 Subject: [PATCH 1/4] feat(cli): add `--profile-override` CLI flag --- libs/cli/deepagents_cli/config.py | 35 +++++++-- libs/cli/deepagents_cli/main.py | 38 ++++++++- libs/cli/deepagents_cli/non_interactive.py | 10 ++- libs/cli/tests/unit_tests/test_config.py | 87 +++++++++++++++++++++ libs/cli/tests/unit_tests/test_main_args.py | 28 +++++++ 5 files changed, 191 insertions(+), 7 deletions(-) diff --git a/libs/cli/deepagents_cli/config.py b/libs/cli/deepagents_cli/config.py index 39b5a246c21..a5401224807 100644 --- a/libs/cli/deepagents_cli/config.py +++ b/libs/cli/deepagents_cli/config.py @@ -1331,6 +1331,7 @@ def create_model( model_spec: str | None = None, *, extra_kwargs: dict[str, Any] | None = None, + profile_overrides: dict[str, Any] | None = None, ) -> ModelResult: """Create a chat model. @@ -1349,6 +1350,9 @@ def create_model( extra_kwargs: Additional kwargs to pass to the model constructor. These take highest priority, overriding values from the config file. + profile_overrides: Extra profile fields from `--profile-override`. + + Merged on top of config file profile overrides (CLI wins). Returns: A `ModelResult` containing the model and its metadata. @@ -1412,15 +1416,15 @@ def create_model( # Apply profile overrides from config.toml (e.g., max_input_tokens) if provider: - profile_overrides = config.get_profile_overrides( + config_profile_overrides = config.get_profile_overrides( provider, model_name=model_name ) - if profile_overrides: + if config_profile_overrides: logger.debug( "Applying profile overrides for '%s' (provider '%s'): %s", model_name, provider, - profile_overrides, + config_profile_overrides, ) # Intentionally over-defensive profile = getattr(model, "profile", None) @@ -1428,9 +1432,9 @@ def create_model( # Copy original profile and overlay config overrides on top. # Duplicate keys use the override value; keys only in the # original (e.g., tool_calling) are preserved unchanged. - merged = {**profile, **profile_overrides} + merged = {**profile, **config_profile_overrides} else: - merged = profile_overrides + merged = config_profile_overrides try: model.profile = merged # type: ignore[union-attr] except (AttributeError, TypeError, ValueError) as exc: @@ -1442,6 +1446,27 @@ def create_model( exc, ) + # CLI --profile-override takes highest priority (on top of config.toml) + if profile_overrides: + logger.debug( + "Applying CLI --profile-override: %s", + profile_overrides, + ) + profile = getattr(model, "profile", None) + if isinstance(profile, dict): + merged = {**profile, **profile_overrides} + else: + merged = profile_overrides + try: + model.profile = merged # type: ignore[union-attr] + except (AttributeError, TypeError, ValueError) as exc: + logger.warning( + "Could not apply CLI profile overrides to model '%s': %s. " + "Overrides will be ignored.", + model_name, + exc, + ) + # Extract context limit from model profile (if available) context_limit: int | None = None profile = getattr(model, "profile", None) diff --git a/libs/cli/deepagents_cli/main.py b/libs/cli/deepagents_cli/main.py index 5aea7d2e4e2..a919dff5f0f 100644 --- a/libs/cli/deepagents_cli/main.py +++ b/libs/cli/deepagents_cli/main.py @@ -307,6 +307,14 @@ def __call__( "These take priority, overriding config file values.", ) + parser.add_argument( + "--profile-override", + metavar="JSON", + help="Override model profile fields as a JSON string " + "(e.g., '{\"max_input_tokens\": 4096}'). " + "Merged on top of config file profile overrides.", + ) + parser.add_argument( "--default-model", metavar="MODEL", @@ -434,6 +442,7 @@ async def run_textual_cli_async( sandbox_setup: str | None = None, model_name: str | None = None, model_params: dict[str, Any] | None = None, + profile_override: dict[str, Any] | None = None, thread_id: str | None = None, is_resumed: bool = False, initial_prompt: str | None = None, @@ -452,6 +461,9 @@ async def run_textual_cli_async( model_params: Extra kwargs from `--model-params` to pass to the model. These override config file values. + profile_override: Extra profile fields from `--profile-override`. + + Merged on top of config file profile overrides. thread_id: Thread ID to use (new or resumed) is_resumed: Whether this is a resumed session initial_prompt: Optional prompt to auto-submit when session starts @@ -469,7 +481,11 @@ async def run_textual_cli_async( from deepagents_cli.tools import fetch_url, http_request, web_search try: - result = create_model(model_name, extra_kwargs=model_params) + result = create_model( + model_name, + extra_kwargs=model_params, + profile_overrides=profile_override, + ) except ModelConfigError as e: from deepagents_cli.app import AppResult @@ -743,6 +759,24 @@ def cli_main() -> None: ) sys.exit(1) + profile_override: dict[str, Any] | None = None + raw_profile = getattr(args, "profile_override", None) + if raw_profile: + try: + profile_override = json.loads(raw_profile) + except json.JSONDecodeError as e: + console.print( + "[bold red]Error:[/bold red] " + f"--profile-override is not valid JSON: {e}" + ) + sys.exit(1) + if not isinstance(profile_override, dict): + console.print( + "[bold red]Error:[/bold red] " + "--profile-override must be a JSON object" + ) + sys.exit(1) + apply_stdin_pipe(args) if (args.quiet or args.no_stream) and not args.non_interactive_message: @@ -877,6 +911,7 @@ def cli_main() -> None: assistant_id=args.agent, model_name=getattr(args, "model", None), model_params=model_params, + profile_override=profile_override, sandbox_type=args.sandbox, sandbox_id=args.sandbox_id, sandbox_setup=getattr(args, "sandbox_setup", None), @@ -976,6 +1011,7 @@ def cli_main() -> None: sandbox_setup=getattr(args, "sandbox_setup", None), model_name=getattr(args, "model", None), model_params=model_params, + profile_override=profile_override, thread_id=thread_id, is_resumed=is_resumed, initial_prompt=getattr(args, "initial_prompt", None), diff --git a/libs/cli/deepagents_cli/non_interactive.py b/libs/cli/deepagents_cli/non_interactive.py index 9fb6326a552..893ad80a266 100644 --- a/libs/cli/deepagents_cli/non_interactive.py +++ b/libs/cli/deepagents_cli/non_interactive.py @@ -586,6 +586,7 @@ async def run_non_interactive( assistant_id: str = "agent", model_name: str | None = None, model_params: dict[str, Any] | None = None, + profile_override: dict[str, Any] | None = None, sandbox_type: str = "none", # str (not None) to match argparse choices sandbox_id: str | None = None, sandbox_setup: str | None = None, @@ -612,6 +613,9 @@ async def run_non_interactive( model_params: Extra kwargs from `--model-params` to pass to the model. These override config file values. + profile_override: Extra profile fields from `--profile-override`. + + Merged on top of config file profile overrides. sandbox_type: Type of sandbox (`'none'`, `'modal'`, `'runloop'`, `'daytona'`, `'langsmith'`). sandbox_id: Optional existing sandbox ID to reuse. @@ -633,7 +637,11 @@ async def run_non_interactive( # uses _write_text() -> sys.stdout directly. console = Console(stderr=True) if quiet else Console() try: - result = create_model(model_name, extra_kwargs=model_params) + result = create_model( + model_name, + extra_kwargs=model_params, + profile_overrides=profile_override, + ) except ModelConfigError as e: console.print(f"[bold red]Error:[/bold red] {e}") return 1 diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 632909003f3..c51404f29cf 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -572,6 +572,93 @@ def test_profile_override_logs_warning_on_frozen_model( assert result.context_limit == 200000 +class TestCreateModelCLIProfileOverrides: + """Tests for CLI --profile-override in create_model.""" + + @patch("langchain.chat_models.init_chat_model") + def test_cli_profile_override_sets_context_limit( + self, mock_init_chat_model: Mock, tmp_path: Path + ) -> None: + """CLI profile override for max_input_tokens flows to context_limit.""" + config_path = tmp_path / "config.toml" + config_path.write_text("") # empty config + mock_model = Mock() + mock_model.profile = {"max_input_tokens": 200000, "tool_calling": True} + mock_init_chat_model.return_value = mock_model + + clear_caches() + with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path): + result = create_model( + "anthropic:claude-sonnet-4-5", + profile_overrides={"max_input_tokens": 4096}, + ) + + assert result.context_limit == 4096 + + @patch("langchain.chat_models.init_chat_model") + def test_cli_profile_override_beats_config_toml( + self, mock_init_chat_model: Mock, tmp_path: Path + ) -> None: + """CLI --profile-override wins over config.toml profile.""" + config_path = tmp_path / "config.toml" + config_path.write_text(""" +[models.providers.anthropic.profile] +max_input_tokens = 8192 +""") + mock_model = Mock() + mock_model.profile = {"max_input_tokens": 200000, "tool_calling": True} + mock_init_chat_model.return_value = mock_model + + clear_caches() + with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path): + result = create_model( + "anthropic:claude-sonnet-4-5", + profile_overrides={"max_input_tokens": 4096}, + ) + + # CLI (4096) beats config.toml (8192) + assert result.context_limit == 4096 + + @patch("langchain.chat_models.init_chat_model") + def test_cli_profile_override_preserves_other_keys( + self, mock_init_chat_model: Mock, tmp_path: Path + ) -> None: + """CLI override merges into profile without dropping other keys.""" + config_path = tmp_path / "config.toml" + config_path.write_text("") + mock_model = Mock() + mock_model.profile = {"max_input_tokens": 200000, "tool_calling": True} + mock_init_chat_model.return_value = mock_model + + clear_caches() + with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path): + create_model( + "anthropic:claude-sonnet-4-5", + profile_overrides={"max_input_tokens": 4096}, + ) + + assert mock_model.profile == {"max_input_tokens": 4096, "tool_calling": True} + + @patch("langchain.chat_models.init_chat_model") + def test_cli_profile_override_on_model_without_profile( + self, mock_init_chat_model: Mock, tmp_path: Path + ) -> None: + """CLI override applied even when model has no profile attr.""" + config_path = tmp_path / "config.toml" + config_path.write_text("") + mock_model = Mock(spec=["invoke"]) + mock_init_chat_model.return_value = mock_model + + clear_caches() + with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path): + result = create_model( + "anthropic:claude-sonnet-4-5", + profile_overrides={"max_input_tokens": 4096}, + ) + + assert result.context_limit == 4096 + + class TestParseShellAllowList: """Test parsing shell allow-list strings.""" diff --git a/libs/cli/tests/unit_tests/test_main_args.py b/libs/cli/tests/unit_tests/test_main_args.py index b32c2f74c5d..082e99444d0 100644 --- a/libs/cli/tests/unit_tests/test_main_args.py +++ b/libs/cli/tests/unit_tests/test_main_args.py @@ -209,6 +209,34 @@ def test_combined_with_model(self, mock_argv: MockArgvType) -> None: assert parsed.model_params == '{"temperature": 0.5, "max_tokens": 2048}' +class TestProfileOverrideArgument: + """Tests for --profile-override argument parsing.""" + + def test_stores_json_string(self, mock_argv: MockArgvType) -> None: + """--profile-override stores the raw JSON string.""" + with mock_argv("--profile-override", '{"max_input_tokens": 4096}'): + parsed = parse_args() + assert parsed.profile_override == '{"max_input_tokens": 4096}' + + def test_not_specified_is_none(self, mock_argv: MockArgvType) -> None: + """profile_override is None when not provided.""" + with mock_argv(): + parsed = parse_args() + assert parsed.profile_override is None + + def test_combined_with_model(self, mock_argv: MockArgvType) -> None: + """--profile-override works alongside --model.""" + with mock_argv( + "--model", + "gpt-4o", + "--profile-override", + '{"max_input_tokens": 4096}', + ): + parsed = parse_args() + assert parsed.model == "gpt-4o" + assert parsed.profile_override == '{"max_input_tokens": 4096}' + + def _make_args( *, non_interactive_message: str | None = None, From 43af89f313b1fd02d5fa57829f42f79f09f0dc5f Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 3 Mar 2026 14:00:59 -0500 Subject: [PATCH 2/4] cr --- libs/cli/deepagents_cli/config.py | 95 ++++++++++++--------- libs/cli/deepagents_cli/non_interactive.py | 8 +- libs/cli/tests/unit_tests/test_config.py | 29 ++++++- libs/cli/tests/unit_tests/test_main_args.py | 28 ++++++ 4 files changed, 116 insertions(+), 44 deletions(-) diff --git a/libs/cli/deepagents_cli/config.py b/libs/cli/deepagents_cli/config.py index a5401224807..248e72afdfc 100644 --- a/libs/cli/deepagents_cli/config.py +++ b/libs/cli/deepagents_cli/config.py @@ -1327,6 +1327,53 @@ def apply_to_settings(self) -> None: settings.model_context_limit = self.context_limit +def _apply_profile_overrides( + model: BaseChatModel, + overrides: dict[str, Any], + model_name: str, + *, + label: str, + raise_on_failure: bool = False, +) -> None: + """Merge `overrides` into `model.profile`. + + If the model already has a dict profile, overrides are layered on top + so existing keys (e.g., `tool_calling`) are preserved unchanged. + + Args: + model: The chat model whose profile will be updated. + overrides: Key/value pairs to merge into the profile. + model_name: Model name used in log/error messages. + label: Human-readable source label for messages + (e.g., `"config.toml"`, `"CLI --profile-override"`). + raise_on_failure: When `True`, raise `ModelConfigError` instead + of logging a warning if assignment fails. + + Raises: + ModelConfigError: If `raise_on_failure` is `True` and the model + rejects profile assignment. + """ + logger.debug("Applying %s profile overrides: %s", label, overrides) + profile = getattr(model, "profile", None) + merged = {**profile, **overrides} if isinstance(profile, dict) else overrides + try: + model.profile = merged # type: ignore[union-attr] + except (AttributeError, TypeError, ValueError) as exc: + if raise_on_failure: + msg = ( + f"Could not apply {label} to model '{model_name}': {exc}. " + f"The model may not support profile assignment." + ) + raise ModelConfigError(msg) from exc + logger.warning( + "Could not apply %s profile overrides to model '%s': %s. " + "Overrides will be ignored.", + label, + model_name, + exc, + ) + + def create_model( model_spec: str | None = None, *, @@ -1420,52 +1467,22 @@ def create_model( provider, model_name=model_name ) if config_profile_overrides: - logger.debug( - "Applying profile overrides for '%s' (provider '%s'): %s", - model_name, - provider, + _apply_profile_overrides( + model, config_profile_overrides, + model_name, + label=f"config.toml (provider '{provider}')", ) - # Intentionally over-defensive - profile = getattr(model, "profile", None) - if isinstance(profile, dict): - # Copy original profile and overlay config overrides on top. - # Duplicate keys use the override value; keys only in the - # original (e.g., tool_calling) are preserved unchanged. - merged = {**profile, **config_profile_overrides} - else: - merged = config_profile_overrides - try: - model.profile = merged # type: ignore[union-attr] - except (AttributeError, TypeError, ValueError) as exc: - logger.warning( - "Could not apply profile overrides to model '%s' " - "(provider '%s'): %s. Overrides will be ignored.", - model_name, - provider, - exc, - ) # CLI --profile-override takes highest priority (on top of config.toml) if profile_overrides: - logger.debug( - "Applying CLI --profile-override: %s", + _apply_profile_overrides( + model, profile_overrides, + model_name, + label="CLI --profile-override", + raise_on_failure=True, ) - profile = getattr(model, "profile", None) - if isinstance(profile, dict): - merged = {**profile, **profile_overrides} - else: - merged = profile_overrides - try: - model.profile = merged # type: ignore[union-attr] - except (AttributeError, TypeError, ValueError) as exc: - logger.warning( - "Could not apply CLI profile overrides to model '%s': %s. " - "Overrides will be ignored.", - model_name, - exc, - ) # Extract context limit from model profile (if available) context_limit: int | None = None diff --git a/libs/cli/deepagents_cli/non_interactive.py b/libs/cli/deepagents_cli/non_interactive.py index 893ad80a266..681af9dff2f 100644 --- a/libs/cli/deepagents_cli/non_interactive.py +++ b/libs/cli/deepagents_cli/non_interactive.py @@ -586,11 +586,11 @@ async def run_non_interactive( assistant_id: str = "agent", model_name: str | None = None, model_params: dict[str, Any] | None = None, - profile_override: dict[str, Any] | None = None, sandbox_type: str = "none", # str (not None) to match argparse choices sandbox_id: str | None = None, sandbox_setup: str | None = None, *, + profile_override: dict[str, Any] | None = None, quiet: bool = False, stream: bool = True, ) -> int: @@ -613,14 +613,14 @@ async def run_non_interactive( model_params: Extra kwargs from `--model-params` to pass to the model. These override config file values. - profile_override: Extra profile fields from `--profile-override`. - - Merged on top of config file profile overrides. sandbox_type: Type of sandbox (`'none'`, `'modal'`, `'runloop'`, `'daytona'`, `'langsmith'`). sandbox_id: Optional existing sandbox ID to reuse. sandbox_setup: Optional path to setup script to run in the sandbox after creation. + profile_override: Extra profile fields from `--profile-override`. + + Merged on top of config file profile overrides. quiet: When `True`, all console output (headers, status messages, tool notifications, HITL decisions, errors) is redirected to stderr so that only the agent's response text appears on stdout. diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index c51404f29cf..494e51c0717 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -566,7 +566,8 @@ def test_profile_override_logs_warning_on_frozen_model( result = create_model("anthropic:claude-sonnet-4-5") assert any( - "Could not apply profile overrides" in r.message for r in caplog.records + "Could not apply" in r.message and "profile overrides" in r.message + for r in caplog.records ) # Falls back to original profile extraction assert result.context_limit == 200000 @@ -658,6 +659,32 @@ def test_cli_profile_override_on_model_without_profile( assert result.context_limit == 4096 + @patch("langchain.chat_models.init_chat_model") + def test_cli_profile_override_raises_on_frozen_model( + self, + mock_init_chat_model: Mock, + tmp_path: Path, + ) -> None: + """CLI --profile-override raises when model rejects assignment.""" + config_path = tmp_path / "config.toml" + config_path.write_text("") + mock_model = Mock() + type(mock_model).profile = property( + fget=lambda _: {"max_input_tokens": 200000}, + fset=lambda _, __: (_ for _ in ()).throw(AttributeError("frozen")), + ) + mock_init_chat_model.return_value = mock_model + + clear_caches() + with ( + patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path), + pytest.raises(ModelConfigError, match="Could not apply CLI"), + ): + create_model( + "anthropic:claude-sonnet-4-5", + profile_overrides={"max_input_tokens": 4096}, + ) + class TestParseShellAllowList: """Test parsing shell allow-list strings.""" diff --git a/libs/cli/tests/unit_tests/test_main_args.py b/libs/cli/tests/unit_tests/test_main_args.py index 082e99444d0..2622ec376dc 100644 --- a/libs/cli/tests/unit_tests/test_main_args.py +++ b/libs/cli/tests/unit_tests/test_main_args.py @@ -236,6 +236,34 @@ def test_combined_with_model(self, mock_argv: MockArgvType) -> None: assert parsed.model == "gpt-4o" assert parsed.profile_override == '{"max_input_tokens": 4096}' + def test_invalid_json_exits(self) -> None: + """--profile-override with invalid JSON exits with code 1.""" + from deepagents_cli.main import cli_main + + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + with ( + patch.object(sys, "argv", ["deepagents", "--profile-override", "{bad"]), + patch.object(sys, "stdin", mock_stdin), + pytest.raises(SystemExit) as exc_info, + ): + cli_main() + assert exc_info.value.code == 1 + + def test_non_dict_json_exits(self) -> None: + """--profile-override with JSON array exits with code 1.""" + from deepagents_cli.main import cli_main + + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + with ( + patch.object(sys, "argv", ["deepagents", "--profile-override", "[1,2]"]), + patch.object(sys, "stdin", mock_stdin), + pytest.raises(SystemExit) as exc_info, + ): + cli_main() + assert exc_info.value.code == 1 + def _make_args( *, From 1ac908e01dc0b4994b4a618dd814613140603f77 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 3 Mar 2026 14:05:17 -0500 Subject: [PATCH 3/4] chore: trigger From 927c94141b8c9e912f44f46eeff64affd428319d Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 3 Mar 2026 14:09:24 -0500 Subject: [PATCH 4/4] chore: trigger