jev-mcp-python 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 (65) hide show
  1. jev_mcp/__init__.py +1 -0
  2. jev_mcp/__main__.py +3 -0
  3. jev_mcp/domain/__init__.py +32 -0
  4. jev_mcp/domain/answers.py +25 -0
  5. jev_mcp/domain/json.py +49 -0
  6. jev_mcp/domain/questions.py +75 -0
  7. jev_mcp/domain/usage.py +16 -0
  8. jev_mcp/errors.py +59 -0
  9. jev_mcp/extract/__init__.py +1 -0
  10. jev_mcp/extract/candidates.py +75 -0
  11. jev_mcp/extract/dialect.py +400 -0
  12. jev_mcp/extract/executor.py +118 -0
  13. jev_mcp/extract/worker.py +198 -0
  14. jev_mcp/ids.py +49 -0
  15. jev_mcp/limits.py +218 -0
  16. jev_mcp/policy/__init__.py +98 -0
  17. jev_mcp/policy/actions.py +41 -0
  18. jev_mcp/policy/claims.py +103 -0
  19. jev_mcp/policy/extract.py +73 -0
  20. jev_mcp/policy/ranking.py +41 -0
  21. jev_mcp/policy/review.py +73 -0
  22. jev_mcp/policy/screen.py +48 -0
  23. jev_mcp/policy/thresholds.py +74 -0
  24. jev_mcp/providers/__init__.py +26 -0
  25. jev_mcp/providers/base.py +236 -0
  26. jev_mcp/providers/cloudflare.py +59 -0
  27. jev_mcp/providers/compatible.py +43 -0
  28. jev_mcp/providers/openrouter.py +47 -0
  29. jev_mcp/providers/resolver.py +106 -0
  30. jev_mcp/providers/typesafe.py +127 -0
  31. jev_mcp/py.typed +0 -0
  32. jev_mcp/serialize.py +199 -0
  33. jev_mcp/server.py +176 -0
  34. jev_mcp/settings.py +73 -0
  35. jev_mcp/stdio.py +99 -0
  36. jev_mcp/telemetry.py +223 -0
  37. jev_mcp/text.py +42 -0
  38. jev_mcp/tools/__init__.py +20 -0
  39. jev_mcp/tools/arguments.py +447 -0
  40. jev_mcp/tools/base.py +153 -0
  41. jev_mcp/tools/classify.py +187 -0
  42. jev_mcp/tools/common.py +96 -0
  43. jev_mcp/tools/compare.py +143 -0
  44. jev_mcp/tools/decide.py +206 -0
  45. jev_mcp/tools/extract.py +262 -0
  46. jev_mcp/tools/find.py +113 -0
  47. jev_mcp/tools/gate.py +236 -0
  48. jev_mcp/tools/observed.py +69 -0
  49. jev_mcp/tools/rerank.py +139 -0
  50. jev_mcp/tools/review.py +236 -0
  51. jev_mcp/tools/screen.py +126 -0
  52. jev_mcp/tools/toolset.py +92 -0
  53. jev_mcp/tools/verify.py +141 -0
  54. jev_mcp/validation/__init__.py +25 -0
  55. jev_mcp/validation/caps.py +93 -0
  56. jev_mcp/validation/choice.py +65 -0
  57. jev_mcp/validation/extract.py +48 -0
  58. jev_mcp/validation/noul.py +15 -0
  59. jev_mcp/validation/numbers.py +21 -0
  60. jev_mcp/validation/score.py +20 -0
  61. jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
  62. jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
  63. jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
  64. jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
  65. jev_mcp_python-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,187 @@
