arkaos 5.17.2 → 5.18.0
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.
- package/THE-ARKAOS-GUIDE.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +2 -1
- package/arka/skills/costs/SKILL.md +16 -0
- package/config/claude-agents/marta-cqo.md +10 -0
- package/config/decisions/corpora/bash-effect.jsonl +38 -0
- package/config/decisions/corpora/creation-intent.jsonl +47 -0
- package/config/decisions/corpora/dispatch-role.jsonl +32 -0
- package/config/decisions/corpora/forge-complexity.jsonl +102 -0
- package/config/decisions/corpora/forge-departments.jsonl +34 -0
- package/config/decisions/corpora/heldout/learning-signal.jsonl +17 -0
- package/config/decisions/corpora/heldout/qg-prescreen.jsonl +20 -0
- package/config/decisions/corpora/heldout/slop-score.jsonl +20 -0
- package/config/decisions/corpora/learning-signal.jsonl +35 -0
- package/config/decisions/corpora/phantom-action.jsonl +34 -0
- package/config/decisions/corpora/qg-prescreen.jsonl +46 -0
- package/config/decisions/corpora/refine.jsonl +104 -0
- package/config/decisions/corpora/route.jsonl +33 -0
- package/config/decisions/corpora/skill-hints.jsonl +34 -0
- package/config/decisions/corpora/skill-proposer.jsonl +32 -0
- package/config/decisions/corpora/slop-score.jsonl +33 -0
- package/config/decisions/corpora/subagent-discipline.jsonl +32 -0
- package/config/decisions/corpora/sycophancy.jsonl +34 -0
- package/config/decisions/corpora/topic-drift.jsonl +34 -0
- package/config/decisions/corpora/ui-in-ts.jsonl +60 -0
- package/config/hooks/gate-manifest.json +1 -0
- package/config/models.yaml +10 -2
- package/core/decisions/__init__.py +8 -0
- package/core/decisions/backoff.py +137 -0
- package/core/decisions/cache.py +92 -0
- package/core/decisions/client.py +191 -0
- package/core/decisions/config.py +159 -0
- package/core/decisions/cost_effect.py +242 -0
- package/core/decisions/engine.py +400 -0
- package/core/decisions/models.py +118 -0
- package/core/decisions/paths.py +39 -0
- package/core/decisions/privacy.py +378 -0
- package/core/decisions/promotion.py +126 -0
- package/core/decisions/registry.py +23 -0
- package/core/decisions/replay.py +852 -0
- package/core/decisions/replay_report.py +413 -0
- package/core/decisions/shadow.py +147 -0
- package/core/decisions/site.py +343 -0
- package/core/decisions/sites/__init__.py +1 -0
- package/core/decisions/sites/command.py +58 -0
- package/core/decisions/sites/dispatch.py +161 -0
- package/core/decisions/sites/forge.py +230 -0
- package/core/decisions/sites/governance.py +361 -0
- package/core/decisions/sites/prompt.py +195 -0
- package/core/decisions/sites/quality.py +281 -0
- package/core/decisions/telemetry.py +335 -0
- package/core/decisions/telemetry_cli.py +149 -0
- package/core/decisions/transport.py +97 -0
- package/core/egress/audit.py +46 -6
- package/core/egress/credentials.py +1296 -0
- package/core/egress/policy.py +7 -4
- package/core/evals/verdict_labels.py +25 -5
- package/core/forge/budget.py +32 -0
- package/core/forge/complexity.py +11 -3
- package/core/forge/jev_sites.py +80 -0
- package/core/forge/orchestrator.py +76 -72
- package/core/governance/aggregate_guard.py +175 -77
- package/core/governance/carry_advisor.py +36 -19
- package/core/governance/evidence_checks.py +134 -55
- package/core/governance/fence_recovery.py +165 -0
- package/core/governance/harness_scanner.py +4 -0
- package/core/governance/jev_advisory.py +154 -0
- package/core/governance/leak_scanner.py +2 -2
- package/core/governance/literal_git.py +66 -0
- package/core/governance/qg_prescreen.py +518 -0
- package/core/governance/qg_tier.py +17 -0
- package/core/governance/reviewer_ledger.py +225 -46
- package/core/governance/skill_proposer.py +38 -13
- package/core/governance/slop_check.py +169 -0
- package/core/hooks/operator_message.py +128 -0
- package/core/hooks/stop.py +418 -38
- package/core/hooks/stop_budget.py +43 -0
- package/core/hooks/subagent_stop.py +39 -9
- package/core/hooks/ups_dispatch.py +243 -0
- package/core/hooks/user_prompt_submit.py +458 -106
- package/core/keys.py +4 -0
- package/core/runtime/model_router.py +38 -0
- package/core/runtime/pricing.py +8 -0
- package/core/synapse/command_menu.py +70 -0
- package/core/synapse/layers.py +108 -66
- package/core/workflow/flow_enforcer.py +71 -1
- package/core/workflow/frontend_gate.py +57 -1
- package/core/workflow/transcript_scope.py +34 -25
- package/departments/quality/SKILL.md +29 -2
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/config-seed.js +71 -1
- package/installer/keys.js +1 -1
- package/installer/update.js +29 -0
- package/knowledge/commands-registry.json +17 -4
- package/knowledge/commands-registry.json.bak +6739 -0
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/synapse-bridge.py +17 -1
- package/scripts/tools/corpus_merge.py +402 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Decision engine — one bounded call per turn for every active site.
|
|
2
|
+
|
|
3
|
+
:func:`decide` never raises. Paths:
|
|
4
|
+
|
|
5
|
+
* every site ``off`` (or bypass / disabled) → heuristics, no disk, no network;
|
|
6
|
+
* no transport (no key) → heuristics, no disk, no network;
|
|
7
|
+
* every live site ``shadow`` → detached worker (``shadow.py``), heuristics now;
|
|
8
|
+
* any site ``act`` → ONE synchronous call carrying the questions of every
|
|
9
|
+
live site (keys ``<site>__<question>``), capped by the smallest ceiling.
|
|
10
|
+
|
|
11
|
+
Each live path writes one telemetry line per site. Unavailability of any
|
|
12
|
+
kind (``DecisionUnavailable.reason``) lands in ``Outcome.reason`` and the
|
|
13
|
+
caller acts on its heuristic.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
|
|
24
|
+
from core.decisions import cache
|
|
25
|
+
from core.decisions.backoff import blocked
|
|
26
|
+
from core.decisions.client import DecisionUnavailable, post_decision
|
|
27
|
+
from core.decisions.config import (
|
|
28
|
+
DecisionsConfig,
|
|
29
|
+
Mode,
|
|
30
|
+
load_decisions_config,
|
|
31
|
+
site_mode,
|
|
32
|
+
site_timeout_ms,
|
|
33
|
+
threshold_for,
|
|
34
|
+
)
|
|
35
|
+
from core.decisions.models import Answer, DecisionRequest, DecisionResponse, Question, State
|
|
36
|
+
from core.decisions.privacy import prepare_state, strictest_state_class
|
|
37
|
+
from core.decisions.registry import SITES
|
|
38
|
+
from core.decisions.shadow import spawn_shadow
|
|
39
|
+
from core.decisions.site import Outcome, Site, SiteCall, answer_confidence, valid_answers
|
|
40
|
+
from core.decisions.telemetry import (
|
|
41
|
+
COST_CATEGORY,
|
|
42
|
+
DecisionRecord,
|
|
43
|
+
call_cost_usd,
|
|
44
|
+
record,
|
|
45
|
+
record_call_cost,
|
|
46
|
+
state_sha16,
|
|
47
|
+
)
|
|
48
|
+
from core.decisions.transport import Transport, resolve_transport
|
|
49
|
+
|
|
50
|
+
MIN_CALL_MS = 50 # below this the call cannot finish: skip it ("deadline")
|
|
51
|
+
# The endpoint's model echo is untrusted text bound for telemetry.
|
|
52
|
+
SAFE_MODEL_RE = re.compile(r"[A-Za-z0-9._/:-]{1,80}")
|
|
53
|
+
UNSAFE_MODEL_ECHO = "unsafe-model-echo"
|
|
54
|
+
|
|
55
|
+
RunResult = tuple[DecisionResponse | None, str, int]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class CallMeta:
|
|
60
|
+
"""What every telemetry line of one call shares."""
|
|
61
|
+
|
|
62
|
+
call_id: str
|
|
63
|
+
session_id: str
|
|
64
|
+
state_sha16: str
|
|
65
|
+
transport: Transport
|
|
66
|
+
latency_ms: int
|
|
67
|
+
reason: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def question_key(site_name: str, question: str) -> str:
|
|
71
|
+
"""Wire key ``<site>__<question>`` (hyphens → underscores)."""
|
|
72
|
+
return f"{site_name.replace('-', '_')}__{question}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def active(cfg: DecisionsConfig | None = None, names: Collection[str] | None = None) -> bool:
|
|
76
|
+
"""True when at least one site is live AND a transport exists.
|
|
77
|
+
|
|
78
|
+
``names`` scopes the question to one caller's sites (the UPS asks about
|
|
79
|
+
its own, so a live bash-effect never wakes a prompt stage whose sites
|
|
80
|
+
are all off); None means every registered site.
|
|
81
|
+
"""
|
|
82
|
+
cfg = cfg or load_decisions_config()
|
|
83
|
+
scope = SITES.values() if names is None else [SITES[n] for n in names if n in SITES]
|
|
84
|
+
if not any(site_mode(cfg, site) != "off" for site in scope):
|
|
85
|
+
return False
|
|
86
|
+
return resolve_transport(cfg) is not None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def asked_questions(calls: Sequence[SiteCall], state: State) -> dict[str, Question]:
|
|
90
|
+
"""Every question of every call for ``state``, keyed ``<site>__<question>``."""
|
|
91
|
+
questions: dict[str, Question] = {}
|
|
92
|
+
for call in calls:
|
|
93
|
+
for name, question in call.site.questions_of(state).items():
|
|
94
|
+
questions[question_key(call.site.name, name)] = question
|
|
95
|
+
return questions
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_request(calls: Sequence[SiteCall], state: State, model: str) -> DecisionRequest:
|
|
99
|
+
"""One request holding every question of every call."""
|
|
100
|
+
return DecisionRequest(model=model, state=state, questions=asked_questions(calls, state))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def decide(
|
|
104
|
+
calls: Sequence[SiteCall],
|
|
105
|
+
state: State,
|
|
106
|
+
*,
|
|
107
|
+
session_id: str,
|
|
108
|
+
timeout_ms: int | None = None,
|
|
109
|
+
cfg: DecisionsConfig | None = None,
|
|
110
|
+
model: str | None = None,
|
|
111
|
+
) -> dict[str, Outcome]:
|
|
112
|
+
"""Outcome per site name. Never raises: internal errors → heuristics.
|
|
113
|
+
|
|
114
|
+
``model`` is the ``decisions.model`` of ``models.yaml`` (alias or id);
|
|
115
|
+
None keeps the transport default.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
return _decide(calls, state, session_id, timeout_ms, cfg, model)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
return _heuristics(calls, {}, f"internal:{type(exc).__name__}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _decide(
|
|
124
|
+
calls: Sequence[SiteCall],
|
|
125
|
+
state: State,
|
|
126
|
+
session_id: str,
|
|
127
|
+
timeout_ms: int | None,
|
|
128
|
+
cfg: DecisionsConfig | None,
|
|
129
|
+
model: str | None = None,
|
|
130
|
+
) -> dict[str, Outcome]:
|
|
131
|
+
cfg = cfg or load_decisions_config()
|
|
132
|
+
modes: dict[str, Mode] = {c.site.name: site_mode(cfg, c.site) for c in calls}
|
|
133
|
+
live = [c for c in calls if modes[c.site.name] != "off"]
|
|
134
|
+
if not live:
|
|
135
|
+
return _heuristics(calls, modes, "off")
|
|
136
|
+
transport = resolve_transport(cfg, model=model)
|
|
137
|
+
if transport is None:
|
|
138
|
+
return _heuristics(calls, modes, "no-transport")
|
|
139
|
+
if all(modes[c.site.name] == "shadow" for c in live):
|
|
140
|
+
spawn_shadow(live, state, session_id)
|
|
141
|
+
return _heuristics(calls, modes, "shadow")
|
|
142
|
+
timeout = _effective_timeout_ms(cfg, live, modes, timeout_ms)
|
|
143
|
+
outcomes = _heuristics(calls, modes, "off")
|
|
144
|
+
outcomes.update(run_and_record(
|
|
145
|
+
live, state, modes=modes, cfg=cfg, transport=transport,
|
|
146
|
+
session_id=session_id, timeout_s=timeout / 1000,
|
|
147
|
+
))
|
|
148
|
+
return outcomes
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _heuristics(
|
|
152
|
+
calls: Sequence[SiteCall], modes: Mapping[str, Mode], reason: str
|
|
153
|
+
) -> dict[str, Outcome]:
|
|
154
|
+
return {
|
|
155
|
+
c.site.name: Outcome(
|
|
156
|
+
value=c.heuristic, heuristic=c.heuristic, jev=None, confidence=None,
|
|
157
|
+
mode=modes.get(c.site.name, "off"), acted_on="heuristic", reason=reason,
|
|
158
|
+
)
|
|
159
|
+
for c in calls
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _effective_timeout_ms(
|
|
164
|
+
cfg: DecisionsConfig,
|
|
165
|
+
live: Sequence[SiteCall],
|
|
166
|
+
modes: Mapping[str, Mode],
|
|
167
|
+
timeout_ms: int | None,
|
|
168
|
+
) -> int:
|
|
169
|
+
base = cfg.hook_timeout_ms if timeout_ms is None else timeout_ms
|
|
170
|
+
ceilings = [site_timeout_ms(cfg, c.site) for c in live if modes[c.site.name] == "act"]
|
|
171
|
+
return min([base, *ceilings])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def run_and_record(
|
|
175
|
+
calls: Sequence[SiteCall],
|
|
176
|
+
state: State,
|
|
177
|
+
*,
|
|
178
|
+
modes: Mapping[str, Mode],
|
|
179
|
+
cfg: DecisionsConfig,
|
|
180
|
+
transport: Transport,
|
|
181
|
+
session_id: str,
|
|
182
|
+
timeout_s: float,
|
|
183
|
+
) -> dict[str, Outcome]:
|
|
184
|
+
"""One call for ``calls``, resolved per site and written to telemetry."""
|
|
185
|
+
if timeout_s * 1000 < MIN_CALL_MS:
|
|
186
|
+
response, reason, latency = None, "deadline", 0
|
|
187
|
+
else:
|
|
188
|
+
response, reason, latency = run_sync(
|
|
189
|
+
calls, state, transport=transport, cfg=cfg,
|
|
190
|
+
timeout_s=timeout_s, session_id=session_id,
|
|
191
|
+
)
|
|
192
|
+
outcomes = {
|
|
193
|
+
c.site.name: resolve(
|
|
194
|
+
c, response, modes[c.site.name], threshold_for(cfg, c.site), reason, state=state)
|
|
195
|
+
for c in calls
|
|
196
|
+
}
|
|
197
|
+
meta = CallMeta(uuid.uuid4().hex[:12], session_id, state_sha16(state),
|
|
198
|
+
transport, latency, reason)
|
|
199
|
+
record_outcomes(calls, outcomes, response, meta, state=state)
|
|
200
|
+
return outcomes
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_sync(
|
|
204
|
+
calls: Sequence[SiteCall],
|
|
205
|
+
state: State,
|
|
206
|
+
*,
|
|
207
|
+
transport: Transport,
|
|
208
|
+
cfg: DecisionsConfig,
|
|
209
|
+
timeout_s: float,
|
|
210
|
+
session_id: str = "",
|
|
211
|
+
cost_category: str = COST_CATEGORY,
|
|
212
|
+
mark_local: bool = False,
|
|
213
|
+
) -> RunResult:
|
|
214
|
+
"""backoff → privacy → cache → POST → cache.put. ``(response|None, reason, ms)``.
|
|
215
|
+
|
|
216
|
+
``cost_category`` labels the ledger row of a fresh call (the replay
|
|
217
|
+
harness passes ``decision-replay``). ``mark_local`` (replay only)
|
|
218
|
+
reports a failure decided on this machine as ``<reason>:local``
|
|
219
|
+
(:meth:`DecisionUnavailable.run_reason`); live telemetry keeps the
|
|
220
|
+
bare reason vocabulary.
|
|
221
|
+
"""
|
|
222
|
+
cause = blocked()
|
|
223
|
+
if cause is not None:
|
|
224
|
+
# The trip's cause rides along ("backoff:http-401") so telemetry and
|
|
225
|
+
# /arka decisions say WHY the breaker is open, not just that it is.
|
|
226
|
+
return None, f"backoff:{cause}", 0
|
|
227
|
+
start = time.monotonic()
|
|
228
|
+
try:
|
|
229
|
+
return _fetch(calls, state, transport, cfg, timeout_s, session_id, cost_category)
|
|
230
|
+
except DecisionUnavailable as exc:
|
|
231
|
+
reason = exc.run_reason() if mark_local else exc.reason
|
|
232
|
+
return None, reason, int((time.monotonic() - start) * 1000)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _fetch(
|
|
236
|
+
calls: Sequence[SiteCall],
|
|
237
|
+
state: State,
|
|
238
|
+
transport: Transport,
|
|
239
|
+
cfg: DecisionsConfig,
|
|
240
|
+
timeout_s: float,
|
|
241
|
+
session_id: str,
|
|
242
|
+
cost_category: str = COST_CATEGORY,
|
|
243
|
+
) -> RunResult:
|
|
244
|
+
redact = cfg.redact_clients and any(c.site.redact_default for c in calls)
|
|
245
|
+
# The strictest class governs a mixed call (a diff site keeps the whole
|
|
246
|
+
# request fail-closed); session_id scopes the once-per-session notice.
|
|
247
|
+
prepared = prepare_state(
|
|
248
|
+
state, redact=redact, session_id=session_id,
|
|
249
|
+
state_class=strictest_state_class(c.site.state_class for c in calls),
|
|
250
|
+
)
|
|
251
|
+
# Dynamic questions are built from the PREPARED state, so a candidate
|
|
252
|
+
# menu is redacted like the state it came from.
|
|
253
|
+
if not asked_questions(calls, prepared):
|
|
254
|
+
raise DecisionUnavailable("no-questions")
|
|
255
|
+
request = build_request(calls, prepared, transport.model)
|
|
256
|
+
key = cache.cache_key(transport.model, prepared, request.questions)
|
|
257
|
+
hit = cache.get(key, cfg.cache_ttl_seconds)
|
|
258
|
+
if hit is not None:
|
|
259
|
+
return hit, "cache-hit", 0
|
|
260
|
+
response, latency_ms = post_decision(transport, request, timeout_s)
|
|
261
|
+
cache.put(key, response)
|
|
262
|
+
record_call_cost(session_id, transport, response.usage, category=cost_category)
|
|
263
|
+
return response, "ok", latency_ms
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def site_answers(site_name: str, response: DecisionResponse) -> dict[str, Answer]:
|
|
267
|
+
"""This site's answers, keyed by bare question name."""
|
|
268
|
+
prefix = question_key(site_name, "")
|
|
269
|
+
return {
|
|
270
|
+
key[len(prefix):]: answer
|
|
271
|
+
for key, answer in response.answers.items()
|
|
272
|
+
if key.startswith(prefix)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def valid_site_answers(
|
|
277
|
+
site: Site, response: DecisionResponse, state: State | None = None
|
|
278
|
+
) -> dict[str, Answer]:
|
|
279
|
+
"""This site's answers, cleaned against the questions asked for ``state``.
|
|
280
|
+
|
|
281
|
+
The ONLY answers that leave the engine (``Outcome.answers``, the
|
|
282
|
+
telemetry line): an off-menu choice, an unasked key or a malformed
|
|
283
|
+
distribution is untrusted and survives neither on disk nor in the
|
|
284
|
+
hook's context. Dynamic questions are rebuilt from the RAW state:
|
|
285
|
+
should redaction have rewritten a candidate id, Jev's answer names
|
|
286
|
+
an id this menu lacks and the site abstains (fail-closed).
|
|
287
|
+
"""
|
|
288
|
+
return valid_answers(site.questions_of(state), site_answers(site.name, response))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def resolve(
|
|
292
|
+
call: SiteCall,
|
|
293
|
+
response: DecisionResponse | None,
|
|
294
|
+
mode: Mode,
|
|
295
|
+
threshold: float,
|
|
296
|
+
reason: str = "ok",
|
|
297
|
+
*,
|
|
298
|
+
state: State | None = None,
|
|
299
|
+
) -> Outcome:
|
|
300
|
+
"""Turn the response into this site's Outcome (mode and direction applied).
|
|
301
|
+
|
|
302
|
+
``state`` is the call's state; a site with ``questions_for`` needs it
|
|
303
|
+
to know which answers it may accept (without it, it abstains).
|
|
304
|
+
"""
|
|
305
|
+
h = call.heuristic
|
|
306
|
+
if response is None:
|
|
307
|
+
return Outcome(h, h, None, None, mode, "heuristic", reason)
|
|
308
|
+
answers = valid_site_answers(call.site, response, state)
|
|
309
|
+
return replace(_judge(call, answers, mode, threshold, state), answers=answers)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _judge(
|
|
313
|
+
call: SiteCall, answers: dict[str, Answer], mode: Mode, threshold: float, state: State | None
|
|
314
|
+
) -> Outcome:
|
|
315
|
+
h = call.heuristic
|
|
316
|
+
jev = call.site.judge(answers, threshold, state)
|
|
317
|
+
confidence = _min_confidence(answers)
|
|
318
|
+
if jev is None:
|
|
319
|
+
return Outcome(h, h, None, confidence, mode, "heuristic", "abstain")
|
|
320
|
+
if mode != "act":
|
|
321
|
+
return Outcome(h, h, jev, confidence, mode, "heuristic", "shadow")
|
|
322
|
+
return _apply_direction(call, jev, confidence, mode)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _apply_direction(
|
|
326
|
+
call: SiteCall, jev: object, confidence: float | None, mode: Mode
|
|
327
|
+
) -> Outcome:
|
|
328
|
+
site, h = call.site, call.heuristic
|
|
329
|
+
if site.direction == "escalate_only" and jev != h:
|
|
330
|
+
check = site.is_escalation
|
|
331
|
+
if check is None or not check(jev, h):
|
|
332
|
+
return Outcome(h, h, jev, confidence, mode, "heuristic", "downgrade-blocked")
|
|
333
|
+
return Outcome(jev, h, jev, confidence, mode, "jev", "jev")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _min_confidence(answers: Mapping[str, Answer]) -> float | None:
|
|
337
|
+
values = [c for c in (answer_confidence(a) for a in answers.values()) if c is not None]
|
|
338
|
+
return round(min(values), 4) if values else None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _compact(answers: Mapping[str, Answer]) -> dict[str, dict[str, object]]:
|
|
342
|
+
out: dict[str, dict[str, object]] = {}
|
|
343
|
+
for name, a in answers.items():
|
|
344
|
+
value = a.choice if a.choice is not None else a.noul if a.noul is not None else a.score
|
|
345
|
+
out[name] = {"v": value, "c": answer_confidence(a)}
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _model_echo(response: DecisionResponse | None, transport: Transport) -> str:
|
|
350
|
+
"""The echoed model when it is a safe token; the requested one when absent."""
|
|
351
|
+
if response is None or not response.model:
|
|
352
|
+
return transport.model
|
|
353
|
+
if SAFE_MODEL_RE.fullmatch(response.model):
|
|
354
|
+
return response.model
|
|
355
|
+
return UNSAFE_MODEL_ECHO
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def record_outcomes(
|
|
359
|
+
calls: Sequence[SiteCall],
|
|
360
|
+
outcomes: Mapping[str, Outcome],
|
|
361
|
+
response: DecisionResponse | None,
|
|
362
|
+
meta: CallMeta,
|
|
363
|
+
*,
|
|
364
|
+
state: State | None = None,
|
|
365
|
+
) -> None:
|
|
366
|
+
"""One telemetry line per site; cost and tokens split across sites."""
|
|
367
|
+
share = max(1, len(calls))
|
|
368
|
+
fresh = response is not None and meta.reason == "ok"
|
|
369
|
+
cost = call_cost_usd(meta.transport, response.usage) if fresh and response else None
|
|
370
|
+
tokens = response.usage.input_tokens // share if fresh and response else 0
|
|
371
|
+
for call in calls:
|
|
372
|
+
out = outcomes[call.site.name]
|
|
373
|
+
keys = sorted(call.site.questions_of(state))
|
|
374
|
+
record(_record_for(call, out, response, meta, (cost, share, tokens), keys))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _record_for(
|
|
378
|
+
call: SiteCall,
|
|
379
|
+
out: Outcome,
|
|
380
|
+
response: DecisionResponse | None,
|
|
381
|
+
meta: CallMeta,
|
|
382
|
+
split: tuple[float | None, int, int],
|
|
383
|
+
question_keys: list[str],
|
|
384
|
+
) -> DecisionRecord:
|
|
385
|
+
cost, share, tokens = split
|
|
386
|
+
# ``out.answers`` is already cleaned (resolve) and empty on fallbacks.
|
|
387
|
+
return DecisionRecord(
|
|
388
|
+
session_id=meta.session_id, site=call.site.name, mode=out.mode,
|
|
389
|
+
call_id=meta.call_id, question_keys=question_keys,
|
|
390
|
+
answers=_compact(out.answers), confidence=out.confidence,
|
|
391
|
+
latency_ms=meta.latency_ms, input_tokens=tokens,
|
|
392
|
+
cost_usd=round(cost / share, 10) if cost is not None else 0.0,
|
|
393
|
+
transport=meta.transport.name,
|
|
394
|
+
model=_model_echo(response, meta.transport),
|
|
395
|
+
fallback_used=out.jev is None, reason=out.reason,
|
|
396
|
+
heuristic_result=out.heuristic, jev_result=out.jev,
|
|
397
|
+
agree=(out.jev == out.heuristic) if out.jev is not None else None,
|
|
398
|
+
acted_on=out.acted_on, state_sha16=meta.state_sha16,
|
|
399
|
+
cache_hit=meta.reason == "cache-hit",
|
|
400
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Pydantic contract of the OpenRouter Decisions endpoint (Jev).
|
|
2
|
+
|
|
3
|
+
Request: ``{"model", "state", "questions": {key: {type, instructions,
|
|
4
|
+
criteria}}}``. Response: ``{"model", "answers": {key: {...}}, "usage"}``.
|
|
5
|
+
Criteria shape depends on the question type: ``choice`` maps option →
|
|
6
|
+
description (1..255 options), ``score`` lists 2..10 ordered levels,
|
|
7
|
+
``noul`` (yes/no probability) takes none.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
16
|
+
|
|
17
|
+
MAX_CHOICE_OPTIONS = 255
|
|
18
|
+
SCORE_LEVELS = (2, 10)
|
|
19
|
+
|
|
20
|
+
State = str | dict[str, Any] | list[Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QuestionType(StrEnum):
|
|
24
|
+
"""The three typed question kinds Jev answers."""
|
|
25
|
+
|
|
26
|
+
NOUL = "noul"
|
|
27
|
+
CHOICE = "choice"
|
|
28
|
+
SCORE = "score"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Question(BaseModel):
|
|
32
|
+
"""One typed question; criteria are validated against the type."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
type: QuestionType
|
|
37
|
+
instructions: str = Field(min_length=1)
|
|
38
|
+
criteria: dict[str, str] | list[str] | None = None
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def _criteria_match_type(self) -> Question:
|
|
42
|
+
if self.type is QuestionType.NOUL:
|
|
43
|
+
_require(self.criteria is None, "noul takes no criteria")
|
|
44
|
+
elif self.type is QuestionType.CHOICE:
|
|
45
|
+
ok = isinstance(self.criteria, dict)
|
|
46
|
+
_require(ok, "choice needs a dict of options")
|
|
47
|
+
size = len(self.criteria or {})
|
|
48
|
+
_require(1 <= size <= MAX_CHOICE_OPTIONS, "choice needs 1..255 options")
|
|
49
|
+
else:
|
|
50
|
+
ok = isinstance(self.criteria, list)
|
|
51
|
+
_require(ok, "score needs a list of levels")
|
|
52
|
+
low, high = SCORE_LEVELS
|
|
53
|
+
_require(low <= len(self.criteria or []) <= high, "score needs 2..10 levels")
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def to_payload(self) -> dict[str, Any]:
|
|
57
|
+
"""Wire form: ``criteria`` omitted when absent."""
|
|
58
|
+
payload: dict[str, Any] = {"type": self.type.value, "instructions": self.instructions}
|
|
59
|
+
if self.criteria is not None:
|
|
60
|
+
payload["criteria"] = self.criteria
|
|
61
|
+
return payload
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require(condition: bool, message: str) -> None:
|
|
65
|
+
if not condition:
|
|
66
|
+
raise ValueError(message)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DecisionRequest(BaseModel):
|
|
70
|
+
"""One call: a state and every question asked about it."""
|
|
71
|
+
|
|
72
|
+
model: str = Field(min_length=1)
|
|
73
|
+
state: State
|
|
74
|
+
questions: dict[str, Question] = Field(min_length=1)
|
|
75
|
+
|
|
76
|
+
def to_payload(self) -> dict[str, Any]:
|
|
77
|
+
"""JSON body for ``POST /api/alpha/decisions``."""
|
|
78
|
+
return {
|
|
79
|
+
"model": self.model,
|
|
80
|
+
"state": self.state,
|
|
81
|
+
"questions": {k: q.to_payload() for k, q in self.questions.items()},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Answer(BaseModel):
|
|
86
|
+
"""One typed answer. Unknown fields are ignored (alpha endpoint)."""
|
|
87
|
+
|
|
88
|
+
model_config = ConfigDict(extra="ignore")
|
|
89
|
+
|
|
90
|
+
type: QuestionType | None = None
|
|
91
|
+
choice: str | None = None
|
|
92
|
+
# Real responses carry a fractional expected level (e.g. 2.85);
|
|
93
|
+
# ``legend``/``probabilities`` are keyed "0".."n" (smoke 2026-09-23).
|
|
94
|
+
score: float | str | None = None
|
|
95
|
+
noul: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
96
|
+
probabilities: dict[str, float] | list[float] | None = None
|
|
97
|
+
legend: Any = None
|
|
98
|
+
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Usage(BaseModel):
|
|
102
|
+
"""Token accounting; ``cost`` is the provider-reported USD cost."""
|
|
103
|
+
|
|
104
|
+
model_config = ConfigDict(extra="ignore")
|
|
105
|
+
|
|
106
|
+
input_tokens: int = 0
|
|
107
|
+
output_tokens: int = 0
|
|
108
|
+
cost: float | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class DecisionResponse(BaseModel):
|
|
112
|
+
"""The endpoint's answer map plus usage."""
|
|
113
|
+
|
|
114
|
+
model_config = ConfigDict(extra="ignore")
|
|
115
|
+
|
|
116
|
+
model: str = ""
|
|
117
|
+
answers: dict[str, Answer]
|
|
118
|
+
usage: Usage = Field(default_factory=Usage)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Filesystem roots of the decisions layer, each with an env override.
|
|
2
|
+
|
|
3
|
+
Resolved at call time (never cached at import) so tests and sandboxes
|
|
4
|
+
redirect them with an environment variable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
CACHE_ENV = "ARKA_DECISIONS_CACHE_DIR"
|
|
13
|
+
TELEMETRY_ENV = "ARKA_DECISIONS_TELEMETRY_PATH"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cache_root() -> Path:
|
|
17
|
+
"""``~/.arkaos/cache/decisions`` unless ``ARKA_DECISIONS_CACHE_DIR`` is set."""
|
|
18
|
+
override = os.environ.get(CACHE_ENV, "").strip()
|
|
19
|
+
if override:
|
|
20
|
+
return Path(override)
|
|
21
|
+
return Path.home() / ".arkaos" / "cache" / "decisions"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def spool_dir() -> Path:
|
|
25
|
+
"""Where shadow calls wait for their detached worker."""
|
|
26
|
+
return cache_root() / "spool"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def telemetry_path() -> Path:
|
|
30
|
+
"""``~/.arkaos/telemetry/decisions.jsonl`` unless overridden."""
|
|
31
|
+
override = os.environ.get(TELEMETRY_ENV, "").strip()
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override)
|
|
34
|
+
return Path.home() / ".arkaos" / "telemetry" / "decisions.jsonl"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def repo_root() -> Path:
|
|
38
|
+
"""The checkout this package runs from (PYTHONPATH for workers)."""
|
|
39
|
+
return Path(__file__).resolve().parents[2]
|