1use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use serde_json::{Map, Value, json};
12
13use crate::cost::Rates;
14use crate::evaluate::{self, Outcome, ReportOptions};
15use crate::headless;
16use crate::session::Session;
17use crate::skill::SKILL_MD;
18use crate::{cost, presets, sketch};
19
20pub const PROTOCOL_VERSION: &str = "2025-06-18";
22
23pub const KNOWN_PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
25
26pub const SERVER_NAME: &str = "jev";
28
29pub const PARSE_ERROR: i64 = -32700;
31pub const INVALID_REQUEST: i64 = -32600;
32pub const METHOD_NOT_FOUND: i64 = -32601;
33pub const INVALID_PARAMS: i64 = -32602;
34
35#[derive(Debug, Clone)]
38pub struct Sent {
39 pub outcome: Outcome,
40 pub raw: Option<Value>,
41}
42
43impl Sent {
44 pub fn failed(error: impl Into<String>) -> Self {
45 Self {
46 outcome: Outcome::Failed {
47 error: error.into(),
48 },
49 raw: None,
50 }
51 }
52}
53
54pub type Ask =
59 Arc<dyn Fn(Session) -> Pin<Box<dyn Future<Output = Sent> + Send>> + Send + Sync + 'static>;
60
61#[derive(Clone)]
63pub struct Host {
64 pub version: String,
66 pub model: String,
68 pub live: bool,
70 pub rates: Option<Rates>,
72 pub ask: Ask,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ToolResult {
78 pub text: String,
79 pub is_error: bool,
80}
81
82impl ToolResult {
83 fn ok(text: impl Into<String>) -> Self {
84 Self {
85 text: text.into(),
86 is_error: false,
87 }
88 }
89
90 fn failed(text: impl Into<String>) -> Self {
91 Self {
92 text: text.into(),
93 is_error: true,
94 }
95 }
96
97 pub fn to_value(&self) -> Value {
98 let mut value = json!({ "content": [{ "type": "text", "text": self.text }] });
99 if self.is_error {
100 value["isError"] = Value::Bool(true);
101 }
102 value
103 }
104}
105
106const PAGE_DESCRIPTION: &str = "The request as a jev sketch page, or a raw /v1/systemone request body. \
107 jev_notation has the notation.";
108
109pub fn tools() -> Value {
111 let page = json!({ "type": "string", "description": PAGE_DESCRIPTION });
112 let state = json!({
113 "type": "string",
114 "description": "Judge this text instead of the state written on the page.",
115 });
116 let model = json!({
117 "type": "string",
118 "description":
119 "The model to ask. Defaults to the one the page pins, then to the server's default.",
120 });
121 let threshold = json!({
122 "type": "number",
123 "description": "What counts as a yes for a noul, from 0 to 1. Defaults to 0.5.",
124 });
125 let price = json!({
126 "type": "string",
127 "description": "Dollars per million tokens, input then output, as \"0.20/1.00\".",
128 });
129 json!([
130 {
131 "name": "jev_notation",
132 "title": "jev notation",
133 "description":
134 "The jev sketch notation and workflow: how to write a page of questions, what each \
135 kind of question answers with, and how to check, price, run and score one. Read \
136 this before writing a page, and again when one will not parse.",
137 "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
138 },
139 {
140 "name": "jev_check",
141 "title": "Check a page",
142 "description":
143 "Parse a page and report every problem with a line number, or say what it parsed \
144 into. Sends nothing and costs nothing — use it on every page before running one.",
145 "inputSchema": {
146 "type": "object",
147 "properties": { "page": page },
148 "required": ["page"],
149 "additionalProperties": false,
150 },
151 },
152 {
153 "name": "jev_request",
154 "title": "Request body",
155 "description":
156 "The exact JSON body this page would POST to /v1/systemone. Sends nothing.",
157 "inputSchema": {
158 "type": "object",
159 "properties": { "page": page, "state": state, "model": model },
160 "required": ["page"],
161 "additionalProperties": false,
162 },
163 },
164 {
165 "name": "jev_cost",
166 "title": "Estimate cost",
167 "description":
168 "Estimated tokens for this page, per question and on both sides of the wire, \
169 priced when rates are given. Sends nothing. Check this before a run over many \
170 cases.",
171 "inputSchema": {
172 "type": "object",
173 "properties": { "page": page, "state": state, "model": model, "price": price },
174 "required": ["page"],
175 "additionalProperties": false,
176 },
177 },
178 {
179 "name": "jev_ask",
180 "title": "Ask the questions",
181 "description":
182 "Send the page and return one answer per question. Spends money when the server \
183 holds an API key; without one every answer is simulated noise and must not be \
184 reported as judgement.",
185 "inputSchema": {
186 "type": "object",
187 "properties": {
188 "page": page,
189 "state": state,
190 "model": model,
191 "threshold": threshold,
192 "json": {
193 "type": "boolean",
194 "description": "Return the raw response body instead of the answer page.",
195 },
196 },
197 "required": ["page"],
198 "additionalProperties": false,
199 },
200 },
201 {
202 "name": "jev_eval",
203 "title": "Score a page",
204 "description":
205 "Run a page over labelled cases and score the answers: accuracy per question, a \
206 confusion table, a threshold sweep for each noul. One request per case, so price \
207 it first.",
208 "inputSchema": {
209 "type": "object",
210 "properties": {
211 "page": page,
212 "cases": {
213 "type": "string",
214 "description":
215 "JSON Lines, one labelled state per line: \
216 {\"state\": \"...\", \"expect\": {\"is_urgent\": true}}.",
217 },
218 "model": model,
219 "threshold": threshold,
220 "price": price,
221 "concurrency": {
222 "type": "integer",
223 "description": "How many cases are in the air at once. Defaults to 4.",
224 "minimum": 1,
225 },
226 "compare": {
227 "type": "string",
228 "description":
229 "A second page to run over the same cases. The report becomes the \
230 difference: deltas per question, the cases whose answer flipped, and \
231 an exact McNemar test.",
232 },
233 "calibrate": {
234 "type": "boolean",
235 "description":
236 "Also return the page with the bars this run supports written in: \
237 each noul's best-F1 @threshold, and the lowest @confidence at which a \
238 choice or score reaches targetAccuracy. Nothing else on the page \
239 changes.",
240 },
241 "targetAccuracy": {
242 "type": "number",
243 "description": "The accuracy a confidence bar has to reach. Defaults to 0.9.",
244 "minimum": 0,
245 "maximum": 1,
246 },
247 "json": {
248 "type": "boolean",
249 "description": "Return the report as JSON instead of a table.",
250 },
251 },
252 "required": ["page", "cases"],
253 "additionalProperties": false,
254 },
255 },
256 {
257 "name": "jev_code",
258 "title": "Page as code",
259 "description":
260 "The page as a working Rust program against typesafe-ai-sdk. Start here instead of \
261 writing a client by hand.",
262 "inputSchema": {
263 "type": "object",
264 "properties": { "page": page, "model": model, "threshold": threshold },
265 "required": ["page"],
266 "additionalProperties": false,
267 },
268 },
269 {
270 "name": "jev_presets",
271 "title": "Ready-made pages",
272 "description":
273 "Worked pages to start from — support triage, content moderation, lead \
274 qualification and reply grading — each as a sketch page ready to edit.",
275 "inputSchema": {
276 "type": "object",
277 "properties": {
278 "name": {
279 "type": "string",
280 "description": "One preset by name. Omit for all of them.",
281 },
282 },
283 "additionalProperties": false,
284 },
285 },
286 ])
287}
288
289const SIMULATED: &str = "\nSimulated answers: deterministic noise, not judgement. \
291 The server has no TYPESAFE_API_KEY, so nothing was sent.";
292
293fn string_arg(args: &Value, name: &str) -> Result<String, String> {
294 match args.get(name) {
295 None | Some(Value::Null) => Err(format!("{name} is required.")),
296 Some(Value::String(text)) => Ok(text.clone()),
297 Some(_) => Err(format!("{name} must be a string.")),
298 }
299}
300
301fn optional_string(args: &Value, name: &str) -> Result<Option<String>, String> {
302 match args.get(name) {
303 None | Some(Value::Null) => Ok(None),
304 Some(Value::String(text)) => Ok(Some(text.clone())),
305 Some(_) => Err(format!("{name} must be a string.")),
306 }
307}
308
309fn threshold_arg(args: &Value) -> Result<f64, String> {
310 let value = match args.get("threshold") {
311 None | Some(Value::Null) => return Ok(0.5),
312 Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
313 Some(_) => return Err("threshold must be a number.".to_owned()),
314 };
315 if !(0.0..=1.0).contains(&value) {
316 return Err("threshold must be from 0 to 1.".to_owned());
317 }
318 Ok(value)
319}
320
321fn bool_arg(args: &Value, name: &str) -> Result<bool, String> {
322 match args.get(name) {
323 None | Some(Value::Null) => Ok(false),
324 Some(Value::Bool(value)) => Ok(*value),
325 Some(_) => Err(format!("{name} must be true or false.")),
326 }
327}
328
329fn concurrency_arg(args: &Value) -> Result<usize, String> {
330 let workers = match args.get("concurrency") {
331 None | Some(Value::Null) => return Ok(4),
332 Some(Value::Number(n)) => n.as_i64().unwrap_or(0),
333 Some(_) => return Err("concurrency must be a number.".to_owned()),
334 };
335 if workers < 1 {
336 return Err("concurrency must be a whole number of 1 or more.".to_owned());
337 }
338 Ok(workers as usize)
339}
340
341fn rates_arg(args: &Value, host: &Host) -> Result<Option<Rates>, String> {
343 match optional_string(args, "price")? {
344 None => Ok(host.rates),
345 Some(text) => cost::parse_rates(&text).map(Some),
346 }
347}
348
349fn session_arg(args: &Value, host: &Host) -> Result<(Session, String), String> {
351 let mut session = headless::load(&string_arg(args, "page")?)?;
352 if let Some(state) = optional_string(args, "state")? {
353 session.state = Value::String(state);
354 }
355 if let Some(model) = optional_string(args, "model")? {
356 session.model = Some(model);
357 }
358 let model = session.model.clone().unwrap_or_else(|| host.model.clone());
359 Ok((session, model))
360}
361
362async fn ask(args: &Value, host: &Host) -> Result<ToolResult, String> {
363 let (session, model) = session_arg(args, host)?;
364 if let Some(why) = headless::sendable(&session) {
365 return Err(why);
366 }
367 let threshold = threshold_arg(args)?;
368 let rates = rates_arg(args, host)?;
369 let json_wanted = bool_arg(args, "json")?;
370
371 let sent = (host.ask)(session.clone()).await;
372 let (answers, usage) = match sent.outcome {
373 Outcome::Failed { error } => return Ok(ToolResult::failed(error)),
374 Outcome::Ok { answers, usage } => (answers, usage),
375 };
376 if json_wanted {
377 return Ok(ToolResult::ok(headless::answers_json(
378 &answers,
379 &model,
380 sent.raw.as_ref(),
381 )));
382 }
383 let mut text = headless::session_answers_text(&answers, threshold, &session);
384 text.push_str(&headless::usage_text(
385 &session,
386 &model,
387 rates,
388 usage.as_ref(),
389 ));
390 if !host.live {
391 text.push_str(SIMULATED);
392 }
393 Ok(ToolResult::ok(text))
394}
395
396async fn score(args: &Value, host: &Host) -> Result<ToolResult, String> {
397 let (session, model) = session_arg(args, host)?;
398 let threshold = threshold_arg(args)?;
399 let concurrency = concurrency_arg(args)?;
400 let rates = rates_arg(args, host)?;
401 if let Some(second) = optional_string(args, "compare")? {
402 return compared(
403 args,
404 host,
405 &second,
406 (session, model),
407 threshold,
408 concurrency,
409 rates,
410 )
411 .await;
412 }
413 let cases = evaluate::parse_cases(&string_arg(args, "cases")?, &session)?;
414 if cases.is_empty() {
415 return Err("cases is empty: nothing to score.".to_owned());
416 }
417 let calibrating = bool_arg(args, "calibrate")?;
418 let target = match args.get("targetAccuracy") {
419 None | Some(Value::Null) => evaluate::DEFAULT_TARGET,
420 Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
421 Some(_) => return Err("targetAccuracy must be a number.".to_owned()),
422 };
423 if !(0.0..=1.0).contains(&target) {
424 return Err("targetAccuracy must be from 0 to 1.".to_owned());
425 }
426 let page = string_arg(args, "page")?;
427 if calibrating && page.trim_start().starts_with('{') {
428 return Err(
429 "calibrate needs a .jev page: a request body has nowhere to keep a bar.".to_owned(),
430 );
431 }
432 let json_wanted = bool_arg(args, "json")?;
433
434 let outcomes = evaluate::run(&session, &cases, asker(host), concurrency).await;
435 let report = evaluate::report(
436 &session,
437 &cases,
438 &outcomes,
439 ReportOptions {
440 model: &model,
441 threshold,
442 rates,
443 },
444 );
445 let calibration = (calibrating && report.errors.is_empty())
446 .then(|| evaluate::calibrate(&session, &cases, &outcomes, &report, target));
447 let calibrated = calibration
448 .as_ref()
449 .map(|c| sketch::set_bars(&page, &c.changed));
450 let refused = calibrating && calibration.is_none();
451 let why = evaluate::not_calibrating(report.errors.len());
452
453 if json_wanted {
454 let mut json = evaluate::report_json(&report);
455 if let Value::Object(object) = &mut json {
456 if let (Some(calibration), Some(text)) = (&calibration, &calibrated) {
457 let mut value = evaluate::calibration_json(calibration, "page");
458 value.insert("text".to_owned(), Value::String(text.clone()));
459 object.insert("calibration".to_owned(), Value::Object(value));
460 }
461 if refused {
462 object.insert("calibration".to_owned(), json!({ "refused": why }));
463 }
464 }
465 let body = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
466 return Ok(ToolResult::ok(format!("{body}\n")));
467 }
468 let mut text = evaluate::report_text(&report);
469 if let (Some(calibration), Some(page)) = (&calibration, &calibrated) {
470 text.push('\n');
471 text.push_str(&evaluate::calibration_text(calibration, "the page"));
472 text.push_str(&format!("\n# the page, calibrated\n\n{page}"));
473 }
474 if refused {
475 text.push_str(&format!("\n{why}\n"));
476 }
477 if !host.live {
478 text.push_str(SIMULATED);
479 }
480 Ok(ToolResult::ok(text))
481}
482
483fn asker(
485 host: &Host,
486) -> impl Fn(Session) -> Pin<Box<dyn Future<Output = Outcome> + Send>> + Send + Sync + Clone + 'static
487{
488 let send = Arc::clone(&host.ask);
489 move |one: Session| {
490 let send = Arc::clone(&send);
491 Box::pin(async move { send(one).await.outcome })
492 }
493}
494
495async fn compared(
497 args: &Value,
498 host: &Host,
499 second: &str,
500 (a, model_a): (Session, String),
501 threshold: f64,
502 concurrency: usize,
503 rates: Option<Rates>,
504) -> Result<ToolResult, String> {
505 let mut b = headless::load(second).map_err(|e| format!("compare: {e}"))?;
506 if let Some(model) = optional_string(args, "model")? {
508 b.model = Some(model);
509 }
510 let model_b = b.model.clone().unwrap_or_else(|| host.model.clone());
511 let labels = evaluate::Labels { a: "a", b: "b" };
512 let (cases_a, cases_b) =
513 evaluate::parse_compare_cases(&string_arg(args, "cases")?, &a, &b, labels)?;
514 let json_wanted = bool_arg(args, "json")?;
515 let (outcomes_a, outcomes_b) = evaluate::run_compare(
516 evaluate::Leg {
517 session: &a,
518 cases: &cases_a,
519 ask: asker(host),
520 },
521 evaluate::Leg {
522 session: &b,
523 cases: &cases_b,
524 ask: asker(host),
525 },
526 concurrency,
527 )
528 .await;
529 let comparison = evaluate::compare(
530 evaluate::Side {
531 label: "a",
532 session: &a,
533 cases: &cases_a,
534 outcomes: &outcomes_a,
535 model: &model_a,
536 },
537 evaluate::Side {
538 label: "b",
539 session: &b,
540 cases: &cases_b,
541 outcomes: &outcomes_b,
542 model: &model_b,
543 },
544 evaluate::CompareOptions { threshold, rates },
545 );
546 if json_wanted {
547 let body = serde_json::to_string_pretty(&evaluate::compare_json(&comparison))
548 .map_err(|e| e.to_string())?;
549 return Ok(ToolResult::ok(format!("{body}\n")));
550 }
551 let mut text = evaluate::compare_text(&comparison);
552 if !host.live {
553 text.push_str(SIMULATED);
554 }
555 Ok(ToolResult::ok(text))
556}
557
558fn preset_page(preset: &presets::Preset) -> String {
560 format!(
561 "# {} — {}\n\n{}",
562 preset.name,
563 preset.about,
564 sketch::render(&presets::to_session(preset))
565 )
566}
567
568fn preset_pages(args: &Value) -> Result<ToolResult, String> {
569 match optional_string(args, "name")? {
570 Some(name) => {
571 let Some(preset) = presets::find(&name) else {
572 let names = presets::PRESETS
573 .iter()
574 .map(|p| p.name)
575 .collect::<Vec<_>>()
576 .join(", ");
577 return Err(format!("no preset {name:?}; there is {names}."));
578 };
579 Ok(ToolResult::ok(preset_page(preset)))
580 }
581 None => Ok(ToolResult::ok(
582 presets::PRESETS
583 .iter()
584 .map(preset_page)
585 .collect::<Vec<_>>()
586 .join("\n\n"),
587 )),
588 }
589}
590
591pub async fn call(name: &str, args: &Value, host: &Host) -> ToolResult {
593 let outcome = match name {
594 "jev_notation" => Ok(ToolResult::ok(SKILL_MD)),
595 "jev_check" => string_arg(args, "page").map(|page| match headless::check_text(&page) {
596 Ok(summary) => ToolResult::ok(format!("{summary}\n")),
597 Err(problems) => ToolResult::failed(problems),
598 }),
599 "jev_request" => session_arg(args, host)
600 .map(|(session, model)| ToolResult::ok(headless::request_text(&session, &model))),
601 "jev_cost" => rates_arg(args, host).and_then(|rates| {
602 session_arg(args, host).map(|(session, model)| {
603 ToolResult::ok(headless::cost_text(&session, &model, rates))
604 })
605 }),
606 "jev_ask" => ask(args, host).await,
607 "jev_eval" => score(args, host).await,
608 "jev_code" => threshold_arg(args).and_then(|threshold| {
609 session_arg(args, host).map(|(session, model)| {
610 ToolResult::ok(headless::code_text(&session, &model, threshold))
611 })
612 }),
613 "jev_presets" => preset_pages(args),
614 other => {
615 return ToolResult::failed(format!("No tool named {other:?}. tools/list has them."));
616 }
617 };
618 match outcome {
619 Ok(result) => result,
620 Err(why) => ToolResult::failed(format!("{name}: {why}")),
621 }
622}
623
624fn reply(id: Value, result: Value) -> Value {
625 json!({ "jsonrpc": "2.0", "id": id, "result": result })
626}
627
628fn fault(id: Value, code: i64, message: String) -> Value {
629 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
630}
631
632fn greeting(params: &Value, host: &Host) -> Value {
634 let asked = params.get("protocolVersion").and_then(Value::as_str);
635 let version = match asked {
636 Some(asked) if KNOWN_PROTOCOLS.contains(&asked) => asked,
637 _ => PROTOCOL_VERSION,
638 };
639 let instructions = format!(
640 "jev shapes and sends TypeSafe AI System One questions. Call jev_notation first to learn \
641 the page notation, jev_check to make sure a page parses, jev_cost before anything large, \
642 then jev_ask or jev_eval.{}",
643 if host.live {
644 ""
645 } else {
646 " This server has no API key: every answer is simulated."
647 }
648 );
649 json!({
650 "protocolVersion": version,
651 "capabilities": { "tools": { "listChanged": false } },
652 "serverInfo": { "name": SERVER_NAME, "title": "jev", "version": host.version },
653 "instructions": instructions,
654 })
655}
656
657pub async fn handle(message: &Value, host: &Host) -> Option<Value> {
662 let Some(object) = message.as_object() else {
663 return Some(fault(
664 Value::Null,
665 INVALID_REQUEST,
666 "Expected a JSON-RPC object.".to_owned(),
667 ));
668 };
669 let id = match object.get("id") {
670 None | Some(Value::Null) => None,
671 Some(id) => Some(id.clone()),
672 };
673 let Some(method) = object.get("method").and_then(Value::as_str) else {
674 return id.map(|id| fault(id, INVALID_REQUEST, "No method named.".to_owned()));
675 };
676 let id = id?;
678 let empty = Value::Object(Map::new());
679 let params = object.get("params").unwrap_or(&empty);
680
681 Some(match method {
682 "initialize" => reply(id, greeting(params, host)),
683 "ping" => reply(id, json!({})),
684 "tools/list" => reply(id, json!({ "tools": tools() })),
685 "tools/call" => {
686 let Some(name) = params.get("name").and_then(Value::as_str) else {
687 return Some(fault(
688 id,
689 INVALID_PARAMS,
690 "tools/call needs a tool name.".to_owned(),
691 ));
692 };
693 let args = params.get("arguments").unwrap_or(&empty).clone();
694 reply(id, call(name, &args, host).await.to_value())
695 }
696 "resources/list" => reply(id, json!({ "resources": [] })),
697 "prompts/list" => reply(id, json!({ "prompts": [] })),
698 other => fault(id, METHOD_NOT_FOUND, format!("Unknown method {other:?}.")),
699 })
700}
701
702pub async fn handle_line(line: &str, host: &Host) -> Option<String> {
704 if line.trim().is_empty() {
705 return None;
706 }
707 let message: Value = match serde_json::from_str(line) {
708 Ok(value) => value,
709 Err(e) => {
710 return Some(
711 fault(
712 Value::Null,
713 PARSE_ERROR,
714 format!("Could not parse the message: {e}"),
715 )
716 .to_string(),
717 );
718 }
719 };
720 handle(&message, host).await.map(|value| value.to_string())
721}