1use crate::Runtime;
16use car_eventlog::tool_receipts::ToolClaim;
17use car_eventlog::EventKind;
18use car_ir::ActionProposal;
19use car_verify::goal::{GoalCondition, GoalInputs};
20use car_verify::plan_check::{check_plan, PlanCheckRequest};
21use car_verify::transaction::check_transaction;
22use std::collections::HashMap;
23
24#[derive(Debug, Clone, Default)]
31pub struct GoalGather {
32 pub claims: Vec<ToolClaim>,
36 pub proposal_id: Option<String>,
38 pub plan: Option<PlanCheckRequest>,
40 pub transaction_proposal: Option<ActionProposal>,
43 pub state_keys: Vec<String>,
46 pub command_exits: HashMap<String, i32>,
48 pub model_verdicts: HashMap<String, bool>,
50}
51
52impl Runtime {
53 pub async fn gather_goal_inputs(&self, gather: &GoalGather) -> GoalInputs {
60 let receipts_grounded = Some(
64 self.verify_tool_receipts(&gather.claims, gather.proposal_id.as_deref())
65 .await
66 .grounded,
67 );
68
69 let plan_achieved = gather.plan.as_ref().map(|p| check_plan(p).valid);
70
71 let full = self.state.snapshot();
72 let state = if gather.state_keys.is_empty() {
73 full
74 } else {
75 gather
76 .state_keys
77 .iter()
78 .filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone())))
79 .collect()
80 };
81
82 let state_consistent = gather.transaction_proposal.as_ref().map(|p| {
83 let (values, versions) = self.state.versioned_snapshot();
84 check_transaction(p, &versions, Some(&values)).consistent
85 });
86
87 GoalInputs {
88 receipts_grounded,
89 plan_achieved,
90 state_consistent,
91 state,
92 command_exits: gather.command_exits.clone(),
93 model_verdicts: gather.model_verdicts.clone(),
94 }
95 }
96
97 pub async fn record_goal_evaluated(
110 &self,
111 goal: &str,
112 condition: &GoalCondition,
113 iteration: u32,
114 met: bool,
115 grounded: bool,
116 reason: &str,
117 model: &str,
118 ) -> car_eventlog::Event {
119 let mut data = HashMap::new();
120 data.insert("goal".to_string(), serde_json::json!(goal));
121 data.insert(
122 "condition".to_string(),
123 serde_json::to_value(condition).unwrap_or(serde_json::Value::Null),
124 );
125 data.insert("iteration".to_string(), serde_json::json!(iteration));
126 data.insert("met".to_string(), serde_json::json!(met));
127 data.insert("grounded".to_string(), serde_json::json!(grounded));
128 data.insert("reason".to_string(), serde_json::json!(reason));
129 data.insert("model_id".to_string(), serde_json::json!(model));
130 data.insert(
131 "model_tier".to_string(),
132 serde_json::json!(model_tier(model)),
133 );
134 self.log
135 .lock()
136 .await
137 .append(EventKind::GoalEvaluated, None, None, data)
138 .clone()
139 }
140
141 pub async fn record_turn_completed(
153 &self,
154 decision: &str,
155 stop_reason: Option<&str>,
156 was_truncated: bool,
157 turns: u32,
158 model: &str,
159 ) -> car_eventlog::Event {
160 let data = turn_completed_data(decision, stop_reason, was_truncated, turns, model);
161 self.log
162 .lock()
163 .await
164 .append(EventKind::TurnCompleted, None, None, data)
165 .clone()
166 }
167}
168
169pub fn turn_completed_data(
184 decision: &str,
185 stop_reason: Option<&str>,
186 was_truncated: bool,
187 turns: u32,
188 model: &str,
189) -> HashMap<String, serde_json::Value> {
190 let mut data = HashMap::new();
191 data.insert("decision".to_string(), serde_json::json!(decision));
192 data.insert("stop_reason".to_string(), serde_json::json!(stop_reason));
193 data.insert(
194 "was_truncated".to_string(),
195 serde_json::json!(was_truncated),
196 );
197 data.insert("turns".to_string(), serde_json::json!(turns));
198 data.insert("model_id".to_string(), serde_json::json!(model));
199 data.insert(
200 "model_tier".to_string(),
201 serde_json::json!(model_tier(model)),
202 );
203 data
204}
205
206pub fn model_tier(model: &str) -> &'static str {
216 match model.split('/').next().unwrap_or("") {
217 "mlx" | "mlx-vlm" | "vllm-mlx" | "local" | "apple" | "qwen" => "local",
218 "anthropic" | "openai" | "google" | "gemini" | "parslee" => "cloud",
219 _ => "unknown",
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::ToolExecutor;
227 use car_eventlog::tool_receipts::{ClaimKind, ToolClaim};
228 use car_ir::{Action, ActionProposal, ActionType};
229 use car_verify::goal::{evaluate_goal, GoalCondition};
230 use serde_json::Value;
231 use std::sync::Arc;
232
233 struct EchoExec;
234 #[async_trait::async_trait]
235 impl ToolExecutor for EchoExec {
236 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
237 match tool {
238 "echo" => Ok(params.get("message").cloned().unwrap_or(Value::Null)),
239 _ => Err(format!("unknown tool: {tool}")),
240 }
241 }
242 }
243
244 fn tool_call(tool: &str, params: HashMap<String, Value>) -> Action {
245 let mut a = Action::tool_call(tool);
246 a.parameters = params;
247 a
248 }
249
250 fn state_write(key: &str, value: Value) -> Action {
251 let mut a = Action::new(ActionType::StateWrite);
252 a.parameters = [
253 ("key".to_string(), Value::from(key)),
254 ("value".to_string(), value),
255 ]
256 .into();
257 a
258 }
259
260 fn proposal(actions: Vec<Action>) -> ActionProposal {
261 ActionProposal {
262 id: "goal-test".to_string(),
263 source: "test".to_string(),
264 actions,
265 timestamp: chrono::Utc::now(),
266 context: HashMap::new(),
267 }
268 }
269
270 #[tokio::test]
275 async fn gather_reads_real_receipts_and_state() {
276 let rt = Runtime::new().with_executor(Arc::new(EchoExec));
277 rt.register_tool("echo").await;
278
279 let p = proposal(vec![
280 tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
281 state_write("tests_passing", Value::Bool(true)),
282 ]);
283 let r = rt.execute(&p).await;
284 assert!(r.all_succeeded(), "setup proposal must succeed");
285
286 let gather = GoalGather {
288 claims: vec![ToolClaim {
289 kind: ClaimKind::Invoked,
290 tool: "echo".to_string(),
291 call_id: None,
292 count: None,
293 text: None,
294 }],
295 proposal_id: Some("goal-test".to_string()),
296 ..Default::default()
297 };
298 let inputs = rt.gather_goal_inputs(&gather).await;
299 assert_eq!(inputs.receipts_grounded, Some(true));
300 assert_eq!(inputs.state.get("tests_passing"), Some(&Value::Bool(true)));
301
302 let cond = GoalCondition::AllOf {
303 conditions: vec![
304 GoalCondition::ToolReceiptsGrounded,
305 GoalCondition::StatePredicate {
306 key: "tests_passing".into(),
307 equals: Value::Bool(true),
308 },
309 ],
310 };
311 let verdict = evaluate_goal(&cond, &inputs);
312 assert!(verdict.met && verdict.grounded, "{}", verdict.reason);
313 }
314
315 #[tokio::test]
318 async fn fabricated_claim_fails_the_receipts_condition() {
319 let rt = Runtime::new().with_executor(Arc::new(EchoExec));
320 rt.register_tool("echo").await;
321 let r = rt
322 .execute(&proposal(vec![tool_call(
323 "echo",
324 [("message".to_string(), Value::from("hi"))].into(),
325 )]))
326 .await;
327 assert!(r.all_succeeded());
328
329 let gather = GoalGather {
331 claims: vec![ToolClaim {
332 kind: ClaimKind::Invoked,
333 tool: "deploy".to_string(),
334 call_id: None,
335 count: None,
336 text: Some("I deployed it".to_string()),
337 }],
338 proposal_id: Some("goal-test".to_string()),
339 ..Default::default()
340 };
341 let inputs = rt.gather_goal_inputs(&gather).await;
342 assert_eq!(inputs.receipts_grounded, Some(false));
343
344 let verdict = evaluate_goal(&GoalCondition::ToolReceiptsGrounded, &inputs);
345 assert!(
346 !verdict.met,
347 "a hallucinated tool claim must not satisfy the goal"
348 );
349 assert!(verdict.reason.contains("hallucinated"));
350 }
351
352 #[tokio::test]
353 async fn record_goal_evaluated_appends_auditable_event() {
354 let rt = Runtime::new();
355 let condition = GoalCondition::Command {
356 id: "tests".to_string(),
357 expect_exit: 0,
358 };
359 rt.record_goal_evaluated(
360 "make tests pass",
361 &condition,
362 2,
363 true,
364 true,
365 "command `test` exited 0",
366 "mlx/qwen3-8b:4bit",
367 )
368 .await;
369
370 let log = rt.log.lock().await;
371 let event = log.events().last().expect("goal event should be recorded");
372 assert_eq!(event.kind, car_eventlog::EventKind::GoalEvaluated);
373 assert_eq!(
374 event.data.get("goal"),
375 Some(&serde_json::json!("make tests pass"))
376 );
377 assert_eq!(
378 event.data.get("condition"),
379 Some(&serde_json::json!({"kind": "command", "id": "tests", "expect_exit": 0}))
380 );
381 assert_eq!(event.data.get("iteration"), Some(&serde_json::json!(2)));
382 assert_eq!(event.data.get("met"), Some(&serde_json::json!(true)));
383 assert_eq!(event.data.get("grounded"), Some(&serde_json::json!(true)));
384 assert_eq!(
385 event.data.get("reason"),
386 Some(&serde_json::json!("command `test` exited 0"))
387 );
388 assert_eq!(
391 event.data.get("model_id"),
392 Some(&serde_json::json!("mlx/qwen3-8b:4bit"))
393 );
394 assert_eq!(
395 event.data.get("model_tier"),
396 Some(&serde_json::json!("local"))
397 );
398 }
399
400 #[test]
401 fn model_tier_maps_provider_prefixes() {
402 assert_eq!(model_tier("mlx/qwen3-8b:4bit"), "local");
404 assert_eq!(model_tier("qwen/qwen3-8b"), "local");
405 assert_eq!(model_tier("apple/foundation"), "local");
406 assert_eq!(model_tier("anthropic/claude-opus-4-8"), "cloud");
408 assert_eq!(model_tier("openai/gpt-5.5"), "cloud");
409 assert_eq!(model_tier("google/gemini-2.5-pro"), "cloud");
410 assert_eq!(model_tier("parslee/reasoning"), "cloud");
411 assert_eq!(model_tier("acme/whatever"), "unknown");
414 assert_eq!(model_tier(""), "unknown");
415 }
416
417 #[tokio::test]
418 async fn record_turn_completed_appends_auditable_event() {
419 let rt = Runtime::new();
420 rt.record_turn_completed(
421 "empty_tool_calls",
422 Some("length"),
423 true,
424 7,
425 "mlx/qwen3-8b:4bit",
426 )
427 .await;
428
429 let log = rt.log.lock().await;
430 let event = log.events().last().expect("turn event should be recorded");
431 assert_eq!(event.kind, car_eventlog::EventKind::TurnCompleted);
432 assert_eq!(
433 event.data.get("decision"),
434 Some(&serde_json::json!("empty_tool_calls"))
435 );
436 assert_eq!(
437 event.data.get("stop_reason"),
438 Some(&serde_json::json!("length"))
439 );
440 assert_eq!(
441 event.data.get("was_truncated"),
442 Some(&serde_json::json!(true))
443 );
444 assert_eq!(event.data.get("turns"), Some(&serde_json::json!(7)));
445 assert_eq!(
446 event.data.get("model_id"),
447 Some(&serde_json::json!("mlx/qwen3-8b:4bit"))
448 );
449 assert_eq!(
451 event.data.get("model_tier"),
452 Some(&serde_json::json!("local"))
453 );
454 }
455}