quantlab-factor-agent 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.
Files changed (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,219 @@
1
+ """The round in progress, replayed from the events since the run was last opened, and one LLM call read in full.
2
+
3
+ A round a resume dropped left its events before the resume, so it is not
4
+ among them; a run without events has no round in progress to show. An LLM
5
+ call is read by the number of its call file, from the file itself: tokens,
6
+ truncation and the model's reasoning are the provider's reply's; what it
7
+ concerned (round, slots) only the events file says.
8
+ """
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+
15
+ from factor_agent import events
16
+ from factor_agent.llm._raw import raw_facts
17
+ from factor_agent.llm.base import Reply
18
+ from factor_agent.web._files import Replayed, loads, utc
19
+ from factor_agent.web._health import Tokens
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Check:
24
+ """One check of a pending trial's candidate: the parser and the Gate on one version of it, or the reviewer.
25
+
26
+ ``by`` is ``"gate"`` or ``"reviewer"``. For the Gate, ``step`` is the
27
+ version's: ``"proposed"`` (the first), ``"fixed"`` (sent back by the
28
+ parser or the Gate) or ``"rewritten"`` (sent back by the reviewer), and
29
+ ``stage`` is ``"parse"`` or ``"gate"`` when it refused the version,
30
+ ``None`` when it passed it. ``passed`` is true when the Gate passed the
31
+ version or the reviewer found it consistent. ``reasons`` are the Gate's
32
+ reasons for a refusal, or the reviewer's reason for its verdict either
33
+ way; ``hints`` are the Gate's.
34
+ """
35
+
36
+ by: str
37
+ step: str | None
38
+ passed: bool
39
+ stage: str | None
40
+ reasons: tuple[str, ...]
41
+ hints: tuple[str, ...]
42
+ time: datetime
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PendingTrial:
47
+ """A pending trial: what is known so far of one slot of the round in progress.
48
+
49
+ ``stage`` is the furthest it went: ``"planned"`` (its research direction
50
+ is chosen), ``"proposed"``, ``"fixed"`` (after the Gate refused it),
51
+ ``"rewritten"`` (after the reviewer found it inconsistent), and
52
+ ``"consistent"`` or ``"inconsistent"`` as the reviewer last found it.
53
+ ``direction`` is its research direction as the plan gives it. ``latest``
54
+ is its latest version as the proposer wrote it, ``None`` before the
55
+ first. ``checks`` are the Gate's outcomes and the reviewer's verdicts
56
+ so far, the earliest first. It becomes a trial when its round is written.
57
+ """
58
+
59
+ slot: int
60
+ stage: str
61
+ direction: dict
62
+ latest: object
63
+ checks: tuple[Check, ...]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class RoundCall:
68
+ """An LLM call of the round in progress, as the call timeline shows it.
69
+
70
+ ``number`` is its call file's number, by which ``read_call`` reads it;
71
+ ``None`` while it has not come back, or when it failed (``error`` says
72
+ how). ``group`` is its proposal group and ``slots`` the slots it
73
+ concerns; both are empty for the planner, whose call concerns the round.
74
+ ``sent`` and ``returned`` are when it was sent and came back (UTC).
75
+ """
76
+
77
+ number: int | None
78
+ role: str
79
+ model: str
80
+ group: int | None
81
+ slots: tuple[int, ...]
82
+ sent: datetime
83
+ returned: datetime | None
84
+ error: str | None
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RunningRound:
89
+ """The round in progress, as its events tell since the run was last opened.
90
+
91
+ ``started`` is when the round before it was written, or the run was
92
+ opened when it is the first since. ``calls`` are its LLM calls in the
93
+ order they were sent, and ``evaluation`` when the Evaluator started on
94
+ it (``None`` before). ``pending`` holds a pending trial per slot of its
95
+ plan, in slot order; it is empty until the plan is made.
96
+ """
97
+
98
+ round: int
99
+ started: datetime
100
+ calls: tuple[RoundCall, ...]
101
+ evaluation: datetime | None
102
+ pending: tuple[PendingTrial, ...]
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class LLMCall:
107
+ """One LLM call of a run, in full, as its call file holds it.
108
+
109
+ ``number`` is the number of its call file. ``round``, ``group`` and
110
+ ``slots`` are the round, proposal group and slots it concerned, as the
111
+ events file says (``None`` and empty without one). ``time`` is when it
112
+ came back (UTC) and ``seconds`` how long it took (``None`` without an
113
+ events file). ``tokens``, ``truncated`` (cut off at the output limit,
114
+ ``None`` when the provider's reply does not say) and ``empty`` are read
115
+ from the provider's reply. ``reasoning`` is the model's reasoning when
116
+ the provider returns it, ``None`` otherwise.
117
+ """
118
+
119
+ number: int
120
+ role: str
121
+ model: str
122
+ round: int | None
123
+ group: int | None
124
+ slots: tuple[int, ...]
125
+ time: datetime
126
+ seconds: float | None
127
+ tokens: Tokens
128
+ truncated: bool | None
129
+ empty: bool
130
+ prompt: str
131
+ reply: str
132
+ reasoning: str | None
133
+
134
+
135
+ def running(number: int, replayed: Replayed) -> RunningRound | None:
136
+ """Round ``number``, in progress, from the events since the run was last opened; ``None`` without events."""
137
+ if replayed.opened is None:
138
+ return None
139
+ started = replayed.opened.time
140
+ calls: dict[int, RoundCall] = {}
141
+ evaluation = None
142
+ directions: list[dict] = []
143
+ stages: dict[int, str] = {}
144
+ latest: dict[int, object] = {}
145
+ checks: dict[int, list[Check]] = {}
146
+ for event in replayed.current:
147
+ if isinstance(event, events.RoundEnd):
148
+ started = event.time
149
+ if getattr(event, "round", None) != number:
150
+ continue
151
+ if isinstance(event, events.CallEnd):
152
+ sent = calls[event.call].sent if event.call in calls else event.time - timedelta(seconds=event.seconds)
153
+ calls[event.call] = RoundCall(
154
+ number=_call_number(event.file), role=event.role, model=event.model, group=event.group,
155
+ slots=tuple(event.slots), sent=sent, returned=event.time, error=event.error,
156
+ )
157
+ elif isinstance(event, events.CallStart):
158
+ calls[event.call] = RoundCall(number=None, role=event.role, model=event.model, group=event.group,
159
+ slots=tuple(event.slots), sent=event.time, returned=None, error=None)
160
+ elif isinstance(event, events.EvaluationStart):
161
+ evaluation = event.time
162
+ elif isinstance(event, events.Plan):
163
+ directions = event.directions
164
+ stages = dict.fromkeys(range(len(directions)), "planned")
165
+ elif isinstance(event, events.GateOutcome):
166
+ stages[event.slot], latest[event.slot] = event.step, event.item
167
+ checks.setdefault(event.slot, []).append(Check(
168
+ by="gate", step=event.step, passed=event.passed, stage=event.stage, reasons=tuple(event.reasons),
169
+ hints=tuple(event.hints), time=event.time,
170
+ ))
171
+ elif isinstance(event, events.ReviewVerdict):
172
+ stages[event.slot] = "consistent" if event.consistent else "inconsistent"
173
+ checks.setdefault(event.slot, []).append(Check(
174
+ by="reviewer", step=None, passed=event.consistent, stage=None, reasons=(event.reason,), hints=(),
175
+ time=event.time,
176
+ ))
177
+ pending = tuple(
178
+ PendingTrial(slot=slot, stage=stages[slot], direction=direction, latest=latest.get(slot),
179
+ checks=tuple(checks.get(slot, ())))
180
+ for slot, direction in enumerate(directions)
181
+ )
182
+ return RunningRound(round=number, started=started, calls=tuple(calls.values()), evaluation=evaluation,
183
+ pending=pending)
184
+
185
+
186
+ def _call_number(file: str | None) -> int | None:
187
+ """The number of the call file named ``file``, as ``<number>-<role>.json``."""
188
+ return int(file.partition("-")[0]) if file else None
189
+
190
+
191
+ def read_call(run_dir: str | Path, number: int) -> LLMCall | None:
192
+ """The LLM call of the run in ``run_dir`` whose call file has ``number``, ``None`` when it has none.
193
+
194
+ A call of a round a resume dropped is not read: its file is no longer
195
+ in the run's sequence of calls.
196
+ """
197
+ run_dir = Path(run_dir)
198
+ files = list((run_dir / "llm_log").glob(f"{number:06d}-*.json")) if number > 0 else []
199
+ if not files:
200
+ return None
201
+ path = files[0]
202
+ try:
203
+ call = loads(path.read_text())
204
+ except (FileNotFoundError, json.JSONDecodeError):
205
+ return None
206
+ request, reply = call.get("request") or {}, call.get("reply") or {}
207
+ text, raw = reply.get("text") or "", reply.get("raw") or ""
208
+ facts = events.reply_facts(Reply(text=text, usage=reply.get("usage") or {}, model_id=reply.get("model_id") or "",
209
+ raw=raw))
210
+ end = next((e for e in reversed(events.read(run_dir / events.FILE))
211
+ if isinstance(e, events.CallEnd) and e.file == path.name), None)
212
+ return LLMCall(
213
+ number=number, role=request.get("role") or path.stem.partition("-")[2], model=request.get("model") or "",
214
+ round=end.round if end else None, group=end.group if end else None, slots=tuple(end.slots) if end else (),
215
+ time=end.time if end else utc(path.stat().st_mtime), seconds=end.seconds if end else None,
216
+ tokens=Tokens(facts["input_tokens"], facts["output_tokens"], facts["cache_hits"], facts["reasoning"]),
217
+ truncated=facts["truncated"], empty=facts["empty"], prompt=request.get("prompt") or "", reply=text,
218
+ reasoning=raw_facts(raw).reasoning,
219
+ )
@@ -0,0 +1,112 @@
1
+ """The trials page: the evaluated trials on the admission map, and the others grouped by what refused them."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from factor_agent.web._files import Replayed
7
+ from factor_agent.web._ledger import Trial, in_direction, monotonicity, thresholds
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Placed:
12
+ """An evaluated trial as the admission map places it, with its statistics in its direction.
13
+
14
+ ``round`` and ``slot`` name the trial, ``status`` is accepted or rejected.
15
+ ``factor_direction`` is +1 when a higher value went with a higher training
16
+ return, -1 otherwise. ``ic_mean``, ``ic_t``, ``partial_ic_mean`` and
17
+ ``partial_ic_t`` are in that direction; the trial's ``metrics`` keep the
18
+ raw values. ``largest_correlation`` is the larger absolute correlation
19
+ with the nearest reference or accepted factor and the nearest known one,
20
+ ``None`` when there was none to compare with. ``threshold`` is the t
21
+ threshold of its round, as ``Factor.threshold`` (``None`` while nothing gives it).
22
+ ``monotonicity`` is its decile monotonicity in its direction, as admission's.
23
+ """
24
+
25
+ round: int
26
+ slot: int
27
+ status: str
28
+ factor_direction: int
29
+ ic_mean: float | None
30
+ ic_t: float | None
31
+ partial_ic_mean: float | None
32
+ partial_ic_t: float | None
33
+ largest_correlation: float | None
34
+ threshold: float | None
35
+ monotonicity: float | None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TrialRef:
40
+ """A trial named by its round and its slot in the round."""
41
+
42
+ round: int
43
+ slot: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Refusal:
48
+ """The trials one stage refused for the same reason before the Evaluator scored them.
49
+
50
+ ``stage`` is ``"parse"``, ``"gate"``, ``"review"`` or ``"evaluate"`` (code
51
+ the Evaluator could not build). ``reason`` is the first trial's final
52
+ reason; the others' may differ in the numbers, reply or code they quote.
53
+ ``rounds`` are the rounds the trials belong to, the earliest first.
54
+ """
55
+
56
+ stage: str
57
+ reason: str
58
+ rounds: tuple[int, ...]
59
+ trials: tuple[TrialRef, ...]
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class TrialsPage:
64
+ """The admission map's trials, the latest round's threshold and the trials never evaluated, as ``RunView`` holds them."""
65
+
66
+ evaluated: tuple[Placed, ...]
67
+ threshold: float | None
68
+ never_evaluated: tuple[Refusal, ...]
69
+
70
+
71
+ #: The order of the stages that refuse a trial before the Evaluator scores it.
72
+ _STAGES = ("parse", "gate", "review", "evaluate")
73
+
74
+ #: What a reason quotes after its message: a reply, code or a value.
75
+ _QUOTED = re.compile(r": ['\"`].*$", re.DOTALL)
76
+
77
+ #: A number in a reason.
78
+ _NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?")
79
+
80
+
81
+ def trials_page(trials: tuple[Trial, ...], replayed: Replayed) -> TrialsPage:
82
+ """What the trials page shows of ``trials``."""
83
+ by_round = thresholds(trials, replayed)
84
+ evaluated = []
85
+ for trial in trials:
86
+ if not trial.metrics:
87
+ continue
88
+ metrics = trial.metrics
89
+ sign, oriented = in_direction(metrics)
90
+ correlations = [abs(c) for c in (metrics.get("nearest_correlation"), metrics.get("nearest_known_correlation"))
91
+ if c is not None]
92
+ evaluated.append(Placed(
93
+ round=trial.round, slot=trial.slot, status=trial.status, factor_direction=sign,
94
+ ic_mean=oriented["ic_mean"], ic_t=oriented["ic_t"], partial_ic_mean=oriented["partial_ic_mean"],
95
+ partial_ic_t=oriented["partial_ic_t"], largest_correlation=max(correlations) if correlations else None,
96
+ threshold=by_round[trial.round][0],
97
+ monotonicity=monotonicity(tuple(metrics.get("decile_returns") or ()), sign),
98
+ ))
99
+ groups: dict[tuple[str, str], list[Trial]] = {}
100
+ for trial in trials:
101
+ if trial.metrics or trial.status == "accepted":
102
+ continue
103
+ reason = trial.reasons[0] if trial.reasons else ""
104
+ groups.setdefault((trial.stage, _NUMBER.sub("#", _QUOTED.sub("", reason))), []).append(trial)
105
+ never = [Refusal(stage=stage, reason=members[0].reasons[0] if members[0].reasons else "",
106
+ rounds=tuple(sorted({t.round for t in members})),
107
+ trials=tuple(TrialRef(t.round, t.slot) for t in members))
108
+ for (stage, _), members in groups.items()]
109
+ never.sort(key=lambda g: (_STAGES.index(g.stage) if g.stage in _STAGES else len(_STAGES), g.trials[0].round,
110
+ g.trials[0].slot))
111
+ return TrialsPage(evaluated=tuple(evaluated), threshold=by_round[max(by_round)][0] if by_round else None,
112
+ never_evaluated=tuple(never))
@@ -0,0 +1,118 @@
1
+ """The frontend's web service: JSON routes over ``run_view`` and the built page, on localhost only.
2
+
3
+ ``factor-agent-web <folder>`` serves the run directories under a folder on
4
+ ``http://127.0.0.1:<port>``. It binds the loopback interface and has no
5
+ option to bind another: the pages show prompts, code and statistics and
6
+ there is no login. On a server, forward the port over SSH.
7
+
8
+ Routes:
9
+
10
+ - ``GET /api/runs``: every run under the folder (``list_runs``), the latest written first;
11
+ - ``GET /api/run?name=<name>``: one run (``RunView.read``) by its name in the list, 404 for any other name;
12
+ - ``GET /api/run/call?name=<name>&number=<number>``: one LLM call of a run (``read_call``) by the number
13
+ of its call file, 404 when the run has no call file of that number;
14
+ - ``GET /api/run/files?name=<name>``: the config of each start or resume of a run and its snapshot's
15
+ files (``read_files``), read only when the page shows them;
16
+ - ``GET /api/run/ic?name=<name>&round=<round>&slot=<slot>``: a trial's daily IC and partial IC (``read_ic``),
17
+ 404 when it was not evaluated or the run kept none;
18
+ - ``GET /api/run/version?name=<name>``: a text that changes whenever the run writes (``run_version``),
19
+ which the page polls before reading the run again;
20
+ - ``GET /``: the page, built from ``frontend/`` into ``factor_agent/web/static``.
21
+
22
+ FastAPI and uvicorn come with the ``web`` extra and are imported only when
23
+ the app is made, so the module imports without them.
24
+ """
25
+
26
+ import argparse
27
+ from pathlib import Path
28
+
29
+ from factor_agent.web.run_view import RunView, find_run, list_runs, read_call, read_files, read_ic, run_version
30
+
31
+ #: The built page: ``npm run build`` in ``frontend/`` writes it, and it is committed.
32
+ STATIC = Path(__file__).parent / "static"
33
+
34
+ #: The interface the service binds: the loopback interface only.
35
+ HOST = "127.0.0.1"
36
+
37
+
38
+ def create_app(root: str | Path):
39
+ """The web service over the run directories under ``root``.
40
+
41
+ Parameters
42
+ ----------
43
+ root : str or Path
44
+ A folder of run directories, at any depth, or a run directory itself.
45
+
46
+ Returns
47
+ -------
48
+ fastapi.FastAPI
49
+
50
+ Raises
51
+ ------
52
+ ImportError
53
+ When the ``web`` extra is not installed.
54
+ """
55
+ from fastapi import FastAPI, HTTPException
56
+ from fastapi.staticfiles import StaticFiles
57
+
58
+ root = Path(root).resolve()
59
+ app = FastAPI(title="factor-agent", docs_url=None, redoc_url=None, openapi_url=None)
60
+
61
+ @app.get("/api/runs")
62
+ def runs():
63
+ return list_runs(root)
64
+
65
+ def run_dir(name: str) -> Path:
66
+ # Only a listed name is read: a name never reaches the file system unchecked.
67
+ found = find_run(root, name)
68
+ if found is None:
69
+ raise HTTPException(404, f"no run named {name!r} under {root}")
70
+ return found
71
+
72
+ @app.get("/api/run")
73
+ def run(name: str):
74
+ return RunView.read(run_dir(name), name=name)
75
+
76
+ @app.get("/api/run/call")
77
+ def call(name: str, number: int):
78
+ found = read_call(run_dir(name), number)
79
+ if found is None:
80
+ raise HTTPException(404, f"run {name!r} has no LLM call number {number}")
81
+ return found
82
+
83
+ @app.get("/api/run/files")
84
+ def files(name: str):
85
+ return read_files(run_dir(name))
86
+
87
+ @app.get("/api/run/ic")
88
+ def ic(name: str, round: int, slot: int):
89
+ found = read_ic(run_dir(name), round, slot)
90
+ if found is None:
91
+ raise HTTPException(404, f"run {name!r} kept no daily IC of round {round}, slot {slot}")
92
+ return found
93
+
94
+ @app.get("/api/run/version")
95
+ def version(name: str):
96
+ return run_version(run_dir(name))
97
+
98
+ app.mount("/", StaticFiles(directory=STATIC, html=True), name="page")
99
+ return app
100
+
101
+
102
+ def main(argv: list[str] | None = None) -> None:
103
+ """Serve the run directories under a folder on localhost: the ``factor-agent-web`` console script."""
104
+ parser = argparse.ArgumentParser(
105
+ prog="factor-agent-web",
106
+ description="Serve the run directories under a folder on http://127.0.0.1:<port> (localhost only).",
107
+ )
108
+ parser.add_argument("root", type=Path, help="a folder of run directories, or a run directory")
109
+ parser.add_argument("--port", type=int, default=8000, help="the port to listen on (default: 8000)")
110
+ args = parser.parse_args(argv)
111
+ try:
112
+ import uvicorn
113
+ except ImportError:
114
+ parser.exit(1, "factor-agent-web: the frontend needs the web extra: uv sync --extra web\n")
115
+ if not args.root.is_dir():
116
+ parser.exit(1, f"factor-agent-web: {args.root} is not a folder\n")
117
+ print(f"factor-agent-web: serving the runs under {args.root.resolve()} on http://{HOST}:{args.port}", flush=True)
118
+ uvicorn.run(create_app(args.root), host=HOST, port=args.port)