From ecaae708f707c605cf3ef2974d95fedef3c9b119 Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 24 Jun 2026 22:51:27 -0400 Subject: [PATCH] feat(provider): support startup args settings --- bot_bottle/agent_provider.py | 9 ++++ bot_bottle/contrib/claude/agent_provider.py | 5 ++- bot_bottle/contrib/codex/agent_provider.py | 5 ++- bot_bottle/contrib/pi/agent_provider.py | 3 ++ bot_bottle/manifest_agent.py | 34 ++++++++++++--- tests/unit/test_agent_provider.py | 46 +++++++++++++++++++++ tests/unit/test_manifest_egress.py | 29 ++++++++++++- 7 files changed, 122 insertions(+), 9 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index ca2a6ab..9179276 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -371,6 +371,15 @@ def build_agent_provision_plan( ) +def provider_startup_args( + provider_settings: dict[str, object] | None, +) -> tuple[str, ...]: + raw = (provider_settings or {}).get("startup_args", ()) + if not isinstance(raw, (list, tuple)): + return () + return tuple(arg for arg in raw if isinstance(arg, str)) + + def prompt_args( prompt_mode: PromptMode, prompt_path: str | None, diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 198d1dd..14e010d 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -20,6 +20,7 @@ from ...agent_provider import ( AgentProvisionDir, AgentProvisionFile, AgentProvisionPlan, + provider_startup_args, ) from ...backend.docker import util as docker_mod from ...egress import EgressRoute @@ -115,8 +116,9 @@ class ClaudeAgentProvider(AgentProvider): color: str = "", provider_settings: dict[str, object] | None = None, ) -> AgentProvisionPlan: - del forward_host_credentials, host_env, provider_settings + del forward_host_credentials, host_env resolved_guest_env = dict(guest_env or {}) + startup_args = provider_startup_args(provider_settings) guest_home = self.guest_home trusted_path = trusted_project_path or guest_home @@ -199,6 +201,7 @@ class ClaudeAgentProvider(AgentProvider): env_vars=env_vars, guest_env=resolved_guest_env, has_prompt=has_prompt, + startup_args=startup_args, dirs=dirs, files=tuple(files), egress_routes=egress_routes, diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 57f7e82..cca0e47 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -22,6 +22,7 @@ from ...agent_provider import ( AgentProvisionCommand, AgentProvisionFile, AgentProvisionPlan, + provider_startup_args, ) from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute @@ -79,8 +80,9 @@ class CodexAgentProvider(AgentProvider): color: str = "", provider_settings: dict[str, object] | None = None, ) -> AgentProvisionPlan: - del auth_token, label, color, provider_settings + del auth_token, label, color resolved_guest_env = dict(guest_env or {}) + startup_args = provider_startup_args(provider_settings) guest_home = self.guest_home trusted_path = trusted_project_path or guest_home @@ -163,6 +165,7 @@ class CodexAgentProvider(AgentProvider): env_vars=env_vars, guest_env=resolved_guest_env, has_prompt=has_prompt, + startup_args=startup_args, dirs=tuple(dirs), files=tuple(files), pre_copy=tuple(pre_copy), diff --git a/bot_bottle/contrib/pi/agent_provider.py b/bot_bottle/contrib/pi/agent_provider.py index dd88913..c3d47a5 100644 --- a/bot_bottle/contrib/pi/agent_provider.py +++ b/bot_bottle/contrib/pi/agent_provider.py @@ -21,6 +21,7 @@ from ...agent_provider import ( AgentProvisionDir, AgentProvisionFile, AgentProvisionPlan, + provider_startup_args, ) from ...egress import EgressRoute from ...log import die, info @@ -199,6 +200,7 @@ class PiAgentProvider(AgentProvider): models_payload, base_url, api_key_env, models, provider_name = ( _pi_models_json(settings) ) + extra_startup_args = provider_startup_args(provider_settings) models_file = state_dir / "pi-models.json" models_file.write_text(json.dumps(models_payload, indent=2) + "\n") models_file.chmod(0o600) @@ -219,6 +221,7 @@ class PiAgentProvider(AgentProvider): startup_args=( "--models", ",".join(f"{provider_name}/{model}" for model in models), + *extra_startup_args, ), dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),), files=(AgentProvisionFile(models_file, _models_path(guest_home)),), diff --git a/bot_bottle/manifest_agent.py b/bot_bottle/manifest_agent.py index bbb6c9a..0ebfd42 100644 --- a/bot_bottle/manifest_agent.py +++ b/bot_bottle/manifest_agent.py @@ -199,13 +199,10 @@ def _parse_provider_settings( ) -> dict[str, object]: if raw is None: return {} - if template != "pi": - raise ManifestError( - f"bottle '{bottle_name}' agent_provider.settings is only " - "supported for template 'pi'" - ) settings = as_json_object(raw, f"bottle '{bottle_name}' agent_provider.settings") - allowed = { + + common_allowed = {"startup_args"} + pi_allowed = { "provider", "base_url", "api", @@ -218,12 +215,37 @@ def _parse_provider_settings( "supports_developer_role", "supports_reasoning_effort", } + if template == "pi": + allowed = common_allowed | pi_allowed + elif template in ("claude", "codex"): + allowed = common_allowed + elif template not in PROVIDER_TEMPLATES: + return dict(settings) + else: + allowed = common_allowed + for key in settings: if key not in allowed: raise ManifestError( f"bottle '{bottle_name}' agent_provider.settings has unknown " f"key {key!r}; allowed: {', '.join(sorted(allowed))}" ) + startup_args = settings.get("startup_args") + if startup_args is not None: + if not isinstance(startup_args, list): + raise ManifestError( + f"bottle '{bottle_name}' agent_provider.settings.startup_args " + f"must be an array of strings" + ) + for i, arg in enumerate(startup_args): + if not isinstance(arg, str) or not arg: + raise ManifestError( + f"bottle '{bottle_name}' agent_provider.settings." + f"startup_args[{i}] must be a non-empty string" + ) + if template != "pi": + return dict(settings) + for key in ("provider", "base_url", "api", "api_key", "api_key_env"): value = settings.get(key) if value is not None and (not isinstance(value, str) or not value): diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 6237306..efbb758 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -168,6 +168,34 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) + def test_claude_plan_uses_startup_args_from_provider_settings(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + plan = build_agent_provision_plan( + template="claude", + dockerfile="", + state_dir=Path(tmp), + instance_name="bot-bottle-test", + prompt_file=Path(tmp) / "prompt.txt", + provider_settings={ + "startup_args": ["--model", "opus"], + }, + ) + self.assertEqual(("--model", "opus"), plan.startup_args) + + def test_codex_plan_uses_startup_args_from_provider_settings(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + plan = build_agent_provision_plan( + template="codex", + dockerfile="", + state_dir=Path(tmp), + instance_name="bot-bottle-test", + prompt_file=Path(tmp) / "prompt.txt", + provider_settings={ + "startup_args": ["--model", "gpt-5-codex"], + }, + ) + self.assertEqual(("--model", "gpt-5-codex"), plan.startup_args) + def test_codex_forward_host_credentials_populates_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: home = Path(tmp) / "host-codex" @@ -394,6 +422,24 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertNotIn("OPENROUTER_API_KEY", plan.guest_env) self.assertTrue(provider["compat"]["supportsReasoningEffort"]) + def test_pi_plan_appends_startup_args_from_provider_settings(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + plan = build_agent_provision_plan( + template="pi", + dockerfile="", + state_dir=Path(tmp), + instance_name="bot-bottle-test", + prompt_file=Path(tmp) / "prompt.txt", + provider_settings={ + "models": ["qwen3:14b"], + "startup_args": ["--no-stream"], + }, + ) + self.assertEqual( + ("--models", "ollama/qwen3:14b", "--no-stream"), + plan.startup_args, + ) + def test_pi_prompt_mode_appends_system_prompt_interactively(self): self.assertEqual( ["--append-system-prompt", "/home/node/.bot-bottle-prompt.txt"], diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index cf70825..dd48eab 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -167,13 +167,40 @@ class TestAgentProviderHostCredentials(unittest.TestCase): }, }) - def test_settings_rejected_for_claude(self): + def test_startup_args_allowed_for_claude(self): + b = _provider_config_bottle({ + "template": "claude", + "settings": {"startup_args": ["--model", "opus"]}, + }) + self.assertEqual( + {"startup_args": ["--model", "opus"]}, + b.agent_provider.settings, + ) + + def test_startup_args_allowed_for_codex(self): + b = _provider_config_bottle({ + "template": "codex", + "settings": {"startup_args": ["--model", "gpt-5-codex"]}, + }) + self.assertEqual( + {"startup_args": ["--model", "gpt-5-codex"]}, + b.agent_provider.settings, + ) + + def test_provider_specific_settings_still_rejected_for_claude(self): with self.assertRaises(ManifestError): _provider_config_bottle({ "template": "claude", "settings": {"models": ["qwen2.5-coder:7b"]}, }) + def test_startup_args_must_be_string_array(self): + with self.assertRaises(ManifestError): + _provider_config_bottle({ + "template": "codex", + "settings": {"startup_args": ["--model", 42]}, + }) + def test_settings_models_must_be_non_empty_string_array(self): with self.assertRaises(ManifestError): _provider_config_bottle({ -- 2.52.0