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 ADDED
@@ -0,0 +1 @@
1
+ """Perferox package."""
@@ -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())