monkeybot-cli 0.5.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/PKG-INFO +1 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/pyproject.toml +1 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/skills/monkeybot/SKILL.md +3 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/skills/monkeybot/references/config-sections.md +1 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_local_shell.py +7 -43
- monkeybot_cli-0.5.1/src/monkeybot_cli/commands/run_cmd.py +197 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/extras_catalog.py +2 -1
- monkeybot_cli-0.5.1/src/monkeybot_cli/process_tree.py +44 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/providers.py +11 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold.py +1 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/env.example +4 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +13 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_local_shell.py +16 -15
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_extras_catalog.py +5 -0
- monkeybot_cli-0.5.1/tests/test_run_cmd.py +277 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_scaffold.py +1 -0
- monkeybot_cli-0.5.0/src/monkeybot_cli/commands/run_cmd.py +0 -63
- monkeybot_cli-0.5.0/tests/test_run_cmd.py +0 -90
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/.gitignore +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_file_index.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_renderer.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_session.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_status_bar.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_theme.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_tool_display.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_tui.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_tui_widgets.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/chat.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/doctor.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/loop.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/new.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/refresh.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/talk.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/validate.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/compat.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/config_resolve.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/exit_commands.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/gateway_health.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/main.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/opensandbox_lifecycle.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/output.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/audio_io.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/client.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/gateway_manager.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/push_to_talk.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/session.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/session_controller.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/talk_ui.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/wire_encode.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/runtime_python.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/AGENT.md +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/Dockerfile +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/command_allowlist.yaml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/dockerignore +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/image-generator/SKILL.md +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/image-generator/generate_image.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/loop/SKILL.md +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/mcp.json +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/opensandbox.docker.toml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/permissions.yaml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/session_controller.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/terminal_markdown.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_continue.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_e2e.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_errors.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_file_index.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_grounding.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_renderer_parity.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_session.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_status_bar.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_theme.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_tool_display.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_tui.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_cli.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_config_resolve.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_doctor.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_gateway_health.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_opensandbox_lifecycle.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_realtime_session_controller.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_runtime_python.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_terminal_markdown.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/uv.lock +0 -0
|
@@ -111,7 +111,9 @@ uv sync
|
|
|
111
111
|
| `vertex-claude` | `GCP_PROJECT_ID` / `GOOGLE_CLOUD_PROJECT` / `ANTHROPIC_VERTEX_PROJECT_ID` (ADC) | `monkeybot[vertex-claude]` |
|
|
112
112
|
| `aws_bedrock` | `AWS_ACCESS_KEY_ID` / `AWS_PROFILE` + `AWS_REGION` | `monkeybot[bedrock]` |
|
|
113
113
|
| `huggingface` | `HF_TOKEN` (or `HUGGINGFACE_API_KEY`) | `monkeybot[huggingface]` |
|
|
114
|
-
| `ollama` |
|
|
114
|
+
| `ollama-cloud` | `OLLAMA_API_KEY` | `monkeybot[ollama]` |
|
|
115
|
+
| `ollama-local` | None required — `OLLAMA_BASE_URL` (default `http://localhost:11434`) | `monkeybot[ollama]` |
|
|
116
|
+
| `ollama` | Legacy auto-route (key + blank URL = cloud; explicit URL wins) | `monkeybot[ollama]` |
|
|
115
117
|
|
|
116
118
|
**Agent-first dependencies.** The CLI is thin — it does **not** install provider/storage extras globally. `monkeybot new` scaffolds a `pyproject.toml` with the selected provider (and any `--with` extras). Run plain `uv sync` in the agent directory. `monkeybot run` / `chat` spawn the gateway from that project's interpreter (`.venv/bin/python`, else `uv run python`), and `doctor` checks extras in that same interpreter. For a config-only tree (just `monkeybot_config/`, no `pyproject.toml`) the gateway uses the CLI's interpreter when it already has MonkeyBot 3.x (and MemPalace if memory is on). If memory is enabled and that interpreter cannot import MemPalace, `run` / `chat` provision a cached CLI-managed venv under `~/.cache/monkeybot/runtimes/` holding `monkeybot[memory]` pinned to the running core (never rewrites a `pyproject.toml`). `doctor` reuses that cache when it is already present. Otherwise extras must be installed in the CLI env (`uv tool install --with 'monkeybot[<extra>]' monkeybot-cli`).
|
|
117
119
|
|
|
@@ -45,7 +45,7 @@ Validate check ids: `paths.agent_md.exists`, `paths.skills_path.exists`, `paths.
|
|
|
45
45
|
| `max_turns` | `1000` | Hard cap on turns per run |
|
|
46
46
|
| `summarization_model` | (main model) | Cheaper model for history summarization (env `CONTEXT_SUMMARIZATION_MODEL`) |
|
|
47
47
|
|
|
48
|
-
Validate check ids: `model.provider.supported`, `model.name.present`. Supported YAML providers: `gemini`/`vertex`, `openai`, `anthropic`, `vertex-claude`, `huggingface`, `ollama`, `aws_bedrock`, `fake`.
|
|
48
|
+
Validate check ids: `model.provider.supported`, `model.name.present`. Supported YAML providers: `gemini`/`vertex`, `openai`, `anthropic`, `vertex-claude`, `huggingface`, `ollama-cloud`, `ollama-local`, `ollama`, `aws_bedrock`, `fake`.
|
|
49
49
|
|
|
50
50
|
## `gcp` / `anthropic_vertex` (non-secret identifiers)
|
|
51
51
|
|
|
@@ -5,54 +5,18 @@ Output is local-only — never sent to the agent.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
import os
|
|
9
|
-
import signal
|
|
10
8
|
import subprocess
|
|
11
9
|
import sys
|
|
12
10
|
import threading
|
|
13
11
|
from pathlib import Path
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
from monkeybot_cli.process_tree import IS_WINDOWS, kill_process_tree, popen_kwargs_for_platform
|
|
16
14
|
|
|
17
15
|
# Cap capture before TUI truncation so `!yes`-style floods cannot OOM the process.
|
|
18
16
|
_MAX_CAPTURE_CHARS = 100_000
|
|
19
17
|
_READ_CHUNK = 4_096
|
|
20
18
|
|
|
21
19
|
|
|
22
|
-
def _popen_kwargs_for_platform() -> dict[str, object]:
|
|
23
|
-
if _IS_WINDOWS:
|
|
24
|
-
# getattr fallback: this constant only exists on Windows builds of
|
|
25
|
-
# `subprocess`, so a plain attribute access breaks importing/testing
|
|
26
|
-
# this module on POSIX even though the branch never runs there.
|
|
27
|
-
return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
|
|
28
|
-
return {"start_new_session": True}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _kill_process_tree(pid: int) -> None:
|
|
32
|
-
"""Best-effort kill of the shell process group/tree.
|
|
33
|
-
|
|
34
|
-
Swallow lookup/permission races — leader exit can make killpg raise
|
|
35
|
-
``PermissionError`` even though descendants still need a signal.
|
|
36
|
-
"""
|
|
37
|
-
if _IS_WINDOWS:
|
|
38
|
-
try:
|
|
39
|
-
subprocess.run(
|
|
40
|
-
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
41
|
-
capture_output=True,
|
|
42
|
-
check=False,
|
|
43
|
-
)
|
|
44
|
-
except OSError:
|
|
45
|
-
pass
|
|
46
|
-
return
|
|
47
|
-
try:
|
|
48
|
-
os.killpg(pid, signal.SIGKILL)
|
|
49
|
-
except (ProcessLookupError, PermissionError, OSError):
|
|
50
|
-
try:
|
|
51
|
-
os.kill(pid, signal.SIGKILL)
|
|
52
|
-
except (ProcessLookupError, PermissionError, OSError):
|
|
53
|
-
pass
|
|
54
|
-
|
|
55
|
-
|
|
56
20
|
def _append_capped(chunks: list[str], total: int, data: str) -> tuple[int, bool]:
|
|
57
21
|
"""Append ``data`` up to the capture cap. Returns (new_total, hit_cap)."""
|
|
58
22
|
remain = _MAX_CAPTURE_CHARS - total
|
|
@@ -84,7 +48,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
|
|
|
84
48
|
stdout=subprocess.PIPE,
|
|
85
49
|
stderr=subprocess.STDOUT,
|
|
86
50
|
text=True,
|
|
87
|
-
**
|
|
51
|
+
**popen_kwargs_for_platform(),
|
|
88
52
|
)
|
|
89
53
|
assert proc.stdout is not None
|
|
90
54
|
|
|
@@ -104,7 +68,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
|
|
|
104
68
|
state["total"], hit = _append_capped(chunks, state["total"], chunk)
|
|
105
69
|
if hit:
|
|
106
70
|
state["capped"] = True
|
|
107
|
-
|
|
71
|
+
kill_process_tree(proc.pid)
|
|
108
72
|
break
|
|
109
73
|
except (ValueError, OSError):
|
|
110
74
|
# stdout closed from the main thread to unblock a stuck read.
|
|
@@ -119,12 +83,12 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
|
|
|
119
83
|
proc.wait(timeout=timeout)
|
|
120
84
|
except subprocess.TimeoutExpired:
|
|
121
85
|
timed_out = True
|
|
122
|
-
|
|
86
|
+
kill_process_tree(proc.pid)
|
|
123
87
|
|
|
124
88
|
# Shell may have exited while a descendant still holds the pipe —
|
|
125
89
|
# kill the tree and close stdout so the reader cannot block forever.
|
|
126
90
|
if reader.is_alive():
|
|
127
|
-
|
|
91
|
+
kill_process_tree(proc.pid)
|
|
128
92
|
stop_reader.set()
|
|
129
93
|
try:
|
|
130
94
|
proc.stdout.close()
|
|
@@ -139,7 +103,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
|
|
|
139
103
|
reader.join(timeout=0.1)
|
|
140
104
|
except Exception:
|
|
141
105
|
timed_out = True
|
|
142
|
-
|
|
106
|
+
kill_process_tree(proc.pid)
|
|
143
107
|
stop_reader.set()
|
|
144
108
|
try:
|
|
145
109
|
proc.stdout.close()
|
|
@@ -149,7 +113,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
|
|
|
149
113
|
|
|
150
114
|
try:
|
|
151
115
|
if proc.poll() is None:
|
|
152
|
-
|
|
116
|
+
kill_process_tree(proc.pid)
|
|
153
117
|
try:
|
|
154
118
|
proc.wait(timeout=1)
|
|
155
119
|
except Exception:
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""monkeybot run — launch the SSE gateway as a subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import FrameType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from monkeybot_cli.config_resolve import (
|
|
17
|
+
load_agent_dotenv,
|
|
18
|
+
load_config_doc,
|
|
19
|
+
resolve_agent_root,
|
|
20
|
+
resolve_config,
|
|
21
|
+
)
|
|
22
|
+
from monkeybot_cli.opensandbox_lifecycle import (
|
|
23
|
+
ensure_opensandbox_for_agent,
|
|
24
|
+
is_sandbox_enabled,
|
|
25
|
+
server_url_from_config,
|
|
26
|
+
)
|
|
27
|
+
from monkeybot_cli.process_tree import IS_WINDOWS, kill_process_tree, popen_kwargs_for_platform
|
|
28
|
+
from monkeybot_cli.runtime_python import gateway_argv, prepare_runtime_python
|
|
29
|
+
|
|
30
|
+
_SHUTDOWN_TIMEOUT_SECS = 5.0
|
|
31
|
+
_KILL_TIMEOUT_SECS = 1.0
|
|
32
|
+
_Handler = Callable[[int, FrameType | None], Any] | int | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cli_exit_status(rc: int | None, received: int | None) -> int:
|
|
36
|
+
"""Map a child ``wait()`` status to a CLI exit code.
|
|
37
|
+
|
|
38
|
+
``wait()`` returns a negative signal number when the child dies from a
|
|
39
|
+
signal; ``SystemExit(-15)`` becomes shell status 241. Unix convention is
|
|
40
|
+
``128 + signal`` (143 for SIGTERM, 130 for SIGINT).
|
|
41
|
+
|
|
42
|
+
When the shim received SIGINT, return 130 even if the child exited 0 —
|
|
43
|
+
that matches the conventional Ctrl-C exit status.
|
|
44
|
+
"""
|
|
45
|
+
if received == signal.SIGINT:
|
|
46
|
+
return 130
|
|
47
|
+
if rc is None:
|
|
48
|
+
return 1
|
|
49
|
+
if rc < 0:
|
|
50
|
+
return 128 + (-rc)
|
|
51
|
+
return rc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _signal_process_group(proc: subprocess.Popen[bytes], signum: int) -> None:
|
|
55
|
+
if proc.poll() is not None:
|
|
56
|
+
return
|
|
57
|
+
if IS_WINDOWS:
|
|
58
|
+
try:
|
|
59
|
+
proc.send_signal(signum)
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
print(f"failed to signal gateway: {exc}", file=sys.stderr)
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
os.killpg(proc.pid, signum)
|
|
65
|
+
return
|
|
66
|
+
except ProcessLookupError:
|
|
67
|
+
return
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
try:
|
|
70
|
+
proc.send_signal(signum)
|
|
71
|
+
except OSError:
|
|
72
|
+
print(f"failed to signal gateway: {exc}", file=sys.stderr)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_gateway_process(
|
|
76
|
+
cmd: list[str],
|
|
77
|
+
*,
|
|
78
|
+
env: dict[str, str],
|
|
79
|
+
cwd: Path,
|
|
80
|
+
shutdown_timeout: float = _SHUTDOWN_TIMEOUT_SECS,
|
|
81
|
+
kill_timeout: float = _KILL_TIMEOUT_SECS,
|
|
82
|
+
) -> int:
|
|
83
|
+
"""Start the gateway and forward stop signals to its process group.
|
|
84
|
+
|
|
85
|
+
Electron sends SIGTERM only to this CLI shim. ``subprocess.run`` dies on
|
|
86
|
+
that signal and leaves uvicorn holding the port. On POSIX the child is
|
|
87
|
+
started in a new session so SIGTERM/SIGINT reach the whole tree (``uv run``
|
|
88
|
+
plus the gateway grandchild), then escalate to SIGKILL if it ignores the
|
|
89
|
+
signal for ``shutdown_timeout`` seconds.
|
|
90
|
+
|
|
91
|
+
``start_new_session`` also detaches the gateway from the terminal's
|
|
92
|
+
foreground process group (stdin reads would raise SIGTTIN). That is fine
|
|
93
|
+
for uvicorn today and required for ``killpg``.
|
|
94
|
+
|
|
95
|
+
On Windows, ``send_signal(SIGTERM)`` only terminates the immediate child;
|
|
96
|
+
grandchildren may still be orphaned.
|
|
97
|
+
|
|
98
|
+
Interactive Ctrl-C already hits the foreground process group; forwarding
|
|
99
|
+
SIGINT matters here because the child is in a new session and would
|
|
100
|
+
otherwise keep running.
|
|
101
|
+
"""
|
|
102
|
+
received: int | None = None
|
|
103
|
+
proc: subprocess.Popen[bytes] | None = None
|
|
104
|
+
signal_forwarded = False
|
|
105
|
+
deadline: float | None = None
|
|
106
|
+
|
|
107
|
+
def _forward(signum: int, _frame: object | None) -> None:
|
|
108
|
+
nonlocal received, signal_forwarded, deadline
|
|
109
|
+
if received is None:
|
|
110
|
+
received = signum
|
|
111
|
+
if proc is not None:
|
|
112
|
+
_signal_process_group(proc, signum)
|
|
113
|
+
signal_forwarded = True
|
|
114
|
+
if deadline is None:
|
|
115
|
+
deadline = time.monotonic() + shutdown_timeout
|
|
116
|
+
|
|
117
|
+
restored: dict[int, _Handler] = {}
|
|
118
|
+
forwarded = [signal.SIGINT, signal.SIGTERM]
|
|
119
|
+
if not IS_WINDOWS:
|
|
120
|
+
forwarded.append(signal.SIGHUP)
|
|
121
|
+
for sig in forwarded:
|
|
122
|
+
try:
|
|
123
|
+
restored[sig] = signal.signal(sig, _forward)
|
|
124
|
+
except (ValueError, OSError) as exc:
|
|
125
|
+
print(f"failed to install {sig.name} handler: {exc}", file=sys.stderr)
|
|
126
|
+
try:
|
|
127
|
+
proc = subprocess.Popen(
|
|
128
|
+
cmd,
|
|
129
|
+
env=env,
|
|
130
|
+
cwd=cwd,
|
|
131
|
+
**popen_kwargs_for_platform(),
|
|
132
|
+
)
|
|
133
|
+
if received is not None and not signal_forwarded:
|
|
134
|
+
_signal_process_group(proc, received)
|
|
135
|
+
if deadline is None:
|
|
136
|
+
deadline = time.monotonic() + shutdown_timeout
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
rc = proc.wait(timeout=0.25)
|
|
140
|
+
except subprocess.TimeoutExpired:
|
|
141
|
+
if deadline is None or time.monotonic() < deadline:
|
|
142
|
+
continue
|
|
143
|
+
print(
|
|
144
|
+
"gateway did not exit after signal; killing process tree",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
kill_process_tree(proc.pid)
|
|
148
|
+
try:
|
|
149
|
+
proc.wait(timeout=kill_timeout)
|
|
150
|
+
except subprocess.TimeoutExpired:
|
|
151
|
+
print("gateway still running after kill", file=sys.stderr)
|
|
152
|
+
rc = proc.returncode
|
|
153
|
+
return _cli_exit_status(rc, received)
|
|
154
|
+
finally:
|
|
155
|
+
for sig, handler in restored.items():
|
|
156
|
+
try:
|
|
157
|
+
signal.signal(sig, handler)
|
|
158
|
+
except (ValueError, OSError) as exc:
|
|
159
|
+
print(f"failed to restore {sig.name} handler: {exc}", file=sys.stderr)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_run(args: argparse.Namespace) -> int:
|
|
163
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
164
|
+
config_path = resolve_config(args.config, cwd=cwd)
|
|
165
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
166
|
+
env = os.environ.copy()
|
|
167
|
+
if config_path is not None:
|
|
168
|
+
env["MONKEYBOT_CONFIG"] = str(config_path)
|
|
169
|
+
if args.port:
|
|
170
|
+
env["PORT"] = str(args.port)
|
|
171
|
+
# --cwd intentionally pins the subprocess and runtime directory. Without
|
|
172
|
+
# it, an explicit config determines the agent root.
|
|
173
|
+
agent_root = resolve_agent_root(cwd=cwd, config_path=config_path)
|
|
174
|
+
if config_path is not None:
|
|
175
|
+
_, cfg_doc = load_config_doc(config_path)
|
|
176
|
+
if is_sandbox_enabled(cfg_doc):
|
|
177
|
+
if not ensure_opensandbox_for_agent(
|
|
178
|
+
agent_root,
|
|
179
|
+
server_url=server_url_from_config(cfg_doc),
|
|
180
|
+
# Fail fast: Mac app health-checks the gateway immediately.
|
|
181
|
+
docker_wait_secs=2.0,
|
|
182
|
+
):
|
|
183
|
+
print(
|
|
184
|
+
"Continuing without a healthy OpenSandbox — run_command may fail.",
|
|
185
|
+
flush=True,
|
|
186
|
+
)
|
|
187
|
+
runtime = prepare_runtime_python(agent_root, config_path)
|
|
188
|
+
cmd = gateway_argv(runtime)
|
|
189
|
+
return run_gateway_process(cmd, env=env, cwd=agent_root)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
193
|
+
p = subparsers.add_parser("run", help="Start the monkeybot SSE gateway")
|
|
194
|
+
p.add_argument("--config", help="Path to monkeybot.yaml (sets MONKEYBOT_CONFIG)")
|
|
195
|
+
p.add_argument("--port", type=int, help="Listen port (sets PORT)")
|
|
196
|
+
p.add_argument("--cwd", help="Working directory for the gateway process")
|
|
197
|
+
p.set_defaults(func=run_run)
|
|
@@ -24,7 +24,8 @@ PROVIDER_CHOICES: tuple[ExtraChoice, ...] = (
|
|
|
24
24
|
ExtraChoice("vertex-claude", "Claude on Vertex AI"),
|
|
25
25
|
ExtraChoice("aws_bedrock", "AWS Bedrock"),
|
|
26
26
|
ExtraChoice("huggingface", "Hugging Face"),
|
|
27
|
-
ExtraChoice("ollama", "Ollama
|
|
27
|
+
ExtraChoice("ollama-cloud", "Ollama Cloud"),
|
|
28
|
+
ExtraChoice("ollama-local", "Ollama (local)"),
|
|
28
29
|
ExtraChoice("nvidia", "NVIDIA NIM"),
|
|
29
30
|
ExtraChoice("openrouter", "OpenRouter"),
|
|
30
31
|
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared helpers for spawning and tearing down process groups/trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def popen_kwargs_for_platform() -> dict[str, object]:
|
|
14
|
+
if IS_WINDOWS:
|
|
15
|
+
# getattr fallback: this constant only exists on Windows builds of
|
|
16
|
+
# `subprocess`, so a plain attribute access breaks importing/testing
|
|
17
|
+
# this module on POSIX even though the branch never runs there.
|
|
18
|
+
return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
|
|
19
|
+
return {"start_new_session": True}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def kill_process_tree(pid: int) -> None:
|
|
23
|
+
"""Best-effort kill of the shell process group/tree.
|
|
24
|
+
|
|
25
|
+
Swallow lookup/permission races — leader exit can make killpg raise
|
|
26
|
+
``PermissionError`` even though descendants still need a signal.
|
|
27
|
+
"""
|
|
28
|
+
if IS_WINDOWS:
|
|
29
|
+
try:
|
|
30
|
+
subprocess.run(
|
|
31
|
+
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
check=False,
|
|
34
|
+
)
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
os.killpg(pid, signal.SIGKILL)
|
|
40
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
41
|
+
try:
|
|
42
|
+
os.kill(pid, signal.SIGKILL)
|
|
43
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
44
|
+
pass
|
|
@@ -59,6 +59,17 @@ PROVIDER_SPECS: dict[str, ProviderSpec] = {
|
|
|
59
59
|
(),
|
|
60
60
|
credentials_optional=True,
|
|
61
61
|
),
|
|
62
|
+
"ollama-cloud": ProviderSpec(
|
|
63
|
+
("ollama-cloud", "ollama_cloud"),
|
|
64
|
+
"ollama",
|
|
65
|
+
("OLLAMA_API_KEY",),
|
|
66
|
+
),
|
|
67
|
+
"ollama-local": ProviderSpec(
|
|
68
|
+
("ollama-local", "ollama_local"),
|
|
69
|
+
"ollama",
|
|
70
|
+
(),
|
|
71
|
+
credentials_optional=True,
|
|
72
|
+
),
|
|
62
73
|
"fake": ProviderSpec(("fake",), None, (), credentials_optional=True),
|
|
63
74
|
}
|
|
64
75
|
|
|
@@ -28,7 +28,10 @@ DB_URL=sqlite:///data/monkeybot.db
|
|
|
28
28
|
# Hugging Face
|
|
29
29
|
# HF_TOKEN=
|
|
30
30
|
|
|
31
|
-
# Ollama (
|
|
31
|
+
# Ollama Cloud (model.provider: ollama-cloud) — key from ollama.com/settings/keys
|
|
32
|
+
# OLLAMA_API_KEY=
|
|
33
|
+
|
|
34
|
+
# Ollama local (model.provider: ollama-local) — no API key needed
|
|
32
35
|
# OLLAMA_BASE_URL=http://localhost:11434
|
|
33
36
|
|
|
34
37
|
# Optional web search
|
|
@@ -87,7 +87,7 @@ paths:
|
|
|
87
87
|
workspace_root: ./workspace
|
|
88
88
|
|
|
89
89
|
model:
|
|
90
|
-
# gemini | openai | anthropic | vertex-claude | huggingface | ollama | nvidia | openrouter | aws_bedrock | fake
|
|
90
|
+
# gemini | openai | anthropic | vertex-claude | huggingface | ollama-cloud | ollama-local | ollama | nvidia | openrouter | aws_bedrock | fake
|
|
91
91
|
provider: gemini
|
|
92
92
|
name: gemini-3.1-flash-live-preview
|
|
93
93
|
temperature: 0.7
|
|
@@ -214,6 +214,18 @@ todo_list:
|
|
|
214
214
|
enabled: true
|
|
215
215
|
mirror_to_disk: true
|
|
216
216
|
|
|
217
|
+
# Desktop-only, macOS-only local computer control (open/reveal files & folders,
|
|
218
|
+
# launch apps, read/write the clipboard, browse/move/trash files) via
|
|
219
|
+
# computer_* tools. Off by default — server/Cloud Run deployments should never
|
|
220
|
+
# set this. The Monkeybot desktop app sets MONKEYBOT_COMPUTER_TOOLS when it
|
|
221
|
+
# spawns a gateway with this feature turned on in its settings; every computer_*
|
|
222
|
+
# call still asks for approval by default (a built-in permissions.yaml baseline,
|
|
223
|
+
# not configurable here) and "Always allow" is scoped to the exact action, not
|
|
224
|
+
# the whole tool. See monkeybot_config/permissions.yaml and
|
|
225
|
+
# monkeybot_config/approvals.json (machine-written, do not hand-edit).
|
|
226
|
+
computer:
|
|
227
|
+
enabled: false
|
|
228
|
+
|
|
217
229
|
sandbox:
|
|
218
230
|
# Opt-in: requires Docker Desktop (OpenSandbox). Leave false for normal Mac
|
|
219
231
|
# installs so the gateway starts without Docker. When enabled, ``monkeybot run``
|
|
@@ -8,6 +8,7 @@ import time
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
import monkeybot_cli.chat_local_shell as chat_local_shell
|
|
11
|
+
import monkeybot_cli.process_tree as process_tree
|
|
11
12
|
from monkeybot_cli.chat_local_shell import run_local_shell, truncate_output
|
|
12
13
|
|
|
13
14
|
|
|
@@ -30,7 +31,7 @@ def test_run_local_shell_uses_cwd(tmp_path: Path) -> None:
|
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
def test_kill_process_tree_swallows_permission_error(monkeypatch) -> None:
|
|
33
|
-
monkeypatch.setattr(
|
|
34
|
+
monkeypatch.setattr(process_tree, "IS_WINDOWS", False)
|
|
34
35
|
|
|
35
36
|
def _boom_killpg(pid: int, sig: int) -> None:
|
|
36
37
|
raise PermissionError("race")
|
|
@@ -38,9 +39,9 @@ def test_kill_process_tree_swallows_permission_error(monkeypatch) -> None:
|
|
|
38
39
|
def _boom_kill(pid: int, sig: int) -> None:
|
|
39
40
|
raise ProcessLookupError()
|
|
40
41
|
|
|
41
|
-
monkeypatch.setattr(
|
|
42
|
-
monkeypatch.setattr(
|
|
43
|
-
|
|
42
|
+
monkeypatch.setattr(process_tree.os, "killpg", _boom_killpg)
|
|
43
|
+
monkeypatch.setattr(process_tree.os, "kill", _boom_kill)
|
|
44
|
+
process_tree.kill_process_tree(4321) # must not raise
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def test_run_local_shell_timeout(tmp_path: Path) -> None:
|
|
@@ -104,22 +105,22 @@ def test_run_local_shell_timeout_kills_grandchildren(tmp_path: Path) -> None:
|
|
|
104
105
|
|
|
105
106
|
|
|
106
107
|
def test_popen_kwargs_use_process_group_on_posix(monkeypatch) -> None:
|
|
107
|
-
monkeypatch.setattr(
|
|
108
|
-
assert
|
|
108
|
+
monkeypatch.setattr(process_tree, "IS_WINDOWS", False)
|
|
109
|
+
assert process_tree.popen_kwargs_for_platform() == {"start_new_session": True}
|
|
109
110
|
|
|
110
111
|
|
|
111
112
|
def test_popen_kwargs_use_new_process_group_on_windows(monkeypatch) -> None:
|
|
112
|
-
monkeypatch.setattr(
|
|
113
|
-
kwargs =
|
|
113
|
+
monkeypatch.setattr(process_tree, "IS_WINDOWS", True)
|
|
114
|
+
kwargs = process_tree.popen_kwargs_for_platform()
|
|
114
115
|
assert set(kwargs) == {"creationflags"}
|
|
115
116
|
|
|
116
117
|
|
|
117
118
|
def test_kill_process_tree_uses_killpg_on_posix(monkeypatch) -> None:
|
|
118
|
-
monkeypatch.setattr(
|
|
119
|
+
monkeypatch.setattr(process_tree, "IS_WINDOWS", False)
|
|
119
120
|
calls: list[tuple[int, int]] = []
|
|
120
|
-
monkeypatch.setattr(
|
|
121
|
-
|
|
122
|
-
assert calls == [(4321,
|
|
121
|
+
monkeypatch.setattr(process_tree.os, "killpg", lambda pid, sig: calls.append((pid, sig)))
|
|
122
|
+
process_tree.kill_process_tree(4321)
|
|
123
|
+
assert calls == [(4321, process_tree.signal.SIGKILL)]
|
|
123
124
|
|
|
124
125
|
|
|
125
126
|
def test_kill_process_tree_uses_taskkill_on_windows(monkeypatch) -> None:
|
|
@@ -128,14 +129,14 @@ def test_kill_process_tree_uses_taskkill_on_windows(monkeypatch) -> None:
|
|
|
128
129
|
os.killpg doesn't exist on Windows, so the POSIX-only kill path would
|
|
129
130
|
crash a timed-out `!` command instead of finishing the tool block there.
|
|
130
131
|
"""
|
|
131
|
-
monkeypatch.setattr(
|
|
132
|
+
monkeypatch.setattr(process_tree, "IS_WINDOWS", True)
|
|
132
133
|
calls: list[list[str]] = []
|
|
133
134
|
monkeypatch.setattr(
|
|
134
|
-
|
|
135
|
+
process_tree.subprocess,
|
|
135
136
|
"run",
|
|
136
137
|
lambda argv, **kwargs: calls.append(list(argv)),
|
|
137
138
|
)
|
|
138
|
-
|
|
139
|
+
process_tree.kill_process_tree(4321)
|
|
139
140
|
assert calls == [["taskkill", "/F", "/T", "/PID", "4321"]]
|
|
140
141
|
|
|
141
142
|
|
|
@@ -21,6 +21,8 @@ def test_normalize_extra_token_features_and_providers() -> None:
|
|
|
21
21
|
assert normalize_extra_token("anthropic") == "claude"
|
|
22
22
|
assert normalize_extra_token("aws_bedrock") == "bedrock"
|
|
23
23
|
assert normalize_extra_token("claude") == "claude"
|
|
24
|
+
assert normalize_extra_token("ollama-cloud") == "ollama"
|
|
25
|
+
assert normalize_extra_token("ollama-local") == "ollama"
|
|
24
26
|
assert normalize_extra_token("fake") is None
|
|
25
27
|
assert normalize_extra_token("not-a-real-extra") is None
|
|
26
28
|
|
|
@@ -42,6 +44,9 @@ def test_extra_module_covers_catalog_extras() -> None:
|
|
|
42
44
|
def test_provider_extra_name() -> None:
|
|
43
45
|
assert provider_extra_name("openai") == "openai"
|
|
44
46
|
assert provider_extra_name("anthropic") == "claude"
|
|
47
|
+
assert provider_extra_name("ollama-cloud") == "ollama"
|
|
48
|
+
assert provider_extra_name("ollama-local") == "ollama"
|
|
49
|
+
assert provider_extra_name("ollama") == "ollama"
|
|
45
50
|
assert provider_extra_name("fake") is None
|
|
46
51
|
|
|
47
52
|
|