monkeybot-cli 0.5.0__tar.gz → 0.6.0__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.6.0}/PKG-INFO +3 -3
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/pyproject.toml +3 -3
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/skills/monkeybot/SKILL.md +5 -3
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/skills/monkeybot/references/config-sections.md +9 -21
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_local_shell.py +7 -43
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_session.py +3 -3
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_tui.py +5 -5
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/chat.py +2 -3
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/doctor.py +153 -0
- monkeybot_cli-0.6.0/src/monkeybot_cli/commands/run_cmd.py +197 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/validate.py +2 -13
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/extras_catalog.py +2 -1
- monkeybot_cli-0.6.0/src/monkeybot_cli/process_tree.py +44 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/providers.py +11 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/talk_ui.py +0 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold.py +1 -0
- monkeybot_cli-0.6.0/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +41 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/env.example +4 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +27 -9
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_e2e.py +5 -1
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_local_shell.py +16 -15
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_session.py +2 -2
- monkeybot_cli-0.6.0/tests/test_doctor.py +217 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_extras_catalog.py +5 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_gateway_health.py +2 -2
- monkeybot_cli-0.6.0/tests/test_run_cmd.py +277 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_scaffold.py +1 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/uv.lock +11 -3
- monkeybot_cli-0.5.0/src/monkeybot_cli/commands/run_cmd.py +0 -63
- monkeybot_cli-0.5.0/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +0 -16
- monkeybot_cli-0.5.0/tests/test_doctor.py +0 -85
- monkeybot_cli-0.5.0/tests/test_run_cmd.py +0 -90
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/.gitignore +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_file_index.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_renderer.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_status_bar.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_theme.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_tool_display.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_tui_widgets.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/loop.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/new.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/refresh.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/talk.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/compat.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/config_resolve.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/exit_commands.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/gateway_health.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/main.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/opensandbox_lifecycle.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/output.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/audio_io.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/client.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/gateway_manager.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/push_to_talk.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/session.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/session_controller.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/wire_encode.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/runtime_python.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/AGENT.md +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/Dockerfile +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/__init__.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/command_allowlist.yaml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/dockerignore +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/image-generator/SKILL.md +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/image-generator/generate_image.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/loop/SKILL.md +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/mcp.json +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/opensandbox.docker.toml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/permissions.yaml +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/session_controller.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/terminal_markdown.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_continue.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_errors.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_file_index.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_grounding.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_renderer_parity.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_status_bar.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_theme.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_tool_display.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_tui.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_cli.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_config_resolve.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_opensandbox_lifecycle.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_realtime_session_controller.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_runtime_python.py +0 -0
- {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_terminal_markdown.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: monkeybot-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: CLI to create, configure, validate, and chat with monkeybot agents.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: httpx>=0.27.0
|
|
7
|
-
Requires-Dist: monkeybot-browser-mcp<1,>=0.
|
|
8
|
-
Requires-Dist: monkeybot[cli]<4,>=3.
|
|
7
|
+
Requires-Dist: monkeybot-browser-mcp<1,>=0.5.0
|
|
8
|
+
Requires-Dist: monkeybot[cli]<4,>=3.1.0
|
|
9
9
|
Requires-Dist: python-dotenv>=1.0.0
|
|
10
10
|
Requires-Dist: pyyaml>=6.0.2
|
|
11
11
|
Requires-Dist: textual>=8.2.8
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "monkeybot-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "CLI to create, configure, validate, and chat with monkeybot agents."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [
|
|
11
11
|
# `cli` pulls realtime + typer so `monkeybot talk` works out of the box (text mode).
|
|
12
12
|
# Audio mic/PTT needs the agent (or this env) to also install monkeybot[cli-realtime].
|
|
13
|
-
"monkeybot[cli]>=3.
|
|
14
|
-
"monkeybot-browser-mcp>=0.
|
|
13
|
+
"monkeybot[cli]>=3.1.0,<4",
|
|
14
|
+
"monkeybot-browser-mcp>=0.5.0,<1",
|
|
15
15
|
"httpx>=0.27.0",
|
|
16
16
|
"pyyaml>=6.0.2",
|
|
17
17
|
"python-dotenv>=1.0.0",
|
|
@@ -56,7 +56,7 @@ npx skills add human-plus-machine/monkeybot --skill monkeybot
|
|
|
56
56
|
- `monkeybot_config/monkeybot.yaml` — non-secret settings (model, paths, gateway, behavior).
|
|
57
57
|
- `.env` — secrets and machine-local paths (API keys, GCP project, DB URL).
|
|
58
58
|
- sidecars: `monkeybot_config/AGENT.md` (system prompt), `mcp.json` (MCP servers), `command_allowlist.yaml`.
|
|
59
|
-
- **Precedence — important:** environment variables and `.env` win over `monkeybot.yaml
|
|
59
|
+
- **Precedence — important:** environment variables and `.env` win over `monkeybot.yaml` except `model.*` (provider, name, sampling, context window, summarization model, max turns, cache retention), which is YAML-only. If a user edits YAML but nothing changes, suspect a stale `.env` shadowing it (the YAML→env mapping lives in `runtime_env.py:ENV_MAP`).
|
|
60
60
|
- **Defaults are fine on day one.** Most first-time users only touch `model`, `.env` credentials, and `AGENT.md`.
|
|
61
61
|
|
|
62
62
|
## Running CLI commands
|
|
@@ -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`). Requests send `keep_alive` 24h so prefix KV cache survives idle. | `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
|
|
|
@@ -158,7 +160,7 @@ Decision → config map:
|
|
|
158
160
|
| "Run untrusted code" | `sandbox.enabled` + `SANDBOX_API_KEY` |
|
|
159
161
|
| "Use specialist agents" | `subagents.personas` + `monkeybot_config/agents/*.md` |
|
|
160
162
|
| "Connect external tools" | `mcp.json` (`mcpServers` object), then `validate --check-mcp` |
|
|
161
|
-
| "Control cost / context size" | `model
|
|
163
|
+
| "Control cost / context size" | `model.*` |
|
|
162
164
|
| "Restrict dangerous commands" | `tools.denied_patterns`, `command_allowlist.yaml` |
|
|
163
165
|
| "Multiple environments" | top-level `includes:` fragments |
|
|
164
166
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Deep reference for every `monkeybot.yaml` section. Load this only when a user needs to customize beyond Tier 1. The canonical, fully-commented template lives at `cli/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml` in the monkeybot repo (copied to `monkeybot_config/monkeybot.example.yaml` when you scaffold); this file adds the **"when would I change this?"** context the comments don't.
|
|
4
4
|
|
|
5
|
-
**Precedence:** env vars and `.env` win over YAML. The YAML→env mapping is `ENV_MAP` in `src/monkeybot/core/config/runtime_env.py`. If a YAML edit has no effect, check for a shadowing env var.
|
|
5
|
+
**Precedence:** env vars and `.env` win over YAML for `ENV_MAP` keys. `model.*` (provider, name, temperature, max_tokens, thinking_budget, context_window, summarization_model, max_turns, cache_retention) is YAML-only — leftover `MODEL_*` env vars are ignored. The YAML→env mapping is `ENV_MAP` in `src/monkeybot/core/config/runtime_env.py`. If a YAML edit has no effect, check for a shadowing env var (except `model.*`).
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -34,6 +34,8 @@ Validate check ids: `paths.agent_md.exists`, `paths.skills_path.exists`, `paths.
|
|
|
34
34
|
|
|
35
35
|
## `model`
|
|
36
36
|
|
|
37
|
+
YAML-only for provider, name, sampling, context window, summarization model, max turns, and cache retention. Leftover `MODEL_*` / `MAX_TURNS` / `CONTEXT_SUMMARIZATION_MODEL` env vars are ignored.
|
|
38
|
+
|
|
37
39
|
| Field | Default | When to change |
|
|
38
40
|
|---|---|---|
|
|
39
41
|
| `provider` | `gemini` | Switch LLM vendor (see provider table in SKILL.md) |
|
|
@@ -41,11 +43,13 @@ Validate check ids: `paths.agent_md.exists`, `paths.skills_path.exists`, `paths.
|
|
|
41
43
|
| `temperature` | `0.7` | Lower for deterministic output, higher for creative |
|
|
42
44
|
| `max_tokens` | `60000` | Cap per-response length |
|
|
43
45
|
| `thinking_budget` | `-1` | Gemini: `-1` model default, `0` off, `N` token budget. Ollama reasoning models: `-1` server default, `0` off (`reasoning_effort: none`) |
|
|
44
|
-
| `context_window` | `1000000` | Summarization trigger (tokens); also drives soft-spill / `read_file` char budgets |
|
|
46
|
+
| `context_window` | `1000000` | Summarization trigger (tokens); also drives soft-spill / `read_file` char budgets. **Not** Ollama `num_ctx` |
|
|
47
|
+
| `keep_alive` | `24h` (ollama-local only) | How long local Ollama keeps the model (and KV prefix cache) loaded. YAML only. Set `"0"` to omit. See `docs/ollama-local.md` |
|
|
48
|
+
| `num_ctx` | (unset) | Optional pinned Ollama `num_ctx`. YAML only. Omit = server default. Do not copy `context_window` |
|
|
45
49
|
| `max_turns` | `1000` | Hard cap on turns per run |
|
|
46
|
-
| `summarization_model` | (main model) | Cheaper model for history summarization (
|
|
50
|
+
| `summarization_model` | (main model) | Cheaper model for history summarization (YAML-only)
|
|
47
51
|
|
|
48
|
-
Validate check ids: `model.provider.supported`, `model.name.present`. Supported YAML providers: `gemini`/`vertex`, `openai`, `anthropic`, `vertex-claude`, `huggingface`, `ollama`, `aws_bedrock`, `fake`.
|
|
52
|
+
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
53
|
|
|
50
54
|
## `gcp` / `anthropic_vertex` (non-secret identifiers)
|
|
51
55
|
|
|
@@ -71,22 +75,6 @@ Required when `memory_storage_uri` is `gcs://…` or `provider: vertex-claude` (
|
|
|
71
75
|
| `graceful_shutdown_timeout_sec` | `5` | Allow longer drain on shutdown |
|
|
72
76
|
| `cors_allow_origins` | `http://localhost:5173` | **Custom web UI** — set its origin, or `"*"` for any |
|
|
73
77
|
|
|
74
|
-
## `context_curation`
|
|
75
|
-
|
|
76
|
-
Trims memory injected into context. `enabled: true` by default.
|
|
77
|
-
|
|
78
|
-
Recent window by default; LLM curator only when the index is token-heavy. On curator failure, falls back to the window.
|
|
79
|
-
|
|
80
|
-
| Field | Default | Notes |
|
|
81
|
-
|---|---|---|
|
|
82
|
-
| `memory_window_lines` | `12` | Recent index lines injected; also caps curator-selected lines |
|
|
83
|
-
| `memory_index_cap` | `200` | Organizer keeps this many INDEX.md entries; older rows move to `INDEX.archive.md` |
|
|
84
|
-
| `memory_token_threshold` | `2000` | Call curator when estimated index tokens exceed this |
|
|
85
|
-
| `curator_model` | `gemini-3-flash` | Separate small model; empty = main model |
|
|
86
|
-
| `timeout_sec` | `10` | Curator call timeout |
|
|
87
|
-
|
|
88
|
-
When the prompt shows fewer entries than exist, a structural confidence score triggers a `search_memory` nudge. Skill names are always shown in full in the prompt; use `list_skills` to get the skills root path.
|
|
89
|
-
|
|
90
78
|
## `memory`
|
|
91
79
|
|
|
92
80
|
MemPalace capture, wake-up, and search teaching are on by default. Turn them off with `memory.enabled: false` or `MONKEYBOT_MEMORY_HOOK_ENABLED=0`. The vector stack is the optional `monkeybot[memory]` extra — omit it (and set `enabled: false`) when you do not want chromadb/onnxruntime.
|
|
@@ -176,4 +164,4 @@ Validate check: `config.includes.resolve`.
|
|
|
176
164
|
|
|
177
165
|
## `fake_provider`
|
|
178
166
|
|
|
179
|
-
Test-only. `events_json` feeds `
|
|
167
|
+
Test-only. `events_json` feeds `model.provider: fake` scripted runs (env `MONKEYBOT_FAKE_PROVIDER_EVENTS`). Not for production.
|
|
@@ -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:
|
|
@@ -229,7 +229,7 @@ class ChatSessionController:
|
|
|
229
229
|
self.usage = UsageStore()
|
|
230
230
|
self.session_id: str | None = None
|
|
231
231
|
self.stream_error = False
|
|
232
|
-
self.
|
|
232
|
+
self.transcript_dir: str | None = None
|
|
233
233
|
self._client: httpx.AsyncClient | None = None
|
|
234
234
|
self._event_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
235
235
|
self._stream_task: asyncio.Task[None] | None = None
|
|
@@ -924,9 +924,9 @@ class ChatSessionController:
|
|
|
924
924
|
resp = await self._client.delete(f"{self.base}/sessions/{self.session_id}")
|
|
925
925
|
if resp.status_code == 200:
|
|
926
926
|
data = resp.json()
|
|
927
|
-
report = data.get("
|
|
927
|
+
report = data.get("transcript_dir") if isinstance(data, dict) else None
|
|
928
928
|
if isinstance(report, str) and report.strip():
|
|
929
|
-
self.
|
|
929
|
+
self.transcript_dir = report.strip()
|
|
930
930
|
except Exception:
|
|
931
931
|
logger.warning(
|
|
932
932
|
"session DELETE on close failed session_id=%s",
|
|
@@ -1680,8 +1680,8 @@ class ChatApp(App[int]):
|
|
|
1680
1680
|
src = session_dir / "transcript.ndjson"
|
|
1681
1681
|
if not src.is_file():
|
|
1682
1682
|
self._mount_system(
|
|
1683
|
-
"No trace file found —
|
|
1684
|
-
"
|
|
1683
|
+
"No trace file found — set runtime.transcript_enabled: true in monkeybot.yaml "
|
|
1684
|
+
"and restart the gateway",
|
|
1685
1685
|
error=True,
|
|
1686
1686
|
)
|
|
1687
1687
|
return
|
|
@@ -1829,10 +1829,10 @@ class ChatApp(App[int]):
|
|
|
1829
1829
|
async def _close_session_and_exit(self) -> None:
|
|
1830
1830
|
# Must not be named `_shutdown` — that shadows Textual.App._shutdown.
|
|
1831
1831
|
await self._controller.close()
|
|
1832
|
-
report_dir = getattr(self._controller, "
|
|
1832
|
+
report_dir = getattr(self._controller, "transcript_dir", None)
|
|
1833
1833
|
if isinstance(report_dir, str) and report_dir:
|
|
1834
|
-
self._mount_system(f"Transcript
|
|
1835
|
-
print(f"Transcript
|
|
1834
|
+
self._mount_system(f"Transcript → {report_dir}")
|
|
1835
|
+
print(f"Transcript → {report_dir}", flush=True)
|
|
1836
1836
|
self._exit_code = 1 if self._controller.stream_error else self._exit_code
|
|
1837
1837
|
self.exit(self._exit_code)
|
|
1838
1838
|
|
|
@@ -626,9 +626,9 @@ async def _plain_chat_session(
|
|
|
626
626
|
await controller.submit(user_line)
|
|
627
627
|
finally:
|
|
628
628
|
await controller.close()
|
|
629
|
-
if controller.
|
|
629
|
+
if controller.transcript_dir:
|
|
630
630
|
print(
|
|
631
|
-
f"{_DIM}Transcript
|
|
631
|
+
f"{_DIM}Transcript → {controller.transcript_dir}{_RESET}",
|
|
632
632
|
flush=True,
|
|
633
633
|
)
|
|
634
634
|
await renderer.stop_io_worker()
|
|
@@ -678,7 +678,6 @@ def _spawn_gateway(config_path: Path | None, agent_root: Path, port: int) -> _Sp
|
|
|
678
678
|
env["MONKEYBOT_CONFIG"] = str(config_path)
|
|
679
679
|
env["PORT"] = str(port)
|
|
680
680
|
env.setdefault("LOG_LEVEL", "error")
|
|
681
|
-
env.setdefault("MONKEYBOT_TRANSCRIPT_ENABLED", "1")
|
|
682
681
|
log_file = tempfile.NamedTemporaryFile(
|
|
683
682
|
mode="w+",
|
|
684
683
|
prefix="monkeybot-gateway-",
|
|
@@ -192,6 +192,146 @@ def _add_layout_checks(report: CommandReport, layout: AgentLayout) -> None:
|
|
|
192
192
|
)
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
_OLLAMA_LOCAL_DOCS = "docs/ollama-local.md"
|
|
196
|
+
_OLLAMA_NUM_CTX_LARGE = 32_768
|
|
197
|
+
_OLLAMA_REASONING_TAGS = ("qwen3", "gemma4", "gemma-4", "deepseek-r1", "qwq", "magistral")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_local_ollama_provider(provider: str) -> bool:
|
|
201
|
+
"""True for ollama-local and legacy ``ollama`` (not ollama-cloud)."""
|
|
202
|
+
key = provider.strip().lower().replace("_", "-")
|
|
203
|
+
return key in {"ollama-local", "ollama"}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _looks_like_reasoning_model(model_name: str) -> bool:
|
|
207
|
+
name = model_name.lower()
|
|
208
|
+
return any(tag in name for tag in _OLLAMA_REASONING_TAGS)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_int(raw: object) -> int | None:
|
|
212
|
+
if raw is None or raw == "":
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
return int(raw)
|
|
216
|
+
except (TypeError, ValueError):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _strict_positive_int(raw: object) -> tuple[int | None, bool]:
|
|
221
|
+
"""Return ``(value, invalid)``. Unset is ``(None, False)``; garbage or ``<1`` is invalid."""
|
|
222
|
+
if raw is None or raw == "":
|
|
223
|
+
return None, False
|
|
224
|
+
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
225
|
+
return None, True
|
|
226
|
+
if raw < 1:
|
|
227
|
+
return None, True
|
|
228
|
+
return raw, False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _add_ollama_local_checks(
|
|
232
|
+
report: CommandReport,
|
|
233
|
+
*,
|
|
234
|
+
provider: str,
|
|
235
|
+
model_name: str,
|
|
236
|
+
thinking_budget: object,
|
|
237
|
+
num_ctx: object,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Warn on local-Ollama prefix-cache traps. Always emit the four check ids."""
|
|
240
|
+
local = _is_local_ollama_provider(provider)
|
|
241
|
+
mlx = "-mlx" in model_name.lower()
|
|
242
|
+
check(
|
|
243
|
+
report,
|
|
244
|
+
id="ollama.local.mlx_runner",
|
|
245
|
+
category="provider",
|
|
246
|
+
severity="warning",
|
|
247
|
+
passed=not mlx,
|
|
248
|
+
skip=not local,
|
|
249
|
+
message=(
|
|
250
|
+
f"model.name {model_name!r} looks like an MLX pack; prefix KV cache "
|
|
251
|
+
"often does not reuse between agent steps"
|
|
252
|
+
if mlx
|
|
253
|
+
else "model.name is not an MLX pack"
|
|
254
|
+
),
|
|
255
|
+
field="model.name",
|
|
256
|
+
value=model_name or None,
|
|
257
|
+
remediation=(
|
|
258
|
+
f"Prefer a GGUF tag for tool-calling loops. See {_OLLAMA_LOCAL_DOCS}." if mlx else None
|
|
259
|
+
),
|
|
260
|
+
docs=_OLLAMA_LOCAL_DOCS,
|
|
261
|
+
)
|
|
262
|
+
budget = _parse_int(thinking_budget)
|
|
263
|
+
if budget is None:
|
|
264
|
+
budget = -1
|
|
265
|
+
reasoning = _looks_like_reasoning_model(model_name)
|
|
266
|
+
thinking_on_default = budget == -1
|
|
267
|
+
check(
|
|
268
|
+
report,
|
|
269
|
+
id="ollama.local.thinking_default",
|
|
270
|
+
category="provider",
|
|
271
|
+
severity="warning",
|
|
272
|
+
passed=not thinking_on_default,
|
|
273
|
+
skip=not local or not reasoning,
|
|
274
|
+
message=(
|
|
275
|
+
"thinking_budget is -1 (server default); reasoning models generate "
|
|
276
|
+
"thinking tokens before the first visible reply"
|
|
277
|
+
if thinking_on_default
|
|
278
|
+
else f"thinking_budget is {budget}"
|
|
279
|
+
),
|
|
280
|
+
field="model.thinking_budget",
|
|
281
|
+
value=budget,
|
|
282
|
+
remediation=(
|
|
283
|
+
f"Set thinking_budget: 0 to send reasoning_effort: none. See {_OLLAMA_LOCAL_DOCS}."
|
|
284
|
+
if thinking_on_default
|
|
285
|
+
else None
|
|
286
|
+
),
|
|
287
|
+
docs=_OLLAMA_LOCAL_DOCS,
|
|
288
|
+
)
|
|
289
|
+
ctx, ctx_invalid = _strict_positive_int(num_ctx)
|
|
290
|
+
check(
|
|
291
|
+
report,
|
|
292
|
+
id="ollama.local.num_ctx_invalid",
|
|
293
|
+
category="provider",
|
|
294
|
+
severity="error",
|
|
295
|
+
passed=not ctx_invalid,
|
|
296
|
+
skip=not local or (ctx is None and not ctx_invalid),
|
|
297
|
+
message=(
|
|
298
|
+
f"num_ctx must be a positive integer, got {num_ctx!r}"
|
|
299
|
+
if ctx_invalid
|
|
300
|
+
else (f"num_ctx is {ctx}" if ctx is not None else "num_ctx is unset")
|
|
301
|
+
),
|
|
302
|
+
field="model.num_ctx",
|
|
303
|
+
value=num_ctx,
|
|
304
|
+
remediation=(
|
|
305
|
+
f"Set a positive integer num_ctx (e.g. 8192), or omit it. See {_OLLAMA_LOCAL_DOCS}."
|
|
306
|
+
if ctx_invalid
|
|
307
|
+
else None
|
|
308
|
+
),
|
|
309
|
+
docs=_OLLAMA_LOCAL_DOCS,
|
|
310
|
+
)
|
|
311
|
+
large = ctx is not None and ctx > _OLLAMA_NUM_CTX_LARGE
|
|
312
|
+
check(
|
|
313
|
+
report,
|
|
314
|
+
id="ollama.local.num_ctx_large",
|
|
315
|
+
category="provider",
|
|
316
|
+
severity="warning",
|
|
317
|
+
passed=not large,
|
|
318
|
+
skip=not local or ctx is None or ctx_invalid,
|
|
319
|
+
message=(
|
|
320
|
+
f"num_ctx {ctx} is large for a local runner; prefill will be slow"
|
|
321
|
+
if large
|
|
322
|
+
else (f"num_ctx is {ctx}" if ctx is not None else "num_ctx is unset")
|
|
323
|
+
),
|
|
324
|
+
field="model.num_ctx",
|
|
325
|
+
value=ctx,
|
|
326
|
+
remediation=(
|
|
327
|
+
f"Pin a modest num_ctx (e.g. 8192). Do not copy context_window. See {_OLLAMA_LOCAL_DOCS}."
|
|
328
|
+
if large
|
|
329
|
+
else None
|
|
330
|
+
),
|
|
331
|
+
docs=_OLLAMA_LOCAL_DOCS,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
195
335
|
def run_doctor(args: argparse.Namespace) -> int:
|
|
196
336
|
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
197
337
|
config_path = resolve_config(args.config, cwd=cwd)
|
|
@@ -324,6 +464,19 @@ def run_doctor(args: argparse.Namespace) -> int:
|
|
|
324
464
|
skip=True,
|
|
325
465
|
)
|
|
326
466
|
|
|
467
|
+
thinking_raw = model.get("thinking_budget") if isinstance(model, dict) else None
|
|
468
|
+
if thinking_raw is None:
|
|
469
|
+
thinking_raw = "-1"
|
|
470
|
+
num_ctx_raw = model.get("num_ctx") if isinstance(model, dict) else None
|
|
471
|
+
model_name = str(model.get("name", "")).strip() if isinstance(model, dict) else ""
|
|
472
|
+
_add_ollama_local_checks(
|
|
473
|
+
report,
|
|
474
|
+
provider=provider,
|
|
475
|
+
model_name=model_name,
|
|
476
|
+
thinking_budget=thinking_raw,
|
|
477
|
+
num_ctx=num_ctx_raw,
|
|
478
|
+
)
|
|
479
|
+
|
|
327
480
|
runtime_cfg = doc.get("runtime") if isinstance(doc.get("runtime"), dict) else {}
|
|
328
481
|
port = int(runtime_cfg.get("port", 8080)) if isinstance(runtime_cfg, dict) else 8080
|
|
329
482
|
free = _port_free(port)
|
|
@@ -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)
|