1
+ """jev_classify: batch-assign items to classes from a shared catalog (`index.ts:398-526`)."""
2
+
3
+ from typing import Any
4
+
5
+ from jev_mcp.domain import ChoiceQuestion, Question
6
+ from jev_mcp.limits import CLASSIFY
7
+ from jev_mcp.policy import DEFAULT_CLASSIFY_AUTO_ACCEPT, DEFAULT_MINIMUM_MARGIN
8
+ from jev_mcp.tools.base import JevTool, Runtime, ToolError, ToolResult, caller_actions, define, frame, headline
9
+ from jev_mcp.tools.observed import classification_decision, validate_choice
10
+ from jev_mcp.validation import margin, top_probability
11
+ from jev_mcp.validation.caps import CapLedger, classify_budget_error, exceeds
12
+
13
+ DEFAULT_PURPOSE = "Assign each item to exactly one class."
14
+
15
+ DEFINITION = define(
16
+ "jev_classify",
17
+ "Classify items against a shared label set",
18
+ "Assign each item to one class from a shared catalog with TypeSafe Jev, in one batched request: the class "
19
+ "catalog is sent once and every item becomes an independent Choice question. Returns per item: the chosen "
20
+ "class, the full distribution, confidence, winner-to-runner-up margin, and an auto-versus-review decision. Auto "
21
+ "requires both a high top probability (default 0.85) and a clear margin (default 0.50); everything else is "
22
+ "flagged for review. Include a manual_review class in the catalog if you want an explicit escape hatch; the tool "
23
+ "never invents one.",
24
+ {
25
+ "type": "object",
26
+ "properties": {
27
+ "items": {
28
+ "type": "array",
29
+ "items": {
30
+ "type": "object",
31
+ "properties": {"id": {"type": "string"}, "text": {"type": "string"}},
32
+ "required": ["text"],
33
+ "additionalProperties": False,
34
+ },
35
+ "minItems": CLASSIFY.items_min,
36
+ "maxItems": CLASSIFY.items_max,
37
+ "description": f"Items to classify. Text is truncated at {CLASSIFY.item_units} characters; send "
38
+ "bounded excerpts, not whole documents.",
39
+ },
40
+ "classes": {
41
+ "type": "array",
42
+ "items": {
43
+ "type": "object",
44
+ "properties": {"id": {"type": "string"}, "description": {"type": "string"}},
45
+ "required": ["description"],
46
+ "additionalProperties": False,
47
+ },
48
+ "minItems": CLASSIFY.classes_min,
49
+ "maxItems": CLASSIFY.classes_max,
50
+ "description": "Shared class catalog. Strong descriptions carry the decision: a precise definition, "
51
+ "what belongs, what does not, precedence over overlapping classes, and a short example.",
52
+ },
53
+ "purpose": {"type": "string", "description": "What this classification is for; shared across all items."},
54
+ "context": {
55
+ "anyOf": [{"type": "string"}, {"type": "object", "additionalProperties": {}}],
56
+ "description": "Shared context available to every item's judgment: policies, catalogs, anything "
57
+ "stable.",
58
+ },
59
+ "auto_accept": {
60
+ "type": "number",
61
+ "minimum": 0,
62
+ "maximum": 1,
63
+ "description": "Minimum top probability for auto. Default 0.85.",
64
+ },
65
+ "minimum_margin": {
66
+ "type": "number",
67
+ "minimum": 0,
68
+ "maximum": 1,
69
+ "description": "Minimum winner-to-runner-up gap for auto. Default 0.5.",
70
+ },
71
+ },
72
+ "required": ["items", "classes"],
73
+ "additionalProperties": False,
74
+ },
75
+ )
76
+
77
+
78
+ def _opaque(
79
+ raw: list[dict[str, str]], kind: str, key_prefix: str, text_key: str, cap: int, ledger: CapLedger
80
+ ) -> list[tuple[str, str, str]]:
81
+ """`(external id, wire key, capped text)` per entry. Caller ids are kept verbatim and must be unique;
82
+ an omitted id becomes `{kind}{index}`, unchecked against supplied ones, as in the reference."""
83
+ seen: set[str] = set()
84
+ out: list[tuple[str, str, str]] = []
85
+ for index, entry in enumerate(raw):
86
+ supplied = entry.get("id")
87
+ if supplied is not None:
88
+ if supplied in seen:
89
+ raise ToolError(f"Duplicate {kind} id: {supplied}")
90
+ seen.add(supplied)
91
+ external = supplied if supplied is not None else f"{kind}{index}"
92
+ out.append((external, f"{key_prefix}{index}", ledger.text(entry[text_key], cap, "item")))
93
+ return out
94
+
95
+
96
+ async def handle(args: dict[str, Any], runtime: Runtime) -> ToolResult:
97
+ auto_accept: float = args.get("auto_accept", DEFAULT_CLASSIFY_AUTO_ACCEPT)
98
+ minimum_margin: float = args.get("minimum_margin", DEFAULT_MINIMUM_MARGIN)
99
+
100
+ # Opaque wire keys (i0, c0) so sanitizing can never rename or collide caller ids.
101
+ ledger = CapLedger()
102
+ items = _opaque(args["items"], "item", "i", "text", CLASSIFY.item_units, ledger)
103
+ classes = _opaque(args["classes"], "class", "c", "description", CLASSIFY.class_description_units, ledger)
104
+ if exceeds(len(items) * len(classes), CLASSIFY.item_class_pairs):
105
+ raise ToolError(classify_budget_error(len(items), len(classes), CLASSIFY.item_class_pairs))
106
+
107
+ # The catalog is sent once in state; each question carries only its own item.
108
+ state = {
109
+ "purpose": args.get("purpose", DEFAULT_PURPOSE),
110
+ "context": args.get("context"),
111
+ "classes": [{"id": key, "description": description} for _, key, description in classes],
112
+ }
113
+ class_keys = [key for _, key, _ in classes]
114
+ criteria = dict.fromkeys(class_keys)
115
+ questions: dict[str, Question] = {
116
+ key: ChoiceQuestion(
117
+ {"task": "Which class does this item belong to?", "item": {"id": key, "text": text}}, criteria
118
+ )
119
+ for _, key, text in items
120
+ }
121
+ evaluation = await runtime.ask(state, questions)
122
+
123
+ key_to_external = {key: external for external, key, _ in classes}
124
+ results: list[dict[str, object]] = []
125
+ for external, key, _ in items:
126
+ answer = validate_choice(evaluation.answers.get(key), class_keys)
127
+ if answer is None:
128
+ results.append(
129
+ {
130
+ "id": external,
131
+ "status": "invalid_response",
132
+ "classification": None,
133
+ "probabilities": None,
134
+ "confidence": None,
135
+ "margin": None,
136
+ "decision": "review",
137
+ }
138
+ )
139
+ continue
140
+ gap = margin(answer.probabilities)
141
+ top = top_probability(answer)
142
+ results.append(
143
+ {
144
+ "id": external,
145
+ "classification": key_to_external.get(answer.choice, answer.choice),
146
+ "probabilities": {
147
+ class_external: answer.probabilities.get(class_key, 0) for class_external, class_key, _ in classes
148
+ },
149
+ "confidence": answer.confidence,
150
+ "margin": gap,
151
+ "top_probability": top,
152
+ "decision": classification_decision(top, gap, auto_accept, minimum_margin),
153
+ }
154
+ )
155
+
156
+ by_class: dict[str, int] = {}
157
+ for result in results:
158
+ classification = result["classification"]
159
+ if isinstance(classification, str):
160
+ by_class[classification] = by_class.get(classification, 0) + 1
161
+
162
+ item_actions = caller_actions(r["decision"] for r in results)
163
+ return ToolResult(
164
+ frame(
165
+ "jev_classify",
166
+ evaluation,
167
+ {
168
+ "summary": {
169
+ "items": len(results),
170
+ "auto": sum(1 for r in results if r["decision"] == "auto"),
171
+ "review": sum(
172
+ 1 for r in results if r["decision"] == "review" and r.get("status") != "invalid_response"
173
+ ),
174
+ "invalid_response": sum(1 for r in results if r.get("status") == "invalid_response"),
175
+ "by_class": by_class,
176
+ },
177
+ "thresholds": {"auto_accept": auto_accept, "minimum_margin": minimum_margin},
178
+ "results": results,
179
+ },
180
+ ),
181
+ action=headline(item_actions),
182
+ item_actions=item_actions,
183
+ truncated=ledger.scopes,
184
+ )
185
+
186
+
187
+ TOOL = JevTool(DEFINITION, handle)
@@ -0,0 +1,96 @@
1
+ """Schema fragments and helpers shared by several tools (`index.ts:88-116`, `lib.ts:363-374`)."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, cast
5
+
6
+ from jev_mcp.ids import ensure_unique_ids
7
+ from jev_mcp.limits import CANDIDATES, VERIFY
8
+
9
+ EVIDENCE_SCHEMA: dict[str, Any] = {
10
+ "anyOf": [
11
+ {"type": "string", "description": "A single evidence document."},
12
+ {
13
+ "type": "object",
14
+ "properties": {
15
+ "id": {"type": "string", "description": "Short identifier for this evidence item."},
16
+ "text": {"type": "string", "description": "The evidence text."},
17
+ },
18
+ "required": ["text"],
19
+ "additionalProperties": False,
20
+ "description": "A single evidence item.",
21
+ },
22
+ {
23
+ "type": "array",
24
+ "items": {
25
+ "type": "object",
26
+ "properties": {
27
+ "id": {
28
+ "type": "string",
29
+ "description": "Short identifier for this evidence item (e.g. 'site-html', 'rfc-4.1.3').",
30
+ },
31
+ "text": {"type": "string", "description": "The evidence text."},
32
+ },
33
+ "required": ["text"],
34
+ "additionalProperties": False,
35
+ },
36
+ "minItems": VERIFY.evidence_min,
37
+ "description": "Multiple evidence items; each claim is also matched to the item it rests on.",
38
+ },
39
+ ]
40
+ }
41
+ """`evidenceSchema` (`index.ts:88-103`): one string, one `{id?, text}`, or a non-empty array of them."""
42
+
43
+
44
+ def candidates_schema(description: str) -> dict[str, Any]:
45
+ """`candidatesSchema` (`index.ts:105-116`), shared by jev_find and jev_rerank."""
46
+ return {
47
+ "type": "array",
48
+ "items": {
49
+ "type": "object",
50
+ "properties": {
51
+ "id": {
52
+ "type": "string",
53
+ "description": "Short identifier for this candidate (e.g. a file path, note name, or line id).",
54
+ },
55
+ "text": {"type": "string", "description": "The candidate's text."},
56
+ },
57
+ "required": ["text"],
58
+ "additionalProperties": False,
59
+ },
60
+ "minItems": CANDIDATES.min_items,
61
+ "maxItems": CANDIDATES.max_items,
62
+ "description": description,
63
+ }
64
+
65
+
66
+ def evidence_items(raw: object) -> list[dict[str, object]]:
67
+ """Parsed `evidence` as a list of `{id?, text}` items: a string is one item with id `evidence`."""
68
+ if isinstance(raw, str):
69
+ return [{"id": "evidence", "text": raw}]
70
+ if isinstance(raw, list):
71
+ return cast(list[dict[str, object]], raw)
72
+ return [cast(dict[str, object], raw)]
73
+
74
+
75
+ def normalize_evidence(raw: object) -> list[dict[str, object]]:
76
+ """`normalizeEvidence` (`lib.ts:363-368`): evidence items with safe, unique ids."""
77
+ return ensure_unique_ids(evidence_items(raw), "evidence").items
78
+
79
+
80
+ _JS_WHITESPACE = "".join(
81
+ map(chr, (0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680, *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F))
82
+ ) + "".join(map(chr, (0x205F, 0x3000, 0xFEFF)))
83
+ """ECMAScript WhiteSpace and LineTerminator, what `String.prototype.trim` strips. Python's `strip()` differs."""
84
+
85
+
86
+ def js_trim(text: str) -> str:
87
+ return text.strip(_JS_WHITESPACE)
88
+
89
+
90
+ def has_non_empty_evidence(items: list[dict[str, object]]) -> bool:
91
+ """`hasNonEmptyEvidence` (`lib.ts:371-374`)."""
92
+ return any(js_trim(str(item["text"])) for item in items)
93
+
94
+
95
+ def text_of(item: Mapping[str, object]) -> str:
96
+ return str(item["text"])
@@ -0,0 +1,143 @@
1
+ """jev_compare: the fact relation between two passages, optionally per aspect (`index.ts:790-879`)."""
2
+
3
+ from typing import Any
4
+
5
+ from jev_mcp.domain import ChoiceQuestion, Question
6
+ from jev_mcp.limits import COMPARE
7
+ from jev_mcp.policy import DEFAULT_CLASSIFY_AUTO_ACCEPT, DEFAULT_MINIMUM_MARGIN
8
+ from jev_mcp.tools.base import JevTool, Runtime, ToolResult, caller_actions, define, frame, headline
9
+ from jev_mcp.tools.observed import classification_decision, validate_choice
10
+ from jev_mcp.validation import margin, top_probability
11
+ from jev_mcp.validation.caps import CapLedger
12
+
13
+ COMPARE_RELATIONS = {
14
+ "same_fact": "Both passages state the same underlying fact or claim",
15
+ "contradicts": "The passages state opposing facts about the same subject",
16
+ "different_facts": "The passages discuss different subjects or make non-overlapping claims",
17
+ }
18
+ """`COMPARE_RELATIONS` (`lib.ts:188-192`)."""
19
+
20
+ ASPECT_RELATIONS = {
21
+ "same_fact": "Both passages make comparable assertions about this aspect and they agree",
22
+ "contradicts": "Both passages address this aspect and their assertions conflict",
23
+ "different_facts": "The passages do not both make a comparable assertion about this aspect: at least one does "
24
+ "not address it, or their mentions do not overlap",
25
+ }
26
+ """`ASPECT_RELATIONS` (`lib.ts:199-204`): per aspect, the third outcome usually means one passage is silent."""
27
+
28
+ DEFINITION = define(
29
+ "jev_compare",
30
+ "Compare two passages for factual agreement",
31
+ "Judge the relation between two passages with TypeSafe Jev: same_fact, contradicts, or different_facts, with "
32
+ "the full probability distribution, confidence, and an auto-versus-review decision. Optionally supply aspects "
33
+ "(price, date, method, …) and each gets an independent per-aspect judgment in the same single request. Use for "
34
+ "source reconciliation, changelog-vs-code drift, or merge sanity checks. The request supplies no evidence beyond "
35
+ "the two passages, so a same_fact verdict means they agree with each other, not that they are true.",
36
+ {
37
+ "type": "object",
38
+ "properties": {
39
+ "passage_a": {
40
+ "type": "string",
41
+ "minLength": COMPARE.passage_min,
42
+ "maxLength": COMPARE.passage_max,
43
+ "description": f"First passage. Rejected above {COMPARE.passage_max:,} characters.",
44
+ },
45
+ "passage_b": {
46
+ "type": "string",
47
+ "minLength": COMPARE.passage_min,
48
+ "maxLength": COMPARE.passage_max,
49
+ "description": f"Second passage. Rejected above {COMPARE.passage_max:,} characters.",
50
+ },
51
+ "aspects": {
52
+ "type": "array",
53
+ "items": {"type": "string", "minLength": COMPARE.aspect_min, "maxLength": COMPARE.aspect_max},
54
+ "maxItems": COMPARE.aspects_max,
55
+ "description": "Named aspects to judge independently (e.g. 'price', 'launch date'). Each tests one "
56
+ "property.",
57
+ },
58
+ "purpose": {"type": "string", "description": "What this comparison is for; helps disambiguate overlap."},
59
+ "auto_accept": {
60
+ "type": "number",
61
+ "minimum": 0,
62
+ "maximum": 1,
63
+ "description": "Minimum top probability for auto. Default 0.85.",
64
+ },
65
+ "minimum_margin": {
66
+ "type": "number",
67
+ "minimum": 0,
68
+ "maximum": 1,
69
+ "description": "Minimum winner-to-runner-up gap for auto. Default 0.5.",
70
+ },
71
+ },
72
+ "required": ["passage_a", "passage_b"],
73
+ "additionalProperties": False,
74
+ },
75
+ )
76
+
77
+
78
+ async def handle(args: dict[str, Any], runtime: Runtime) -> ToolResult:
79
+ auto_accept: float = args.get("auto_accept", DEFAULT_CLASSIFY_AUTO_ACCEPT)
80
+ minimum_margin: float = args.get("minimum_margin", DEFAULT_MINIMUM_MARGIN)
81
+ aspects: list[str] = args.get("aspects", [])
82
+
83
+ questions: dict[str, Question] = {
84
+ "overall": ChoiceQuestion(
85
+ "Do the two passages state the same underlying fact, contradict each other, or discuss different facts?",
86
+ COMPARE_RELATIONS,
87
+ )
88
+ }
89
+ for i, aspect in enumerate(aspects):
90
+ questions[f"aspect_{i}"] = ChoiceQuestion(
91
+ f'Judging only the aspect "{aspect}" of the two passages in the state, which relation holds?',
92
+ ASPECT_RELATIONS,
93
+ )
94
+ # The schema rejects a passage over its cap first, so this ledger never records a cut.
95
+ ledger = CapLedger()
96
+ state = {
97
+ "purpose": args.get("purpose"),
98
+ "passage_a": ledger.text(args["passage_a"], COMPARE.passage_max, "context"),
99
+ "passage_b": ledger.text(args["passage_b"], COMPARE.passage_max, "context"),
100
+ "aspects": aspects,
101
+ }
102
+ evaluation = await runtime.ask(state, questions)
103
+
104
+ def judge(raw: object) -> dict[str, object]:
105
+ answer = validate_choice(raw, COMPARE_RELATIONS)
106
+ if answer is None:
107
+ return {
108
+ "relation": None,
109
+ "probabilities": None,
110
+ "confidence": None,
111
+ "margin": None,
112
+ "decision": "review",
113
+ "status": "invalid_response",
114
+ }
115
+ gap = margin(answer.probabilities)
116
+ return {
117
+ "relation": answer.choice,
118
+ "probabilities": answer.probabilities,
119
+ "confidence": answer.confidence,
120
+ "margin": gap,
121
+ "decision": classification_decision(top_probability(answer), gap, auto_accept, minimum_margin),
122
+ }
123
+
124
+ overall = judge(evaluation.answers.get("overall"))
125
+ return ToolResult(
126
+ frame(
127
+ "jev_compare",
128
+ evaluation,
129
+ {
130
+ "overall": overall,
131
+ "aspects": [
132
+ {"aspect": aspect, **judge(evaluation.answers.get(f"aspect_{i}"))}
133
+ for i, aspect in enumerate(aspects)
134
+ ],
135
+ "thresholds": {"auto_accept": auto_accept, "minimum_margin": minimum_margin},
136
+ },
137
+ ),
138
+ action=headline(caller_actions([overall["decision"]])), # the aspects are not the headline
139
+ truncated=ledger.scopes,
140
+ )
141
+
142
+
143
+ TOOL = JevTool(DEFINITION, handle)
@@ -0,0 +1,206 @@
1
+ """jev_decide: one bounded decision with escape hatches and requirement checks (`index.ts:531-671`)."""
2
+
3
+ from typing import Any
4
+
5
+ from jev_mcp.domain import ChoiceQuestion, Question
6
+ from jev_mcp.limits import DECIDE
7
+ from jev_mcp.policy import RequirementCheck
8
+ from jev_mcp.tools.base import JevTool, Runtime, ToolError, ToolResult, define, frame
9
+ from jev_mcp.tools.observed import contradicts_recommendation, validate_choice
10
+
11
+ ESCAPE_HATCHES = {
12
+ "ask_user": "A consequential user preference or requirement is missing; ask instead of inventing it",
13
+ "investigate": "Gather missing technical or factual evidence before selecting a candidate",
14
+ "none": "None of the supplied candidates fits the known requirements",
15
+ }
16
+ """`DECIDE_ESCAPE_HATCHES` (`lib.ts:141-145`)."""
17
+
18
+ RELATION_CRITERIA = {
19
+ "supported": "The evidence and mechanism support this specific requirement",
20
+ "contradicted": "The evidence or mechanism contradicts this specific requirement, not merely another requirement",
21
+ "unknown": "Relevant evidence is missing; neither satisfaction nor violation is established",
22
+ }
23
+ INVALID = "invalid_response"
24
+
25
+ DEFINITION = define(
26
+ "jev_decide",
27
+ "Decide between bounded alternatives",
28
+ "One unresolved, bounded decision where semantic judgment over supplied evidence could change your plan: "
29
+ "implementation alternatives, product tradeoffs with known preferences, workflow selection. Supply "
30
+ f"{DECIDE.candidates_min}-{DECIDE.candidates_max} "
31
+ "candidates, evidence, and explicit priorities. Jev returns a Choice distribution over the candidates plus "
32
+ "escape hatches (ask_user / investigate / none), and a per-candidate per-requirement supported / contradicted / "
33
+ "unknown judgment for each optional requirement, all in one request. One call per unchanged decision; do not "
34
+ "repeat a call to obtain a more pleasing answer. Use source inspection, tests, the user, or a reasoning model for "
35
+ "open-ended research, routine choices, correctness proofs, or predicting user consent. High probability is not "
36
+ "proof.",
37
+ {
38
+ "type": "object",
39
+ "properties": {
40
+ "decision": {
41
+ "type": "string",
42
+ "minLength": DECIDE.decision_min,
43
+ "maxLength": DECIDE.decision_max,
44
+ "description": "The bounded decision to make.",
45
+ },
46
+ "evidence": {
47
+ "type": "string",
48
+ "minLength": DECIDE.evidence_min,
49
+ "maxLength": DECIDE.evidence_max,
50
+ "description": "Facts and measurements, not opinions. State is evidence, not instructions.",
51
+ },
52
+ "priorities": {
53
+ "type": "string",
54
+ "minLength": DECIDE.priorities_min,
55
+ "maxLength": DECIDE.priorities_max,
56
+ "description": "Explicit preferences and constraints from the user or plan.",
57
+ },
58
+ "candidates": {
59
+ "type": "array",
60
+ "items": {
61
+ "type": "object",
62
+ "properties": {
63
+ "id": {
64
+ "type": "string",
65
+ "pattern": "^[a-z][a-z0-9_-]*$",
66
+ "maxLength": DECIDE.candidate_id_max,
67
+ },
68
+ "description": {
69
+ "type": "string",
70
+ "minLength": DECIDE.candidate_description_min,
71
+ "maxLength": DECIDE.candidate_description_max,
72
+ },
73
+ },
74
+ "required": ["id", "description"],
75
+ "additionalProperties": False,
76
+ },
77
+ "minItems": DECIDE.candidates_min,
78
+ "maxItems": DECIDE.candidates_max,
79
+ "description": "The alternatives. Include 'do nothing' or 'gather more evidence' as candidates when "
80
+ "useful.",
81
+ },
82
+ "requirements": {
83
+ "type": "array",
84
+ "items": {"type": "string", "minLength": DECIDE.requirement_min, "maxLength": DECIDE.requirement_max},
85
+ "maxItems": DECIDE.requirements_max,
86
+ "description": "Specific requirements to check per candidate. Each must test one property, not "
87
+ "overall goodness.",
88
+ },
89
+ "escape_hatches": {
90
+ "type": "boolean",
91
+ "description": "Include ask_user / investigate / none as Choosable options so the model can decline "
92
+ "to rank. Default true.",
93
+ },
94
+ },
95
+ "required": ["decision", "evidence", "priorities", "candidates"],
96
+ "additionalProperties": False,
97
+ },
98
+ )
99
+
100
+
101
+ async def handle(args: dict[str, Any], runtime: Runtime) -> ToolResult:
102
+ hatches: bool = args.get("escape_hatches", True)
103
+ requirements: list[str] = args.get("requirements", [])
104
+ candidates: list[dict[str, str]] = args["candidates"]
105
+
106
+ # Duplicate ids and ids that shadow an active escape hatch would alias wire keys.
107
+ seen: set[str] = set()
108
+ for candidate in candidates:
109
+ candidate_id = candidate["id"]
110
+ if candidate_id in seen:
111
+ raise ToolError("Duplicate candidate id: " + candidate_id)
112
+ if hatches and candidate_id in ESCAPE_HATCHES:
113
+ raise ToolError(
114
+ f'Candidate id "{candidate_id}" collides with an escape hatch; rename it or set escape_hatches: false.'
115
+ )
116
+ seen.add(candidate_id)
117
+
118
+ # Opaque positional wire keys; validated slug ids come back verbatim.
119
+ key_to_id = {f"option_{i}": candidate["id"] for i, candidate in enumerate(candidates)}
120
+ criteria = {f"option_{i}": candidate["description"] for i, candidate in enumerate(candidates)}
121
+ if hatches:
122
+ criteria.update(ESCAPE_HATCHES)
123
+
124
+ questions: dict[str, Question] = {
125
+ "recommendation": ChoiceQuestion(
126
+ "Which candidate best fits the decision, evidence, and priorities? "
127
+ + ("Select a candidate or an escape hatch. " if hatches else "")
128
+ + "Do not invent missing facts, preferences, or approvals.",
129
+ criteria,
130
+ )
131
+ }
132
+ for i in range(len(candidates)):
133
+ for j in range(len(requirements)):
134
+ questions[f"check_{i}_{j}"] = ChoiceQuestion(
135
+ f"How does the mechanism in candidates[{i}] relate to requirements[{j}], using the evidence? Judge "
136
+ "only this property, not the candidate overall desirability. Missing evidence is not contradiction.",
137
+ RELATION_CRITERIA,
138
+ )
139
+ state = {
140
+ "decision": args["decision"],
141
+ "evidence": args["evidence"],
142
+ "priorities": args["priorities"],
143
+ "candidates": [
144
+ {"id": key, "description": candidate["description"]}
145
+ for key, candidate in zip(key_to_id, candidates, strict=True)
146
+ ],
147
+ "requirements": requirements,
148
+ }
149
+ evaluation = await runtime.ask(state, questions)
150
+ answers = evaluation.answers
151
+
152
+ rec = validate_choice(answers.get("recommendation"), criteria)
153
+ # Q3 (ADR-0012): a malformed check stays visible as invalid_response and never warns.
154
+ checks = [
155
+ RequirementCheck(candidate["id"], j, answer.choice if answer is not None else INVALID)
156
+ for i, candidate in enumerate(candidates)
157
+ for j in range(len(requirements))
158
+ for answer in [validate_choice(answers.get(f"check_{i}_{j}"), RELATION_CRITERIA)]
159
+ ]
160
+ contradicted = (
161
+ contradicts_recommendation([c for c in checks if c.answer != INVALID], key_to_id[rec.choice])
162
+ if rec is not None and rec.choice in key_to_id
163
+ else []
164
+ )
165
+
166
+ recommendation: dict[str, object]
167
+ if rec is None:
168
+ recommendation = {
169
+ "selected": None,
170
+ "escaped": None,
171
+ "confidence": None,
172
+ "probabilities": None,
173
+ "status": INVALID,
174
+ }
175
+ else:
176
+ recommendation = {
177
+ "selected": key_to_id.get(rec.choice, rec.choice),
178
+ "escaped": rec.choice not in key_to_id,
179
+ "confidence": rec.confidence,
180
+ "probabilities": {key_to_id.get(key, key): p for key, p in rec.probabilities.items()},
181
+ }
182
+ warnings: list[str] = []
183
+ if contradicted:
184
+ plural = "s" if len(contradicted) > 1 else ""
185
+ numbers = ", ".join(str(index + 1) for index in contradicted)
186
+ warnings.append(
187
+ f"Requirement{plural} {numbers} contradicted by the recommended candidate; inspect before acting"
188
+ )
189
+
190
+ return ToolResult(
191
+ frame(
192
+ "jev_decide",
193
+ evaluation,
194
+ {
195
+ "recommendation": recommendation,
196
+ "requirements_checked": len(requirements),
197
+ "checks": [
198
+ {"candidate": c.candidate, "requirement": c.requirement, "answer": c.answer} for c in checks
199
+ ],
200
+ "warnings": warnings,
201
+ },
202
+ )
203
+ )
204
+
205
+
206
+ TOOL = JevTool(DEFINITION, handle)