diff --git a/bot_bottle/backend/docker/bottle.py b/bot_bottle/backend/docker/bottle.py index 7be70fd..361d3ea 100644 --- a/bot_bottle/backend/docker/bottle.py +++ b/bot_bottle/backend/docker/bottle.py @@ -26,6 +26,7 @@ class DockerBottle(Bottle): agent_provider_template: str = "claude", terminal_title: str = "", terminal_color: str = "", + agent_workdir: str = "/home/node", ): self.name = container self._teardown = teardown @@ -35,6 +36,7 @@ class DockerBottle(Bottle): self.terminal_title = terminal_title self.terminal_color = terminal_color self.agent_provider_template = agent_provider_template + self.agent_workdir = agent_workdir self._closed = False def agent_argv( @@ -47,6 +49,8 @@ class DockerBottle(Bottle): cmd = ["docker", "exec"] if tty: cmd.append("-it") + if self.agent_workdir and self.agent_workdir != "/home/node": + cmd.extend(["-w", self.agent_workdir]) cmd.extend([self.name, self.agent_command, *full_argv]) return cmd diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 43109a8..b247e1e 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -178,6 +178,7 @@ def launch( agent_provider_template=plan.agent_provider_template, terminal_title=plan.spec.label or plan.spec.agent_name, terminal_color=plan.spec.color, + agent_workdir=plan.workspace_plan.workdir, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py index 7693f18..ca3ff71 100644 --- a/bot_bottle/backend/smolmachines/bottle.py +++ b/bot_bottle/backend/smolmachines/bottle.py @@ -20,6 +20,7 @@ from __future__ import annotations import subprocess import sys import time +import shlex from typing import Mapping, cast from ...agent_provider import PromptMode, prompt_args @@ -72,6 +73,7 @@ class SmolmachinesBottle(Bottle): agent_provider_template: str = "claude", terminal_title: str = "", terminal_color: str = "", + agent_workdir: str = "/home/node", ) -> None: self.name = machine_name # In-VM path to the agent's prompt file. None when the @@ -88,6 +90,7 @@ class SmolmachinesBottle(Bottle): self.terminal_title = terminal_title self.terminal_color = terminal_color self.agent_provider_template = agent_provider_template + self.agent_workdir = agent_workdir def agent_argv( self, argv: list[str], *, tty: bool = True, @@ -95,8 +98,14 @@ class SmolmachinesBottle(Bottle): flags = ["smolvm", "machine", "exec", "--name", self.name] if tty: flags += ["-i", "-t"] - agent_tail = ["env", *_env_assignments_for("node", self._guest_env), - self.agent_command] + agent_tail = ["env", *_env_assignments_for("node", self._guest_env)] + if self.agent_workdir and self.agent_workdir != _HOME_FOR["node"]: + agent_tail += [ + "sh", "-lc", + f"cd {shlex.quote(self.agent_workdir)} && exec \"$@\"", + "bot-bottle-agent", + ] + agent_tail.append(self.agent_command) provider_prompt_args = prompt_args( cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=argv, ) diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index b9e9739..36c6785 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -106,6 +106,7 @@ def launch( agent_provider_template=plan.agent_provider_template, terminal_title=plan.spec.label or plan.spec.agent_name, terminal_color=plan.spec.color, + agent_workdir=plan.workspace_plan.workdir, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/contrib/pi/Dockerfile b/bot_bottle/contrib/pi/Dockerfile index 7f7e501..20a5f29 100644 --- a/bot_bottle/contrib/pi/Dockerfile +++ b/bot_bottle/contrib/pi/Dockerfile @@ -6,11 +6,11 @@ FROM node:22-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - git \ - ca-certificates \ - curl \ - fd-find \ - ripgrep \ + git \ + ca-certificates \ + curl \ + fd-find \ + ripgrep \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ && rm -rf /var/lib/apt/lists/* @@ -21,9 +21,21 @@ RUN apt-get update \ RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \ && npm cache clean --force +RUN mkdir -p /home/node/.pi/agent \ + /home/node/.pi/context-mode/sessions \ + /tmp/pi-subagents-uid-1000 \ + && chown -R node:node /home/node/.pi /tmp \ + && chmod -R u+rwX /tmp \ + && chown root:root /tmp /var/tmp \ + && chmod 1777 /tmp /var/tmp + USER node WORKDIR /home/node -RUN mkdir -p /home/node/.pi/agent +RUN pi install npm:@harms-haus/pi-cwd \ + && pi install npm:pi-web-access \ + && pi install npm:context-mode \ + && pi install npm:pi-subagents \ + && pi install npm:pi-mcp-adapter CMD ["pi"] diff --git a/bot_bottle/contrib/pi/agent_provider.py b/bot_bottle/contrib/pi/agent_provider.py index af76ac4..dfc9120 100644 --- a/bot_bottle/contrib/pi/agent_provider.py +++ b/bot_bottle/contrib/pi/agent_provider.py @@ -33,6 +33,8 @@ if TYPE_CHECKING: _DEFAULT_BASE_URL = "http://ollama:11434/v1" _DEFAULT_MODEL = "qwen2.5-coder:7b" _DEFAULT_PROVIDER_NAME = "ollama" +_DEFAULT_CONTEXT_WINDOW = 4096 +_DEFAULT_MAX_TOKENS = 1024 def _skills_dir(guest_home: str) -> str: @@ -43,10 +45,29 @@ def _prompt_path(guest_home: str) -> str: return f"{guest_home}/.bot-bottle-prompt.txt" +def _append_system_path(guest_home: str) -> str: + return f"{guest_home}/.pi/agent/APPEND_SYSTEM.md" + + def _models_path(guest_home: str) -> str: return f"{guest_home}/.pi/agent/models.json" +def _runtime_state_repair_script(guest_home: str) -> str: + home = shlex.quote(guest_home) + pi_home = shlex.quote(f"{guest_home}/.pi") + context_sessions = shlex.quote(f"{guest_home}/.pi/context-mode/sessions") + return ( + f"mkdir -p {context_sessions} /tmp/pi-subagents-uid-1000 && " + f"chown node:node {home} && " + f"chown -R node:node {pi_home} /tmp && " + "chmod -R u+rwX /tmp && " + f"chmod 755 {home} && " + "chown root:root /tmp /var/tmp && " + "chmod 1777 /tmp /var/tmp" + ) + + def _settings_value( settings: dict[str, object], key: str, @@ -74,14 +95,33 @@ def _pi_models_json( supports_reasoning_effort = bool( _settings_value(settings, "supports_reasoning_effort", False) ) + max_tokens_field = str( + _settings_value(settings, "max_tokens_field", "max_tokens") + ) + context_window = int(_settings_value( + settings, "context_window", _DEFAULT_CONTEXT_WINDOW, + )) + max_tokens = int(_settings_value( + settings, "max_tokens", _DEFAULT_MAX_TOKENS, + )) + input_context_window = max(1, context_window - max_tokens) provider: dict[str, object] = { "baseUrl": base_url, "api": api, "compat": { "supportsDeveloperRole": supports_developer_role, "supportsReasoningEffort": supports_reasoning_effort, + "maxTokensField": max_tokens_field, }, - "models": [{"id": model} for model in models], + "models": [ + { + "id": model, + "name": model, + "contextWindow": input_context_window, + "maxTokens": max_tokens, + } + for model in models + ], } if api_key is not None: provider["apiKey"] = str(api_key) @@ -201,16 +241,28 @@ class PiAgentProvider(AgentProvider): def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None: prompt_path = _prompt_path(plan.guest_home) + append_system_path = _append_system_path(plan.guest_home) bottle.cp_in(str(plan.prompt_file), prompt_path) # type: ignore bottle.exec( - f"chown node:node {prompt_path} && chmod 600 {prompt_path}", + f"mkdir -p {shlex.quote(plan.guest_home)}/.pi/agent && " + f"cp {shlex.quote(prompt_path)} {shlex.quote(append_system_path)} && " + f"chown node:node {shlex.quote(prompt_path)} " + f"{shlex.quote(append_system_path)} && " + f"chmod 600 {shlex.quote(prompt_path)} " + f"{shlex.quote(append_system_path)}", user="root", ) - agent = plan.spec.manifest.agents[plan.spec.agent_name] - return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None + # Pi's `--append-system-prompt` takes literal text, not a file path. + # Use its documented APPEND_SYSTEM.md discovery path instead. + return None def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None: provision = plan.agent_provision + _exec( + bottle, + _runtime_state_repair_script(plan.guest_home), + "could not prepare pi runtime state", + ) for d in provision.dirs: path = shlex.quote(d.guest_path) _exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}") diff --git a/bot_bottle/manifest_agent.py b/bot_bottle/manifest_agent.py index 739cb56..bbb6c9a 100644 --- a/bot_bottle/manifest_agent.py +++ b/bot_bottle/manifest_agent.py @@ -212,6 +212,9 @@ def _parse_provider_settings( "api_key", "api_key_env", "models", + "context_window", + "max_tokens_field", + "max_tokens", "supports_developer_role", "supports_reasoning_effort", } @@ -228,6 +231,14 @@ def _parse_provider_settings( f"bottle '{bottle_name}' agent_provider.settings.{key} must " "be a non-empty string" ) + max_tokens_field = settings.get("max_tokens_field") + if max_tokens_field is not None and max_tokens_field not in ( + "max_tokens", "max_completion_tokens", + ): + raise ManifestError( + f"bottle '{bottle_name}' agent_provider.settings.max_tokens_field " + "must be 'max_tokens' or 'max_completion_tokens'" + ) if settings.get("api_key") is not None and settings.get("api_key_env") is not None: raise ManifestError( f"bottle '{bottle_name}' agent_provider.settings may set either " @@ -253,4 +264,13 @@ def _parse_provider_settings( f"bottle '{bottle_name}' agent_provider.settings.{key} must " f"be a boolean (was {type(value).__name__})" ) + for key in ("context_window", "max_tokens"): + value = settings.get(key) + if value is not None and ( + not isinstance(value, int) or isinstance(value, bool) or value <= 0 + ): + raise ManifestError( + f"bottle '{bottle_name}' agent_provider.settings.{key} must " + f"be a positive integer (was {type(value).__name__})" + ) return dict(settings) diff --git a/docs/prds/0058-pi-agent-provider.md b/docs/prds/0058-pi-agent-provider.md index 7191a77..836fb4d 100644 --- a/docs/prds/0058-pi-agent-provider.md +++ b/docs/prds/0058-pi-agent-provider.md @@ -66,12 +66,20 @@ Supported keys: - `api_key`: string, defaults to `ollama` - `api_key_env`: string, optional host env var name for egress auth injection - `models`: non-empty array of strings, defaults to `["qwen2.5-coder:7b"]` +- `context_window`: positive integer, defaults to `4096`; this is the Ollama + runtime context, and bot-bottle subtracts `max_tokens` before writing Pi's + `contextWindow` so output space is reserved +- `max_tokens`: positive integer, defaults to `1024` +- `max_tokens_field`: `max_tokens` or `max_completion_tokens`, defaults to + `max_tokens` - `supports_developer_role`: boolean, defaults to `false` - `supports_reasoning_effort`: boolean, defaults to `false` The snake-case manifest keys are converted into Pi's JSON field names: -`baseUrl`, `apiKey`, `supportsDeveloperRole`, and -`supportsReasoningEffort`. +`baseUrl`, `apiKey`, `contextWindow`, `maxTokens`, +`supportsDeveloperRole`, and `supportsReasoningEffort`. `context_window` +describes the server's total context; Pi's `contextWindow` receives +`context_window - max_tokens` because Pi uses it as an input compaction target. `api_key` and `api_key_env` are mutually exclusive. When targeting a hosted provider through bot-bottle's egress sidecar, omit `api_key` and set diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 225a3eb..ecb7e15 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -289,7 +289,16 @@ class TestAgentProviderRuntime(unittest.TestCase): 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("max_tokens", provider["compat"]["maxTokensField"]) + self.assertEqual( + [{ + "id": "qwen2.5-coder:7b", + "name": "qwen2.5-coder:7b", + "contextWindow": 3072, + "maxTokens": 1024, + }], + 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) @@ -307,6 +316,9 @@ class TestAgentProviderRuntime(unittest.TestCase): "api": "openai-responses", "api_key": "local", "models": ["gpt-oss:20b", "qwen3:14b"], + "context_window": 65536, + "max_tokens_field": "max_completion_tokens", + "max_tokens": 12000, "supports_developer_role": True, "supports_reasoning_effort": True, }, @@ -317,11 +329,28 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual("openai-responses", provider["api"]) self.assertEqual("local", provider["apiKey"]) self.assertEqual( - [{"id": "gpt-oss:20b"}, {"id": "qwen3:14b"}], + [ + { + "id": "gpt-oss:20b", + "name": "gpt-oss:20b", + "contextWindow": 53536, + "maxTokens": 12000, + }, + { + "id": "qwen3:14b", + "name": "qwen3:14b", + "contextWindow": 53536, + "maxTokens": 12000, + }, + ], provider["models"], ) self.assertTrue(provider["compat"]["supportsDeveloperRole"]) self.assertTrue(provider["compat"]["supportsReasoningEffort"]) + self.assertEqual( + "max_completion_tokens", + provider["compat"]["maxTokensField"], + ) def test_pi_plan_can_target_openrouter_with_egress_injected_api_key(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: @@ -345,8 +374,14 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual("https://openrouter.ai/api/v1", provider["baseUrl"]) self.assertEqual("openai-completions", provider["api"]) self.assertEqual("egress-placeholder", provider["apiKey"]) + self.assertEqual("max_tokens", provider["compat"]["maxTokensField"]) self.assertEqual( - [{"id": "google/gemma-4-26b-a4b-it:free"}], + [{ + "id": "google/gemma-4-26b-a4b-it:free", + "name": "google/gemma-4-26b-a4b-it:free", + "contextWindow": 3072, + "maxTokens": 1024, + }], provider["models"], ) self.assertEqual( diff --git a/tests/unit/test_contrib_pi_provider.py b/tests/unit/test_contrib_pi_provider.py index 17d3a18..ade0db2 100644 --- a/tests/unit/test_contrib_pi_provider.py +++ b/tests/unit/test_contrib_pi_provider.py @@ -20,6 +20,7 @@ from bot_bottle.manifest import Manifest _URL = "http://supervise:9100/" +_PI_DOCKERFILE = Path(__file__).resolve().parents[2] / "bot_bottle/contrib/pi/Dockerfile" def _make_bottle(exec_result: ExecResult | None = None) -> MagicMock: @@ -93,7 +94,7 @@ class TestPiProvisionPrompt(unittest.TestCase): result = PiAgentProvider().provision_prompt( _plan(agent_prompt="hello"), bottle, ) - self.assertEqual("/home/node/.bot-bottle-prompt.txt", result) + self.assertIsNone(result) bottle.cp_in.assert_called_once_with( "/tmp/state/demo-abc12/agent/prompt.txt", "/home/node/.bot-bottle-prompt.txt", @@ -102,6 +103,12 @@ class TestPiProvisionPrompt(unittest.TestCase): self.assertTrue( any("chown node:node" in s and "/home/node/.bot-bottle-prompt.txt" in s + and "/home/node/.pi/agent/APPEND_SYSTEM.md" in s + for s in scripts) + ) + self.assertTrue( + any("cp /home/node/.bot-bottle-prompt.txt" in s + and "/home/node/.pi/agent/APPEND_SYSTEM.md" in s for s in scripts) ) @@ -165,6 +172,14 @@ class TestPiProvision(unittest.TestCase): self.assertTrue( any("mkdir -p" in s and "/home/node/.pi/agent" in s for s in scripts) ) + self.assertTrue( + any("/home/node/.pi/context-mode/sessions" in s + and "/tmp/pi-subagents-uid-1000" in s + and "chown node:node /home/node" in s + and "chown -R node:node /home/node/.pi /tmp" in s + and "chmod 755 /home/node" 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) @@ -191,5 +206,20 @@ class TestPiSuperviseMcp(unittest.TestCase): bottle.exec.assert_not_called() +class TestPiDockerfile(unittest.TestCase): + def test_installs_pi_cwd_at_build_time(self): + dockerfile = _PI_DOCKERFILE.read_text() + self.assertIn("pi install npm:@harms-haus/pi-cwd", dockerfile) + + def test_prepares_pi_extension_state_dirs_and_tmp_for_node(self): + dockerfile = _PI_DOCKERFILE.read_text() + self.assertIn("/home/node/.pi/context-mode/sessions", dockerfile) + self.assertIn("/tmp/pi-subagents-uid-1000", dockerfile) + self.assertIn("chown -R node:node /home/node/.pi /tmp", dockerfile) + self.assertIn("chmod -R u+rwX /tmp", dockerfile) + self.assertIn("chown root:root /tmp /var/tmp", dockerfile) + self.assertIn("chmod 1777 /tmp /var/tmp", dockerfile) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_docker_bottle.py b/tests/unit/test_docker_bottle.py index dfbc3da..289421a 100644 --- a/tests/unit/test_docker_bottle.py +++ b/tests/unit/test_docker_bottle.py @@ -41,6 +41,15 @@ def _pi_bottle(prompt_path: str | None = None) -> DockerBottle: ) +def _workspace_bottle() -> DockerBottle: + return DockerBottle( + container="bot-bottle-dev-abc", + teardown=lambda: None, + prompt_path_in_container=None, + agent_workdir="/home/node/workspace", + ) + + class TestClaudeArgv(unittest.TestCase): def test_minimal_argv_no_prompt(self): argv = _bottle().agent_argv([]) @@ -89,6 +98,16 @@ class TestClaudeArgv(unittest.TestCase): argv, ) + def test_workspace_workdir_is_used_when_set(self): + argv = _workspace_bottle().agent_argv([]) + self.assertEqual( + [ + "docker", "exec", "-it", "-w", "/home/node/workspace", + "bot-bottle-dev-abc", "claude", + ], + argv, + ) + def test_caller_argv_not_mutated(self): # `agent_argv` builds `full_argv` from a copy, so a # caller passing a long-lived list (e.g., the dashboard's diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index 01c8bcb..4f8b7a3 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -120,6 +120,9 @@ class TestAgentProviderHostCredentials(unittest.TestCase): "api": "openai-completions", "api_key": "ollama", "models": ["qwen2.5-coder:7b"], + "context_window": 65536, + "max_tokens_field": "max_tokens", + "max_tokens": 12000, "supports_developer_role": False, "supports_reasoning_effort": False, }, @@ -131,6 +134,9 @@ class TestAgentProviderHostCredentials(unittest.TestCase): "api": "openai-completions", "api_key": "ollama", "models": ["qwen2.5-coder:7b"], + "context_window": 65536, + "max_tokens_field": "max_tokens", + "max_tokens": 12000, "supports_developer_role": False, "supports_reasoning_effort": False, }, diff --git a/tests/unit/test_smolmachines_bottle.py b/tests/unit/test_smolmachines_bottle.py index 9446348..7c98ef2 100644 --- a/tests/unit/test_smolmachines_bottle.py +++ b/tests/unit/test_smolmachines_bottle.py @@ -37,6 +37,14 @@ def _pi_bottle(prompt_path: str | None = None) -> SmolmachinesBottle: ) +def _workspace_bottle() -> SmolmachinesBottle: + return SmolmachinesBottle( + "bot-bottle-dev-abc", + prompt_path=None, + agent_workdir="/home/node/workspace", + ) + + def _unwrap(argv: list[str]) -> list[str]: """Strip the pty_resize wrapper from the front of a TTY-mode argv, return the inner smolvm argv. Mirrors what the kernel @@ -141,6 +149,19 @@ class TestClaudeArgvWrapped(unittest.TestCase): ) self.assertNotIn("-p", argv) + def test_workspace_workdir_wraps_agent_command(self): + argv = _unwrap(_workspace_bottle().agent_argv([])) + agent_idx = argv.index("claude") + self.assertEqual( + [ + "sh", "-lc", + "cd /home/node/workspace && exec \"$@\"", + "bot-bottle-agent", + "claude", + ], + argv[agent_idx - 4:agent_idx + 1], + ) + class TestClaudeArgvNoTTY(unittest.TestCase): """`tty=False` paths skip the pty_resize wrapper — there's no