fix(pi): prepare runtime state and agent workdir
lint / lint (push) Failing after 1m58s
test / unit (push) Successful in 41s
test / integration (push) Successful in 24s
Update Quality Badges / update-badges (push) Successful in 1m27s

This commit is contained in:
2026-06-10 00:02:28 -04:00
parent 86374ab293
commit 504144eb9c
13 changed files with 236 additions and 18 deletions
+4
View File
@@ -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
+1
View File
@@ -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)
+11 -2
View File
@@ -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,
)
@@ -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)
+13 -1
View File
@@ -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"]
+56 -4
View File
@@ -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}")
+20
View File
@@ -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)
+10 -2
View File
@@ -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
+38 -3
View File
@@ -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(
+31 -1
View File
@@ -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()
+19
View File
@@ -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
+6
View File
@@ -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,
},
+21
View File
@@ -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