feat: add pi agent provider
This commit is contained in:
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from bot_bottle.agent_provider import (
|
||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||
build_agent_provision_plan,
|
||||
prompt_args,
|
||||
)
|
||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||
|
||||
@@ -260,6 +261,73 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual({}, plan.provisioned_env)
|
||||
|
||||
def test_pi_plan_writes_default_ollama_models(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = build_agent_provision_plan(
|
||||
template="pi",
|
||||
dockerfile="/tmp/Dockerfile.pi",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
)
|
||||
models = json.loads(Path(tmp, "pi-models.json").read_text())
|
||||
self.assertEqual("pi", plan.template)
|
||||
self.assertEqual("pi", plan.command)
|
||||
self.assertEqual("print_read_prompt_file", plan.prompt_mode)
|
||||
self.assertEqual("/tmp/Dockerfile.pi", plan.dockerfile)
|
||||
self.assertEqual("bot-bottle-pi:latest", plan.image)
|
||||
self.assertEqual(
|
||||
("/home/node/.pi/agent",),
|
||||
tuple(d.guest_path for d in plan.dirs),
|
||||
)
|
||||
self.assertEqual(
|
||||
("/home/node/.pi/agent/models.json",),
|
||||
tuple(f.guest_path for f in plan.files),
|
||||
)
|
||||
provider = models["providers"]["ollama"]
|
||||
self.assertEqual("http://ollama:11434/v1", provider["baseUrl"])
|
||||
self.assertEqual("openai-completions", provider["api"])
|
||||
self.assertEqual("ollama", provider["apiKey"])
|
||||
self.assertEqual([{"id": "qwen2.5-coder:7b"}], provider["models"])
|
||||
self.assertEqual("ollama", plan.egress_routes[0].host)
|
||||
self.assertEqual("", plan.egress_routes[0].auth_scheme)
|
||||
self.assertEqual("", plan.egress_routes[0].token_ref)
|
||||
|
||||
def test_pi_plan_uses_provider_settings(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
build_agent_provision_plan(
|
||||
template="pi",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
provider_settings={
|
||||
"base_url": "http://host.docker.internal:11434/v1",
|
||||
"api": "openai-responses",
|
||||
"api_key": "local",
|
||||
"models": ["gpt-oss:20b", "qwen3:14b"],
|
||||
"supports_developer_role": True,
|
||||
"supports_reasoning_effort": True,
|
||||
},
|
||||
)
|
||||
models = json.loads(Path(tmp, "pi-models.json").read_text())
|
||||
provider = models["providers"]["ollama"]
|
||||
self.assertEqual("http://host.docker.internal:11434/v1", provider["baseUrl"])
|
||||
self.assertEqual("openai-responses", provider["api"])
|
||||
self.assertEqual("local", provider["apiKey"])
|
||||
self.assertEqual(
|
||||
[{"id": "gpt-oss:20b"}, {"id": "qwen3:14b"}],
|
||||
provider["models"],
|
||||
)
|
||||
self.assertTrue(provider["compat"]["supportsDeveloperRole"])
|
||||
self.assertTrue(provider["compat"]["supportsReasoningEffort"])
|
||||
|
||||
def test_pi_prompt_mode_uses_print_flag(self):
|
||||
self.assertEqual(
|
||||
["-p", "Read and follow the instructions in /home/node/.bot-bottle-prompt.txt."],
|
||||
prompt_args("print_read_prompt_file", "/home/node/.bot-bottle-prompt.txt"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Unit: PiAgentProvider provisioning (PRD 0058, contrib/pi)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
)
|
||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
|
||||
from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import Manifest
|
||||
|
||||
|
||||
_URL = "http://supervise:9100/"
|
||||
|
||||
|
||||
def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock:
|
||||
bottle = MagicMock(spec=Bottle)
|
||||
bottle.name = "bot-bottle-demo-abc12"
|
||||
bottle.exec.return_value = (
|
||||
exec_result if exec_result is not None
|
||||
else ExecResult(returncode=0, stdout="", stderr="")
|
||||
)
|
||||
return bottle
|
||||
|
||||
|
||||
def _exec_scripts(bottle: MagicMock) -> list[str]:
|
||||
return [c.args[0] for c in bottle.exec.call_args_list]
|
||||
|
||||
|
||||
def _plan(
|
||||
*,
|
||||
agent_prompt: str = "",
|
||||
skills: list[str] | None = None,
|
||||
agent_provision: AgentProvisionPlan | None = None,
|
||||
) -> DockerBottlePlan:
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"agent_provider": {"template": "pi"}}},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": list(skills or []),
|
||||
"prompt": agent_prompt,
|
||||
"bottle": "dev",
|
||||
},
|
||||
},
|
||||
})
|
||||
spec = BottleSpec(
|
||||
manifest=manifest, agent_name="demo",
|
||||
copy_cwd=False, user_cwd="/tmp/x",
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=Path("/tmp/stage"),
|
||||
slug="demo-abc12",
|
||||
forwarded_env={},
|
||||
git_gate_plan=GitGatePlan(
|
||||
slug="demo-abc12",
|
||||
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
|
||||
hook_script=Path("/tmp/git-gate-hook"),
|
||||
access_hook_script=Path("/tmp/git-gate-access-hook"),
|
||||
upstreams=(),
|
||||
),
|
||||
egress_plan=EgressPlan(
|
||||
slug="demo-abc12",
|
||||
routes_path=Path("/tmp/routes.yaml"),
|
||||
routes=(),
|
||||
token_env_map={},
|
||||
),
|
||||
supervise_plan=None,
|
||||
use_runsc=False,
|
||||
agent_provision=agent_provision or AgentProvisionPlan(
|
||||
template="pi", command="pi", prompt_mode="print_read_prompt_file",
|
||||
image="bot-bottle-pi:latest", dockerfile="",
|
||||
guest_home="/home/node",
|
||||
instance_name="bot-bottle-demo-abc12",
|
||||
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
||||
guest_env={},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPiProvisionPrompt(unittest.TestCase):
|
||||
def test_cp_uses_bottle_cp_in_and_chowns(self):
|
||||
bottle = _make_bottle()
|
||||
result = PiAgentProvider().provision_prompt(
|
||||
_plan(agent_prompt="hello"), bottle,
|
||||
)
|
||||
self.assertEqual("/home/node/.bot-bottle-prompt.txt", result)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
"/tmp/state/demo-abc12/agent/prompt.txt",
|
||||
"/home/node/.bot-bottle-prompt.txt",
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("chown node:node" in s
|
||||
and "/home/node/.bot-bottle-prompt.txt" in s
|
||||
for s in scripts)
|
||||
)
|
||||
|
||||
def test_returns_none_when_agent_has_no_prompt(self):
|
||||
bottle = _make_bottle()
|
||||
result = PiAgentProvider().provision_prompt(_plan(agent_prompt=""), bottle)
|
||||
self.assertIsNone(result)
|
||||
bottle.cp_in.assert_called_once()
|
||||
|
||||
|
||||
class TestPiProvisionSkills(unittest.TestCase):
|
||||
def test_noop_when_agent_has_no_skills(self):
|
||||
bottle = _make_bottle()
|
||||
PiAgentProvider().provision_skills(_plan(skills=[]), bottle)
|
||||
bottle.cp_in.assert_not_called()
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
def test_mkdir_plus_cp_per_skill(self):
|
||||
bottle = _make_bottle()
|
||||
with patch(
|
||||
"bot_bottle.backend.util.host_skill_dir",
|
||||
side_effect=lambda n: f"/host/skills/{n}", # type: ignore
|
||||
), patch(
|
||||
"bot_bottle.contrib.pi.agent_provider.os.path.isdir",
|
||||
return_value=True,
|
||||
):
|
||||
PiAgentProvider().provision_skills(_plan(skills=["search"]), bottle)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("mkdir -p" in s and "/home/node/.pi/agent/skills" in s
|
||||
for s in scripts)
|
||||
)
|
||||
bottle.cp_in.assert_called_once()
|
||||
self.assertEqual(
|
||||
"/home/node/.pi/agent/skills/search/",
|
||||
bottle.cp_in.call_args.args[1],
|
||||
)
|
||||
|
||||
|
||||
class TestPiProvision(unittest.TestCase):
|
||||
def test_creates_dir_and_copies_models_config(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="pi", command="pi", prompt_mode="print_read_prompt_file",
|
||||
image="", dockerfile="", guest_home="/home/node",
|
||||
instance_name="bot-bottle-demo-abc12",
|
||||
prompt_file=Path("/tmp/prompt.txt"),
|
||||
guest_env={},
|
||||
dirs=(AgentProvisionDir("/home/node/.pi/agent"),),
|
||||
files=(AgentProvisionFile(
|
||||
Path("/tmp/pi-models.json"),
|
||||
"/home/node/.pi/agent/models.json",
|
||||
),),
|
||||
)
|
||||
bottle = _make_bottle()
|
||||
PiAgentProvider().provision(_plan(agent_provision=provision), bottle)
|
||||
bottle.cp_in.assert_called_once_with(
|
||||
"/tmp/pi-models.json",
|
||||
"/home/node/.pi/agent/models.json",
|
||||
)
|
||||
scripts = _exec_scripts(bottle)
|
||||
self.assertTrue(
|
||||
any("mkdir -p" in s and "/home/node/.pi/agent" in s for s in scripts)
|
||||
)
|
||||
self.assertTrue(
|
||||
any("chown" in s and "/home/node/.pi/agent/models.json" in s
|
||||
for s in scripts)
|
||||
)
|
||||
|
||||
def test_dies_when_dir_creation_fails(self):
|
||||
provision = AgentProvisionPlan(
|
||||
template="pi", command="pi", prompt_mode="print_read_prompt_file",
|
||||
image="", dockerfile="", guest_home="/home/node",
|
||||
instance_name="bot-bottle-demo-abc12",
|
||||
prompt_file=Path("/tmp/prompt.txt"),
|
||||
guest_env={},
|
||||
dirs=(AgentProvisionDir("/home/node/.pi/agent"),),
|
||||
)
|
||||
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n"))
|
||||
with self.assertRaises(SystemExit):
|
||||
PiAgentProvider().provision(_plan(agent_provision=provision), bottle)
|
||||
|
||||
|
||||
class TestPiSuperviseMcp(unittest.TestCase):
|
||||
def test_noop(self):
|
||||
bottle = _make_bottle()
|
||||
PiAgentProvider().provision_supervise_mcp(_plan(), bottle, _URL)
|
||||
bottle.exec.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -111,6 +111,51 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
|
||||
"auth_token": "SOME_TOKEN",
|
||||
})
|
||||
|
||||
def test_settings_allowed_for_pi(self):
|
||||
b = _provider_config_bottle({
|
||||
"template": "pi",
|
||||
"settings": {
|
||||
"base_url": "http://ollama:11434/v1",
|
||||
"api": "openai-completions",
|
||||
"api_key": "ollama",
|
||||
"models": ["qwen2.5-coder:7b"],
|
||||
"supports_developer_role": False,
|
||||
"supports_reasoning_effort": False,
|
||||
},
|
||||
})
|
||||
self.assertEqual(
|
||||
{
|
||||
"base_url": "http://ollama:11434/v1",
|
||||
"api": "openai-completions",
|
||||
"api_key": "ollama",
|
||||
"models": ["qwen2.5-coder:7b"],
|
||||
"supports_developer_role": False,
|
||||
"supports_reasoning_effort": False,
|
||||
},
|
||||
b.agent_provider.settings,
|
||||
)
|
||||
|
||||
def test_settings_rejected_for_claude(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_provider_config_bottle({
|
||||
"template": "claude",
|
||||
"settings": {"models": ["qwen2.5-coder:7b"]},
|
||||
})
|
||||
|
||||
def test_settings_models_must_be_non_empty_string_array(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_provider_config_bottle({
|
||||
"template": "pi",
|
||||
"settings": {"models": []},
|
||||
})
|
||||
|
||||
def test_settings_boolean_flags_must_be_boolean(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_provider_config_bottle({
|
||||
"template": "pi",
|
||||
"settings": {"supports_developer_role": "no"},
|
||||
})
|
||||
|
||||
|
||||
class TestMatches(unittest.TestCase):
|
||||
def test_optional(self):
|
||||
|
||||
Reference in New Issue
Block a user