feat(provider): support startup args settings
test / unit (pull_request) Successful in 41s
test / integration (pull_request) Successful in 26s
lint / lint (push) Successful in 2m12s
test / unit (push) Successful in 41s
test / integration (push) Successful in 26s
Update Quality Badges / update-badges (push) Successful in 2m9s

This commit was merged in pull request #265.
This commit is contained in:
2026-06-24 22:51:27 -04:00
parent 2e790268b0
commit ecaae708f7
7 changed files with 122 additions and 9 deletions
+9
View File
@@ -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( def prompt_args(
prompt_mode: PromptMode, prompt_mode: PromptMode,
prompt_path: str | None, prompt_path: str | None,
+4 -1
View File
@@ -20,6 +20,7 @@ from ...agent_provider import (
AgentProvisionDir, AgentProvisionDir,
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
provider_startup_args,
) )
from ...backend.docker import util as docker_mod from ...backend.docker import util as docker_mod
from ...egress import EgressRoute from ...egress import EgressRoute
@@ -115,8 +116,9 @@ class ClaudeAgentProvider(AgentProvider):
color: str = "", color: str = "",
provider_settings: dict[str, object] | None = None, provider_settings: dict[str, object] | None = None,
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del forward_host_credentials, host_env, provider_settings del forward_host_credentials, host_env
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
startup_args = provider_startup_args(provider_settings)
guest_home = self.guest_home guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home trusted_path = trusted_project_path or guest_home
@@ -199,6 +201,7 @@ class ClaudeAgentProvider(AgentProvider):
env_vars=env_vars, env_vars=env_vars,
guest_env=resolved_guest_env, guest_env=resolved_guest_env,
has_prompt=has_prompt, has_prompt=has_prompt,
startup_args=startup_args,
dirs=dirs, dirs=dirs,
files=tuple(files), files=tuple(files),
egress_routes=egress_routes, egress_routes=egress_routes,
+4 -1
View File
@@ -22,6 +22,7 @@ from ...agent_provider import (
AgentProvisionCommand, AgentProvisionCommand,
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
provider_startup_args,
) )
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
@@ -79,8 +80,9 @@ class CodexAgentProvider(AgentProvider):
color: str = "", color: str = "",
provider_settings: dict[str, object] | None = None, provider_settings: dict[str, object] | None = None,
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del auth_token, label, color, provider_settings del auth_token, label, color
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
startup_args = provider_startup_args(provider_settings)
guest_home = self.guest_home guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home trusted_path = trusted_project_path or guest_home
@@ -163,6 +165,7 @@ class CodexAgentProvider(AgentProvider):
env_vars=env_vars, env_vars=env_vars,
guest_env=resolved_guest_env, guest_env=resolved_guest_env,
has_prompt=has_prompt, has_prompt=has_prompt,
startup_args=startup_args,
dirs=tuple(dirs), dirs=tuple(dirs),
files=tuple(files), files=tuple(files),
pre_copy=tuple(pre_copy), pre_copy=tuple(pre_copy),
+3
View File
@@ -21,6 +21,7 @@ from ...agent_provider import (
AgentProvisionDir, AgentProvisionDir,
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
provider_startup_args,
) )
from ...egress import EgressRoute from ...egress import EgressRoute
from ...log import die, info from ...log import die, info
@@ -199,6 +200,7 @@ class PiAgentProvider(AgentProvider):
models_payload, base_url, api_key_env, models, provider_name = ( models_payload, base_url, api_key_env, models, provider_name = (
_pi_models_json(settings) _pi_models_json(settings)
) )
extra_startup_args = provider_startup_args(provider_settings)
models_file = state_dir / "pi-models.json" models_file = state_dir / "pi-models.json"
models_file.write_text(json.dumps(models_payload, indent=2) + "\n") models_file.write_text(json.dumps(models_payload, indent=2) + "\n")
models_file.chmod(0o600) models_file.chmod(0o600)
@@ -219,6 +221,7 @@ class PiAgentProvider(AgentProvider):
startup_args=( startup_args=(
"--models", "--models",
",".join(f"{provider_name}/{model}" for model in models), ",".join(f"{provider_name}/{model}" for model in models),
*extra_startup_args,
), ),
dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),), dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),),
files=(AgentProvisionFile(models_file, _models_path(guest_home)),), files=(AgentProvisionFile(models_file, _models_path(guest_home)),),
+28 -6
View File
@@ -199,13 +199,10 @@ def _parse_provider_settings(
) -> dict[str, object]: ) -> dict[str, object]:
if raw is None: if raw is None:
return {} 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") settings = as_json_object(raw, f"bottle '{bottle_name}' agent_provider.settings")
allowed = {
common_allowed = {"startup_args"}
pi_allowed = {
"provider", "provider",
"base_url", "base_url",
"api", "api",
@@ -218,12 +215,37 @@ def _parse_provider_settings(
"supports_developer_role", "supports_developer_role",
"supports_reasoning_effort", "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: for key in settings:
if key not in allowed: if key not in allowed:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' agent_provider.settings has unknown " f"bottle '{bottle_name}' agent_provider.settings has unknown "
f"key {key!r}; allowed: {', '.join(sorted(allowed))}" 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"): for key in ("provider", "base_url", "api", "api_key", "api_key_env"):
value = settings.get(key) value = settings.get(key)
if value is not None and (not isinstance(value, str) or not value): if value is not None and (not isinstance(value, str) or not value):
+46
View File
@@ -168,6 +168,34 @@ class TestAgentProviderRuntime(unittest.TestCase):
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) 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): def test_codex_forward_host_credentials_populates_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
home = Path(tmp) / "host-codex" home = Path(tmp) / "host-codex"
@@ -394,6 +422,24 @@ class TestAgentProviderRuntime(unittest.TestCase):
self.assertNotIn("OPENROUTER_API_KEY", plan.guest_env) self.assertNotIn("OPENROUTER_API_KEY", plan.guest_env)
self.assertTrue(provider["compat"]["supportsReasoningEffort"]) 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): def test_pi_prompt_mode_appends_system_prompt_interactively(self):
self.assertEqual( self.assertEqual(
["--append-system-prompt", "/home/node/.bot-bottle-prompt.txt"], ["--append-system-prompt", "/home/node/.bot-bottle-prompt.txt"],
+28 -1
View File
@@ -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): with self.assertRaises(ManifestError):
_provider_config_bottle({ _provider_config_bottle({
"template": "claude", "template": "claude",
"settings": {"models": ["qwen2.5-coder:7b"]}, "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): def test_settings_models_must_be_non_empty_string_array(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_provider_config_bottle({ _provider_config_bottle({