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
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
+
@@ -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
@@ -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")
@@ -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)