use std::io::Read;
use serde_json::{json, Value};
use skilltest_core::mock::{decide, stub_command, AppliedAction};
fn main() {
let mut input = String::new();
if std::io::stdin().read_to_string(&mut input).is_err() {
emit_error("could not read request from stdin");
}
let request: Value = match serde_json::from_str(input.trim()) {
Ok(value) => value,
Err(err) => emit_error(&format!("request was not valid JSON: {err}")),
};
let op = request.get("op").and_then(Value::as_str).unwrap_or("");
let response = match op {
"respond" => respond(&request),
"user" => user(&request),
"judge" => judge(&request),
other => emit_error(&format!("unknown op `{other}`")),
};
println!("{response}");
}
fn respond(request: &Value) -> Value {
let instructions = request
.get("skill")
.and_then(|s| s.get("instructions"))
.and_then(Value::as_str)
.unwrap_or("");
let reply = marker(instructions, "fake-reply:").unwrap_or_else(|| "ok".to_string());
let calls = tool_calls(instructions);
let Some(mocks) = request.get("mocks") else {
let events = plain_events(&calls);
if events.is_empty() {
return json!({ "message": reply, "done": false });
}
return json!({ "message": reply, "done": false, "events": events });
};
let rules = mocks.get("rules").filter(|r| !r.is_null());
let mut events = Vec::new();
let mut records = Vec::new();
let mut surfaced: Vec<String> = Vec::new();
for (index, (name, command)) in calls.iter().enumerate() {
let input = json!({ "command": command });
let decision = rules.and_then(|r| decide(r, Some(name), Some(&input)));
let record_action = decision
.as_ref()
.map_or("allow", |(_, action)| action.kind());
records.push(json!({
"tool": name,
"input": input,
"action": record_action,
"rule": decision.as_ref().map(|(rule, _)| rule),
}));
match decision {
None => events.push(event(index, name, json!({ "command": command }), None)),
Some((_, AppliedAction::Deny { message })) => {
events.push(event(
index,
name,
json!({ "command": command }),
Some(format!("denied: {message}")),
));
surfaced.push(format!("[{name}] denied: {message}"));
}
Some((_, AppliedAction::Stub { output, exit_code })) => {
events.push(event(
index,
name,
json!({ "command": stub_command(output, exit_code) }),
Some(format!("{output}\n")),
));
surfaced.push(format!("[{name}] {output}"));
}
Some((_, AppliedAction::Rewrite { input })) => {
events.push(event(index, name, input.clone(), None));
surfaced.push(format!("[{name}] input rewritten to {input}"));
}
}
}
let message = if surfaced.is_empty() {
reply
} else {
format!("{reply}\n{}", surfaced.join("\n"))
};
if events.is_empty() {
json!({ "message": message, "done": false, "mock_calls": records })
} else {
json!({ "message": message, "done": false, "events": events, "mock_calls": records })
}
}
fn event(index: usize, name: &str, input: Value, output: Option<String>) -> Value {
match output {
Some(output) => json!({
"kind": "tool_call",
"name": name,
"input": input,
"output": output,
"index": index,
}),
None => json!({
"kind": "tool_call",
"name": name,
"input": input,
"index": index,
}),
}
}
fn plain_events(calls: &[(String, String)]) -> Vec<Value> {
calls
.iter()
.enumerate()
.map(|(index, (name, command))| event(index, name, json!({ "command": command }), None))
.collect()
}
fn tool_calls(instructions: &str) -> Vec<(String, String)> {
let mut calls: Vec<(String, String)> = Vec::new();
for line in instructions.lines() {
if let Some(idx) = line.find("fake-tools:") {
let rest = line[idx + "fake-tools:".len()..]
.trim()
.trim_end_matches("-->")
.trim();
if let Ok(count) = rest.parse::<usize>() {
calls.extend((0..count).map(|_| ("noop".to_string(), String::new())));
}
} else if let Some(idx) = line.find("fake-tool:") {
let rest = line[idx + "fake-tool:".len()..]
.trim()
.trim_end_matches("-->")
.trim();
let (name, command) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
calls.push((name.to_string(), command.trim().to_string()));
}
}
calls
}
fn user(request: &Value) -> Value {
let persona = request.get("persona").and_then(Value::as_str).unwrap_or("");
let reply = marker(persona, "say:").unwrap_or_else(|| "continue".to_string());
json!({ "message": reply, "stop": false })
}
fn judge(request: &Value) -> Value {
let kind = request
.get("kind")
.and_then(Value::as_str)
.unwrap_or("boolean");
let criterion = request
.get("criterion")
.and_then(Value::as_str)
.unwrap_or("");
let messages = request
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let assistant_text = assistant_text(&messages);
let assistant_turns = assistant_turns(&messages);
let required = backtick_substrings(criterion);
let present = required
.iter()
.filter(|needle| assistant_text.contains(needle.as_str()))
.count();
let turns_ok = match required_turns(criterion) {
Some(n) => assistant_turns >= n,
None => true,
};
match kind {
"numeric" => {
let min = request.get("min").and_then(Value::as_f64).unwrap_or(0.0);
let max = request.get("max").and_then(Value::as_f64).unwrap_or(1.0);
let fraction = if required.is_empty() {
1.0
} else {
present as f64 / required.len() as f64
};
let base = if turns_ok { fraction } else { 0.0 };
let value = min + (max - min) * base;
json!({
"value": value,
"reason": format!(
"{present}/{} required substrings present, turns_ok={turns_ok}",
required.len()
),
})
}
_ => {
let value = present == required.len() && turns_ok;
json!({
"value": value,
"reason": format!(
"{present}/{} required substrings present, turns_ok={turns_ok}",
required.len()
),
})
}
}
}
fn assistant_text(messages: &[Value]) -> String {
messages
.iter()
.filter(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
.filter_map(|m| m.get("content").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
}
fn assistant_turns(messages: &[Value]) -> usize {
messages
.iter()
.filter(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
.count()
}
fn marker(text: &str, marker: &str) -> Option<String> {
text.lines().find_map(|line| {
line.find(marker).map(|idx| {
line[idx + marker.len()..]
.trim()
.trim_end_matches("-->")
.trim()
.to_string()
})
})
}
fn backtick_substrings(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = text.char_indices();
while let Some((start, ch)) = chars.next() {
if ch == '`' {
let rest = &text[start + 1..];
if let Some(end) = rest.find('`') {
let needle = &rest[..end];
if !needle.is_empty() {
out.push(needle.to_string());
}
for _ in 0..=end {
chars.next();
}
}
}
}
out
}
fn required_turns(criterion: &str) -> Option<usize> {
let idx = criterion.find("turns>=")?;
let digits: String = criterion[idx + "turns>=".len()..]
.chars()
.take_while(char::is_ascii_digit)
.collect();
digits.parse().ok()
}
fn emit_error(message: &str) -> ! {
eprintln!("fake-provider: {message}");
std::process::exit(1);
}