fa-quant 1.2.1__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.
- fa_quant-1.2.1.dist-info/METADATA +236 -0
- fa_quant-1.2.1.dist-info/RECORD +106 -0
- fa_quant-1.2.1.dist-info/WHEEL +4 -0
- fa_quant-1.2.1.dist-info/entry_points.txt +2 -0
- fa_quant-1.2.1.dist-info/licenses/LICENSE +21 -0
- financial_agent/__init__.py +1 -0
- financial_agent/agents/__init__.py +0 -0
- financial_agent/agents/claude/__init__.py +0 -0
- financial_agent/agents/gpt/__init__.py +0 -0
- financial_agent/agents/grok/__init__.py +0 -0
- financial_agent/dl/__init__.py +0 -0
- financial_agent/dl/models/__init__.py +0 -0
- financial_agent/dl/models/baseline_model.py +23 -0
- financial_agent/dl/preprocessing/__init__.py +0 -0
- financial_agent/dl/preprocessing/data_windowing.py +135 -0
- financial_agent/harness/__init__.py +73 -0
- financial_agent/harness/__main__.py +430 -0
- financial_agent/harness/_system/factor_scoring.py +175 -0
- financial_agent/harness/_system/settlement_ledger.py +1222 -0
- financial_agent/harness/audit.py +393 -0
- financial_agent/harness/equity_policy.toml +179 -0
- financial_agent/harness/fingerprint.py +55 -0
- financial_agent/harness/gates.py +522 -0
- financial_agent/harness/handlers.py +77 -0
- financial_agent/harness/helpers.py +47 -0
- financial_agent/harness/kernels.py +696 -0
- financial_agent/harness/ledger.py +325 -0
- financial_agent/harness/lifecycle.py +127 -0
- financial_agent/harness/manifest.py +113 -0
- financial_agent/harness/market_calendar.py +50 -0
- financial_agent/harness/mcp.py +505 -0
- financial_agent/harness/policy.py +248 -0
- financial_agent/harness/runner.py +275 -0
- financial_agent/harness/schema.py +451 -0
- financial_agent/harness/scoring.py +363 -0
- financial_agent/harness/skills.py +458 -0
- financial_agent/quant/__init__.py +1 -0
- financial_agent/quant/__main__.py +506 -0
- financial_agent/quant/backtest/__init__.py +16 -0
- financial_agent/quant/backtest/config.py +74 -0
- financial_agent/quant/backtest/costs.py +62 -0
- financial_agent/quant/backtest/engine.py +248 -0
- financial_agent/quant/backtest/metrics.py +102 -0
- financial_agent/quant/backtest/result.py +51 -0
- financial_agent/quant/data/__init__.py +21 -0
- financial_agent/quant/data/bars.py +103 -0
- financial_agent/quant/data/cache.py +168 -0
- financial_agent/quant/data/providers.py +138 -0
- financial_agent/quant/data/quality.py +104 -0
- financial_agent/quant/data/synthetic.py +67 -0
- financial_agent/quant/execution/__init__.py +24 -0
- financial_agent/quant/execution/alpaca.py +293 -0
- financial_agent/quant/execution/broker.py +98 -0
- financial_agent/quant/execution/ibkr.py +634 -0
- financial_agent/quant/execution/journal.py +139 -0
- financial_agent/quant/execution/paper.py +254 -0
- financial_agent/quant/execution/reconcile.py +35 -0
- financial_agent/quant/mcp.py +210 -0
- financial_agent/quant/portfolio.py +56 -0
- financial_agent/quant/quant_policy.toml +40 -0
- financial_agent/quant/risk/__init__.py +21 -0
- financial_agent/quant/risk/killswitch.py +61 -0
- financial_agent/quant/risk/limits.py +109 -0
- financial_agent/quant/risk/orders.py +91 -0
- financial_agent/quant/risk/pretrade.py +157 -0
- financial_agent/quant/risk/state.py +86 -0
- financial_agent/quant/runner/__init__.py +20 -0
- financial_agent/quant/runner/config.py +254 -0
- financial_agent/quant/runner/promotion.py +277 -0
- financial_agent/quant/runner/report.py +231 -0
- financial_agent/quant/runner/session.py +705 -0
- financial_agent/quant/strategies/__init__.py +19 -0
- financial_agent/quant/strategies/base.py +44 -0
- financial_agent/quant/strategies/benchmarks.py +54 -0
- financial_agent/quant/strategies/momentum.py +80 -0
- financial_agent/quant/strategies/registry.py +65 -0
- financial_agent/quant/strategies/research.py +226 -0
- financial_agent/quant/strategies/trend.py +91 -0
- financial_agent/quant/validation/__init__.py +41 -0
- financial_agent/quant/validation/bootstrap.py +39 -0
- financial_agent/quant/validation/certificate.py +189 -0
- financial_agent/quant/validation/gate.py +304 -0
- financial_agent/quant/validation/overfitting.py +148 -0
- financial_agent/quant/validation/stress.py +33 -0
- financial_agent/quant/validation/walkforward.py +157 -0
- financial_agent/rl/__init__.py +13 -0
- financial_agent/rl/agent.py +112 -0
- financial_agent/rl/environment.py +177 -0
- financial_agent/rl/trainer.py +142 -0
- financial_agent/trading/__init__.py +0 -0
- financial_agent/trading/autotrain/__init__.py +0 -0
- financial_agent/trading/autotrain/training.py +92 -0
- financial_agent/trading/backtest.py +118 -0
- financial_agent/trading/config/__init__.py +0 -0
- financial_agent/trading/config/config.py +1587 -0
- financial_agent/trading/marketdata/__init__.py +0 -0
- financial_agent/trading/marketdata/yahoodownloader.py +99 -0
- financial_agent/trading/model/__init__.py +0 -0
- financial_agent/trading/model/models.py +466 -0
- financial_agent/trading/preprocessing/__init__.py +0 -0
- financial_agent/trading/preprocessing/data.py +32 -0
- financial_agent/trading/preprocessing/preprocessors.py +162 -0
- financial_agent/trading/utils/__init__.py +0 -0
- financial_agent/trading/utils/gpu_config.py +213 -0
- financial_agent/trading/utils/main.py +52 -0
- financial_agent/trading/utils/setup_gpu_for_notebooks.py +130 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""The harness tools over the Model Context Protocol (stdio, plan Phase 4).
|
|
2
|
+
|
|
3
|
+
Any MCP client (Claude Code, the Agent SDK, Codex, Gemini CLI) can call the
|
|
4
|
+
same functions the CLI exposes, with typed inputs and JSON results, instead of
|
|
5
|
+
parsing shell output. Standard library only: newline-delimited JSON-RPC 2.0 on
|
|
6
|
+
stdin/stdout, implementing ``initialize``, ``ping``, ``tools/list`` and
|
|
7
|
+
``tools/call``.
|
|
8
|
+
|
|
9
|
+
Every tool is read-only or a pure computation. Packages are addressed by name
|
|
10
|
+
and must live directly under the output directory; nothing here writes files,
|
|
11
|
+
and no order-placement tool exists (plan §3.7).
|
|
12
|
+
|
|
13
|
+
python -m financial_agent.harness mcp
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import json
|
|
20
|
+
import types
|
|
21
|
+
import typing
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import IO, Any, Callable, Mapping
|
|
25
|
+
|
|
26
|
+
from financial_agent.harness.gates import go_reachability, replay_history
|
|
27
|
+
from financial_agent.harness.kernels import (
|
|
28
|
+
EquityInputs,
|
|
29
|
+
Holding,
|
|
30
|
+
KernelError,
|
|
31
|
+
MarketInputs,
|
|
32
|
+
PriceRisk,
|
|
33
|
+
equity_record,
|
|
34
|
+
market_forecast_records,
|
|
35
|
+
portfolio_feasibility,
|
|
36
|
+
price_risk,
|
|
37
|
+
)
|
|
38
|
+
from financial_agent.harness.manifest import (
|
|
39
|
+
gate_report,
|
|
40
|
+
render_manifest_sections,
|
|
41
|
+
replay_dict,
|
|
42
|
+
)
|
|
43
|
+
from financial_agent.harness.market_calendar import session
|
|
44
|
+
from financial_agent.harness.policy import FAMILIES, Policy, load_policy
|
|
45
|
+
from financial_agent.harness.schema import validate_payload
|
|
46
|
+
from financial_agent.harness.scoring import score_request
|
|
47
|
+
|
|
48
|
+
SERVER_INFO = {"name": "financial-agent-harness", "version": "0.1.0"}
|
|
49
|
+
PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
50
|
+
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR = (
|
|
51
|
+
-32700,
|
|
52
|
+
-32600,
|
|
53
|
+
-32601,
|
|
54
|
+
-32602,
|
|
55
|
+
-32603,
|
|
56
|
+
)
|
|
57
|
+
INSTRUCTIONS = (
|
|
58
|
+
"Deterministic tools for the daily equity research run. The model decides; "
|
|
59
|
+
"these tools compute and enforce. Never type a number a tool can compute: "
|
|
60
|
+
"build ledger records with build_equity_records and build_market_forecasts, "
|
|
61
|
+
"and run gate before a package moves to PUBLISHED."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ToolError(ValueError):
|
|
66
|
+
"""A tool call the server refuses; reported as an ``isError`` result."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Tool:
|
|
71
|
+
name: str
|
|
72
|
+
description: str
|
|
73
|
+
input_schema: dict[str, Any]
|
|
74
|
+
handler: Callable[[Mapping[str, Any]], Any]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _json_type(annotation: Any) -> dict[str, Any]:
|
|
78
|
+
origin = typing.get_origin(annotation)
|
|
79
|
+
args = [a for a in typing.get_args(annotation) if a is not type(None)]
|
|
80
|
+
if origin in (typing.Union, types.UnionType):
|
|
81
|
+
inner = _json_type(args[0]) if len(args) == 1 else {}
|
|
82
|
+
return {"anyOf": [inner, {"type": "null"}]}
|
|
83
|
+
if annotation is str:
|
|
84
|
+
return {"type": "string"}
|
|
85
|
+
if annotation is bool:
|
|
86
|
+
return {"type": "boolean"}
|
|
87
|
+
if annotation is int:
|
|
88
|
+
return {"type": "integer"}
|
|
89
|
+
if annotation is float:
|
|
90
|
+
return {"type": "number"}
|
|
91
|
+
if origin in (tuple, list):
|
|
92
|
+
return {"type": "array", "items": _json_type(args[0])}
|
|
93
|
+
return {"type": "object"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def dataclass_schema(cls: type) -> dict[str, Any]:
|
|
97
|
+
"""Input schema from a kernel's input dataclass: one source of truth."""
|
|
98
|
+
hints = typing.get_type_hints(cls)
|
|
99
|
+
fields = dataclasses.fields(cls)
|
|
100
|
+
return {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {f.name: _json_type(hints[f.name]) for f in fields},
|
|
103
|
+
"required": [
|
|
104
|
+
f.name
|
|
105
|
+
for f in fields
|
|
106
|
+
if f.default is dataclasses.MISSING
|
|
107
|
+
and f.default_factory is dataclasses.MISSING
|
|
108
|
+
],
|
|
109
|
+
"additionalProperties": False,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _object(
|
|
114
|
+
properties: dict[str, Any], required: tuple[str, ...] = ()
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
return {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": properties,
|
|
119
|
+
"required": list(required),
|
|
120
|
+
"additionalProperties": False,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
DATE = {"type": "string", "format": "date", "description": "YYYY-MM-DD"}
|
|
125
|
+
PACKAGE = {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"description": "package directory name, e.g. claude-opus-5-2026-09-03",
|
|
128
|
+
}
|
|
129
|
+
SERIES = {"type": "array", "items": {"type": "number"}, "minItems": 61}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class HarnessServer:
|
|
133
|
+
def __init__(self, output_dir: Path, policy: Policy | None = None) -> None:
|
|
134
|
+
self.output_dir = output_dir
|
|
135
|
+
self.policy = policy or load_policy()
|
|
136
|
+
self.tools = {tool.name: tool for tool in self._tools()}
|
|
137
|
+
|
|
138
|
+
# -- tools -------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _package(self, args: Mapping[str, Any]) -> Path:
|
|
141
|
+
name = str(args.get("package", ""))
|
|
142
|
+
root = self.output_dir.resolve()
|
|
143
|
+
target = (root / name).resolve()
|
|
144
|
+
if not name or target.parent != root or not target.is_dir():
|
|
145
|
+
raise ToolError(f"no package {name!r} directly under {self.output_dir}")
|
|
146
|
+
return target
|
|
147
|
+
|
|
148
|
+
def _tools(self) -> list[Tool]:
|
|
149
|
+
policy = self.policy
|
|
150
|
+
return [
|
|
151
|
+
Tool(
|
|
152
|
+
"gate",
|
|
153
|
+
"Publish gate, status replay (with trading calendar and GO "
|
|
154
|
+
"reachability), and content hash for one run package. A package "
|
|
155
|
+
"may move to PUBLISHED only when pass is true.",
|
|
156
|
+
_object({"package": PACKAGE}, ("package",)),
|
|
157
|
+
lambda a: gate_report(self._package(a), policy),
|
|
158
|
+
),
|
|
159
|
+
Tool(
|
|
160
|
+
"manifest",
|
|
161
|
+
"Markdown for the generated sections of 00_run_manifest.md: "
|
|
162
|
+
"artifact checklist read from disk, status replay, publish gate.",
|
|
163
|
+
_object({"package": PACKAGE}, ("package",)),
|
|
164
|
+
lambda a: render_manifest_sections(self._package(a), policy)[0],
|
|
165
|
+
),
|
|
166
|
+
Tool(
|
|
167
|
+
"schema_check",
|
|
168
|
+
"Findings for a package's 15_predictions.json against ledger "
|
|
169
|
+
"schema v1 (empty means valid).",
|
|
170
|
+
_object({"package": PACKAGE}, ("package",)),
|
|
171
|
+
self._schema_check,
|
|
172
|
+
),
|
|
173
|
+
Tool(
|
|
174
|
+
"replay_history",
|
|
175
|
+
"Recompute every published run status from its ledger and list "
|
|
176
|
+
"the packages whose published status disagrees.",
|
|
177
|
+
_object({"since": DATE, "as_of": DATE}),
|
|
178
|
+
self._replay_history,
|
|
179
|
+
),
|
|
180
|
+
Tool(
|
|
181
|
+
"session",
|
|
182
|
+
"Whether a date is an NYSE trading day, weekend, or holiday, and "
|
|
183
|
+
"the most recent close at or before it.",
|
|
184
|
+
_object({"date": DATE}, ("date",)),
|
|
185
|
+
lambda a: vars(session(str(a["date"]))),
|
|
186
|
+
),
|
|
187
|
+
Tool(
|
|
188
|
+
"reachability",
|
|
189
|
+
"Structural GO blockers: evidence thresholds no name can pass "
|
|
190
|
+
"with the factor families that can score.",
|
|
191
|
+
_object(
|
|
192
|
+
{
|
|
193
|
+
"families": {
|
|
194
|
+
"type": "array",
|
|
195
|
+
"items": {"enum": list(FAMILIES)},
|
|
196
|
+
},
|
|
197
|
+
"ranked_names": {"type": "integer"},
|
|
198
|
+
}
|
|
199
|
+
),
|
|
200
|
+
self._reachability,
|
|
201
|
+
),
|
|
202
|
+
Tool(
|
|
203
|
+
"build_equity_records",
|
|
204
|
+
"Schema-v1 EQUITY_ALPHA ledger records computed from grounded "
|
|
205
|
+
"inputs and judgment (mu adjustment within policy, confidence "
|
|
206
|
+
"no higher than the evidence allows). Refuses out-of-policy input.",
|
|
207
|
+
_object(
|
|
208
|
+
{
|
|
209
|
+
"run_date": DATE,
|
|
210
|
+
"model": {"type": "string"},
|
|
211
|
+
"names": {
|
|
212
|
+
"type": "array",
|
|
213
|
+
"items": dataclass_schema(EquityInputs),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
("run_date", "model", "names"),
|
|
217
|
+
),
|
|
218
|
+
lambda a: [
|
|
219
|
+
equity_record(
|
|
220
|
+
a["run_date"],
|
|
221
|
+
a["model"],
|
|
222
|
+
EquityInputs.from_mapping(item),
|
|
223
|
+
policy,
|
|
224
|
+
)
|
|
225
|
+
for item in a["names"]
|
|
226
|
+
],
|
|
227
|
+
),
|
|
228
|
+
Tool(
|
|
229
|
+
"build_market_forecasts",
|
|
230
|
+
"Schema-v1 MARKET_FORECAST records for SPY, QQQ, SOXX: SPY from "
|
|
231
|
+
"the regime prior, the others as beta x SPY mu, within the bands.",
|
|
232
|
+
_object(
|
|
233
|
+
{
|
|
234
|
+
"run_date": DATE,
|
|
235
|
+
"model": {"type": "string"},
|
|
236
|
+
"regime": {
|
|
237
|
+
"enum": list(policy.get("market_forecast.spy_regime_prior"))
|
|
238
|
+
},
|
|
239
|
+
"etfs": {
|
|
240
|
+
"type": "array",
|
|
241
|
+
"items": dataclass_schema(MarketInputs),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
("run_date", "model", "regime", "etfs"),
|
|
245
|
+
),
|
|
246
|
+
lambda a: market_forecast_records(
|
|
247
|
+
a["run_date"],
|
|
248
|
+
a["model"],
|
|
249
|
+
a["regime"],
|
|
250
|
+
[MarketInputs.from_mapping(item) for item in a["etfs"]],
|
|
251
|
+
policy,
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
Tool(
|
|
255
|
+
"price_risk",
|
|
256
|
+
"Beta, realized/downside vol, tracking error and 60d drawdown from "
|
|
257
|
+
"date-aligned adjusted closes (oldest first, at least 61).",
|
|
258
|
+
_object(
|
|
259
|
+
{"closes": SERIES, "spy_closes": SERIES, "tlt_closes": SERIES},
|
|
260
|
+
("closes", "spy_closes"),
|
|
261
|
+
),
|
|
262
|
+
lambda a: vars(
|
|
263
|
+
price_risk(
|
|
264
|
+
a["closes"], a["spy_closes"], tlt_closes=a.get("tlt_closes")
|
|
265
|
+
)
|
|
266
|
+
),
|
|
267
|
+
),
|
|
268
|
+
Tool(
|
|
269
|
+
"portfolio_feasibility",
|
|
270
|
+
"Portfolio beta, sigma, dd95, average pairwise correlation and "
|
|
271
|
+
"sector weights for proposed NAV weights, with the protected "
|
|
272
|
+
"limits they breach. Pass breaches to the status decision.",
|
|
273
|
+
_object(
|
|
274
|
+
{
|
|
275
|
+
"holdings": {
|
|
276
|
+
"type": "array",
|
|
277
|
+
"minItems": 1,
|
|
278
|
+
"items": _object(
|
|
279
|
+
{
|
|
280
|
+
"ticker": {"type": "string"},
|
|
281
|
+
"weight": {"type": "number"},
|
|
282
|
+
"sector": {"type": "string"},
|
|
283
|
+
"closes": SERIES,
|
|
284
|
+
"earnings_within_window": {"type": "boolean"},
|
|
285
|
+
},
|
|
286
|
+
("ticker", "weight", "sector", "closes"),
|
|
287
|
+
),
|
|
288
|
+
},
|
|
289
|
+
"spy_closes": SERIES,
|
|
290
|
+
},
|
|
291
|
+
("holdings", "spy_closes"),
|
|
292
|
+
),
|
|
293
|
+
lambda a: vars(
|
|
294
|
+
portfolio_feasibility(
|
|
295
|
+
[Holding.from_mapping(h) for h in a["holdings"]],
|
|
296
|
+
a["spy_closes"],
|
|
297
|
+
policy,
|
|
298
|
+
)
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
Tool(
|
|
302
|
+
"score_universe",
|
|
303
|
+
"Slot z-scores, family z, Adj Score, rank and percentile for the "
|
|
304
|
+
"eligible universe, per the normative Metric Definition Table. "
|
|
305
|
+
"Pass each name's pctl, family_z, data quality and penalties to "
|
|
306
|
+
"build_equity_records; never re-derive them.",
|
|
307
|
+
_object(
|
|
308
|
+
{
|
|
309
|
+
"indicators": {
|
|
310
|
+
"description": "technical_indicators.py payload or "
|
|
311
|
+
"its list of records",
|
|
312
|
+
},
|
|
313
|
+
"sectors": {
|
|
314
|
+
"type": "object",
|
|
315
|
+
"additionalProperties": {"type": "string"},
|
|
316
|
+
},
|
|
317
|
+
"universe": {"type": "array", "items": {"type": "string"}},
|
|
318
|
+
"risk": {
|
|
319
|
+
"type": "object",
|
|
320
|
+
"additionalProperties": dataclass_schema(PriceRisk),
|
|
321
|
+
},
|
|
322
|
+
"closes": {
|
|
323
|
+
"type": "object",
|
|
324
|
+
"additionalProperties": SERIES,
|
|
325
|
+
},
|
|
326
|
+
"spy_closes": SERIES,
|
|
327
|
+
"tlt_closes": SERIES,
|
|
328
|
+
"data_quality": {
|
|
329
|
+
"anyOf": [
|
|
330
|
+
{"type": "number"},
|
|
331
|
+
{
|
|
332
|
+
"type": "object",
|
|
333
|
+
"additionalProperties": {"type": "number"},
|
|
334
|
+
},
|
|
335
|
+
]
|
|
336
|
+
},
|
|
337
|
+
"penalties": {
|
|
338
|
+
"type": "object",
|
|
339
|
+
"additionalProperties": {"type": "number"},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
("indicators", "data_quality"),
|
|
343
|
+
),
|
|
344
|
+
lambda a: score_request(a, policy),
|
|
345
|
+
),
|
|
346
|
+
Tool(
|
|
347
|
+
"policy",
|
|
348
|
+
"Read a value from the numeric policy by dotted key (e.g. "
|
|
349
|
+
"evidence.min_pctl), or the whole policy. Cite it; never retype it.",
|
|
350
|
+
_object({"key": {"type": "string"}}),
|
|
351
|
+
self._policy_value,
|
|
352
|
+
),
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
def _schema_check(self, args: Mapping[str, Any]) -> list[str]:
|
|
356
|
+
path = self._package(args) / "15_predictions.json"
|
|
357
|
+
if not path.exists():
|
|
358
|
+
raise ToolError(f"{path.name} not found in {path.parent.name}")
|
|
359
|
+
return validate_payload(
|
|
360
|
+
json.loads(path.read_text(encoding="utf-8")), self.policy
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _replay_history(self, args: Mapping[str, Any]) -> dict[str, Any]:
|
|
364
|
+
replays = replay_history(
|
|
365
|
+
self.output_dir,
|
|
366
|
+
self.policy,
|
|
367
|
+
since=args.get("since"),
|
|
368
|
+
as_of=args.get("as_of"),
|
|
369
|
+
)
|
|
370
|
+
return {
|
|
371
|
+
"replayed": len(replays),
|
|
372
|
+
"agree": sum(1 for _, r in replays if r.agrees),
|
|
373
|
+
"disagreements": {
|
|
374
|
+
name: replay_dict(replay)
|
|
375
|
+
for name, replay in replays
|
|
376
|
+
if not replay.agrees
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
def _reachability(self, args: Mapping[str, Any]) -> dict[str, Any]:
|
|
381
|
+
reach = go_reachability(
|
|
382
|
+
self.policy,
|
|
383
|
+
available_families=args.get("families"),
|
|
384
|
+
ranked_names=args.get("ranked_names"),
|
|
385
|
+
)
|
|
386
|
+
return {
|
|
387
|
+
"reachable": reach.reachable,
|
|
388
|
+
"gating_families": list(reach.gating_families),
|
|
389
|
+
"blockers": list(reach.blockers),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
def _policy_value(self, args: Mapping[str, Any]) -> Any:
|
|
393
|
+
key = args.get("key")
|
|
394
|
+
if not key:
|
|
395
|
+
return self.policy.data
|
|
396
|
+
try:
|
|
397
|
+
return self.policy.get(str(key))
|
|
398
|
+
except (KeyError, TypeError):
|
|
399
|
+
raise ToolError(f"no policy key {key!r}") from None
|
|
400
|
+
|
|
401
|
+
# -- protocol ----------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def handle(self, message: Any) -> dict[str, Any] | None:
|
|
404
|
+
"""One JSON-RPC message in, the response out (``None`` for notices)."""
|
|
405
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
406
|
+
return _error(None, INVALID_REQUEST, "not a JSON-RPC 2.0 message")
|
|
407
|
+
if "id" not in message:
|
|
408
|
+
return None # notifications/initialized, cancelled, ...
|
|
409
|
+
request_id, method = message["id"], message.get("method")
|
|
410
|
+
params = message.get("params") or {}
|
|
411
|
+
if not isinstance(params, dict):
|
|
412
|
+
return _error(request_id, INVALID_PARAMS, "params must be an object")
|
|
413
|
+
if method == "initialize":
|
|
414
|
+
asked = params.get("protocolVersion")
|
|
415
|
+
version = asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0]
|
|
416
|
+
return _result(
|
|
417
|
+
request_id,
|
|
418
|
+
{
|
|
419
|
+
"protocolVersion": version,
|
|
420
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
421
|
+
"serverInfo": SERVER_INFO,
|
|
422
|
+
"instructions": INSTRUCTIONS,
|
|
423
|
+
},
|
|
424
|
+
)
|
|
425
|
+
if method == "ping":
|
|
426
|
+
return _result(request_id, {})
|
|
427
|
+
if method == "tools/list":
|
|
428
|
+
return _result(
|
|
429
|
+
request_id,
|
|
430
|
+
{
|
|
431
|
+
"tools": [
|
|
432
|
+
{
|
|
433
|
+
"name": t.name,
|
|
434
|
+
"description": t.description,
|
|
435
|
+
"inputSchema": t.input_schema,
|
|
436
|
+
"annotations": {
|
|
437
|
+
"readOnlyHint": True,
|
|
438
|
+
"openWorldHint": False,
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
for t in self.tools.values()
|
|
442
|
+
]
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
if method == "tools/call":
|
|
446
|
+
tool = self.tools.get(str(params.get("name")))
|
|
447
|
+
if tool is None:
|
|
448
|
+
return _error(
|
|
449
|
+
request_id, INVALID_PARAMS, f"unknown tool {params.get('name')!r}"
|
|
450
|
+
)
|
|
451
|
+
return _result(request_id, self._call(tool, params.get("arguments") or {}))
|
|
452
|
+
return _error(request_id, METHOD_NOT_FOUND, f"method {method!r} not found")
|
|
453
|
+
|
|
454
|
+
def _call(self, tool: Tool, arguments: Any) -> dict[str, Any]:
|
|
455
|
+
try:
|
|
456
|
+
if not isinstance(arguments, Mapping):
|
|
457
|
+
raise ToolError("arguments must be an object")
|
|
458
|
+
value = tool.handler(arguments)
|
|
459
|
+
except (
|
|
460
|
+
ToolError,
|
|
461
|
+
KernelError,
|
|
462
|
+
KeyError,
|
|
463
|
+
TypeError,
|
|
464
|
+
ValueError,
|
|
465
|
+
OSError,
|
|
466
|
+
) as exc:
|
|
467
|
+
text = f"{type(exc).__name__}: {exc}"
|
|
468
|
+
return {"content": [{"type": "text", "text": text}], "isError": True}
|
|
469
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
470
|
+
return {"content": [{"type": "text", "text": text}], "isError": False}
|
|
471
|
+
|
|
472
|
+
def handle_line(self, line: str) -> dict[str, Any] | None:
|
|
473
|
+
try:
|
|
474
|
+
message = json.loads(line)
|
|
475
|
+
except json.JSONDecodeError as exc:
|
|
476
|
+
return _error(None, PARSE_ERROR, f"parse error: {exc}")
|
|
477
|
+
try:
|
|
478
|
+
return self.handle(message)
|
|
479
|
+
except Exception as exc: # one bad request never stops the server
|
|
480
|
+
request_id = message.get("id") if isinstance(message, dict) else None
|
|
481
|
+
return _error(request_id, INTERNAL_ERROR, f"{type(exc).__name__}: {exc}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
|
|
485
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
489
|
+
return {
|
|
490
|
+
"jsonrpc": "2.0",
|
|
491
|
+
"id": request_id,
|
|
492
|
+
"error": {"code": code, "message": message},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def serve(instream: IO[str], outstream: IO[str], *, output_dir: Path) -> None:
|
|
497
|
+
"""Answer newline-delimited JSON-RPC on ``instream`` until it closes."""
|
|
498
|
+
server = HarnessServer(output_dir)
|
|
499
|
+
for line in instream:
|
|
500
|
+
if not line.strip():
|
|
501
|
+
continue
|
|
502
|
+
response = server.handle_line(line)
|
|
503
|
+
if response is not None:
|
|
504
|
+
outstream.write(json.dumps(response, default=str) + "\n")
|
|
505
|
+
outstream.flush()
|