1use std::sync::Arc;
31
32use crate::llm::types::{CompletionRequest, ContentBlock, Message, TokenUsage};
33use crate::llm::{BoxedProvider, LlmProvider};
34
35pub const DEFAULT_MAX_CONTINUATIONS: u32 = 8;
38
39const JUDGE_MAX_TOKENS: u32 = 256;
41
42const MAX_TRANSCRIPT_CHARS: usize = 12_000;
47
48const JUDGE_SYSTEM_PROMPT: &str = "\
52You are an impartial completion judge. You did NOT do the work; you only verify \
53it. Given an OBJECTIVE and the WORKING TRANSCRIPT/OUTPUT an agent has produced, \
54decide whether the objective is genuinely and verifiably satisfied by the \
55evidence shown — not merely claimed. An agent asserting it is done is NOT \
56evidence; look for the concrete result the objective requires (e.g. a passing \
57test, an exit code, the requested content). Be skeptical: if the evidence is \
58absent, ambiguous, or only asserted, the objective is NOT met.\n\n\
59Respond with EXACTLY one verdict line, then optionally a brief reason:\n\
60 GOAL_MET: YES\n\
61or\n\
62 GOAL_MET: NO: <one sentence on what concrete evidence is still missing>";
63
64pub type GoalSlot = Arc<std::sync::RwLock<Option<GoalCondition>>>;
70
71#[derive(Clone)]
74pub struct GoalCondition {
75 objective: String,
76 judge: Arc<BoxedProvider>,
77 max_continuations: u32,
78 per_criterion: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct GoalVerdict {
88 pub satisfied: bool,
90 pub reason: String,
92}
93
94impl GoalCondition {
95 pub fn new(objective: impl Into<String>, judge: Arc<BoxedProvider>) -> Self {
99 Self {
100 objective: objective.into(),
101 judge,
102 max_continuations: DEFAULT_MAX_CONTINUATIONS,
103 per_criterion: false,
104 }
105 }
106
107 pub fn with_per_criterion(mut self, on: bool) -> Self {
112 self.per_criterion = on;
113 self
114 }
115
116 pub fn with_max_continuations(mut self, n: u32) -> Self {
120 self.max_continuations = n;
121 self
122 }
123
124 pub fn max_continuations(&self) -> u32 {
126 self.max_continuations
127 }
128
129 pub fn objective(&self) -> &str {
131 &self.objective
132 }
133
134 pub fn per_criterion(&self) -> bool {
136 self.per_criterion
137 }
138
139 pub(crate) fn continuation_message(&self, reason: &str) -> String {
143 format!(
144 "The objective is not yet complete. {reason}\n\nContinue working toward this \
145 objective and demonstrate the concrete result it requires: {}",
146 self.objective
147 )
148 }
149
150 pub(crate) async fn evaluate(&self, transcript: &str) -> (GoalVerdict, TokenUsage) {
160 let evidence = tail_chars(transcript, MAX_TRANSCRIPT_CHARS);
161 let user = format!(
162 "OBJECTIVE:\n{}\n\nWORKING TRANSCRIPT (most recent; tool results are the \
163 evidence — `[Tool result: ...]` lines show what actually happened):\n{}\n\n\
164 Is the objective satisfied by the evidence above? Reply with the verdict line.",
165 self.objective, evidence
166 );
167 let system = if self.per_criterion {
168 format!(
169 "{JUDGE_SYSTEM_PROMPT}\n\nThe OBJECTIVE is a list of acceptance criteria. \
170 Evaluate EACH criterion independently against the evidence: output one line \
171 per criterion, `- [met] <criterion>` or `- [NOT MET] <criterion>: <missing \
172 evidence>`, BEFORE the verdict line. GOAL_MET: YES only when EVERY criterion \
173 is met; otherwise GOAL_MET: NO: name the specific unmet criteria."
174 )
175 } else {
176 JUDGE_SYSTEM_PROMPT.to_string()
177 };
178 let max_tokens = if self.per_criterion {
179 JUDGE_MAX_TOKENS * 4
181 } else {
182 JUDGE_MAX_TOKENS
183 };
184 let request = CompletionRequest {
185 system,
186 messages: vec![Message::user(user)],
187 tools: Vec::new(),
188 max_tokens,
189 tool_choice: None,
190 reasoning_effort: None,
191 };
192 match self.judge.complete(request).await {
193 Ok(response) => (
194 parse_goal_verdict(&response_text(&response.content)),
195 response.usage,
196 ),
197 Err(e) => {
198 tracing::warn!(error = %e, "goal judge call failed; treating goal as not-met");
199 (
200 GoalVerdict {
201 satisfied: false,
202 reason: "judge unavailable; continuing".to_string(),
203 },
204 TokenUsage::default(),
205 )
206 }
207 }
208 }
209}
210
211fn tail_chars(s: &str, max: usize) -> String {
215 if s.chars().count() <= max {
216 return s.to_string();
217 }
218 let skip = s.chars().count() - max;
219 let tail: String = s.chars().skip(skip).collect();
220 format!("[... earlier turns omitted ...]\n{tail}")
221}
222
223impl std::fmt::Debug for GoalCondition {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.debug_struct("GoalCondition")
226 .field("objective", &self.objective)
227 .field("max_continuations", &self.max_continuations)
228 .finish_non_exhaustive()
229 }
230}
231
232fn response_text(content: &[ContentBlock]) -> String {
234 content
235 .iter()
236 .filter_map(|b| match b {
237 ContentBlock::Text { text } => Some(text.as_str()),
238 _ => None,
239 })
240 .collect::<Vec<_>>()
241 .join("\n")
242}
243
244pub(crate) fn parse_goal_verdict(text: &str) -> GoalVerdict {
249 for line in text.lines() {
250 let trimmed = line.trim();
251 if let Some(rest) = trimmed.strip_prefix("GOAL_MET:") {
252 let rest = rest.trim();
253 let verdict_token: String = rest
260 .chars()
261 .take_while(|c| c.is_ascii_alphabetic())
262 .collect();
263 if verdict_token.eq_ignore_ascii_case("yes") {
264 return GoalVerdict {
265 satisfied: true,
266 reason: String::new(),
267 };
268 }
269 if let Some(reason) = rest
270 .strip_prefix("NO:")
271 .or_else(|| rest.strip_prefix("no:"))
272 .or_else(|| rest.strip_prefix("No:"))
273 {
274 let reason = reason.trim();
275 return GoalVerdict {
276 satisfied: false,
277 reason: if reason.is_empty() {
278 "objective not yet demonstrated".to_string()
279 } else {
280 reason.to_string()
281 },
282 };
283 }
284 if rest.eq_ignore_ascii_case("no") || rest.eq_ignore_ascii_case("no.") {
285 return GoalVerdict {
286 satisfied: false,
287 reason: "objective not yet demonstrated".to_string(),
288 };
289 }
290 }
292 }
293 GoalVerdict {
294 satisfied: false,
295 reason: "judge did not return a recognized verdict; continuing".to_string(),
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::sync::Mutex;
303
304 struct StubJudge {
306 reply: String,
307 captured: Mutex<Vec<CompletionRequest>>,
308 }
309 impl LlmProvider for StubJudge {
310 async fn complete(
311 &self,
312 request: CompletionRequest,
313 ) -> Result<crate::llm::types::CompletionResponse, crate::error::Error> {
314 self.captured.lock().expect("lock").push(request);
315 Ok(crate::llm::types::CompletionResponse {
316 content: vec![ContentBlock::Text {
317 text: self.reply.clone(),
318 }],
319 stop_reason: crate::llm::types::StopReason::EndTurn,
320 reasoning: None,
321 usage: TokenUsage::default(),
322 model: None,
323 })
324 }
325 }
326
327 #[tokio::test]
328 async fn judge_reports_per_criterion_status() {
329 let judge = Arc::new(StubJudge {
332 reply: "- [met] A: endpoint returns 200\n- [NOT MET] B: no test evidence\n\
333 GOAL_MET: NO: criterion B unmet — tests were never run"
334 .into(),
335 captured: Mutex::new(Vec::new()),
336 });
337 let goal = GoalCondition::new(
338 "- A: GET /health returns 200\n- B: cargo test green",
339 Arc::new(BoxedProvider::from_arc(judge.clone())),
340 )
341 .with_per_criterion(true);
342 let (verdict, _usage) = goal.evaluate("transcript evidence here").await;
343 assert!(!verdict.satisfied);
344 assert!(
345 verdict.reason.contains("criterion B"),
346 "reason names the unmet criterion: {}",
347 verdict.reason
348 );
349 let cont = goal.continuation_message(&verdict.reason);
350 assert!(
351 cont.contains("criterion B"),
352 "continuation carries the specific gap: {cont}"
353 );
354 let reqs = judge.captured.lock().expect("lock");
356 assert!(
357 reqs[0].system.contains("EACH criterion"),
358 "per-criterion instruction missing: {}",
359 reqs[0].system
360 );
361 }
362
363 #[tokio::test]
364 async fn default_judge_prompt_has_no_per_criterion_instruction() {
365 let judge = Arc::new(StubJudge {
366 reply: "GOAL_MET: YES".into(),
367 captured: Mutex::new(Vec::new()),
368 });
369 let goal = GoalCondition::new("ship it", Arc::new(BoxedProvider::from_arc(judge.clone())));
370 let _ = goal.evaluate("evidence").await;
371 let reqs = judge.captured.lock().expect("lock");
372 assert!(
373 !reqs[0].system.contains("EACH criterion"),
374 "off by default (cost): {}",
375 reqs[0].system
376 );
377 }
378
379 #[test]
380 fn parses_yes() {
381 let v = parse_goal_verdict("Looks complete.\nGOAL_MET: YES");
382 assert!(v.satisfied);
383 assert!(v.reason.is_empty());
384 }
385
386 #[test]
387 fn parses_no_with_reason() {
388 let v = parse_goal_verdict("GOAL_MET: NO: tests have not been run yet");
389 assert!(!v.satisfied);
390 assert_eq!(v.reason, "tests have not been run yet");
391 }
392
393 #[test]
394 fn parses_bare_no() {
395 let v = parse_goal_verdict("GOAL_MET: NO");
396 assert!(!v.satisfied);
397 assert!(!v.reason.is_empty());
398 }
399
400 #[test]
401 fn verdict_value_is_case_insensitive() {
402 assert!(parse_goal_verdict("GOAL_MET: yes").satisfied);
405 assert!(parse_goal_verdict("GOAL_MET: YES").satisfied);
406 assert!(!parse_goal_verdict("GOAL_MET: no: x").satisfied);
407 assert!(!parse_goal_verdict("GOAL_MET: NO: x").satisfied);
408 }
409
410 #[test]
411 fn verdict_yes_with_trailing_justification_is_satisfied() {
412 assert!(parse_goal_verdict("GOAL_MET: YES — all criteria met").satisfied);
415 assert!(parse_goal_verdict("GOAL_MET: YES (verified against tests)").satisfied);
416 assert!(parse_goal_verdict("GOAL_MET: yes, the objective is demonstrated").satisfied);
417 assert!(!parse_goal_verdict("GOAL_MET: yesterday it was incomplete").satisfied);
419 }
420
421 #[test]
422 fn unrecognized_reply_is_not_satisfied() {
423 let v = parse_goal_verdict("I have completed everything successfully!");
426 assert!(!v.satisfied, "no verdict line must not count as met");
427 assert!(!v.reason.is_empty());
428 }
429
430 #[test]
431 fn empty_reply_is_not_satisfied() {
432 assert!(!parse_goal_verdict("").satisfied);
433 }
434
435 #[test]
436 fn builder_defaults_and_overrides() {
437 use crate::agent::test_helpers::MockProvider;
438 let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
439 let g = GoalCondition::new("ship it", Arc::clone(&judge));
440 assert_eq!(g.max_continuations(), DEFAULT_MAX_CONTINUATIONS);
441 assert_eq!(g.objective(), "ship it");
442 let g2 = GoalCondition::new("ship it", judge).with_max_continuations(2);
443 assert_eq!(g2.max_continuations(), 2);
444 }
445
446 #[test]
447 fn continuation_message_recites_objective_and_reason() {
448 use crate::agent::test_helpers::MockProvider;
449 let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
450 let g = GoalCondition::new("all tests pass", judge);
451 let msg = g.continuation_message("tests are still failing");
452 assert!(msg.contains("tests are still failing"));
453 assert!(msg.contains("all tests pass"));
454 }
455
456 #[tokio::test]
457 async fn evaluate_uses_independent_judge_and_parses_yes() {
458 use crate::agent::test_helpers::MockProvider;
459 let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![
460 MockProvider::text_response("GOAL_MET: YES", 5, 3),
461 ])));
462 let g = GoalCondition::new("do the thing", judge);
463 let (v, usage) = g.evaluate("I did the thing, here is the result X.").await;
464 assert!(v.satisfied);
465 assert!(usage.input_tokens + usage.output_tokens > 0);
467 }
468
469 #[tokio::test]
470 async fn evaluate_judge_error_fails_toward_not_met() {
471 use crate::agent::test_helpers::MockProvider;
472 let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
474 let g = GoalCondition::new("do the thing", judge);
475 let (v, usage) = g.evaluate("anything").await;
476 assert!(!v.satisfied, "a judge error must not declare the goal met");
477 assert_eq!(
478 usage.input_tokens + usage.output_tokens,
479 0,
480 "a failed judge call contributes no usage"
481 );
482 }
483
484 #[test]
485 fn transcript_tail_keeps_recent_evidence() {
486 let long: String = (0..5000).map(|i| format!("line {i}\n")).collect();
487 let t = tail_chars(&long, 100);
488 assert!(t.starts_with("[... earlier turns omitted ...]"));
489 assert!(t.contains("line 4999"));
491 assert!(!t.contains("line 0\n"));
492 assert_eq!(tail_chars("short", 100), "short");
494 }
495
496 use std::sync::atomic::{AtomicUsize, Ordering};
499
500 use crate::agent::AgentRunner;
501 use crate::error::Error;
502 use crate::llm::types::{
503 CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
504 };
505
506 struct CountingWorker {
509 text: String,
510 calls: Arc<AtomicUsize>,
511 }
512 impl LlmProvider for CountingWorker {
513 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
514 self.calls.fetch_add(1, Ordering::SeqCst);
515 Ok(CompletionResponse {
516 content: vec![ContentBlock::Text {
517 text: self.text.clone(),
518 }],
519 stop_reason: StopReason::EndTurn,
520 reasoning: None,
521 usage: TokenUsage {
522 input_tokens: 1,
523 output_tokens: 1,
524 ..Default::default()
525 },
526 model: None,
527 })
528 }
529 fn model_name(&self) -> Option<&str> {
530 Some("worker-mock")
531 }
532 }
533
534 struct FixedJudge(&'static str);
536 impl LlmProvider for FixedJudge {
537 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
538 Ok(CompletionResponse {
539 content: vec![ContentBlock::Text {
540 text: self.0.to_string(),
541 }],
542 stop_reason: StopReason::EndTurn,
543 reasoning: None,
544 usage: TokenUsage {
545 input_tokens: 1,
546 output_tokens: 1,
547 ..Default::default()
548 },
549 model: None,
550 })
551 }
552 fn model_name(&self) -> Option<&str> {
553 Some("judge-mock")
554 }
555 }
556
557 fn worker(text: &str) -> (Arc<CountingWorker>, Arc<AtomicUsize>) {
558 let calls = Arc::new(AtomicUsize::new(0));
559 let w = Arc::new(CountingWorker {
560 text: text.to_string(),
561 calls: Arc::clone(&calls),
562 });
563 (w, calls)
564 }
565
566 fn judge(verdict: &'static str) -> Arc<BoxedProvider> {
567 Arc::new(BoxedProvider::new(FixedJudge(verdict)))
568 }
569
570 #[tokio::test]
574 async fn goal_satisfied_stops_after_one_turn() {
575 let (w, calls) = worker("I am done.");
576 let runner = AgentRunner::builder(w)
577 .name("w")
578 .system_prompt("sp")
579 .max_turns(10)
580 .goal(GoalCondition::new("obj", judge("GOAL_MET: YES")).with_max_continuations(5))
581 .build()
582 .expect("build");
583 let out = runner.execute("task").await.expect("run ok");
584 assert_eq!(out.goal_met, Some(true));
585 assert_eq!(
586 calls.load(Ordering::SeqCst),
587 1,
588 "satisfied → exactly one turn"
589 );
590 }
591
592 #[tokio::test]
597 async fn goal_unsatisfied_loops_to_cap_then_reports_false() {
598 let (w, calls) = worker("I am done, everything works!");
599 let runner = AgentRunner::builder(w)
600 .name("w")
601 .system_prompt("sp")
602 .max_turns(50)
603 .goal(
604 GoalCondition::new("obj", judge("GOAL_MET: NO: not yet")).with_max_continuations(2),
605 )
606 .build()
607 .expect("build");
608 let out = runner.execute("task").await.expect("run ok");
609 assert_eq!(out.goal_met, Some(false), "cap exhausted → goal unmet");
610 assert_eq!(
611 calls.load(Ordering::SeqCst),
612 3,
613 "initial completion + 2 continuations"
614 );
615 }
616
617 #[tokio::test]
622 async fn worker_self_claim_does_not_satisfy_an_independent_no_judge() {
623 let (w, calls) = worker("Done! Everything is complete and verified.");
624 let runner = AgentRunner::builder(w)
625 .name("w")
626 .system_prompt("sp")
627 .max_turns(50)
628 .goal(
629 GoalCondition::new("obj", judge("GOAL_MET: NO: show the test output"))
630 .with_max_continuations(1),
631 )
632 .build()
633 .expect("build");
634 let out = runner.execute("task").await.expect("run ok");
635 assert_eq!(out.goal_met, Some(false));
636 assert!(
637 calls.load(Ordering::SeqCst) > 1,
638 "the worker's 'I am done' claim must NOT stop the run when the judge says no"
639 );
640 }
641
642 #[tokio::test]
646 async fn goal_continuations_respect_max_turns() {
647 let (w, _calls) = worker("not done yet");
648 let runner = AgentRunner::builder(w)
649 .name("w")
650 .system_prompt("sp")
651 .max_turns(2)
652 .goal(
653 GoalCondition::new("obj", judge("GOAL_MET: NO: keep going"))
654 .with_max_continuations(1000),
655 )
656 .build()
657 .expect("build");
658 let result = runner.execute("task").await;
659 let err = result.expect_err("should hit max_turns, not loop forever");
661 let inner = match err {
662 Error::WithPartialUsage { source, .. } => *source,
663 other => other,
664 };
665 assert!(
666 matches!(inner, Error::MaxTurnsExceeded(2)),
667 "goal continuations must be bounded by max_turns, got {inner:?}"
668 );
669 }
670
671 #[tokio::test]
674 async fn no_goal_leaves_goal_met_none_and_one_turn() {
675 let (w, calls) = worker("done");
676 let runner = AgentRunner::builder(w)
677 .name("w")
678 .system_prompt("sp")
679 .max_turns(10)
680 .build()
681 .expect("build");
682 let out = runner.execute("task").await.expect("run ok");
683 assert_eq!(out.goal_met, None);
684 assert_eq!(calls.load(Ordering::SeqCst), 1);
685 }
686
687 #[tokio::test]
691 async fn goal_converges_when_judge_flips_to_yes() {
692 use crate::agent::test_helpers::MockProvider;
693 let (w, calls) = worker("working on it");
694 let judge_p = Arc::new(BoxedProvider::new(MockProvider::new(vec![
696 MockProvider::text_response("GOAL_MET: NO: not yet", 1, 1),
697 MockProvider::text_response("GOAL_MET: YES", 1, 1),
698 ])));
699 let runner = AgentRunner::builder(w)
700 .name("w")
701 .system_prompt("sp")
702 .max_turns(10)
703 .goal(GoalCondition::new("obj", judge_p).with_max_continuations(5))
704 .build()
705 .expect("build");
706 let out = runner.execute("task").await.expect("run ok");
707 assert_eq!(
708 out.goal_met,
709 Some(true),
710 "the second verdict reaches the goal"
711 );
712 assert_eq!(
713 calls.load(Ordering::SeqCst),
714 2,
715 "1 initial completion (judged NO) + 1 continuation (judged YES)"
716 );
717 }
718
719 #[tokio::test]
723 async fn zero_continuations_judges_once_no_reprompt() {
724 let (w, calls) = worker("I am done");
725 let runner = AgentRunner::builder(w)
726 .name("w")
727 .system_prompt("sp")
728 .max_turns(10)
729 .goal(GoalCondition::new("obj", judge("GOAL_MET: NO: nope")).with_max_continuations(0))
730 .build()
731 .expect("build");
732 let out = runner.execute("task").await.expect("run ok");
733 assert_eq!(out.goal_met, Some(false));
734 assert_eq!(
735 calls.load(Ordering::SeqCst),
736 1,
737 "judged once, no continuation"
738 );
739 }
740
741 #[test]
744 fn goal_with_structured_schema_is_rejected_at_build() {
745 let (w, _calls) = worker("x");
746 let result = AgentRunner::builder(w)
747 .name("w")
748 .system_prompt("sp")
749 .structured_schema(serde_json::json!({"type": "object"}))
750 .goal(GoalCondition::new("obj", judge("GOAL_MET: YES")))
751 .build();
752 let err = match result {
754 Err(e) => e,
755 Ok(_) => panic!("goal + structured_schema must be rejected at build"),
756 };
757 assert!(err.to_string().contains("mutually exclusive"), "got: {err}");
758 }
759}