monkeybot-cli 0.2.1__py3-none-any.whl
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/__init__.py +3 -0
- monkeybot_cli/chat_renderer.py +87 -0
- monkeybot_cli/chat_session.py +911 -0
- monkeybot_cli/chat_status_bar.py +205 -0
- monkeybot_cli/chat_theme.py +91 -0
- monkeybot_cli/chat_tool_display.py +334 -0
- monkeybot_cli/chat_tui.py +1491 -0
- monkeybot_cli/chat_tui_widgets.py +996 -0
- monkeybot_cli/commands/__init__.py +1 -0
- monkeybot_cli/commands/chat.py +817 -0
- monkeybot_cli/commands/doctor.py +293 -0
- monkeybot_cli/commands/loop.py +207 -0
- monkeybot_cli/commands/new.py +207 -0
- monkeybot_cli/commands/run_cmd.py +41 -0
- monkeybot_cli/commands/talk.py +102 -0
- monkeybot_cli/commands/validate.py +385 -0
- monkeybot_cli/compat.py +7 -0
- monkeybot_cli/config_resolve.py +55 -0
- monkeybot_cli/exit_commands.py +13 -0
- monkeybot_cli/extras_catalog.py +95 -0
- monkeybot_cli/gateway_health.py +34 -0
- monkeybot_cli/main.py +38 -0
- monkeybot_cli/opensandbox_lifecycle.py +314 -0
- monkeybot_cli/output.py +110 -0
- monkeybot_cli/providers.py +112 -0
- monkeybot_cli/realtime/__init__.py +13 -0
- monkeybot_cli/realtime/audio_io.py +147 -0
- monkeybot_cli/realtime/client.py +17 -0
- monkeybot_cli/realtime/gateway_manager.py +142 -0
- monkeybot_cli/realtime/push_to_talk.py +128 -0
- monkeybot_cli/realtime/session.py +256 -0
- monkeybot_cli/realtime/session_controller.py +501 -0
- monkeybot_cli/realtime/talk_ui.py +243 -0
- monkeybot_cli/realtime/wire_encode.py +39 -0
- monkeybot_cli/runtime_python.py +91 -0
- monkeybot_cli/scaffold.py +287 -0
- monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
- monkeybot_cli/scaffold_defaults/__init__.py +1 -0
- monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
- monkeybot_cli/scaffold_defaults/env.example +35 -0
- monkeybot_cli/scaffold_defaults/mcp.json +49 -0
- monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
- monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
- monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
- monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
- monkeybot_cli/session_controller.py +7 -0
- monkeybot_cli/terminal_markdown.py +48 -0
- monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
- monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
- monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
- monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""monkeybot doctor — environment readiness checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from monkeybot_cli.config_resolve import (
|
|
16
|
+
load_agent_dotenv,
|
|
17
|
+
load_config_doc,
|
|
18
|
+
resolve_agent_root,
|
|
19
|
+
resolve_config,
|
|
20
|
+
)
|
|
21
|
+
from monkeybot_cli.output import CommandReport, check
|
|
22
|
+
from monkeybot_cli.providers import credentials_present, extra_module, spec_for_provider
|
|
23
|
+
from monkeybot_cli.runtime_python import resolve_runtime_python, run_probe
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _port_free(port: int) -> bool:
|
|
27
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
28
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
29
|
+
try:
|
|
30
|
+
s.bind(("127.0.0.1", port))
|
|
31
|
+
return True
|
|
32
|
+
except OSError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _runtime_python_version(runtime, agent_root: Path) -> tuple[int, int, int]:
|
|
37
|
+
"""Ask the runtime interpreter for its version (major, minor, micro)."""
|
|
38
|
+
code = "import sys; print(sys.version_info[0], sys.version_info[1], sys.version_info[2])"
|
|
39
|
+
kwargs: dict[str, object] = {}
|
|
40
|
+
if runtime.source == "uv":
|
|
41
|
+
kwargs["cwd"] = str(agent_root)
|
|
42
|
+
proc = subprocess.run(
|
|
43
|
+
[*runtime.argv, "-c", code],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
timeout=15.0,
|
|
47
|
+
**kwargs,
|
|
48
|
+
)
|
|
49
|
+
if proc.returncode != 0:
|
|
50
|
+
return (0, 0, 0)
|
|
51
|
+
parts = proc.stdout.strip().split()
|
|
52
|
+
if len(parts) < 3:
|
|
53
|
+
return (0, 0, 0)
|
|
54
|
+
try:
|
|
55
|
+
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
|
56
|
+
except ValueError:
|
|
57
|
+
return (0, 0, 0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _agent_defines_project_extra(agent_root: Path, extra: str) -> bool:
|
|
61
|
+
"""True when the agent ``pyproject.toml`` declares ``extra`` as a project optional."""
|
|
62
|
+
path = agent_root / "pyproject.toml"
|
|
63
|
+
if not path.is_file():
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
68
|
+
return False
|
|
69
|
+
project = data.get("project")
|
|
70
|
+
if not isinstance(project, dict):
|
|
71
|
+
return False
|
|
72
|
+
optional = project.get("optional-dependencies")
|
|
73
|
+
return isinstance(optional, dict) and extra in optional
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extra_remediation(extra: str, agent_root: Path, runtime) -> str:
|
|
77
|
+
"""Remediation text pointing at the agent project, not the CLI env."""
|
|
78
|
+
if runtime.source == "cli":
|
|
79
|
+
return (
|
|
80
|
+
"Config-only tree: install in the CLI env — "
|
|
81
|
+
f"uv tool install --with 'monkeybot[{extra}]' monkeybot-cli"
|
|
82
|
+
)
|
|
83
|
+
if _agent_defines_project_extra(agent_root, extra):
|
|
84
|
+
return f"Install in the agent project: cd {agent_root} && uv sync --extra {extra}"
|
|
85
|
+
return (
|
|
86
|
+
f"Add monkeybot[{extra}] to {agent_root}/pyproject.toml dependencies, "
|
|
87
|
+
f"then run: cd {agent_root} && uv sync"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_doctor(args: argparse.Namespace) -> int:
|
|
92
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
93
|
+
config_path = resolve_config(args.config, cwd=cwd)
|
|
94
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
95
|
+
report = CommandReport(command="doctor", ok=True, config_path=None)
|
|
96
|
+
if config_path:
|
|
97
|
+
report.config_path = str(config_path.resolve())
|
|
98
|
+
|
|
99
|
+
agent_root = resolve_agent_root(cwd=cwd, config_path=config_path)
|
|
100
|
+
runtime = resolve_runtime_python(agent_root)
|
|
101
|
+
|
|
102
|
+
py_version = _runtime_python_version(runtime, agent_root)
|
|
103
|
+
py_ok = py_version >= (3, 11)
|
|
104
|
+
check(
|
|
105
|
+
report,
|
|
106
|
+
id="env.python.version",
|
|
107
|
+
category="env",
|
|
108
|
+
severity="error",
|
|
109
|
+
passed=py_ok,
|
|
110
|
+
message=f"Python {py_version[0]}.{py_version[1]} ({runtime.source})",
|
|
111
|
+
value=f"{py_version[0]}.{py_version[1]}.{py_version[2]}",
|
|
112
|
+
remediation=None if py_ok else "Install Python 3.11+ in the agent project environment",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
_, doc = load_config_doc(str(config_path) if config_path else None)
|
|
116
|
+
model = doc.get("model") if isinstance(doc.get("model"), dict) else {}
|
|
117
|
+
provider = str(model.get("provider", "gemini")) if isinstance(model, dict) else "gemini"
|
|
118
|
+
spec = spec_for_provider(provider)
|
|
119
|
+
|
|
120
|
+
if spec and spec.extra:
|
|
121
|
+
installed = run_probe(
|
|
122
|
+
runtime,
|
|
123
|
+
f"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec({extra_module(spec.extra)!r}) else 1)",
|
|
124
|
+
)
|
|
125
|
+
check(
|
|
126
|
+
report,
|
|
127
|
+
id="provider.extra.installed",
|
|
128
|
+
category="provider",
|
|
129
|
+
severity="error",
|
|
130
|
+
passed=installed,
|
|
131
|
+
message=f"Extra '{spec.extra}' {'installed' if installed else 'missing'} in {runtime.source} env",
|
|
132
|
+
field="model.provider",
|
|
133
|
+
value=provider,
|
|
134
|
+
remediation=_extra_remediation(spec.extra, agent_root, runtime),
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
check(
|
|
138
|
+
report,
|
|
139
|
+
id="provider.extra.installed",
|
|
140
|
+
category="provider",
|
|
141
|
+
severity="error",
|
|
142
|
+
passed=True,
|
|
143
|
+
skip=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if spec:
|
|
147
|
+
creds = credentials_present(spec)
|
|
148
|
+
check(
|
|
149
|
+
report,
|
|
150
|
+
id="provider.credentials.present",
|
|
151
|
+
category="provider",
|
|
152
|
+
severity="error",
|
|
153
|
+
passed=creds,
|
|
154
|
+
message="Provider credentials detected" if creds else "No provider credentials found",
|
|
155
|
+
remediation="Set API keys or ADC in .env (see .env.example)",
|
|
156
|
+
)
|
|
157
|
+
if spec.gcp_adc:
|
|
158
|
+
adc_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "")
|
|
159
|
+
adc_ok = bool(adc_path) and Path(adc_path).is_file()
|
|
160
|
+
check(
|
|
161
|
+
report,
|
|
162
|
+
id="gcp.adc.valid",
|
|
163
|
+
category="provider",
|
|
164
|
+
severity="warning",
|
|
165
|
+
passed=adc_ok or creds,
|
|
166
|
+
message="GOOGLE_APPLICATION_CREDENTIALS set and file exists"
|
|
167
|
+
if adc_ok
|
|
168
|
+
else "ADC file not set (may use gcloud auth)",
|
|
169
|
+
skip=not spec.gcp_adc,
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
check(
|
|
173
|
+
report,
|
|
174
|
+
id="provider.credentials.present",
|
|
175
|
+
category="provider",
|
|
176
|
+
severity="error",
|
|
177
|
+
passed=False,
|
|
178
|
+
skip=True,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
runtime_cfg = doc.get("runtime") if isinstance(doc.get("runtime"), dict) else {}
|
|
182
|
+
port = int(runtime_cfg.get("port", 8080)) if isinstance(runtime_cfg, dict) else 8080
|
|
183
|
+
free = _port_free(port)
|
|
184
|
+
check(
|
|
185
|
+
report,
|
|
186
|
+
id="runtime.port.free",
|
|
187
|
+
category="runtime",
|
|
188
|
+
severity="warning",
|
|
189
|
+
passed=free,
|
|
190
|
+
message=f"Port {port} {'available' if free else 'in use'}",
|
|
191
|
+
value=port,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
web = doc.get("web_search") if isinstance(doc.get("web_search"), dict) else {}
|
|
195
|
+
backend = str(web.get("backend", "duckduckgo")) if isinstance(web, dict) else "duckduckgo"
|
|
196
|
+
if backend == "none":
|
|
197
|
+
check(
|
|
198
|
+
report,
|
|
199
|
+
id="web_search.backend.ready",
|
|
200
|
+
category="tools",
|
|
201
|
+
severity="warning",
|
|
202
|
+
passed=True,
|
|
203
|
+
skip=True,
|
|
204
|
+
)
|
|
205
|
+
elif backend == "duckduckgo":
|
|
206
|
+
if run_probe(runtime, "import ddgs"):
|
|
207
|
+
check(
|
|
208
|
+
report,
|
|
209
|
+
id="web_search.backend.ready",
|
|
210
|
+
category="tools",
|
|
211
|
+
severity="warning",
|
|
212
|
+
passed=True,
|
|
213
|
+
message="duckduckgo available",
|
|
214
|
+
value=backend,
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
check(
|
|
218
|
+
report,
|
|
219
|
+
id="web_search.backend.ready",
|
|
220
|
+
category="tools",
|
|
221
|
+
severity="warning",
|
|
222
|
+
passed=False,
|
|
223
|
+
message="ddgs not installed",
|
|
224
|
+
remediation=_extra_remediation("web-search", agent_root, runtime),
|
|
225
|
+
value=backend,
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
key_var = "TAVILY_API_KEY" if backend == "tavily" else "FIRECRAWL_API_KEY"
|
|
229
|
+
has_key = bool(os.environ.get(key_var, "").strip())
|
|
230
|
+
check(
|
|
231
|
+
report,
|
|
232
|
+
id="web_search.backend.ready",
|
|
233
|
+
category="tools",
|
|
234
|
+
severity="warning",
|
|
235
|
+
passed=has_key,
|
|
236
|
+
message=f"{key_var} {'set' if has_key else 'missing'}",
|
|
237
|
+
value=backend,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if args.check_mcp and config_path:
|
|
241
|
+
paths = doc.get("paths") if isinstance(doc.get("paths"), dict) else {}
|
|
242
|
+
mcp_rel = str(paths.get("mcp_config", "")) if isinstance(paths, dict) else ""
|
|
243
|
+
if mcp_rel:
|
|
244
|
+
base = (
|
|
245
|
+
config_path.parent.parent
|
|
246
|
+
if config_path.parent.name == "monkeybot_config"
|
|
247
|
+
else Path.cwd()
|
|
248
|
+
)
|
|
249
|
+
mcp_path = Path(mcp_rel) if Path(mcp_rel).is_absolute() else (base / mcp_rel)
|
|
250
|
+
if mcp_path.is_file():
|
|
251
|
+
mcp_doc = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
252
|
+
servers = mcp_doc.get("mcpServers", {})
|
|
253
|
+
if isinstance(servers, dict):
|
|
254
|
+
for name, srv in servers.items():
|
|
255
|
+
if not isinstance(srv, dict):
|
|
256
|
+
continue
|
|
257
|
+
url = srv.get("url")
|
|
258
|
+
if isinstance(url, str) and url.startswith("http"):
|
|
259
|
+
try:
|
|
260
|
+
httpx.get(url, timeout=3.0)
|
|
261
|
+
ok = True
|
|
262
|
+
msg = f"{name} ok"
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
ok = False
|
|
265
|
+
msg = str(exc)
|
|
266
|
+
check(
|
|
267
|
+
report,
|
|
268
|
+
id="mcp.server.reachable",
|
|
269
|
+
category="mcp",
|
|
270
|
+
severity="warning",
|
|
271
|
+
passed=ok,
|
|
272
|
+
message=msg,
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
check(
|
|
276
|
+
report,
|
|
277
|
+
id="mcp.server.reachable",
|
|
278
|
+
category="mcp",
|
|
279
|
+
severity="warning",
|
|
280
|
+
passed=True,
|
|
281
|
+
skip=True,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return report.emit(as_json=args.json)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
288
|
+
p = subparsers.add_parser("doctor", help="Check environment readiness for running the agent")
|
|
289
|
+
p.add_argument("--json", action="store_true", help="Emit JSON report")
|
|
290
|
+
p.add_argument("--config", help="Path to monkeybot.yaml")
|
|
291
|
+
p.add_argument("--cwd", help="Working directory")
|
|
292
|
+
p.add_argument("--check-mcp", action="store_true", help="Probe MCP HTTP servers")
|
|
293
|
+
p.set_defaults(func=run_doctor)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""monkeybot loop — manage prompt-first scheduled loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from monkeybot_cli.config_resolve import load_agent_dotenv, load_config_doc, resolve_agent_root, resolve_config
|
|
13
|
+
|
|
14
|
+
DEFAULT_GATEWAY_PORT = 8000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _port_from_config(config_path: Path | None) -> int:
|
|
18
|
+
if config_path is None:
|
|
19
|
+
return DEFAULT_GATEWAY_PORT
|
|
20
|
+
_, doc = load_config_doc(config_path)
|
|
21
|
+
runtime = doc.get("runtime") if isinstance(doc.get("runtime"), dict) else {}
|
|
22
|
+
try:
|
|
23
|
+
return int(runtime.get("port", DEFAULT_GATEWAY_PORT))
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return DEFAULT_GATEWAY_PORT
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _gateway_url(args: argparse.Namespace, config_path: Path | None) -> str:
|
|
29
|
+
if args.url:
|
|
30
|
+
return args.url.rstrip("/")
|
|
31
|
+
port = args.port if args.port else _port_from_config(config_path)
|
|
32
|
+
return f"http://127.0.0.1:{port}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_prompt(args: argparse.Namespace) -> str:
|
|
36
|
+
if args.prompt_file:
|
|
37
|
+
return Path(args.prompt_file).read_text(encoding="utf-8").strip()
|
|
38
|
+
if args.prompt:
|
|
39
|
+
return args.prompt.strip()
|
|
40
|
+
if not sys.stdin.isatty():
|
|
41
|
+
return sys.stdin.read().strip()
|
|
42
|
+
raise SystemExit("Provide --prompt, --prompt-file, or pipe the plan on stdin.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _post_json(url: str, path: str, payload: dict[str, object]) -> dict[str, object]:
|
|
46
|
+
with httpx.Client(timeout=60.0) as client:
|
|
47
|
+
resp = client.post(f"{url}{path}", json=payload)
|
|
48
|
+
if resp.status_code >= 400:
|
|
49
|
+
raise SystemExit(f"{resp.status_code} {resp.text}")
|
|
50
|
+
data = resp.json()
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
raise SystemExit("unexpected response shape")
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_json(url: str, path: str) -> dict[str, object]:
|
|
57
|
+
with httpx.Client(timeout=60.0) as client:
|
|
58
|
+
resp = client.get(f"{url}{path}")
|
|
59
|
+
if resp.status_code >= 400:
|
|
60
|
+
raise SystemExit(f"{resp.status_code} {resp.text}")
|
|
61
|
+
data = resp.json()
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
raise SystemExit("unexpected response shape")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _confirm_loop_start(args: argparse.Namespace, prompt: str) -> None:
|
|
68
|
+
if args.yes:
|
|
69
|
+
return
|
|
70
|
+
guards: list[str] = []
|
|
71
|
+
if args.max_ticks is not None:
|
|
72
|
+
guards.append(f"max_ticks={args.max_ticks}")
|
|
73
|
+
if args.max_runtime:
|
|
74
|
+
guards.append(f"max_runtime={args.max_runtime}")
|
|
75
|
+
if args.unbounded:
|
|
76
|
+
guard_text = "UNBOUNDED (no max_ticks/max_runtime)"
|
|
77
|
+
elif guards:
|
|
78
|
+
guard_text = ", ".join(guards)
|
|
79
|
+
else:
|
|
80
|
+
guard_text = "(guards validated above)"
|
|
81
|
+
preview = prompt if len(prompt) <= 400 else prompt[:400] + "…"
|
|
82
|
+
print(
|
|
83
|
+
"Start scheduled loop?\n"
|
|
84
|
+
f"- session: {args.session_id}\n"
|
|
85
|
+
f"- interval: {args.interval}\n"
|
|
86
|
+
f"- loop_id: {args.loop_id or '(auto)'}\n"
|
|
87
|
+
f"- guards: {guard_text}\n\n"
|
|
88
|
+
f"Plan:\n{preview or '(empty prompt)'}"
|
|
89
|
+
)
|
|
90
|
+
if sys.stdin.isatty():
|
|
91
|
+
answer = input("Proceed? [y/N] ").strip().lower()
|
|
92
|
+
if answer not in {"y", "yes"}:
|
|
93
|
+
raise SystemExit("Aborted.")
|
|
94
|
+
return
|
|
95
|
+
raise SystemExit("Refusing to start loop without --yes on non-interactive stdin.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_loop_run(args: argparse.Namespace) -> int:
|
|
99
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
100
|
+
config_path = resolve_config(args.config, cwd=cwd)
|
|
101
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
102
|
+
url = _gateway_url(args, config_path)
|
|
103
|
+
prompt = _read_prompt(args)
|
|
104
|
+
payload: dict[str, object] = {
|
|
105
|
+
"prompt": prompt,
|
|
106
|
+
"interval": args.interval,
|
|
107
|
+
"session_id": args.session_id,
|
|
108
|
+
"skip_if_busy": not args.queue_if_busy,
|
|
109
|
+
}
|
|
110
|
+
if args.loop_id:
|
|
111
|
+
payload["loop_id"] = args.loop_id
|
|
112
|
+
if args.max_ticks is not None:
|
|
113
|
+
payload["max_ticks"] = args.max_ticks
|
|
114
|
+
if args.max_runtime:
|
|
115
|
+
payload["max_runtime"] = args.max_runtime
|
|
116
|
+
if args.unbounded:
|
|
117
|
+
payload["unbounded"] = True
|
|
118
|
+
if not args.unbounded and args.max_ticks is None and not args.max_runtime:
|
|
119
|
+
raise SystemExit(
|
|
120
|
+
"Provide --max-ticks, --max-runtime, or --unbounded to set loop stop guards"
|
|
121
|
+
)
|
|
122
|
+
_confirm_loop_start(args, prompt)
|
|
123
|
+
payload["confirmed"] = True
|
|
124
|
+
data = _post_json(url, "/scheduler/loops", payload)
|
|
125
|
+
if args.json:
|
|
126
|
+
print(json.dumps(data, indent=2))
|
|
127
|
+
else:
|
|
128
|
+
loop = data.get("loop", {})
|
|
129
|
+
print(f"loop started: {loop.get('loop_id')} (session={loop.get('session_id')})")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_loop_status(args: argparse.Namespace) -> int:
|
|
134
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
135
|
+
config_path = resolve_config(args.config, cwd=cwd)
|
|
136
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
137
|
+
url = _gateway_url(args, config_path)
|
|
138
|
+
if args.loop_id:
|
|
139
|
+
data = _get_json(url, f"/scheduler/loops/{args.loop_id}")
|
|
140
|
+
else:
|
|
141
|
+
data = _get_json(url, "/scheduler/loops")
|
|
142
|
+
print(json.dumps(data, indent=2) if args.json else json.dumps(data, indent=2))
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _loop_action(action: str, args: argparse.Namespace) -> int:
|
|
147
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
148
|
+
config_path = resolve_config(args.config, cwd=cwd)
|
|
149
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
150
|
+
if not args.loop_id:
|
|
151
|
+
raise SystemExit(f"loop {action} requires --id")
|
|
152
|
+
url = _gateway_url(args, config_path)
|
|
153
|
+
data = _post_json(url, f"/scheduler/loops/{args.loop_id}/{action}", {})
|
|
154
|
+
print(json.dumps(data, indent=2) if args.json else json.dumps(data, indent=2))
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
159
|
+
loop = subparsers.add_parser("loop", help="Manage prompt-first scheduled agent loops")
|
|
160
|
+
loop.add_argument("--cwd", help="Agent project root")
|
|
161
|
+
loop.add_argument("--config", help="Path to monkeybot.yaml")
|
|
162
|
+
loop.add_argument("--url", help="Gateway base URL")
|
|
163
|
+
loop.add_argument("--port", type=int, help="Gateway port when --url omitted")
|
|
164
|
+
loop.add_argument("--json", action="store_true", help="JSON output")
|
|
165
|
+
|
|
166
|
+
sub = loop.add_subparsers(dest="loop_command", required=True)
|
|
167
|
+
|
|
168
|
+
run = sub.add_parser("run", help="Register a scheduled loop from a prompt")
|
|
169
|
+
run.add_argument("--interval", required=True, help="Tick interval, e.g. 20s, 5m, 1h")
|
|
170
|
+
run.add_argument("--prompt", help="Loop plan / tick instructions")
|
|
171
|
+
run.add_argument("--prompt-file", help="Read loop plan from a file")
|
|
172
|
+
run.add_argument("--session-id", default="loop-main")
|
|
173
|
+
run.add_argument("--loop-id", help="Optional stable loop id")
|
|
174
|
+
run.add_argument("--max-ticks", type=int)
|
|
175
|
+
run.add_argument("--max-runtime", help="Hard wall-clock limit, e.g. 2h")
|
|
176
|
+
run.add_argument(
|
|
177
|
+
"--unbounded",
|
|
178
|
+
action="store_true",
|
|
179
|
+
help="Run without max_ticks/max_runtime guards (explicit opt-in)",
|
|
180
|
+
)
|
|
181
|
+
run.add_argument(
|
|
182
|
+
"--yes",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help="Skip interactive confirmation prompt",
|
|
185
|
+
)
|
|
186
|
+
run.add_argument(
|
|
187
|
+
"--queue-if-busy",
|
|
188
|
+
action="store_true",
|
|
189
|
+
help="Do not skip ticks when the target session is busy",
|
|
190
|
+
)
|
|
191
|
+
run.set_defaults(func=run_loop_run)
|
|
192
|
+
|
|
193
|
+
status = sub.add_parser("status", help="Show one loop or list all loops")
|
|
194
|
+
status.add_argument("--id", dest="loop_id")
|
|
195
|
+
status.set_defaults(func=run_loop_status)
|
|
196
|
+
|
|
197
|
+
pause = sub.add_parser("pause", help="Pause a loop")
|
|
198
|
+
pause.add_argument("--id", dest="loop_id", required=True)
|
|
199
|
+
pause.set_defaults(func=lambda a: _loop_action("pause", a))
|
|
200
|
+
|
|
201
|
+
resume = sub.add_parser("resume", help="Resume a paused loop")
|
|
202
|
+
resume.add_argument("--id", dest="loop_id", required=True)
|
|
203
|
+
resume.set_defaults(func=lambda a: _loop_action("resume", a))
|
|
204
|
+
|
|
205
|
+
stop = sub.add_parser("stop", help="Stop a loop")
|
|
206
|
+
stop.add_argument("--id", dest="loop_id", required=True)
|
|
207
|
+
stop.set_defaults(func=lambda a: _loop_action("stop", a))
|