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.
- factor_agent/KUNQUANT_LICENSE +201 -0
- factor_agent/__init__.py +8 -0
- factor_agent/_acceptance_model.py +93 -0
- factor_agent/_admission.py +168 -0
- factor_agent/_expression_fold.py +48 -0
- factor_agent/_feedback.py +399 -0
- factor_agent/_max_drawdown.py +51 -0
- factor_agent/_model_cache.py +100 -0
- factor_agent/_scheduler.py +35 -0
- factor_agent/acceptance.py +808 -0
- factor_agent/accepted/__init__.py +1 -0
- factor_agent/aggregations.py +122 -0
- factor_agent/campaign.py +797 -0
- factor_agent/candidate.py +51 -0
- factor_agent/config.py +651 -0
- factor_agent/derived.py +98 -0
- factor_agent/evaluator.py +1096 -0
- factor_agent/events.py +343 -0
- factor_agent/exported.py +177 -0
- factor_agent/gate/__init__.py +359 -0
- factor_agent/gate/_canonical.py +331 -0
- factor_agent/gate/_checks.py +85 -0
- factor_agent/gate/_constraints.py +85 -0
- factor_agent/gate/_dimension.py +177 -0
- factor_agent/gate/_originality.py +146 -0
- factor_agent/gate/_sandbox.py +105 -0
- factor_agent/gate/graph.py +223 -0
- factor_agent/gate/stages.py +383 -0
- factor_agent/gate/vocabulary.py +1097 -0
- factor_agent/ledger.py +85 -0
- factor_agent/llm/__init__.py +0 -0
- factor_agent/llm/_common.py +18 -0
- factor_agent/llm/_raw.py +78 -0
- factor_agent/llm/anthropic.py +100 -0
- factor_agent/llm/base.py +124 -0
- factor_agent/llm/claude_code.py +101 -0
- factor_agent/llm/log.py +74 -0
- factor_agent/llm/openai_compatible.py +113 -0
- factor_agent/llm/replay.py +59 -0
- factor_agent/llm/scripted.py +39 -0
- factor_agent/period_lag.py +91 -0
- factor_agent/prompts.py +557 -0
- factor_agent/researcher.py +615 -0
- factor_agent/scoring.py +143 -0
- factor_agent/segments.py +184 -0
- factor_agent/session.py +500 -0
- factor_agent/snapshot.py +219 -0
- factor_agent/web/__init__.py +7 -0
- factor_agent/web/_acceptance.py +145 -0
- factor_agent/web/_config.py +51 -0
- factor_agent/web/_diagnosis.py +110 -0
- factor_agent/web/_files.py +78 -0
- factor_agent/web/_health.py +207 -0
- factor_agent/web/_ic.py +27 -0
- factor_agent/web/_ledger.py +293 -0
- factor_agent/web/_memory.py +42 -0
- factor_agent/web/_results.py +77 -0
- factor_agent/web/_round.py +219 -0
- factor_agent/web/_trials.py +112 -0
- factor_agent/web/app.py +118 -0
- factor_agent/web/run_view.py +329 -0
- factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
- factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
- factor_agent/web/static/index.html +13 -0
- factor_agent/zoo.py +136 -0
- quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
- quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
- quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
- quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
- quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
factor_agent/ledger.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The trial ledger: one JSON line per candidate, accepted or not."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class TrialRecord:
|
|
10
|
+
"""One candidate's outcome.
|
|
11
|
+
|
|
12
|
+
``status`` is ``"accepted"``, ``"rejected"`` (refused by the reviewer,
|
|
13
|
+
unevaluated, or evaluated and refused by admission) or ``"failed"``
|
|
14
|
+
(never evaluated: refused by the parser or the Gate after every retry
|
|
15
|
+
or, for the rewrite of a candidate the reviewer sent back, once, or its
|
|
16
|
+
graph did not compile). Only an evaluated trial, one with
|
|
17
|
+
``metrics``, counts toward the evaluated-trial count. ``stage`` is
|
|
18
|
+
where it ended: ``"parse"``, ``"gate"``, ``"review"``, ``"evaluate"``
|
|
19
|
+
or ``"admission"``. ``candidate`` is the last version the proposer
|
|
20
|
+
wrote, ``attempts`` every refused version before it (``item``,
|
|
21
|
+
``stage``, ``reasons``), the one the reviewer sent back to be rewritten
|
|
22
|
+
with stage ``"review"`` and the reviewer's reason. ``direction`` is the planned direction the
|
|
23
|
+
candidate was written for (``mechanism_type``, ``action``, ``parent``,
|
|
24
|
+
``direction``). ``stats`` holds the Gate's statistics, ``graph`` the
|
|
25
|
+
graph it built (``Graph.to_json``) and ``metrics`` the evaluation, once
|
|
26
|
+
there are any; ``confirmation`` the evaluation on the confirmation
|
|
27
|
+
segment, which no prompt shows, when the campaign has one.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
round: int
|
|
31
|
+
candidate: dict
|
|
32
|
+
status: str
|
|
33
|
+
stage: str
|
|
34
|
+
reasons: list[str]
|
|
35
|
+
fingerprint: str | None = None
|
|
36
|
+
metrics: dict | None = None
|
|
37
|
+
stats: dict | None = None
|
|
38
|
+
attempts: list[dict] = field(default_factory=list)
|
|
39
|
+
direction: dict | None = None
|
|
40
|
+
graph: list | None = None
|
|
41
|
+
confirmation: dict | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def evaluated(self) -> bool:
|
|
45
|
+
"""Whether the Evaluator scored the candidate: it counts toward the evaluated-trial count."""
|
|
46
|
+
return self.metrics is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Ledger:
|
|
50
|
+
"""Append and read the trial records of a run directory."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: str | Path):
|
|
53
|
+
self.path = Path(path)
|
|
54
|
+
|
|
55
|
+
def append(self, records: list[TrialRecord]) -> None:
|
|
56
|
+
"""Append ``records``; the file and its directory are created when missing."""
|
|
57
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
with self.path.open("a") as out:
|
|
59
|
+
for record in records:
|
|
60
|
+
out.write(json.dumps(asdict(record)) + "\n")
|
|
61
|
+
|
|
62
|
+
def keep(self, rounds: int) -> None:
|
|
63
|
+
"""Drop the records of every round after ``rounds``, such as a round a crash left half written."""
|
|
64
|
+
if not self.path.exists():
|
|
65
|
+
return
|
|
66
|
+
lines = [line for line in self.path.read_text().splitlines() if line]
|
|
67
|
+
kept = []
|
|
68
|
+
for line in lines:
|
|
69
|
+
try:
|
|
70
|
+
record = TrialRecord(**json.loads(line))
|
|
71
|
+
except (json.JSONDecodeError, TypeError): # a line a crash cut short belongs to an unfinished round
|
|
72
|
+
continue
|
|
73
|
+
if record.round <= rounds:
|
|
74
|
+
kept.append(record)
|
|
75
|
+
if len(kept) == len(lines):
|
|
76
|
+
return
|
|
77
|
+
tmp = self.path.with_suffix(".tmp")
|
|
78
|
+
tmp.write_text("".join(json.dumps(asdict(r)) + "\n" for r in kept))
|
|
79
|
+
tmp.replace(self.path)
|
|
80
|
+
|
|
81
|
+
def records(self) -> list[TrialRecord]:
|
|
82
|
+
"""Every record written so far, in order."""
|
|
83
|
+
if not self.path.exists():
|
|
84
|
+
return []
|
|
85
|
+
return [TrialRecord(**json.loads(line)) for line in self.path.read_text().splitlines() if line]
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""What the provider adapters share: the key from its environment variable."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def key_from_environment(name: str) -> str:
|
|
7
|
+
"""The value of the environment variable ``name``.
|
|
8
|
+
|
|
9
|
+
Raises
|
|
10
|
+
------
|
|
11
|
+
ValueError
|
|
12
|
+
If the variable is unset or empty.
|
|
13
|
+
"""
|
|
14
|
+
value = os.environ.get(name, "")
|
|
15
|
+
if not value:
|
|
16
|
+
raise ValueError(f"the environment variable {name} holding the API key is not set")
|
|
17
|
+
return value
|
|
18
|
+
|
factor_agent/llm/_raw.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""What a provider's raw reply says beyond the reply's text and usage: cache hits, reasoning, why it stopped.
|
|
2
|
+
|
|
3
|
+
Every backend keeps the provider's reply as it came (``Reply.raw``): an
|
|
4
|
+
Anthropic message, an OpenAI-compatible chat completion (DeepSeek's among
|
|
5
|
+
them) or the Claude Code CLI's result. ``raw_facts`` reads the few facts
|
|
6
|
+
the events file and the frontend need from any of them, and nothing of
|
|
7
|
+
the reply is read anywhere else. A raw reply that is empty, not JSON or of
|
|
8
|
+
another shape gives no fact.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class RawFacts:
|
|
17
|
+
"""What a provider's raw reply reports; ``None`` for anything it does not.
|
|
18
|
+
|
|
19
|
+
``cache_hits`` are input tokens read from the provider's cache,
|
|
20
|
+
``reasoning_tokens`` output tokens the model spent reasoning, and
|
|
21
|
+
``reasoning`` the model's reasoning itself. ``truncated`` is true when
|
|
22
|
+
the reply was cut off at the output limit, false when it stopped for
|
|
23
|
+
another reason.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
cache_hits: int | None = None
|
|
27
|
+
reasoning_tokens: int | None = None
|
|
28
|
+
truncated: bool | None = None
|
|
29
|
+
reasoning: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def raw_facts(raw: str) -> RawFacts:
|
|
33
|
+
"""The facts of the provider's raw reply ``raw`` (see the module's notes)."""
|
|
34
|
+
try:
|
|
35
|
+
value = json.loads(raw) if raw else {}
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
return RawFacts()
|
|
38
|
+
if not isinstance(value, dict):
|
|
39
|
+
return RawFacts()
|
|
40
|
+
usage = value.get("usage") if isinstance(value.get("usage"), dict) else {}
|
|
41
|
+
choices = value.get("choices")
|
|
42
|
+
choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
|
43
|
+
stop = _first(value.get("stop_reason"), choice.get("finish_reason"))
|
|
44
|
+
return RawFacts(
|
|
45
|
+
cache_hits=_first(
|
|
46
|
+
usage.get("cache_read_input_tokens"),
|
|
47
|
+
_dig(usage, "prompt_tokens_details", "cached_tokens"),
|
|
48
|
+
usage.get("prompt_cache_hit_tokens"),
|
|
49
|
+
),
|
|
50
|
+
reasoning_tokens=_dig(usage, "completion_tokens_details", "reasoning_tokens"),
|
|
51
|
+
truncated=None if stop is None else stop in ("max_tokens", "length"),
|
|
52
|
+
reasoning=_reasoning(value, choice),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _reasoning(value: dict, choice: dict) -> str | None:
|
|
57
|
+
"""The model's reasoning: an OpenAI-compatible message's, or an Anthropic message's thinking blocks."""
|
|
58
|
+
if choice:
|
|
59
|
+
message = choice.get("message")
|
|
60
|
+
text = (message.get("reasoning_content") or message.get("reasoning")) if isinstance(message, dict) else None
|
|
61
|
+
return text if isinstance(text, str) and text else None
|
|
62
|
+
blocks = value.get("content")
|
|
63
|
+
if isinstance(blocks, list):
|
|
64
|
+
thinking = [b.get("thinking") for b in blocks if isinstance(b, dict) and b.get("type") == "thinking"]
|
|
65
|
+
return "\n\n".join(t for t in thinking if isinstance(t, str) and t) or None
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _first(*values):
|
|
70
|
+
"""The first of ``values`` that is not ``None``."""
|
|
71
|
+
return next((v for v in values if v is not None), None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _dig(value, *keys):
|
|
75
|
+
"""``value[keys[0]][keys[1]]...``, ``None`` where a level is not a dict."""
|
|
76
|
+
for key in keys:
|
|
77
|
+
value = value.get(key) if isinstance(value, dict) else None
|
|
78
|
+
return value
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The Anthropic API backend: the Messages API through the ``anthropic`` SDK, its key from ``ANTHROPIC_API_KEY``."""
|
|
2
|
+
|
|
3
|
+
import anthropic
|
|
4
|
+
|
|
5
|
+
from factor_agent.llm._common import key_from_environment
|
|
6
|
+
from factor_agent.llm.base import ProviderQuotaExhausted, ProviderReplyMalformed, Reply, Request
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AnthropicBackend:
|
|
10
|
+
"""Complete a request with one Messages API call.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
model : str
|
|
15
|
+
The model of every call.
|
|
16
|
+
max_tokens : int
|
|
17
|
+
The ``max_tokens`` of every call.
|
|
18
|
+
effort : str, optional
|
|
19
|
+
The ``output_config.effort`` of every call; the model's default if None.
|
|
20
|
+
base_url : str, optional
|
|
21
|
+
Another endpoint than Anthropic's.
|
|
22
|
+
timeout_seconds : float
|
|
23
|
+
Seconds a call may take.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, model: str, max_tokens: int = 16000, effort: str | None = None,
|
|
28
|
+
base_url: str | None = None, timeout_seconds: float = 600.0,
|
|
29
|
+
):
|
|
30
|
+
self.model = model
|
|
31
|
+
self.max_tokens = max_tokens
|
|
32
|
+
self.effort = effort
|
|
33
|
+
self._client = anthropic.Anthropic(
|
|
34
|
+
api_key=key_from_environment("ANTHROPIC_API_KEY"), base_url=base_url, timeout=timeout_seconds,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_config(cls, spec: dict) -> "AnthropicBackend":
|
|
39
|
+
"""Build from a role's config: ``model`` and optionally ``max_tokens``, ``effort``, ``base_url``, ``timeout_seconds``."""
|
|
40
|
+
options = {k: spec[k] for k in ("max_tokens", "effort", "base_url", "timeout_seconds") if k in spec}
|
|
41
|
+
return cls(spec["model"], **options)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def params(self) -> dict:
|
|
45
|
+
"""The parameters every call sends with the prompt."""
|
|
46
|
+
return {"max_tokens": self.max_tokens, **({"effort": self.effort} if self.effort else {})}
|
|
47
|
+
|
|
48
|
+
def complete(self, request: Request) -> Reply:
|
|
49
|
+
"""Send ``request``'s prompt as one user message.
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
ProviderQuotaExhausted
|
|
54
|
+
If Anthropic refuses the call for billing: an error of type
|
|
55
|
+
``billing_error`` (HTTP 402), or the request error saying the
|
|
56
|
+
credit balance is too low.
|
|
57
|
+
anthropic.APIError
|
|
58
|
+
If the call fails otherwise, after the SDK's retries.
|
|
59
|
+
ProviderReplyMalformed
|
|
60
|
+
If Anthropic answers something that is not a message: no body
|
|
61
|
+
(``null``), no content or no token usage.
|
|
62
|
+
"""
|
|
63
|
+
extra = {"output_config": {"effort": self.effort}} if self.effort else {}
|
|
64
|
+
try:
|
|
65
|
+
message = self._client.messages.create(
|
|
66
|
+
model=self.model, max_tokens=self.max_tokens,
|
|
67
|
+
messages=[{"role": "user", "content": request.prompt}], **extra,
|
|
68
|
+
)
|
|
69
|
+
except anthropic.APIStatusError as exc:
|
|
70
|
+
if _is_billing(exc):
|
|
71
|
+
raise ProviderQuotaExhausted(f"anthropic: {self.model}: {exc.message}") from exc
|
|
72
|
+
raise
|
|
73
|
+
lacking = _lacking(message)
|
|
74
|
+
if lacking:
|
|
75
|
+
raise ProviderReplyMalformed(f"anthropic: {self.model} answered a message without {lacking}")
|
|
76
|
+
usage = message.usage
|
|
77
|
+
input_tokens = usage.input_tokens + (usage.cache_creation_input_tokens or 0) + (usage.cache_read_input_tokens or 0)
|
|
78
|
+
return Reply(
|
|
79
|
+
text="".join(block.text for block in message.content if block.type == "text"),
|
|
80
|
+
usage={"input_tokens": input_tokens, "output_tokens": usage.output_tokens},
|
|
81
|
+
model_id=message.model, params=self.params, raw=message.to_json(indent=None),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_billing(exc: "anthropic.APIStatusError") -> bool:
|
|
86
|
+
"""Whether Anthropic refused the call for billing: a ``billing_error``, or a request error on the credit balance."""
|
|
87
|
+
error = exc.body.get("error", {}) if isinstance(exc.body, dict) else {}
|
|
88
|
+
kind, message = error.get("type"), str(error.get("message", ""))
|
|
89
|
+
return kind == "billing_error" or (kind == "invalid_request_error" and "credit balance" in message.lower())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _lacking(message) -> str | None:
|
|
93
|
+
"""What ``message`` lacks to be a reply (its body, the content, the usage), or None."""
|
|
94
|
+
if message is None:
|
|
95
|
+
return "a body"
|
|
96
|
+
if getattr(message, "content", None) is None:
|
|
97
|
+
return "content"
|
|
98
|
+
if getattr(message, "usage", None) is None:
|
|
99
|
+
return "token usage"
|
|
100
|
+
return None
|
factor_agent/llm/base.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The one external seam: an LLM backend turns a request into a reply."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Request:
|
|
10
|
+
"""One call: the role making it, the model, the prompt and the sampling parameters."""
|
|
11
|
+
|
|
12
|
+
role: str
|
|
13
|
+
model: str
|
|
14
|
+
prompt: str
|
|
15
|
+
params: dict = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Reply:
|
|
20
|
+
"""The provider's answer: its text, token usage as the provider reports it and the model that answered.
|
|
21
|
+
|
|
22
|
+
``params`` are the parameters the backend sent with the prompt (such as
|
|
23
|
+
``max_tokens``), and ``raw`` is the provider's reply as it came, before
|
|
24
|
+
the text was taken out of it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
text: str
|
|
28
|
+
usage: dict
|
|
29
|
+
model_id: str
|
|
30
|
+
params: dict = field(default_factory=dict)
|
|
31
|
+
raw: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProviderQuotaExhausted(RuntimeError):
|
|
35
|
+
"""The provider refuses calls because the balance or quota is exhausted; its message says so in its words.
|
|
36
|
+
|
|
37
|
+
Each backend recognises its own provider's refusal. A campaign stops on
|
|
38
|
+
it cleanly, dropping the round in progress, and resumes once topped up.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProviderReplyMalformed(RuntimeError):
|
|
43
|
+
"""The provider answered, without an error, something that is not a reply; its message says what it lacked.
|
|
44
|
+
|
|
45
|
+
Such as a body of ``null`` (DeepSeek, after the blank lines it sends
|
|
46
|
+
while a call waits), a reply without its text or its usage, or output
|
|
47
|
+
that is not the JSON it should be. The SDKs do not retry it, as the
|
|
48
|
+
answer came back as a success; a campaign asks again, a few times
|
|
49
|
+
(``AskingAgain``), before raising it.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LLMBackend(Protocol):
|
|
54
|
+
"""Anything that completes a request."""
|
|
55
|
+
|
|
56
|
+
def complete(self, request: Request) -> Reply: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: Seconds waited before each call asked again after an answer that was not a reply: two calls again, then it raises.
|
|
60
|
+
ASK_AGAIN_WAITS = (5.0, 30.0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AskingAgain:
|
|
64
|
+
"""A backend that asks ``backend`` again when it answers something that is not a reply.
|
|
65
|
+
|
|
66
|
+
A campaign calls every role's backend through one, its own backends and
|
|
67
|
+
those passed in alike.
|
|
68
|
+
|
|
69
|
+
``ProviderReplyMalformed`` is retried after each wait of
|
|
70
|
+
``ASK_AGAIN_WAITS`` and raised once they are spent; every other error
|
|
71
|
+
is raised at once.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, backend: LLMBackend):
|
|
75
|
+
self.backend = backend
|
|
76
|
+
|
|
77
|
+
def complete(self, request: Request) -> Reply:
|
|
78
|
+
"""Complete ``request`` with ``backend``, asking again after an answer that was not a reply."""
|
|
79
|
+
for wait in ASK_AGAIN_WAITS:
|
|
80
|
+
try:
|
|
81
|
+
return self.backend.complete(request)
|
|
82
|
+
except ProviderReplyMalformed:
|
|
83
|
+
time.sleep(wait)
|
|
84
|
+
return self.backend.complete(request)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def make_backend(spec: dict) -> LLMBackend:
|
|
88
|
+
"""Build the backend a role's config names in ``spec["backend"]``.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
ValueError
|
|
93
|
+
If the backend name is unknown, or the config holds a key: keys come
|
|
94
|
+
only from environment variables (``api_key_env`` names one).
|
|
95
|
+
"""
|
|
96
|
+
from factor_agent.llm.anthropic import AnthropicBackend
|
|
97
|
+
from factor_agent.llm.claude_code import ClaudeCodeBackend
|
|
98
|
+
from factor_agent.llm.openai_compatible import OpenAICompatibleBackend
|
|
99
|
+
from factor_agent.llm.replay import ReplayBackend
|
|
100
|
+
from factor_agent.llm.scripted import ScriptedBackend
|
|
101
|
+
|
|
102
|
+
backends = {
|
|
103
|
+
"scripted": ScriptedBackend.from_config, "replay": ReplayBackend.from_config,
|
|
104
|
+
"claude_code": ClaudeCodeBackend.from_config, "anthropic": AnthropicBackend.from_config,
|
|
105
|
+
"openai_compatible": OpenAICompatibleBackend.from_config,
|
|
106
|
+
}
|
|
107
|
+
name = spec.get("backend")
|
|
108
|
+
if name not in backends:
|
|
109
|
+
raise ValueError(f"unknown LLM backend {name!r}; known: {sorted(backends)}")
|
|
110
|
+
secrets = [option for option in spec if _is_secret(option)]
|
|
111
|
+
if secrets:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"LLM backend {name!r}: {secrets} look like keys; a key is read only from an environment "
|
|
114
|
+
f"variable (name it with api_key_env), never from the config"
|
|
115
|
+
)
|
|
116
|
+
return backends[name](spec)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_secret(option: str) -> bool:
|
|
120
|
+
"""Whether a config option's name says it holds a credential."""
|
|
121
|
+
name = option.lower()
|
|
122
|
+
if name == "api_key_env":
|
|
123
|
+
return False
|
|
124
|
+
return name in {"key", "token", "secret", "password"} or name.endswith(("_key", "_token", "_secret", "_password"))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The Claude Code backend: ``claude -p`` headless, JSON output, every tool off, the local login.
|
|
2
|
+
|
|
3
|
+
Every built-in tool, MCP server, skill, slash command and settings file is
|
|
4
|
+
turned off and the call runs from an empty directory, so the model only
|
|
5
|
+
writes text and cannot read the data. ``ANTHROPIC_API_KEY`` and
|
|
6
|
+
``ANTHROPIC_AUTH_TOKEN`` are kept out of the child's environment, so the
|
|
7
|
+
call goes through the Claude Code login. Managed (enterprise) settings
|
|
8
|
+
cannot be turned off from the command line.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
from factor_agent.llm.base import ProviderReplyMalformed, Reply, Request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClaudeCodeBackend:
|
|
20
|
+
"""Complete a request with one headless Claude Code call.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
model : str
|
|
25
|
+
The ``--model`` of every call.
|
|
26
|
+
effort : str, optional
|
|
27
|
+
The ``--effort`` of every call; Claude Code's default if None.
|
|
28
|
+
executable : str
|
|
29
|
+
The Claude Code command.
|
|
30
|
+
timeout_seconds : float
|
|
31
|
+
Seconds a call may take.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, model: str, effort: str | None = None, executable: str = "claude", timeout_seconds: float = 900.0,
|
|
36
|
+
):
|
|
37
|
+
self.model = model
|
|
38
|
+
self.effort = effort
|
|
39
|
+
self.executable = executable
|
|
40
|
+
self.timeout_seconds = timeout_seconds
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_config(cls, spec: dict) -> "ClaudeCodeBackend":
|
|
44
|
+
"""Build from a role's config: ``model`` and optionally ``effort``, ``executable`` and ``timeout_seconds``."""
|
|
45
|
+
options = {k: spec[k] for k in ("effort", "executable", "timeout_seconds") if k in spec}
|
|
46
|
+
return cls(spec["model"], **options)
|
|
47
|
+
|
|
48
|
+
def command(self) -> list[str]:
|
|
49
|
+
"""The command line of every call; the prompt goes on stdin."""
|
|
50
|
+
command = [
|
|
51
|
+
self.executable, "-p", "--output-format", "json", "--model", self.model,
|
|
52
|
+
"--tools", "", "--strict-mcp-config", "--mcp-config", json.dumps({"mcpServers": {}}),
|
|
53
|
+
"--disable-slash-commands", "--setting-sources", "", "--no-session-persistence",
|
|
54
|
+
]
|
|
55
|
+
if self.effort:
|
|
56
|
+
command += ["--effort", self.effort]
|
|
57
|
+
return command
|
|
58
|
+
|
|
59
|
+
def complete(self, request: Request) -> Reply:
|
|
60
|
+
"""Run the call and read its JSON reply.
|
|
61
|
+
|
|
62
|
+
Raises
|
|
63
|
+
------
|
|
64
|
+
RuntimeError
|
|
65
|
+
If Claude Code exits with an error or reports one.
|
|
66
|
+
subprocess.TimeoutExpired
|
|
67
|
+
If the call takes longer than ``timeout_seconds``.
|
|
68
|
+
ProviderReplyMalformed
|
|
69
|
+
If Claude Code succeeds but writes no JSON object with a ``result``.
|
|
70
|
+
"""
|
|
71
|
+
env = {k: v for k, v in os.environ.items() if k not in _CREDENTIALS}
|
|
72
|
+
with tempfile.TemporaryDirectory(prefix="factor_agent_claude_") as empty:
|
|
73
|
+
finished = subprocess.run(
|
|
74
|
+
self.command(), input=request.prompt, capture_output=True, text=True, cwd=empty, env=env,
|
|
75
|
+
timeout=self.timeout_seconds,
|
|
76
|
+
)
|
|
77
|
+
if finished.returncode != 0:
|
|
78
|
+
raise RuntimeError(f"claude_code: exit {finished.returncode}: {(finished.stderr or finished.stdout)[-2000:]}")
|
|
79
|
+
try:
|
|
80
|
+
reply = json.loads(finished.stdout)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
reply = None
|
|
83
|
+
if not isinstance(reply, dict):
|
|
84
|
+
raise ProviderReplyMalformed(f"claude_code: the output is not a JSON object: {finished.stdout[-2000:]!r}")
|
|
85
|
+
if reply.get("is_error"):
|
|
86
|
+
raise RuntimeError(f"claude_code: the call failed: {str(reply.get('result'))[:2000]}")
|
|
87
|
+
if not isinstance(reply.get("result"), str):
|
|
88
|
+
raise ProviderReplyMalformed(f"claude_code: the reply has no result: {finished.stdout[-2000:]!r}")
|
|
89
|
+
usage = reply.get("usage") or {}
|
|
90
|
+
models = reply.get("modelUsage") or {}
|
|
91
|
+
return Reply(
|
|
92
|
+
text=reply["result"],
|
|
93
|
+
usage={k: usage[k] for k in ("input_tokens", "output_tokens") if k in usage},
|
|
94
|
+
model_id=self.model if self.model in models else next(iter(models), self.model),
|
|
95
|
+
params={"effort": self.effort} if self.effort else {},
|
|
96
|
+
raw=finished.stdout.strip(),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#: Environment variables kept from the child, so it answers through the Claude Code login.
|
|
101
|
+
_CREDENTIALS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
|
factor_agent/llm/log.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The LLM log: every call of a run, in full, one JSON file per call under ``llm_log/``.
|
|
2
|
+
|
|
3
|
+
A file ``<number>-<role>.json`` holds the request (role, model, prompt and
|
|
4
|
+
the parameters sent) and the reply (text, usage, the model that
|
|
5
|
+
answered and the provider's raw reply); the numbers count the calls of the
|
|
6
|
+
run from 1, across every role, in the order they finish (calls sent side by
|
|
7
|
+
side are written one at a time); the calls of a round a resume dropped are
|
|
8
|
+
kept in ``dropped/``, out of the sequence. No key is ever part of a request
|
|
9
|
+
or a reply, so none is written.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import asdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from factor_agent.llm.base import LLMBackend, Reply, Request
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LLMLog:
|
|
21
|
+
"""The call files of one run directory's ``llm_log``."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, path: str | Path):
|
|
24
|
+
self.path = Path(path)
|
|
25
|
+
self._writing = threading.Lock()
|
|
26
|
+
|
|
27
|
+
def files(self) -> list[Path]:
|
|
28
|
+
"""The call files, in call order."""
|
|
29
|
+
return sorted(self.path.glob("*.json"))
|
|
30
|
+
|
|
31
|
+
def drop_after(self, count: int) -> None:
|
|
32
|
+
"""Move every call after the first ``count`` to ``dropped/``: calls of a round a crash cut off."""
|
|
33
|
+
for file in self.files()[count:]:
|
|
34
|
+
(self.path / "dropped").mkdir(exist_ok=True)
|
|
35
|
+
file.replace(self.path / "dropped" / file.name)
|
|
36
|
+
|
|
37
|
+
def calls(self) -> list[dict]:
|
|
38
|
+
"""Every recorded call, in call order, as ``{"request": ..., "reply": ...}``."""
|
|
39
|
+
return [json.loads(file.read_text()) for file in self.files()]
|
|
40
|
+
|
|
41
|
+
def write(self, request: Request, reply: Reply) -> Path:
|
|
42
|
+
"""Write the next call file and return its path; the file appears whole or not at all.
|
|
43
|
+
|
|
44
|
+
Safe to call from several threads: one call is written at a time, each under the next number.
|
|
45
|
+
"""
|
|
46
|
+
call = {
|
|
47
|
+
"request": {**asdict(request), "params": {**request.params, **reply.params}},
|
|
48
|
+
"reply": {k: v for k, v in asdict(reply).items() if k != "params"},
|
|
49
|
+
}
|
|
50
|
+
with self._writing:
|
|
51
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
number = len(self.files()) + 1
|
|
53
|
+
path = self.path / f"{number:06d}-{request.role}.json"
|
|
54
|
+
tmp = path.with_suffix(".tmp")
|
|
55
|
+
tmp.write_text(json.dumps(call, indent=2, ensure_ascii=False) + "\n")
|
|
56
|
+
tmp.replace(path)
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RecordingBackend:
|
|
61
|
+
"""A backend that writes every call it passes on to ``log``."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, backend: LLMBackend, log: LLMLog):
|
|
64
|
+
self.backend = backend
|
|
65
|
+
self.log = log
|
|
66
|
+
|
|
67
|
+
def complete(self, request: Request) -> Reply:
|
|
68
|
+
"""Pass ``request`` on and write the call once its reply is back."""
|
|
69
|
+
return self.record(request)[0]
|
|
70
|
+
|
|
71
|
+
def record(self, request: Request) -> tuple[Reply, Path]:
|
|
72
|
+
"""Pass ``request`` on, write the call once its reply is back, and return the reply and the call file."""
|
|
73
|
+
reply = self.backend.complete(request)
|
|
74
|
+
return reply, self.log.write(request, reply)
|