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.
Files changed (90) hide show
  1. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/PKG-INFO +3 -3
  2. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/pyproject.toml +3 -3
  3. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/skills/monkeybot/SKILL.md +5 -3
  4. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/skills/monkeybot/references/config-sections.md +9 -21
  5. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_local_shell.py +7 -43
  6. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_session.py +3 -3
  7. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_tui.py +5 -5
  8. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/chat.py +2 -3
  9. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/doctor.py +153 -0
  10. monkeybot_cli-0.6.0/src/monkeybot_cli/commands/run_cmd.py +197 -0
  11. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/validate.py +2 -13
  12. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/extras_catalog.py +2 -1
  13. monkeybot_cli-0.6.0/src/monkeybot_cli/process_tree.py +44 -0
  14. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/providers.py +11 -0
  15. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/talk_ui.py +0 -1
  16. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold.py +1 -0
  17. monkeybot_cli-0.6.0/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +41 -0
  18. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/env.example +4 -1
  19. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +27 -9
  20. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_e2e.py +5 -1
  21. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_local_shell.py +16 -15
  22. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_session.py +2 -2
  23. monkeybot_cli-0.6.0/tests/test_doctor.py +217 -0
  24. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_extras_catalog.py +5 -0
  25. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_gateway_health.py +2 -2
  26. monkeybot_cli-0.6.0/tests/test_run_cmd.py +277 -0
  27. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_scaffold.py +1 -0
  28. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/uv.lock +11 -3
  29. monkeybot_cli-0.5.0/src/monkeybot_cli/commands/run_cmd.py +0 -63
  30. monkeybot_cli-0.5.0/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +0 -16
  31. monkeybot_cli-0.5.0/tests/test_doctor.py +0 -85
  32. monkeybot_cli-0.5.0/tests/test_run_cmd.py +0 -90
  33. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/.gitignore +0 -0
  34. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/__init__.py +0 -0
  35. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_file_index.py +0 -0
  36. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_renderer.py +0 -0
  37. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_status_bar.py +0 -0
  38. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_theme.py +0 -0
  39. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_tool_display.py +0 -0
  40. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/chat_tui_widgets.py +0 -0
  41. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/__init__.py +0 -0
  42. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/loop.py +0 -0
  43. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/new.py +0 -0
  44. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/refresh.py +0 -0
  45. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/commands/talk.py +0 -0
  46. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/compat.py +0 -0
  47. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/config_resolve.py +0 -0
  48. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/exit_commands.py +0 -0
  49. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/gateway_health.py +0 -0
  50. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/main.py +0 -0
  51. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/opensandbox_lifecycle.py +0 -0
  52. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/output.py +0 -0
  53. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/__init__.py +0 -0
  54. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/audio_io.py +0 -0
  55. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/client.py +0 -0
  56. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/gateway_manager.py +0 -0
  57. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/push_to_talk.py +0 -0
  58. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/session.py +0 -0
  59. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/session_controller.py +0 -0
  60. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/realtime/wire_encode.py +0 -0
  61. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/runtime_python.py +0 -0
  62. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/AGENT.md +0 -0
  63. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/Dockerfile +0 -0
  64. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/__init__.py +0 -0
  65. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/command_allowlist.yaml +0 -0
  66. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/dockerignore +0 -0
  67. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/image-generator/SKILL.md +0 -0
  68. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/image-generator/generate_image.py +0 -0
  69. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/loop/SKILL.md +0 -0
  70. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/mcp.json +0 -0
  71. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/opensandbox.docker.toml +0 -0
  72. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +0 -0
  73. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/scaffold_defaults/permissions.yaml +0 -0
  74. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/session_controller.py +0 -0
  75. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/src/monkeybot_cli/terminal_markdown.py +0 -0
  76. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_continue.py +0 -0
  77. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_errors.py +0 -0
  78. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_file_index.py +0 -0
  79. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_grounding.py +0 -0
  80. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_renderer_parity.py +0 -0
  81. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_status_bar.py +0 -0
  82. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_theme.py +0 -0
  83. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_tool_display.py +0 -0
  84. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_chat_tui.py +0 -0
  85. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_cli.py +0 -0
  86. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_config_resolve.py +0 -0
  87. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_opensandbox_lifecycle.py +0 -0
  88. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_realtime_session_controller.py +0 -0
  89. {monkeybot_cli-0.5.0 → monkeybot_cli-0.6.0}/tests/test_runtime_python.py +0 -0
  90. {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.5.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.2.0
8
- Requires-Dist: monkeybot[cli]<4,>=3.0.0
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.5.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.0.0,<4",
14
- "monkeybot-browser-mcp>=0.2.0,<1",
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`. 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`).
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` | None required — `OLLAMA_BASE_URL` (default `http://localhost:11434`) for a non-default server | `monkeybot[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.*`, `context_curation.*` |
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 (env `CONTEXT_SUMMARIZATION_MODEL`) |
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 `MODEL_PROVIDER=fake` scripted runs (env `MONKEYBOT_FAKE_PROVIDER_EVENTS`). Not for production.
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
- _IS_WINDOWS = sys.platform == "win32"
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
- **_popen_kwargs_for_platform(),
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
- _kill_process_tree(proc.pid)
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
- _kill_process_tree(proc.pid)
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
- _kill_process_tree(proc.pid)
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
- _kill_process_tree(proc.pid)
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
- _kill_process_tree(proc.pid)
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.transcript_report_dir: str | None = None
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("transcript_report_dir") if isinstance(data, dict) else None
927
+ report = data.get("transcript_dir") if isinstance(data, dict) else None
928
928
  if isinstance(report, str) and report.strip():
929
- self.transcript_report_dir = report.strip()
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 — enable transcript capture on the gateway and restart "
1684
- "(external gateways need MONKEYBOT_TRANSCRIPT_ENABLED=1)",
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, "transcript_report_dir", None)
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 report → {report_dir}")
1835
- print(f"Transcript report → {report_dir}", flush=True)
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.transcript_report_dir:
629
+ if controller.transcript_dir:
630
630
  print(
631
- f"{_DIM}Transcript report → {controller.transcript_report_dir}{_RESET}",
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)