perferox 0.1.0__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.
- perferox/__init__.py +1 -0
- perferox/agent_runner.py +158 -0
- perferox/auth.py +46 -0
- perferox/bench.py +114 -0
- perferox/cli.py +47 -0
- perferox/db.py +329 -0
- perferox/init-db.sql +91 -0
- perferox/main_agent.py +278 -0
- perferox/perferox-tui-demo.gif +0 -0
- perferox/prompts.py +144 -0
- perferox/remote.py +131 -0
- perferox/subagent.py +255 -0
- perferox/tools.py +208 -0
- perferox/tui.py +367 -0
- perferox-0.1.0.dist-info/METADATA +72 -0
- perferox-0.1.0.dist-info/RECORD +19 -0
- perferox-0.1.0.dist-info/WHEEL +5 -0
- perferox-0.1.0.dist-info/entry_points.txt +2 -0
- perferox-0.1.0.dist-info/top_level.txt +1 -0
perferox/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Perferox package."""
|
perferox/agent_runner.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tiny CLI for tmux-wrapped Perferox agent processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import closing
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from langchain_core.messages import HumanMessage
|
|
14
|
+
|
|
15
|
+
from perferox import db
|
|
16
|
+
from perferox.auth import build_chat_model
|
|
17
|
+
from perferox.main_agent import build_main_agent_graph
|
|
18
|
+
from perferox.remote import SessionRegistry
|
|
19
|
+
from perferox.subagent import build_subagent_graph, stream_with_trace
|
|
20
|
+
|
|
21
|
+
MAIN_SESSION = "perferox-main"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None) -> int:
|
|
25
|
+
"""Parse the runner command and run the requested whole-agent process."""
|
|
26
|
+
parser = argparse.ArgumentParser(prog="python -m perferox.agent_runner")
|
|
27
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
for name in ("launch-main", "main"):
|
|
29
|
+
subparser = subparsers.add_parser(name)
|
|
30
|
+
subparser.add_argument("--db-path", required=True)
|
|
31
|
+
subparser.add_argument("--trace-dir", default="traces")
|
|
32
|
+
subparser.add_argument("--objective", required=True)
|
|
33
|
+
subparser.add_argument("--cwd", default=".")
|
|
34
|
+
subparsers.choices["main"].add_argument("--poll-s", type=float, default=5.0)
|
|
35
|
+
subagent = subparsers.add_parser("subagent")
|
|
36
|
+
for name in ("agent-id", "db-path", "trace-path", "goal-file", "attempt-cap"):
|
|
37
|
+
subagent.add_argument(f"--{name}", required=True)
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
|
|
40
|
+
if args.command == "launch-main":
|
|
41
|
+
tmux = shutil.which("tmux")
|
|
42
|
+
if tmux is None:
|
|
43
|
+
print("tmux is not installed or not on PATH")
|
|
44
|
+
return 1
|
|
45
|
+
cwd = Path(args.cwd).resolve()
|
|
46
|
+
db_path = (cwd / args.db_path).resolve()
|
|
47
|
+
trace_dir = (cwd / args.trace_dir).resolve()
|
|
48
|
+
trace_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
command = shlex.join(["uv", "run", "python", "-m", "perferox.agent_runner", "main", "--db-path", str(db_path), "--trace-dir", str(trace_dir), "--objective", args.objective, "--cwd", str(cwd)])
|
|
50
|
+
result = subprocess.run([tmux, "new-session", "-d", "-s", MAIN_SESSION, "-c", str(cwd), "--", "bash", "-lc", command], text=True, capture_output=True, check=False)
|
|
51
|
+
print(f"started {MAIN_SESSION}; attach with: tmux attach -t {MAIN_SESSION}" if result.returncode == 0 else (result.stderr or result.stdout).strip())
|
|
52
|
+
return result.returncode
|
|
53
|
+
|
|
54
|
+
if args.command == "main":
|
|
55
|
+
cwd = Path(args.cwd).resolve()
|
|
56
|
+
db_path = (cwd / args.db_path).resolve()
|
|
57
|
+
trace_dir = (cwd / args.trace_dir).resolve()
|
|
58
|
+
trace_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
trace_path = trace_dir / f"{MAIN_SESSION}-{int(time.time())}.jsonl"
|
|
60
|
+
try:
|
|
61
|
+
with closing(db.connect(db_path)) as conn:
|
|
62
|
+
db.init_db(conn)
|
|
63
|
+
db.record_agent_session(conn, session_name=MAIN_SESSION, role="main", trace_ref=str(trace_path))
|
|
64
|
+
graph = build_main_agent_graph(build_chat_model(), db_path, cwd=cwd, trace_dir=trace_dir)
|
|
65
|
+
state = {"objective": args.objective, "messages": [HumanMessage(content=args.objective)]}
|
|
66
|
+
while True:
|
|
67
|
+
for event in stream_with_trace(graph, state, trace_path):
|
|
68
|
+
state = _collect_update(state, event)
|
|
69
|
+
update = _wait_for_main_event(db_path, args.poll_s)
|
|
70
|
+
if update is None:
|
|
71
|
+
return 0
|
|
72
|
+
state["messages"] = [*state.get("messages", []), HumanMessage(content=update)]
|
|
73
|
+
finally:
|
|
74
|
+
with closing(db.connect(db_path)) as conn:
|
|
75
|
+
db.finish_agent_session(conn, session_name=MAIN_SESSION, status="exited")
|
|
76
|
+
|
|
77
|
+
agent_id = int(args.agent_id)
|
|
78
|
+
db_path = Path(args.db_path).resolve()
|
|
79
|
+
trace_path = Path(args.trace_path).resolve()
|
|
80
|
+
session_name = f"perferox-agent-{agent_id}"
|
|
81
|
+
registry = SessionRegistry()
|
|
82
|
+
try:
|
|
83
|
+
with closing(db.connect(db_path)) as conn:
|
|
84
|
+
db.init_db(conn)
|
|
85
|
+
db.record_agent_session(conn, session_name=session_name, role="subagent", agent_id=agent_id, trace_ref=str(trace_path))
|
|
86
|
+
attempt_cap = int(args.attempt_cap)
|
|
87
|
+
graph = build_subagent_graph(build_chat_model(), agent_id, registry, db_path, attempt_cap=attempt_cap, trace_ref=str(trace_path))
|
|
88
|
+
state = {"agent_id": agent_id, "messages": [HumanMessage(content=Path(args.goal_file).read_text(encoding="utf-8"))]}
|
|
89
|
+
for _ in stream_with_trace(graph, state, trace_path):
|
|
90
|
+
pass
|
|
91
|
+
return 0
|
|
92
|
+
finally:
|
|
93
|
+
registry.close(f"agent-{agent_id}")
|
|
94
|
+
with closing(db.connect(db_path)) as conn:
|
|
95
|
+
db.init_db(conn)
|
|
96
|
+
if db.finish_agent_session(conn, session_name=session_name, status="exited"):
|
|
97
|
+
db.append_explorer_state(conn, agent_id=agent_id, line=f"agent-{agent_id} tmux exited; trace {trace_path.name}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _collect_update(state: dict, event: object) -> dict:
|
|
101
|
+
"""Merge one streamed LangGraph update into a reusable graph state."""
|
|
102
|
+
if not isinstance(event, dict):
|
|
103
|
+
return state
|
|
104
|
+
for update in event.values():
|
|
105
|
+
if not isinstance(update, dict):
|
|
106
|
+
continue
|
|
107
|
+
for key, value in update.items():
|
|
108
|
+
if key == "messages":
|
|
109
|
+
state.setdefault(key, []).extend(value)
|
|
110
|
+
else:
|
|
111
|
+
state[key] = value
|
|
112
|
+
return state
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _wait_for_main_event(db_path: Path, poll_s: float) -> str | None:
|
|
116
|
+
"""Wait for a main-agent wakeup or a human End request."""
|
|
117
|
+
previous_running = None
|
|
118
|
+
while True:
|
|
119
|
+
with closing(db.connect(db_path)) as conn:
|
|
120
|
+
db.init_db(conn)
|
|
121
|
+
main_row = conn.execute("SELECT status FROM agent_sessions WHERE session_name = ?", (MAIN_SESSION,)).fetchone()
|
|
122
|
+
notifications = db.take_main_notifications(conn)
|
|
123
|
+
if notifications:
|
|
124
|
+
lines = ["Subagent SQLite write notifications:"]
|
|
125
|
+
for row in notifications:
|
|
126
|
+
prefix = "ANOMALY " if row["kind"] == "anomaly_logged" else ""
|
|
127
|
+
lines.append(
|
|
128
|
+
f"{prefix}notification_id={row['notification_id']} kind={row['kind']} "
|
|
129
|
+
f"agent_id={row['agent_id']} run_id={row['run_id']} table={row['table_name']}"
|
|
130
|
+
)
|
|
131
|
+
lines.append(row["row_json"])
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
tmux = shutil.which("tmux")
|
|
134
|
+
active_query = "SELECT session_name, agent_id, trace_ref FROM agent_sessions WHERE status IN ('running', 'ending') AND role = 'subagent'"
|
|
135
|
+
rows = conn.execute(active_query).fetchall()
|
|
136
|
+
for row in rows:
|
|
137
|
+
alive = tmux and subprocess.run([tmux, "has-session", "-t", row["session_name"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode == 0
|
|
138
|
+
if not alive and db.finish_agent_session(conn, session_name=row["session_name"], status="missing"):
|
|
139
|
+
line = f"agent-{row['agent_id']} tmux missing; trace {Path(row['trace_ref']).name}"
|
|
140
|
+
db.append_explorer_state(conn, agent_id=row["agent_id"], line=line)
|
|
141
|
+
return f"Tmux session update:\n{line}"
|
|
142
|
+
if main_row is not None and main_row["status"] == "ending":
|
|
143
|
+
if not rows:
|
|
144
|
+
return None
|
|
145
|
+
time.sleep(poll_s)
|
|
146
|
+
return "End requested. Do not delegate new subagents; wait for active subagents to wrap up, then summarize."
|
|
147
|
+
running = {row["session_name"] for row in rows}
|
|
148
|
+
if previous_running is not None and running != previous_running:
|
|
149
|
+
return "Tmux session update:\nsubagent tmux sessions changed" if running else "Tmux session update:\nall subagent tmux sessions completed"
|
|
150
|
+
if not running:
|
|
151
|
+
time.sleep(poll_s)
|
|
152
|
+
return "No active subagents are running. Continue exploration from the objective and ExplorerState."
|
|
153
|
+
previous_running = running
|
|
154
|
+
time.sleep(poll_s)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|
perferox/auth.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""ChatGPT OAuth auth and model construction for Perferox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
10
|
+
|
|
11
|
+
DEFAULT_CHAT_MODEL = "gpt-5.5"
|
|
12
|
+
MODEL_ENV_VAR = "PERFEROX_CHAT_MODEL"
|
|
13
|
+
ORIGINATOR = "perferox"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chatgpt_auth_ready() -> bool:
|
|
17
|
+
"""Return whether a refreshable ChatGPT OAuth token is available."""
|
|
18
|
+
try:
|
|
19
|
+
from langchain_openai.chatgpt_oauth import _FileChatGPTOAuthTokenProvider
|
|
20
|
+
|
|
21
|
+
_FileChatGPTOAuthTokenProvider.from_default_store().get_token()
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
return False
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def login_chatgpt_oauth(timeout_s: float = 300.0) -> None:
|
|
28
|
+
"""Run the browser-based ChatGPT OAuth login flow."""
|
|
29
|
+
from langchain_openai.chatgpt_oauth import login_chatgpt
|
|
30
|
+
|
|
31
|
+
login_chatgpt(timeout=timeout_s, open_browser=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_chat_model(model: str | None = None) -> BaseChatModel:
|
|
35
|
+
"""Build Perferox's OAuth-backed LangChain chat model."""
|
|
36
|
+
from langchain_openai.chat_models.codex import _ChatOpenAICodex
|
|
37
|
+
from langchain_openai.chatgpt_oauth import _FileChatGPTOAuthTokenProvider
|
|
38
|
+
|
|
39
|
+
provider = _FileChatGPTOAuthTokenProvider.from_default_store()
|
|
40
|
+
provider.get_token()
|
|
41
|
+
model_name = model or os.environ.get(MODEL_ENV_VAR, DEFAULT_CHAT_MODEL)
|
|
42
|
+
return _ChatOpenAICodex(
|
|
43
|
+
model=model_name,
|
|
44
|
+
originator=ORIGINATOR,
|
|
45
|
+
token_provider=provider,
|
|
46
|
+
)
|
perferox/bench.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""SGLang benchmark command builders and parsers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
BENCH_TIMEOUT_S = 6 * 60 * 60.0
|
|
9
|
+
_CONSOLE_METRIC_LABELS = {
|
|
10
|
+
"Request throughput (req/s)": "request_rps",
|
|
11
|
+
"Input token throughput (tok/s)": "input_tps",
|
|
12
|
+
"Output token throughput (tok/s)": "output_tps",
|
|
13
|
+
"Median TTFT (ms)": "ttft_p50_ms",
|
|
14
|
+
"P99 TTFT (ms)": "ttft_p99_ms",
|
|
15
|
+
"Median TPOT (ms)": "tpot_p50_ms",
|
|
16
|
+
"P99 TPOT (ms)": "tpot_p99_ms",
|
|
17
|
+
"Cache hit rate": "cache_hit_rate",
|
|
18
|
+
"Accept length": "accept_length",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class BenchServingArgs(BaseModel):
|
|
22
|
+
"""Typed common inputs plus raw pass-through flags for SGLang's CLI."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
backend: str = "sglang"
|
|
27
|
+
base_url: str | None = None
|
|
28
|
+
host: str | None = None
|
|
29
|
+
port: int | None = Field(None, ge=1, le=65535)
|
|
30
|
+
ready_check_timeout_sec: int | None = Field(None, ge=0)
|
|
31
|
+
dataset_name: str = "sharegpt"
|
|
32
|
+
dataset_path: str | None = None
|
|
33
|
+
model: str | None = None
|
|
34
|
+
served_model_name: str | None = None
|
|
35
|
+
tokenizer: str | None = None
|
|
36
|
+
num_prompts: int | None = Field(None, ge=1)
|
|
37
|
+
sharegpt_output_len: int | None = Field(None, ge=4)
|
|
38
|
+
sharegpt_context_len: int | None = Field(None, ge=1)
|
|
39
|
+
random_input_len: int | None = Field(None, ge=1)
|
|
40
|
+
random_output_len: int | None = Field(None, ge=0)
|
|
41
|
+
request_rate: float | None = Field(None, gt=0)
|
|
42
|
+
max_concurrency: int | None = Field(None, ge=0)
|
|
43
|
+
output_file: str | None = None
|
|
44
|
+
output_details: bool = False
|
|
45
|
+
print_requests: bool = False
|
|
46
|
+
disable_tqdm: bool = False
|
|
47
|
+
disable_stream: bool = False
|
|
48
|
+
cache_report: bool = False
|
|
49
|
+
seed: int | None = Field(None, ge=0, le=4294967295)
|
|
50
|
+
disable_ignore_eos: bool = False
|
|
51
|
+
temperature: float | None = Field(None, ge=0)
|
|
52
|
+
top_p: float | None = Field(None, gt=0, le=1)
|
|
53
|
+
extra_request_body: dict[str, Any] | None = None
|
|
54
|
+
apply_chat_template: bool = False
|
|
55
|
+
flush_cache: bool = False
|
|
56
|
+
warmup_requests: int | None = Field(None, ge=0)
|
|
57
|
+
tokenize_prompt: bool = False
|
|
58
|
+
header: dict[str, str] | None = None
|
|
59
|
+
extra_args: list[str] = Field(default_factory=list)
|
|
60
|
+
timeout_s: float | None = Field(BENCH_TIMEOUT_S, gt=0)
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def check_serving_constraints(self) -> Self:
|
|
64
|
+
"""Mirror bench_serving's parse-time and early runtime argument checks."""
|
|
65
|
+
if self.backend in ("trt", "truss") and self.model is None:
|
|
66
|
+
raise ValueError(f"backend='{self.backend}' requires model")
|
|
67
|
+
if self.print_requests and self.backend != "sglang-oai-chat":
|
|
68
|
+
raise ValueError("print_requests is only supported with backend='sglang-oai-chat'")
|
|
69
|
+
if self.tokenize_prompt and self.backend != "sglang":
|
|
70
|
+
raise ValueError("tokenize_prompt is only supported with backend='sglang'")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def bench_serving_argv(args: BenchServingArgs) -> list[str]:
|
|
75
|
+
"""Build the SGLang serving benchmark argv from typed fields."""
|
|
76
|
+
data = args.model_dump(exclude={"timeout_s", "extra_request_body", "header", "extra_args"}, exclude_none=True)
|
|
77
|
+
data["extra_request_body"] = json.dumps(args.extra_request_body, separators=(",", ":")) if args.extra_request_body else None
|
|
78
|
+
data["header"] = [f"{key}={value}" for key, value in (args.header or {}).items()]
|
|
79
|
+
argv = ["python", "-m", "sglang.benchmark.serving"]
|
|
80
|
+
for name, value in data.items():
|
|
81
|
+
if value is False or value is None or value == []:
|
|
82
|
+
continue
|
|
83
|
+
argv.append(f"--{name.replace('_', '-')}")
|
|
84
|
+
if value is True:
|
|
85
|
+
continue
|
|
86
|
+
argv.extend(map(str, value if isinstance(value, list) else (value,)))
|
|
87
|
+
argv.extend(args.extra_args)
|
|
88
|
+
return argv
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_bench_serving_metrics(output: str, expected_requests: int | None = None) -> dict[str, float]:
|
|
92
|
+
"""Extract Perferox experiment metrics from SGLang benchmark output."""
|
|
93
|
+
metrics = {}
|
|
94
|
+
for raw_line in output.splitlines():
|
|
95
|
+
label, separator, raw_value = raw_line.partition(":")
|
|
96
|
+
if not separator:
|
|
97
|
+
continue
|
|
98
|
+
label = label.strip()
|
|
99
|
+
metric_name = _CONSOLE_METRIC_LABELS.get(label)
|
|
100
|
+
if metric_name is None and (label != "Successful requests" or not expected_requests):
|
|
101
|
+
continue
|
|
102
|
+
raw_value = raw_value.strip()
|
|
103
|
+
if not raw_value:
|
|
104
|
+
continue
|
|
105
|
+
raw_number = raw_value.split(maxsplit=1)[0].rstrip("%")
|
|
106
|
+
try:
|
|
107
|
+
value = float(raw_number)
|
|
108
|
+
except ValueError:
|
|
109
|
+
continue
|
|
110
|
+
if metric_name is None:
|
|
111
|
+
metrics["error_rate"] = max(expected_requests - value, 0.0) / expected_requests
|
|
112
|
+
continue
|
|
113
|
+
metrics[metric_name] = value / 100.0 if metric_name == "cache_hit_rate" else value
|
|
114
|
+
return metrics
|
perferox/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Small command-line entry point for Perferox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from contextlib import closing
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from perferox import db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
"""Open the TUI, launch the main agent, or request a soft stop."""
|
|
14
|
+
parser = argparse.ArgumentParser(prog="perferox")
|
|
15
|
+
parser.add_argument("--cwd", type=Path, default=Path("."), help="repository/root directory")
|
|
16
|
+
parser.add_argument("--db-path", type=Path, default=Path("perferox.sqlite"), help="SQLite state path")
|
|
17
|
+
parser.add_argument("--trace-dir", type=Path, default=Path("traces"), help="trace directory")
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
19
|
+
run_parser = subparsers.add_parser("run", help="start the main graph without opening the TUI")
|
|
20
|
+
run_parser.add_argument("objective", nargs="+", help="objective for the main agent")
|
|
21
|
+
subparsers.add_parser("end", help="request a soft stop without opening the TUI")
|
|
22
|
+
args = parser.parse_args(argv)
|
|
23
|
+
|
|
24
|
+
cwd = args.cwd.resolve()
|
|
25
|
+
db_path = (cwd / args.db_path).resolve()
|
|
26
|
+
trace_dir = (cwd / args.trace_dir).resolve()
|
|
27
|
+
|
|
28
|
+
if args.command is None:
|
|
29
|
+
from perferox.tui import PerferoxTUI
|
|
30
|
+
|
|
31
|
+
PerferoxTUI(cwd=cwd, db_path=db_path, trace_dir=trace_dir).run()
|
|
32
|
+
return 0
|
|
33
|
+
if args.command == "run":
|
|
34
|
+
from perferox.agent_runner import main as run_agent
|
|
35
|
+
|
|
36
|
+
objective = " ".join(args.objective)
|
|
37
|
+
return run_agent(["launch-main", "--db-path", str(db_path), "--trace-dir", str(trace_dir), "--objective", objective, "--cwd", str(cwd)])
|
|
38
|
+
with closing(db.connect(db_path)) as conn:
|
|
39
|
+
db.init_db(conn)
|
|
40
|
+
stopped = db.request_soft_stop(conn)
|
|
41
|
+
db.append_explorer_state(conn, agent_id=None, line="soft stop requested from CLI")
|
|
42
|
+
print(f"soft stop requested for {stopped} running session(s)")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
raise SystemExit(main())
|