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 "json": {
227 "type": "boolean",
228 "description": "Return the report as JSON instead of a table.",
229 },
230 },
231 "required": ["page", "cases"],
232 "additionalProperties": false,
233 },
234 },
235 {
236 "name": "jev_code",
237 "title": "Page as code",
238 "description":
239 "The page as a working Rust program against typesafe-ai-sdk. Start here instead of \
240 writing a client by hand.",
241 "inputSchema": {
242 "type": "object",
243 "properties": { "page": page, "model": model, "threshold": threshold },
244 "required": ["page"],
245 "additionalProperties": false,
246 },
247 },
248 {
249 "name": "jev_presets",
250 "title": "Ready-made pages",
251 "description":
252 "Worked pages to start from — support triage, content moderation, lead \
253 qualification and reply grading — each as a sketch page ready to edit.",
254 "inputSchema": {
255 "type": "object",
256 "properties": {
257 "name": {
258 "type": "string",
259 "description": "One preset by name. Omit for all of them.",
260 },
261 },
262 "additionalProperties": false,
263 },
264 },
265 ])
266}
267
268const SIMULATED: &str = "\nSimulated answers: deterministic noise, not judgement. \
270 The server has no TYPESAFE_API_KEY, so nothing was sent.";
271
272fn string_arg(args: &Value, name: &str) -> Result<String, String> {
273 match args.get(name) {
274 None | Some(Value::Null) => Err(format!("{name} is required.")),
275 Some(Value::String(text)) => Ok(text.clone()),
276 Some(_) => Err(format!("{name} must be a string.")),
277 }
278}
279
280fn optional_string(args: &Value, name: &str) -> Result<Option<String>, String> {
281 match args.get(name) {
282 None | Some(Value::Null) => Ok(None),
283 Some(Value::String(text)) => Ok(Some(text.clone())),
284 Some(_) => Err(format!("{name} must be a string.")),
285 }
286}
287
288fn threshold_arg(args: &Value) -> Result<f64, String> {
289 let value = match args.get("threshold") {
290 None | Some(Value::Null) => return Ok(0.5),
291 Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
292 Some(_) => return Err("threshold must be a number.".to_owned()),
293 };
294 if !(0.0..=1.0).contains(&value) {
295 return Err("threshold must be from 0 to 1.".to_owned());
296 }
297 Ok(value)
298}
299
300fn bool_arg(args: &Value, name: &str) -> Result<bool, String> {
301 match args.get(name) {
302 None | Some(Value::Null) => Ok(false),
303 Some(Value::Bool(value)) => Ok(*value),
304 Some(_) => Err(format!("{name} must be true or false.")),
305 }
306}
307
308fn concurrency_arg(args: &Value) -> Result<usize, String> {
309 let workers = match args.get("concurrency") {
310 None | Some(Value::Null) => return Ok(4),
311 Some(Value::Number(n)) => n.as_i64().unwrap_or(0),
312 Some(_) => return Err("concurrency must be a number.".to_owned()),
313 };
314 if workers < 1 {
315 return Err("concurrency must be a whole number of 1 or more.".to_owned());
316 }
317 Ok(workers as usize)
318}
319
320fn rates_arg(args: &Value, host: &Host) -> Result<Option<Rates>, String> {
322 match optional_string(args, "price")? {
323 None => Ok(host.rates),
324 Some(text) => cost::parse_rates(&text).map(Some),
325 }
326}
327
328fn session_arg(args: &Value, host: &Host) -> Result<(Session, String), String> {
330 let mut session = headless::load(&string_arg(args, "page")?)?;
331 if let Some(state) = optional_string(args, "state")? {
332 session.state = Value::String(state);
333 }
334 if let Some(model) = optional_string(args, "model")? {
335 session.model = Some(model);
336 }
337 let model = session.model.clone().unwrap_or_else(|| host.model.clone());
338 Ok((session, model))
339}
340
341async fn ask(args: &Value, host: &Host) -> Result<ToolResult, String> {
342 let (session, model) = session_arg(args, host)?;
343 if let Some(why) = headless::sendable(&session) {
344 return Err(why);
345 }
346 let threshold = threshold_arg(args)?;
347 let rates = rates_arg(args, host)?;
348 let json_wanted = bool_arg(args, "json")?;
349
350 let sent = (host.ask)(session.clone()).await;
351 let (answers, usage) = match sent.outcome {
352 Outcome::Failed { error } => return Ok(ToolResult::failed(error)),
353 Outcome::Ok { answers, usage } => (answers, usage),
354 };
355 if json_wanted {
356 return Ok(ToolResult::ok(headless::answers_json(
357 &answers,
358 &model,
359 sent.raw.as_ref(),
360 )));
361 }
362 let mut text = headless::answers_text(&answers, threshold);
363 text.push_str(&headless::usage_text(
364 &session,
365 &model,
366 rates,
367 usage.as_ref(),
368 ));
369 if !host.live {
370 text.push_str(SIMULATED);
371 }
372 Ok(ToolResult::ok(text))
373}
374
375async fn score(args: &Value, host: &Host) -> Result<ToolResult, String> {
376 let (session, model) = session_arg(args, host)?;
377 let cases = evaluate::parse_cases(&string_arg(args, "cases")?, &session)?;
378 if cases.is_empty() {
379 return Err("cases is empty: nothing to score.".to_owned());
380 }
381 let threshold = threshold_arg(args)?;
382 let rates = rates_arg(args, host)?;
383 let concurrency = concurrency_arg(args)?;
384 let json_wanted = bool_arg(args, "json")?;
385
386 let send = Arc::clone(&host.ask);
387 let outcomes = evaluate::run(
388 &session,
389 &cases,
390 move |one: Session| {
391 let send = Arc::clone(&send);
392 async move { send(one).await.outcome }
393 },
394 concurrency,
395 )
396 .await;
397 let report = evaluate::report(
398 &session,
399 &cases,
400 &outcomes,
401 ReportOptions {
402 model: &model,
403 threshold,
404 rates,
405 },
406 );
407 if json_wanted {
408 let body = serde_json::to_string_pretty(&evaluate::report_json(&report))
409 .map_err(|e| e.to_string())?;
410 return Ok(ToolResult::ok(format!("{body}\n")));
411 }
412 let mut text = evaluate::report_text(&report);
413 if !host.live {
414 text.push_str(SIMULATED);
415 }
416 Ok(ToolResult::ok(text))
417}
418
419fn preset_page(preset: &presets::Preset) -> String {
421 format!(
422 "# {} — {}\n\n{}",
423 preset.name,
424 preset.about,
425 sketch::render(&presets::to_session(preset))
426 )
427}
428
429fn preset_pages(args: &Value) -> Result<ToolResult, String> {
430 match optional_string(args, "name")? {
431 Some(name) => {
432 let Some(preset) = presets::find(&name) else {
433 let names = presets::PRESETS
434 .iter()
435 .map(|p| p.name)
436 .collect::<Vec<_>>()
437 .join(", ");
438 return Err(format!("no preset {name:?}; there is {names}."));
439 };
440 Ok(ToolResult::ok(preset_page(preset)))
441 }
442 None => Ok(ToolResult::ok(
443 presets::PRESETS
444 .iter()
445 .map(preset_page)
446 .collect::<Vec<_>>()
447 .join("\n\n"),
448 )),
449 }
450}
451
452pub async fn call(name: &str, args: &Value, host: &Host) -> ToolResult {
454 let outcome = match name {
455 "jev_notation" => Ok(ToolResult::ok(SKILL_MD)),
456 "jev_check" => string_arg(args, "page").map(|page| match headless::check_text(&page) {
457 Ok(summary) => ToolResult::ok(format!("{summary}\n")),
458 Err(problems) => ToolResult::failed(problems),
459 }),
460 "jev_request" => session_arg(args, host)
461 .map(|(session, model)| ToolResult::ok(headless::request_text(&session, &model))),
462 "jev_cost" => rates_arg(args, host).and_then(|rates| {
463 session_arg(args, host).map(|(session, model)| {
464 ToolResult::ok(headless::cost_text(&session, &model, rates))
465 })
466 }),
467 "jev_ask" => ask(args, host).await,
468 "jev_eval" => score(args, host).await,
469 "jev_code" => threshold_arg(args).and_then(|threshold| {
470 session_arg(args, host).map(|(session, model)| {
471 ToolResult::ok(headless::code_text(&session, &model, threshold))
472 })
473 }),
474 "jev_presets" => preset_pages(args),
475 other => {
476 return ToolResult::failed(format!("No tool named {other:?}. tools/list has them."));
477 }
478 };
479 match outcome {
480 Ok(result) => result,
481 Err(why) => ToolResult::failed(format!("{name}: {why}")),
482 }
483}
484
485fn reply(id: Value, result: Value) -> Value {
486 json!({ "jsonrpc": "2.0", "id": id, "result": result })
487}
488
489fn fault(id: Value, code: i64, message: String) -> Value {
490 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
491}
492
493fn greeting(params: &Value, host: &Host) -> Value {
495 let asked = params.get("protocolVersion").and_then(Value::as_str);
496 let version = match asked {
497 Some(asked) if KNOWN_PROTOCOLS.contains(&asked) => asked,
498 _ => PROTOCOL_VERSION,
499 };
500 let instructions = format!(
501 "jev shapes and sends TypeSafe AI System One questions. Call jev_notation first to learn \
502 the page notation, jev_check to make sure a page parses, jev_cost before anything large, \
503 then jev_ask or jev_eval.{}",
504 if host.live {
505 ""
506 } else {
507 " This server has no API key: every answer is simulated."
508 }
509 );
510 json!({
511 "protocolVersion": version,
512 "capabilities": { "tools": { "listChanged": false } },
513 "serverInfo": { "name": SERVER_NAME, "title": "jev", "version": host.version },
514 "instructions": instructions,
515 })
516}
517
518pub async fn handle(message: &Value, host: &Host) -> Option<Value> {
523 let Some(object) = message.as_object() else {
524 return Some(fault(
525 Value::Null,
526 INVALID_REQUEST,
527 "Expected a JSON-RPC object.".to_owned(),
528 ));
529 };
530 let id = match object.get("id") {
531 None | Some(Value::Null) => None,
532 Some(id) => Some(id.clone()),
533 };
534 let Some(method) = object.get("method").and_then(Value::as_str) else {
535 return id.map(|id| fault(id, INVALID_REQUEST, "No method named.".to_owned()));
536 };
537 let id = id?;
539 let empty = Value::Object(Map::new());
540 let params = object.get("params").unwrap_or(&empty);
541
542 Some(match method {
543 "initialize" => reply(id, greeting(params, host)),
544 "ping" => reply(id, json!({})),
545 "tools/list" => reply(id, json!({ "tools": tools() })),
546 "tools/call" => {
547 let Some(name) = params.get("name").and_then(Value::as_str) else {
548 return Some(fault(
549 id,
550 INVALID_PARAMS,
551 "tools/call needs a tool name.".to_owned(),
552 ));
553 };
554 let args = params.get("arguments").unwrap_or(&empty).clone();
555 reply(id, call(name, &args, host).await.to_value())
556 }
557 "resources/list" => reply(id, json!({ "resources": [] })),
558 "prompts/list" => reply(id, json!({ "prompts": [] })),
559 other => fault(id, METHOD_NOT_FOUND, format!("Unknown method {other:?}.")),
560 })
561}
562
563pub async fn handle_line(line: &str, host: &Host) -> Option<String> {
565 if line.trim().is_empty() {
566 return None;
567 }
568 let message: Value = match serde_json::from_str(line) {
569 Ok(value) => value,
570 Err(e) => {
571 return Some(
572 fault(
573 Value::Null,
574 PARSE_ERROR,
575 format!("Could not parse the message: {e}"),
576 )
577 .to_string(),
578 );
579 }
580 };
581 handle(&message, host).await.map(|value| value.to_string())
582}